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.
|
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.
|
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
|
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
|
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).
|
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).
|
- [ ] **WC6** — Nightly full-cold sweep (scheduled, declarative, MAX_TESTS-bounded).
|
||||||
- [x] **WC7** — Trigger/authority/labeling: default `!testme`=cold (unchanged); `--quick` opt-in via
|
- [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);
|
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
|
||||||
|
|
||||||
|
### 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)
|
### 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
|
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.
|
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
|
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:
|
def main() -> int:
|
||||||
recipe = os.environ.get("RECIPE")
|
recipe = os.environ.get("RECIPE")
|
||||||
if not recipe:
|
if not recipe:
|
||||||
@ -914,6 +950,18 @@ def main() -> int:
|
|||||||
if not results:
|
if not results:
|
||||||
print("no tiers ran", file=sys.stderr)
|
print("no tiers ran", file=sys.stderr)
|
||||||
return 1
|
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
|
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