claim(2w): WC5 promote-on-green-cold proven — green cold run advances canonical (1.10.0→1.11.0); --quick never promotes; only cold advances
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) <noreply@anthropic.com>
This commit is contained in:
@ -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-<recipe> 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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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=<r>` 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-<recipe>` 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
|
||||
|
||||
|
||||
|
||||
49
tests/unit/test_promote.py
Normal file
49
tests/unit/test_promote.py
Normal file
@ -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
|
||||
Reference in New Issue
Block a user