feat(canon): M1.1 tagged-promote gate — canonical only advances to a published release tag
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
- should_promote_canonical gains a `tagged` requirement (canon §2.A): a green cold latest run promotes only when the tested head version is a published release tag; an untagged main commit never becomes a canonical. - warm_reconcile.is_released_version(recipe, version): release-tag membership (exact or by version_key). Caller computes `tagged` so the gate stays pure. - unit tests: untagged -> no promote; is_released_version cases. - drive-by (pre-existing reds, unrelated to canon, now green): test_warm_reconcile traefik assertion was stale vs the phase-pxgate spec (probes /api/version, no health_domain); meta.py UPGRADE_BASE_VERSION KEYS help synced to the prevb doc text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -89,7 +89,7 @@ KEYS: tuple[Key, ...] = (
|
|||||||
"UPGRADE_BASE_VERSION",
|
"UPGRADE_BASE_VERSION",
|
||||||
"str",
|
"str",
|
||||||
None,
|
None,
|
||||||
"Exact published tag overriding the upgrade tier's base (default: `recipe_versions[-2]`).",
|
"Optional explicit override pinning the upgrade tier's base to an exact published tag (rare; for a PR that adds a version *above* the newest tag). When unset (the norm) the base is resolved DYNAMICALLY (phase prevb): last-green (warm canonical) → target-branch (`main`) tip → else skip. See `run_recipe_ci.resolve_upgrade_base` + DECISIONS.",
|
||||||
),
|
),
|
||||||
Key(
|
Key(
|
||||||
"BACKUP_VERIFY",
|
"BACKUP_VERIFY",
|
||||||
|
|||||||
@ -907,12 +907,18 @@ def run_quick(
|
|||||||
return overall
|
return overall
|
||||||
|
|
||||||
|
|
||||||
def should_promote_canonical(recipe: str, ref: str | None, overall: int, quick: bool) -> bool:
|
def should_promote_canonical(
|
||||||
|
recipe: str, ref: str | None, overall: int, quick: bool, tagged: bool
|
||||||
|
) -> bool:
|
||||||
"""WC5 gate (pure): a run advances/seeds the canonical iff the recipe is enrolled
|
"""WC5 gate (pure): a run advances/seeds the canonical iff the recipe is enrolled
|
||||||
(WARM_CANONICAL), the run was GREEN (overall==0), it was COLD (not --quick), and it ran on LATEST
|
(WARM_CANONICAL), the run was GREEN (overall==0), it was COLD (not --quick), it ran on LATEST
|
||||||
(no PR head → `ref` empty: the nightly sweep or a manual `RECIPE=<r>` run). A PR `!testme` carries
|
(no PR head → `ref` empty: the nightly sweep or a manual `RECIPE=<r>` run), AND the tested head
|
||||||
REF=PR-head and must NOT promote the canonical to a PR's code. Only cold-on-latest advances it."""
|
version corresponds to a published release TAG (`tagged`, phase canon §2.A). A PR `!testme`
|
||||||
return canonical.is_enrolled(recipe) and overall == 0 and not quick and not ref
|
carries REF=PR-head and must NOT promote to a PR's code. An UNTAGGED head (a `main` commit with
|
||||||
|
no release tag for its version) must never become a canonical — the canonical is always a real
|
||||||
|
release. `tagged` is computed by the caller via warm_reconcile.is_released_version so this gate
|
||||||
|
stays pure. Only cold-on-latest-and-tagged advances it."""
|
||||||
|
return canonical.is_enrolled(recipe) and overall == 0 and not quick and not ref and tagged
|
||||||
|
|
||||||
|
|
||||||
def promote_canonical(recipe: str, head_ref: str | None) -> None:
|
def promote_canonical(recipe: str, head_ref: str | None) -> None:
|
||||||
@ -1482,7 +1488,11 @@ def main() -> int:
|
|||||||
# (WARM_CANONICAL) recipe advances/seeds the canonical. ONLY cold-on-latest advances it (a PR
|
# (WARM_CANONICAL) recipe advances/seeds the canonical. ONLY cold-on-latest advances it (a PR
|
||||||
# `!testme` carries REF and must NOT promote; `--quick` never promotes — handled in run_quick).
|
# `!testme` carries REF and must NOT promote; `--quick` never promotes — handled in run_quick).
|
||||||
# Non-fatal: a promote failure leaves the OLD known-good intact (never lose it) and is logged.
|
# Non-fatal: a promote failure leaves the OLD known-good intact (never lose it) and is logged.
|
||||||
if should_promote_canonical(recipe, ref, overall, quick=False):
|
# canon §2.A tagged-promote gate: only promote when the tested head version is a published
|
||||||
|
# release tag (never an arbitrary untagged `main` commit). head_version is the compose `version`
|
||||||
|
# label of the code under test; is_released_version checks it against the recipe's release tags.
|
||||||
|
tagged = warm_reconcile.is_released_version(recipe, head_version)
|
||||||
|
if should_promote_canonical(recipe, ref, overall, quick=False, tagged=tagged):
|
||||||
try:
|
try:
|
||||||
promote_canonical(recipe, head_ref)
|
promote_canonical(recipe, head_ref)
|
||||||
except Exception as e: # noqa: BLE001 — promote is a post-green bonus; never fail a green run
|
except Exception as e: # noqa: BLE001 — promote is a post-green bonus; never fail a green run
|
||||||
|
|||||||
@ -185,6 +185,20 @@ def latest_version(tags) -> str | None:
|
|||||||
return s[-1] if s else None
|
return s[-1] if s else None
|
||||||
|
|
||||||
|
|
||||||
|
def is_released_version(recipe: str, version: str | None) -> bool:
|
||||||
|
"""True iff `version` corresponds to a PUBLISHED RELEASE TAG of the recipe (phase canon §2.A:
|
||||||
|
the canonical may only ever advance to a real release — never an arbitrary untagged `main`
|
||||||
|
commit). Match is exact, or by `version_key` so a re-formatted-but-equal version still counts.
|
||||||
|
A recipe with no release tags, or a `version` that matches no tag, is NOT a release."""
|
||||||
|
if not version:
|
||||||
|
return False
|
||||||
|
tags = recipe_tags(recipe)
|
||||||
|
if version in tags:
|
||||||
|
return True
|
||||||
|
vk = version_key(version)
|
||||||
|
return any(is_version_tag(t) and version_key(t) == vk for t in tags)
|
||||||
|
|
||||||
|
|
||||||
def _major(semver: str) -> int:
|
def _major(semver: str) -> int:
|
||||||
return _numtuple(semver)[0] if semver else 0
|
return _numtuple(semver)[0] if semver else 0
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Pure predicate. The live promote (deploy canonical at latest → snapshot → registry) is proven on
|
Pure predicate. The live promote (deploy canonical at latest → snapshot → registry) is proven on
|
||||||
custom-html (W3). is_enrolled is monkeypatched so the test doesn't depend on which recipes are
|
custom-html (W3). is_enrolled is monkeypatched so the test doesn't depend on which recipes are
|
||||||
enrolled on disk.
|
enrolled on disk. canon §2.A adds the `tagged` requirement: a promote also requires the tested head
|
||||||
|
version to be a published release tag (computed by the caller via is_released_version).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -20,30 +21,40 @@ def _enrolled(monkeypatch, val):
|
|||||||
monkeypatch.setattr(canonical, "is_enrolled", lambda r: val)
|
monkeypatch.setattr(canonical, "is_enrolled", lambda r: val)
|
||||||
|
|
||||||
|
|
||||||
def test_promote_when_enrolled_green_cold_latest(monkeypatch):
|
def test_promote_when_enrolled_green_cold_latest_tagged(monkeypatch):
|
||||||
_enrolled(monkeypatch, True)
|
_enrolled(monkeypatch, True)
|
||||||
# green (overall 0), cold (quick False), latest (ref None) → promote
|
# green (overall 0), cold (quick False), latest (ref None), tagged → promote
|
||||||
assert rr.should_promote_canonical("custom-html", None, 0, quick=False) is True
|
assert rr.should_promote_canonical("custom-html", None, 0, quick=False, tagged=True) is True
|
||||||
assert rr.should_promote_canonical("custom-html", "", 0, quick=False) is True
|
assert rr.should_promote_canonical("custom-html", "", 0, quick=False, tagged=True) is True
|
||||||
|
|
||||||
|
|
||||||
def test_no_promote_when_not_enrolled(monkeypatch):
|
def test_no_promote_when_not_enrolled(monkeypatch):
|
||||||
_enrolled(monkeypatch, False)
|
_enrolled(monkeypatch, False)
|
||||||
assert rr.should_promote_canonical("hedgedoc", None, 0, quick=False) is False
|
assert rr.should_promote_canonical("hedgedoc", None, 0, quick=False, tagged=True) is False
|
||||||
|
|
||||||
|
|
||||||
def test_no_promote_when_red(monkeypatch):
|
def test_no_promote_when_red(monkeypatch):
|
||||||
_enrolled(monkeypatch, True)
|
_enrolled(monkeypatch, True)
|
||||||
assert rr.should_promote_canonical("custom-html", None, 1, quick=False) is False
|
assert rr.should_promote_canonical("custom-html", None, 1, quick=False, tagged=True) is False
|
||||||
|
|
||||||
|
|
||||||
def test_no_promote_when_quick(monkeypatch):
|
def test_no_promote_when_quick(monkeypatch):
|
||||||
# --quick never promotes (the canonical advances ONLY via cold)
|
# --quick never promotes (the canonical advances ONLY via cold)
|
||||||
_enrolled(monkeypatch, True)
|
_enrolled(monkeypatch, True)
|
||||||
assert rr.should_promote_canonical("custom-html", None, 0, quick=True) is False
|
assert rr.should_promote_canonical("custom-html", None, 0, quick=True, tagged=True) is False
|
||||||
|
|
||||||
|
|
||||||
def test_no_promote_on_pr_head(monkeypatch):
|
def test_no_promote_on_pr_head(monkeypatch):
|
||||||
# a PR `!testme` carries REF=PR-head → must NOT advance the canonical to a PR's code
|
# a PR `!testme` carries REF=PR-head → must NOT advance the canonical to a PR's code
|
||||||
_enrolled(monkeypatch, True)
|
_enrolled(monkeypatch, True)
|
||||||
assert rr.should_promote_canonical("custom-html", "abc123def", 0, quick=False) is False
|
assert (
|
||||||
|
rr.should_promote_canonical("custom-html", "abc123def", 0, quick=False, tagged=True)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_promote_when_untagged(monkeypatch):
|
||||||
|
# canon §2.A: a green cold latest run on an UNTAGGED state (head version is not a release tag)
|
||||||
|
# must NOT promote — the canonical is always a real release, never an arbitrary main commit.
|
||||||
|
_enrolled(monkeypatch, True)
|
||||||
|
assert rr.should_promote_canonical("custom-html", None, 0, quick=False, tagged=False) is False
|
||||||
|
|||||||
@ -33,6 +33,24 @@ def test_latest_none_when_no_tags():
|
|||||||
assert wr.latest_version(["main", "feature-x"]) is None
|
assert wr.latest_version(["main", "feature-x"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_released_version(monkeypatch):
|
||||||
|
# canon §2.A tagged-promote gate. is_released_version(recipe, version) → True iff version is a
|
||||||
|
# published release tag (exact or by version_key). recipe_tags is the only I/O — monkeypatch it.
|
||||||
|
tags = ["1.13.0+1.31.1", "1.12.0+1.30.0", "main", "feature-x"]
|
||||||
|
monkeypatch.setattr(wr, "recipe_tags", lambda r: tags)
|
||||||
|
assert wr.is_released_version("custom-html", "1.13.0+1.31.1") is True # exact tag
|
||||||
|
assert wr.is_released_version("custom-html", "1.12.0+1.30.0") is True
|
||||||
|
assert wr.is_released_version("custom-html", "9.9.9+9.9.9") is False # not a tag → untagged
|
||||||
|
assert wr.is_released_version("custom-html", None) is False
|
||||||
|
assert wr.is_released_version("custom-html", "") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_released_version_no_tags(monkeypatch):
|
||||||
|
# a recipe that has never cut a release → no version is a release
|
||||||
|
monkeypatch.setattr(wr, "recipe_tags", lambda r: ["main"])
|
||||||
|
assert wr.is_released_version("never-released", "0.1.0") is False
|
||||||
|
|
||||||
|
|
||||||
def test_minor_patch_bump_not_major():
|
def test_minor_patch_bump_not_major():
|
||||||
# recipe-semver 10.7.0 -> 10.7.1 (patch); app 26.6.1 -> 26.6.2 (patch). Auto-apply.
|
# recipe-semver 10.7.0 -> 10.7.1 (patch); app 26.6.1 -> 26.6.2 (patch). Auto-apply.
|
||||||
assert wr.is_major_bump("10.7.0+26.6.1", "10.7.1+26.6.2") is False
|
assert wr.is_major_bump("10.7.0+26.6.1", "10.7.1+26.6.2") is False
|
||||||
@ -56,11 +74,14 @@ def test_app_major_bump_held_even_if_no_plus_on_current():
|
|||||||
|
|
||||||
def test_traefik_spec_is_stateless_with_setup():
|
def test_traefik_spec_is_stateless_with_setup():
|
||||||
# WC1.1 traefik = stateless (version-rollback-only, NO snapshot) + its own cert/file-provider
|
# WC1.1 traefik = stateless (version-rollback-only, NO snapshot) + its own cert/file-provider
|
||||||
# setup + health probed on a ROUTED host (the dashboard), not traefik's own domain.
|
# setup. Health is probed on traefik's OWN /api/version endpoint (phase pxgate A1: probing the
|
||||||
|
# dashboard host caused a cold-boot deadlock since deploy-dashboard is After=deploy-proxy, so the
|
||||||
|
# spec carries no `health_domain` override and health_code() falls back to the app domain).
|
||||||
t = wr.SPECS["traefik"]
|
t = wr.SPECS["traefik"]
|
||||||
assert t["stateful"] is False
|
assert t["stateful"] is False
|
||||||
assert callable(t.get("setup"))
|
assert callable(t.get("setup"))
|
||||||
assert t["health_domain"] == "ci.commoninternet.net"
|
assert "health_domain" not in t # probes traefik's own domain, not a routed dashboard host
|
||||||
|
assert t["health_path"] == "/api/version"
|
||||||
assert t["domain"] == "traefik.ci.commoninternet.net"
|
assert t["domain"] == "traefik.ci.commoninternet.net"
|
||||||
# keycloak stays stateful with no custom setup (default path)
|
# keycloak stays stateful with no custom setup (default path)
|
||||||
assert wr.SPECS["keycloak"]["stateful"] is True
|
assert wr.SPECS["keycloak"]["stateful"] is True
|
||||||
|
|||||||
Reference in New Issue
Block a user