diff --git a/machine-docs/BACKLOG-samever.md b/machine-docs/BACKLOG-samever.md new file mode 100644 index 0000000..408b154 --- /dev/null +++ b/machine-docs/BACKLOG-samever.md @@ -0,0 +1,12 @@ +# BACKLOG — phase `samever` + +## Build backlog + +- [x] **M1** — resolver reads head version; step-back chain; unit tests. (CLAIMED 2026-06-17) + - [x] `abra.head_compose_version(recipe)` — parse `coop-cloud..version` from head compose.yml + - [x] `warm_reconcile.version_key` + `newest_older_version` — single coop-cloud ordering source + - [x] resolver chain: override → (canonical if ≠ head) → (newest-older if canonical==head) → main-tip → skip + - [x] unit tests extended (13 pass): step-back, canonical≠head unchanged, no-older→skip, ordering, None-head +- [ ] **M2** — prove in real CI: nightly steady-state (canonical==latest) cold-on-latest steps back + (base_version < latest); PR form (non-version-bump PR, head==canonical); discourse #4 version-bump + UNAFFECTED; spot-check ≥1 other enrolled recipe. Awaiting M1 PASS before starting real-CI runs. diff --git a/machine-docs/JOURNAL-samever.md b/machine-docs/JOURNAL-samever.md new file mode 100644 index 0000000..214e314 --- /dev/null +++ b/machine-docs/JOURNAL-samever.md @@ -0,0 +1,43 @@ +# JOURNAL — phase `samever` (Builder reasoning; Adversary does not read before verdict) + +## 2026-06-17 — M1 design + implementation + +**Root cause (confirmed against `runner/run_recipe_ci.py`):** the warm-canonical path of +`resolve_upgrade_base` returned `BasePlan("version", rec["version"], …)` unconditionally — it was +never given the head's *version*, only `head_ref` (a commit sha), so it could not detect the +canonical==head collision. The ref (main-tip) path was already guarded (`main_tip == head_ref → +skip`); the version path was not. In the nightly steady state a green cold-on-latest run promotes +`canonical → latest`, so the *next* night finds `canonical == latest == version-under-test` and the +upgrade tier deploys base==head: a vacuous same-version "upgrade." + +**Why pass `head_version` as a param rather than read compose inside the resolver:** keeps the +resolver pure/unit-testable (the existing 8 tests inject `canonical.read_registry` / +`lifecycle.recipe_branch_commit` via monkeypatch and never touch the filesystem). The call site +(`main()`) reads it once via `abra.head_compose_version(recipe)` from the head checkout that already +exists on disk. Tests pass `head_version=` directly. + +**Why `version_key`-based equality instead of raw string `==`:** the canonical record version and the +compose label *should* be byte-identical when equal, but routing both through the existing coop-cloud +ordering key (`warm_reconcile.version_key`) means a re-published or incidentally-reformatted equal +version still compares equal, and the step-back's "strictly older" uses the *same* single ordering +source — no hand-rolled semver (plan §2 constraint). `version_key` is the inner key of the existing +`sort_versions`, lifted out so `sort_versions`/`newest_older_version` share it (no behavior change to +`sort_versions` — verified by the unchanged existing warm_reconcile tests). + +**Why the step-back inherits F1d-2 automatically:** it returns `kind="version"` exactly like the +normal canonical base, so it flows through the same deploy path (`abra.recipe_checkout` pins the tag +on disk, non-chaos deploy) — the chosen older base genuinely deploys that pinned version, never +LATEST. No new deploy code; the protection is structural. + +**Skip only when genuinely no older predecessor:** `newest_older_version` returns None only when the +head version is the oldest (or only) published tag — then, and only then, a declared skip +(`"base == head … and no older published predecessor"`), never a same-version no-op. + +**`head_version is None` (compose unreadable / no label):** cannot compare → `same=False` → +preserves prevb behavior exactly (canonical is primary). No regression for any caller that omits +`head_version`; the existing `test_last_green_warm_canonical_is_primary` still passes unchanged. + +**Pre-existing unrelated failures** (confirmed failing on clean `279d84d` with my changes stashed, +so NOT introduced here): `tests/unit/test_meta.py::test_generated_doc_table_in_sync` and +`tests/unit/test_warm_reconcile.py::test_traefik_spec_is_stateless_with_setup` (KeyError +'health_domain'). Out of scope for samever. diff --git a/machine-docs/STATUS-samever.md b/machine-docs/STATUS-samever.md new file mode 100644 index 0000000..bcb354f --- /dev/null +++ b/machine-docs/STATUS-samever.md @@ -0,0 +1,79 @@ +# STATUS — phase `samever` (step-back to older base when canonical == head version) + +SSOT: `/srv/cc-ci/cc-ci-plan/plan-phase-samever-older-base-fallback.md`. +State files: this + BACKLOG-samever.md, REVIEW-samever.md (Adversary), JOURNAL-samever.md. DECISIONS.md shared. + +## Phase +Started 2026-06-17. Gates: **M1** (implemented + unit-tested), **M2** (proven in real CI). + +## Current status + +**Gate: M1 CLAIMED, awaiting Adversary** @2026-06-17T04:24Z + +## M1 — WHAT is claimed + +`resolve_upgrade_base` now reads the head's published version and steps back to a genuinely older +published base when the last-green warm-canonical version equals the head version — never a +same-version no-op, never a needless skip when an older base exists. + +Resolution chain (override / EXPECTED_NA / upgrade∉stages short-circuits unchanged): +1. explicit `UPGRADE_BASE_VERSION` override → unchanged. +2. last-green canonical **IF its version ≠ head version** → `kind="version"` (canonical), unchanged from prevb. +3. last-green canonical **== head version** → **step back**: `newest published version strictly older + than head` → `kind="version"` (the older tag). Reason starts `"step-back: …"`. +4. canonical == head **and no older published tag** → `kind="skip"`, reason + `"base == head () and no older published predecessor"`. +5. no canonical → main-tip ref / skip paths unchanged. +`head_version is None` (compose unreadable) → comparison is False → canonical stays primary (prevb behavior). + +## M1 — WHERE (commit + paths) + +- Implementation commit: **b29bb3f** (feat(samever): …), on `main`. +- `runner/run_recipe_ci.py` — `resolve_upgrade_base(..., head_version=None)` new chain (canonical + block ~lines 147–180); call site `main()` reads `head_version = abra.head_compose_version(recipe)` + (~line 1023) and passes it. +- `runner/harness/abra.py` — `head_compose_version(recipe)` (regex `coop-cloud\.[^.\s]*\.version=([^\s"']+)` + over the head checkout's `compose.yml`; matches quoted + unquoted labels; does NOT match `.chaos-version`). +- `runner/warm_reconcile.py` — `version_key(tag)` (lifted from sort_versions; single ordering source) + + `newest_older_version(tags, version)` (newest tag with `version_key < target`; None if none / version None). +- `tests/unit/test_upgrade_base.py` — 5 new tests (13 total). + +## M1 — HOW to verify (cold, from a clean clone) + +1. Unit suite (the gate): + ``` + nix shell nixpkgs#python311Packages.pytest -c pytest tests/unit/test_upgrade_base.py -v + ``` + **EXPECTED: 13 passed.** New tests: + - `test_canonical_equals_head_steps_back_to_newest_older` — canonical==head==`10.8.0+26.6.3`, + tags `[10.6.0+26.5.0, 10.8.0+26.6.3, 10.7.1+26.6.2, 10.7.0+26.6.0, not-a-version]` → + `plan.version == "10.7.1+26.6.2"` (strictly older; asserts `version_key(plan.version) < version_key(head)`), + `kind=="version"`, reason contains `"step-back"`. main never consulted. + - `test_canonical_differs_from_head_uses_canonical_unchanged` — canonical `10.7.1+26.6.2` ≠ head + `10.8.0+26.6.3` → `version==10.7.1+26.6.2`, reason `"last-green"`; recipe_tags NOT consulted. + - `test_canonical_equals_head_no_older_published_skips` — canonical==head==`1.0.0+3.5.3`, tags + `[1.0.0+3.5.3]` only → `kind=="skip"`, reason contains `"no older published predecessor"`. + - `test_no_head_version_preserves_canonical_primary` — head_version omitted → canonical primary, no step-back. + - `test_newest_older_version_ordering` — ordering helper picks correct strictly-older tag, excludes equal, None-safe. + The 8 prior tests (override / EXPECTED_NA / main-tip / head==main-tip skip / no-predecessor skip / + other-rung) are UNCHANGED and still pass — proving override/ref/skip paths untouched. + +2. Teeth (canonical==head MUST NOT yield a same-version base): in + `test_canonical_equals_head_steps_back_to_newest_older`, `plan.version != head_version` and the + `version_key(plan.version) < version_key(head)` assertion fails loudly if the resolver ever returns + the same version or a newer one. + +3. Compose-label parse (the head-version reader): the regex extracts `10.8.0+26.6.3` from a quoted + label and `3.5.3+1.24.2-rootless` from an unquoted one, and returns no match for a `.chaos-version` + label (verified — see JOURNAL). Real labels confirmed on cc-ci: keycloak `10.8.0+26.6.3`, + gitea `3.5.3+1.24.2-rootless`, discourse `1.0.0+3.5.3`. + +4. F1d-2: the step-back returns `kind="version"`, so it flows through the SAME pinned-tag deploy path + as a normal canonical base (`abra.recipe_checkout` pins the tag on disk) — no new deploy code. + +Note (pre-existing, NOT introduced by this gate): `tests/unit/test_meta.py::test_generated_doc_table_in_sync` +and `tests/unit/test_warm_reconcile.py::test_traefik_spec_is_stateless_with_setup` fail on clean +`279d84d` too (verified by stashing my changes). Out of scope for samever. + +## Blocked +(none)