From 125453df20b071d61710dbc4888ef9052b2550c4 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Fri, 29 May 2026 04:08:14 +0100 Subject: [PATCH] =?UTF-8?q?claim(2w):=20WC5=20promote-on-green-cold=20prov?= =?UTF-8?q?en=20=E2=80=94=20green=20cold=20run=20advances=20canonical=20(1?= =?UTF-8?q?.10.0=E2=86=921.11.0);=20--quick=20never=20promotes;=20only=20c?= =?UTF-8?q?old=20advances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit should_promote_canonical (enrolled+green+cold+latest) + promote_canonical (re-seed canonical at green-verified latest, snapshot+registry, old known-good replaced only on green). +5 unit (70 pass). Live: custom-html canonical advanced 1.10.0+1.28.0 → 1.11.0+1.29.0 via a full green cold run; snapshot refreshed; idle; per-run app torn down. WC6 nightly sweep next. Co-Authored-By: Claude Opus 4.8 (1M context) --- machine-docs/JOURNAL-2w.md | 16 +++++++++++++ machine-docs/STATUS-2w.md | 28 +++++++++++++++++++++- runner/run_recipe_ci.py | 48 +++++++++++++++++++++++++++++++++++++ tests/unit/test_promote.py | 49 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_promote.py diff --git a/machine-docs/JOURNAL-2w.md b/machine-docs/JOURNAL-2w.md index 6dce1fe..f83eb73 100644 --- a/machine-docs/JOURNAL-2w.md +++ b/machine-docs/JOURNAL-2w.md @@ -342,3 +342,19 @@ judged sufficient. No finding. **WC1.1 FULLY closed (keycloak + traefik).** Phase-2w verified: WC1, WC1.1, WC1.2, WC2, WC3, WC4, WC7. Remaining: WC5, WC6, WC8, WC9. Adversary now idle → safe for live cold runs. Building W3 WC5 (promote-on-green-cold) next. + +## 2026-05-29 — W3 WC5 promote-on-green-cold built + proven; claiming. (WC6 next.) + +should_promote_canonical(recipe,ref,overall,quick) = is_enrolled & green & cold & on-latest(no ref); +promote_canonical(recipe,head_ref) = deploy warm- at latest (reattach retained volume if any, +else fresh) → healthy → undeploy → seed_canonical (snapshot+registry, atomic; old known-good replaced +ONLY on green so it's never lost). Wired into main() after a green cold run; non-fatal on failure. ++5 unit tests (70 pass). LIVE: set custom-html canonical to 1.10.0+1.28.0, ran full cold (no REF), +all tiers green + deploy-count=1 → promote advanced canonical 1.10.0→1.11.0+1.29.0, snapshot refreshed, +idle, per-run cust-* torn down, traefik/kc still 200. WC5 proven; claimed. + +Mechanism note: cold runs still use FRESH per-run domains (unchanged); promote re-deploys the +canonical at latest separately (one extra deploy) so the old known-good is never at risk on a red run +(DECISIONS Phase-2w WC5). Next: WC6 nightly sweep (systemd timer: nixos-rebuild switch FIRST then +serial cold sweep over enrolled recipes; need canonical.enrolled_recipes() + a nightly-sweep nix +module). Building WC6 code while the Adversary verifies WC5. diff --git a/machine-docs/STATUS-2w.md b/machine-docs/STATUS-2w.md index c34478b..713fc03 100644 --- a/machine-docs/STATUS-2w.md +++ b/machine-docs/STATUS-2w.md @@ -34,7 +34,11 @@ nightly full-cold sweep. Definition of Done = WC1–WC9 (plan §1), each Adversa head (chaos) → generic UPGRADE+serving+overlay+custom; PASS→undeploy-keep-volume (known-good UNCHANGED, never promote); FAIL→restore last-known-good snapshot then undeploy. Proven live on custom-html (PASS + FAIL). **Adversary PASS @2026-05-29** (REVIEW-2w 31f0e42, gate 3ff2bf6). -- [ ] **WC5** — Canonical advancement via cold only (promote-on-green-cold; seeds on first green cold). +- [x] **WC5** — Canonical advancement via cold only (promote-on-green-cold). `should_promote_canonical` + (enrolled+green+cold+latest) + `promote_canonical` (re-seed canonical at green-verified latest → + snapshot+registry; never lose known-good). Proven live: green cold custom-html run advanced the + canonical 1.10.0+1.28.0 → 1.11.0+1.29.0 (snapshot refreshed, idle, per-run app torn down). + `--quick` never promotes (W2). **CLAIMED — see Gate.** - [ ] **WC6** — Nightly full-cold sweep (scheduled, declarative, MAX_TESTS-bounded). - [x] **WC7** — Trigger/authority/labeling: default `!testme`=cold (unchanged); `--quick` opt-in via bridge `parse_trigger` (`!testme --quick` → CCCI_QUICK=1 Drone param, deployed+live-verified); @@ -128,6 +132,28 @@ headline e2e is green (below). No recipe/harness change needed. ## Gate +### Gate: WC5 — CLAIMED, awaiting Adversary (@2026-05-29) + +**WHAT.** Promote-on-green-cold: a GREEN full-cold run on LATEST (no PR head) of an enrolled +(WARM_CANONICAL) recipe advances/seeds the canonical known-good; `--quick` never promotes; only cold +advances. **WHERE:** `runner/run_recipe_ci.py` (`should_promote_canonical` gate + `promote_canonical` ++ the post-green-cold hook in main()), `runner/harness/canonical.py` (seed_canonical). + +**HOW + EXPECTED (cold):** +1. **Units:** `cc-ci-run -m pytest tests/unit -q` → **70 passed** (incl. test_promote: the gate fires + only for enrolled+green+cold+latest; not on red / quick / PR-head / unenrolled). +2. **Live advancement (custom-html canonical):** set its registry version to an OLDER value + (`canonical.write_registry("custom-html", version="1.10.0+1.28.0", …)`), then a full COLD run + `RECIPE=custom-html cc-ci-run runner/run_recipe_ci.py` (no REF = latest) → install/upgrade/backup/ + restore/custom all pass, deploy-count=1, then `WC5 promote-on-green-cold: (re)seed canonical + custom-html @ 1.11.0+1.29.0` → afterwards `canonical.json` version **ADVANCED to 1.11.0+1.29.0** + (commit=head 8a02606…), snapshot refreshed (`warmsnap.read_meta` version=1.11.0+1.29.0), canonical + idle + volume retained, NO `cust-*` per-run service left (cold teardown sacred). Builder ran this + live: **advanced 1.10.0→1.11.0**. (A PR `!testme` REF=PR-head does NOT promote; `--quick` never + promotes — both gate-checked.) + +--- + ### Gate: W0.10a traefik WC1.1 — ✅ Adversary PASS @2026-05-29 (REVIEW-2w e3b08a9, gate e678d2e) Migration + no-op converge + destructive rollback (lint-breaking tag → rollback to last-good, NO TLS outage — broken deploy rejected at lint before touching the running proxy) all cold-verified. diff --git a/runner/run_recipe_ci.py b/runner/run_recipe_ci.py index 2768f17..9376945 100644 --- a/runner/run_recipe_ci.py +++ b/runner/run_recipe_ci.py @@ -603,6 +603,42 @@ def run_quick(recipe: str, ref: str | None, head_ref: str | None, repo_local: st return overall +def should_promote_canonical(recipe: str, ref: str | None, overall: int, quick: bool) -> bool: + """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 + (no PR head → `ref` empty: the nightly sweep or a manual `RECIPE=` run). A PR `!testme` carries + REF=PR-head and must NOT promote the canonical to a PR's code. Only cold-on-latest advances it.""" + return canonical.is_enrolled(recipe) and overall == 0 and not quick and not ref + + +def promote_canonical(recipe: str, head_ref: str | None) -> None: + """WC5: (re)seed the canonical at the green-verified LATEST. Deploy `warm-` at latest + (reattaching the retained canonical volume if one exists — an in-place version bump — else a fresh + install), wait healthy, undeploy, snapshot + record the registry (atomic replace of the + last-known-good). The OLD known-good is replaced ONLY here, after green (never lost on a red run).""" + import warm_reconcile as wr + + domain = canonical.canonical_domain(recipe) + wr.fetch_recipe(recipe) + latest = wr.latest_version(wr.recipe_tags(recipe)) + if not latest: + print(f"WC5 promote: no version tags for {recipe} — skip", flush=True) + return + meta = _load_meta(recipe) + # The cold run's deploy-count was already asserted + the countfile removed; don't perturb it. + os.environ.pop("CCCI_DEPLOY_COUNT_FILE", None) + print(f"\n===== WC5 promote-on-green-cold: (re)seed canonical {recipe} @ {latest} =====", flush=True) + lifecycle.deploy_app(recipe, domain, version=latest, secrets=True, + deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900))) + lifecycle.wait_healthy(domain, ok_codes=tuple(meta["HEALTH_OK"]), path=meta["HEALTH_PATH"], + deploy_timeout=meta["DEPLOY_TIMEOUT"], http_timeout=meta["HTTP_TIMEOUT"]) + abra.undeploy(domain) + _wait_undeployed(domain) + canonical.seed_canonical(recipe, latest, commit=head_ref) + print(f"WC5 promote: canonical {recipe} advanced to known-good {latest} (idle, volume retained)", + flush=True) + + def main() -> int: recipe = os.environ.get("RECIPE") if not recipe: @@ -914,6 +950,18 @@ def main() -> int: if not results: print("no tiers ran", file=sys.stderr) return 1 + + # WC5 promote-on-green-cold: a GREEN COLD run on LATEST (no PR head) of an enrolled + # (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). + # 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): + try: + promote_canonical(recipe, head_ref) + except Exception as e: # noqa: BLE001 — promote is a post-green bonus; never fail a green run + print(f"!! WC5 promote failed (non-fatal; known-good unchanged): {_scrub(str(e))}", + flush=True) + return overall diff --git a/tests/unit/test_promote.py b/tests/unit/test_promote.py new file mode 100644 index 0000000..2056036 --- /dev/null +++ b/tests/unit/test_promote.py @@ -0,0 +1,49 @@ +"""Unit tests for the WC5 promote-on-green-cold gate (run_recipe_ci.should_promote_canonical). + +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 +enrolled on disk. +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +import run_recipe_ci as rr # noqa: E402 +from harness import canonical # noqa: E402 + + +def _enrolled(monkeypatch, val): + monkeypatch.setattr(canonical, "is_enrolled", lambda r: val) + + +def test_promote_when_enrolled_green_cold_latest(monkeypatch): + _enrolled(monkeypatch, True) + # green (overall 0), cold (quick False), latest (ref None) → promote + assert rr.should_promote_canonical("custom-html", None, 0, quick=False) is True + assert rr.should_promote_canonical("custom-html", "", 0, quick=False) is True + + +def test_no_promote_when_not_enrolled(monkeypatch): + _enrolled(monkeypatch, False) + assert rr.should_promote_canonical("hedgedoc", None, 0, quick=False) is False + + +def test_no_promote_when_red(monkeypatch): + _enrolled(monkeypatch, True) + assert rr.should_promote_canonical("custom-html", None, 1, quick=False) is False + + +def test_no_promote_when_quick(monkeypatch): + # --quick never promotes (the canonical advances ONLY via cold) + _enrolled(monkeypatch, True) + assert rr.should_promote_canonical("custom-html", None, 0, quick=True) is False + + +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 + _enrolled(monkeypatch, True) + assert rr.should_promote_canonical("custom-html", "abc123def", 0, quick=False) is False