diff --git a/docs/recipe-customization.md b/docs/recipe-customization.md index a562288..3a3d87d 100644 --- a/docs/recipe-customization.md +++ b/docs/recipe-customization.md @@ -116,7 +116,7 @@ _This table is GENERATED from the `runner/harness/meta.py` KEYS registry by `scr | `DEPLOY_TIMEOUT` | `int` | `600` | Max seconds to wait for swarm convergence per deploy. | | `HTTP_TIMEOUT` | `int` | `300` | Max seconds to wait for HTTP health after convergence. | | `BACKUP_CAPABLE` | `bool` | `None` | Override the backup-tier capability auto-detect (compose `backupbot.backup` labels). `False` forces an intentional skip of the backup/restore rung; `True` forces the tier on; unset = auto-detect. | -| `EXPECTED_NA` | `dict` | `None` | Declare a non-run rung an INTENTIONAL skip: `{rung: reason}` — the level climbs past it; an undeclared non-run rung is *unverified* and blocks the level above it (classification table: machine-docs/DECISIONS.md phase lvl5). Never overrides an exercised pass/fail; the `lint` rung has no escape hatch. | +| `EXPECTED_NA` | `dict` | `None` | Declare a non-run rung an INTENTIONAL skip: `{rung: reason}` — the level climbs past it; an undeclared non-run rung is *unverified* and blocks the level above it (classification table: machine-docs/DECISIONS.md phase lvl5). Never overrides an exercised pass/fail; the `lint` rung has no escape hatch. Declaring `upgrade` also suppresses the upgrade-tier BASE deploy — the single deploy is the PR head itself — for recipes whose published versions exist but are genuinely undeployable (phase bsky). | | `READY_PROBE` | `hook` | `None` | Callable `(ctx) -> [probe, ...]` returning extra readiness probes, run after install AND after upgrade: HTTP `{host, path, ok}` or TCP `{tcp_host, tcp_port, stable}`. | | `UPGRADE_BASE_VERSION` | `str` | `None` | Exact published tag overriding the upgrade tier's base (default: `recipe_versions[-2]`). | | `BACKUP_VERIFY` | `hook` | `None` | Callable `(ctx) -> bool` post-backup data-capture check; `False` re-runs the backup (truncated-dump race guard), retried up to 3 attempts. | diff --git a/runner/harness/meta.py b/runner/harness/meta.py index ff722b3..724b2ae 100644 --- a/runner/harness/meta.py +++ b/runner/harness/meta.py @@ -76,7 +76,7 @@ KEYS: tuple[Key, ...] = ( "EXPECTED_NA", "dict", None, - "Declare a non-run rung an INTENTIONAL skip: `{rung: reason}` — the level climbs past it; an undeclared non-run rung is *unverified* and blocks the level above it (classification table: machine-docs/DECISIONS.md phase lvl5). Never overrides an exercised pass/fail; the `lint` rung has no escape hatch.", + "Declare a non-run rung an INTENTIONAL skip: `{rung: reason}` — the level climbs past it; an undeclared non-run rung is *unverified* and blocks the level above it (classification table: machine-docs/DECISIONS.md phase lvl5). Never overrides an exercised pass/fail; the `lint` rung has no escape hatch. Declaring `upgrade` also suppresses the upgrade-tier BASE deploy — the single deploy is the PR head itself — for recipes whose published versions exist but are genuinely undeployable (phase bsky).", ), Key( "READY_PROBE", diff --git a/runner/run_recipe_ci.py b/runner/run_recipe_ci.py index 3025c11..89675de 100644 --- a/runner/run_recipe_ci.py +++ b/runner/run_recipe_ci.py @@ -88,6 +88,38 @@ def sso_dep_unverified(declared, deps_ready: bool, requires_deps_skipped: int) - return bool(declared) and not deps_ready and requires_deps_skipped > 0 +def upgrade_base(stages, meta, recipe: str) -> str | None: + """Deploy-once base version decision (pure given meta + the published-version lookup): + previous published version when the upgrade tier will run and one exists (so upgrade goes + previous→target in place), else None (the caller falls back to the target / PR head). + (DECISIONS.) + + A recipe may override the base via recipe_meta UPGRADE_BASE_VERSION when the harness default + (recipe_versions[-2]) is NOT the PR's true predecessor — e.g. a PR that adds a version ABOVE the + newest published tag, where the correct base is [-1] (the newest published), not [-2]. The + override must be an exact published version tag (deployed as a pinned base). (Adversary §7.1.) + + A recipe that declares the upgrade rung in EXPECTED_NA gets NO base: published versions may + exist yet be genuinely undeployable — e.g. bluesky-pds, where every published tag pins the + moving image tag `:0.4` that upstream republished with incompatible main builds, so no + published version can come up as an upgrade base (phase bsky, DECISIONS). Deploying one would + fail the INSTALL tier before the PR-head code is ever exercised. With no base, the single + deploy is the PR head itself and the upgrade tier records "skip", which derive_rungs + classifies as the DECLARED intentional skip (reason from EXPECTED_NA — visible in + results.json `skips.intentional`, never reported as a pass).""" + if "upgrade" not in stages: + return None + if "upgrade" in (meta.EXPECTED_NA or {}): + print( + "== upgrade tier: declared EXPECTED_NA['upgrade'] — no upgrade base will be " + f"deployed; the single deploy is the target/PR head. Reason: " + f"{(meta.EXPECTED_NA or {}).get('upgrade')}", + flush=True, + ) + return None + return meta.UPGRADE_BASE_VERSION or lifecycle.previous_version(recipe) + + def _truthy(v: str | None) -> bool: return str(v or "").strip().lower() in ("1", "true", "yes", "on") @@ -905,16 +937,7 @@ def main() -> int: domain = naming.app_domain(recipe, os.environ.get("PR", "0"), ref) - # Deploy-once base version: previous published version when the upgrade tier will run and one - # exists (so upgrade goes previous→target in place), else the target (current/$REF). (DECISIONS.) - # A recipe may override the base via recipe_meta UPGRADE_BASE_VERSION when the harness default - # (recipe_versions[-2]) is NOT the PR's true predecessor — e.g. a PR that adds a version ABOVE the - # newest published tag, where the correct base is [-1] (the newest published), not [-2]. The - # override must be an exact published version tag (deployed as a pinned base). (Adversary §7.1.) - want_upgrade = "upgrade" in stages - prev = ( - (meta.UPGRADE_BASE_VERSION or lifecycle.previous_version(recipe)) if want_upgrade else None - ) + prev = upgrade_base(stages, meta, recipe) base = prev or target backup_cap = generic.backup_capable(recipe, meta) hook = discovery.install_steps(recipe, repo_local) @@ -1091,7 +1114,7 @@ def main() -> int: junit_dir=junit_dir, ) if prev - else "skip" # only one published version → nothing to upgrade from + else "skip" # no upgrade base: single published version, or declared EXPECTED_NA ) # ---- BACKUP + RESTORE tiers (backup-capable only; else clean N/A) ---- if "backup" in stages: @@ -1274,7 +1297,7 @@ def main() -> int: records=records, results=results, backup_capable=backup_cap, - has_upgrade_target=prev is not None, # structural: a previous published version exists + has_upgrade_target=prev is not None, # structural: a deployable upgrade base exists lint=lint_result, # L5 rung (phase lvl5) clean_teardown=clean_teardown, no_secret_leak=True, # narrowed below by an actual scan of the serialised artifact diff --git a/tests/bluesky-pds/recipe_meta.py b/tests/bluesky-pds/recipe_meta.py index ac9f7ca..7d724ac 100644 --- a/tests/bluesky-pds/recipe_meta.py +++ b/tests/bluesky-pds/recipe_meta.py @@ -6,3 +6,17 @@ HEALTH_PATH = "/xrpc/_health" # PDS health endpoint; returns {"version": ...} o HEALTH_OK = (200,) DEPLOY_TIMEOUT = 600 HTTP_TIMEOUT = 600 + +# UPGRADE rung: published versions exist (0.1.1+v0.4, 0.2.0+v0.4) but BOTH pin the moving image +# tag ghcr.io/bluesky-social/pds:0.4, which upstream republished with main-branch builds +# (@atproto/pds 0.5.1, Node 24, /app/index.ts — no index.js), so NO published version can deploy +# as an upgrade base anymore: the base crash-loops MODULE_NOT_FOUND before the PR head is ever +# exercised (phase bsky root cause; cc-ci-plan/upstream/bluesky-pds.md). Declared intentional +# until a fixed exact-pinned version (0.3.0+v0.4.219, mirror PR #2) is merged AND published — +# then DROP this and set UPGRADE_BASE_VERSION = "0.3.0+v0.4.219" so the upgrade rung is +# exercised again from the first deployable base. +EXPECTED_NA = { + "upgrade": "no deployable upgrade base: every published version pins the moving tag " + "pds:0.4, which upstream republished with incompatible main builds (index.js removed) — " + "re-enable via UPGRADE_BASE_VERSION once a fixed version is published post-merge", +} diff --git a/tests/unit/test_upgrade_base.py b/tests/unit/test_upgrade_base.py new file mode 100644 index 0000000..b4b5c98 --- /dev/null +++ b/tests/unit/test_upgrade_base.py @@ -0,0 +1,79 @@ +"""Unit tests for `run_recipe_ci.upgrade_base` — the deploy-once base-version decision. + +Phase bsky: a recipe whose published versions ALL pin a moving image tag that upstream +republished with incompatible builds (bluesky-pds) has no deployable upgrade base — deploying +one fails the INSTALL tier before the PR head is ever exercised. The sanctioned escape hatch is +the EXISTING declared-intentional-skip mechanism: EXPECTED_NA["upgrade"] now also suppresses +the base deploy (single deploy = PR head; the tier records "skip"; derive_rungs classifies it +intentional with the declared reason). These tests lock the decision matrix; derive_rungs' +classification of the resulting skip is already covered in test_results.py. +""" + +from __future__ import annotations + +import os +import sys +from types import SimpleNamespace + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +import run_recipe_ci # noqa: E402 +from harness import lifecycle # noqa: E402 + +ALL = {"install", "upgrade", "backup", "restore", "custom"} + + +def _meta(expected_na=None, upgrade_base_version=None): + return SimpleNamespace(EXPECTED_NA=expected_na, UPGRADE_BASE_VERSION=upgrade_base_version) + + +def test_default_prev_published(monkeypatch): + # upgrade in stages, ≥2 published versions, nothing declared → recipe_versions[-2] + monkeypatch.setattr(lifecycle, "previous_version", lambda r: "0.1.0+v1") + assert run_recipe_ci.upgrade_base(ALL, _meta(), "somerecipe") == "0.1.0+v1" + + +def test_single_published_version_no_base(monkeypatch): + monkeypatch.setattr(lifecycle, "previous_version", lambda r: None) + assert run_recipe_ci.upgrade_base(ALL, _meta(), "somerecipe") is None + + +def test_upgrade_not_in_stages_no_base(monkeypatch): + monkeypatch.setattr( + lifecycle, + "previous_version", + lambda r: (_ for _ in ()).throw(AssertionError("not consulted")), + ) + assert run_recipe_ci.upgrade_base(ALL - {"upgrade"}, _meta(), "somerecipe") is None + + +def test_upgrade_base_version_override_wins(monkeypatch): + monkeypatch.setattr( + lifecycle, + "previous_version", + lambda r: (_ for _ in ()).throw(AssertionError("not consulted")), + ) + meta = _meta(upgrade_base_version="0.7.0+3.3.1") + assert run_recipe_ci.upgrade_base(ALL, meta, "discourse") == "0.7.0+3.3.1" + + +def test_expected_na_upgrade_suppresses_base(monkeypatch): + # bluesky-pds shape: published versions exist but are undeployable — declared EXPECTED_NA + # upgrade → NO base (single deploy is the PR head), even though previous_version would + # return one and even if UPGRADE_BASE_VERSION is set (the declaration is the stronger, + # documented fact). + monkeypatch.setattr(lifecycle, "previous_version", lambda r: "0.1.1+v0.4") + declared = {"upgrade": "no deployable upgrade base (moving tag republished)"} + assert run_recipe_ci.upgrade_base(ALL, _meta(expected_na=declared), "bluesky-pds") is None + assert ( + run_recipe_ci.upgrade_base( + ALL, _meta(expected_na=declared, upgrade_base_version="0.2.0+v0.4"), "bluesky-pds" + ) + is None + ) + + +def test_expected_na_other_rung_does_not_suppress(monkeypatch): + # an EXPECTED_NA for backup_restore (custom-html-tiny shape) must NOT touch the upgrade base + monkeypatch.setattr(lifecycle, "previous_version", lambda r: "0.1.0+v1") + meta = _meta(expected_na={"backup_restore": "stateless"}) + assert run_recipe_ci.upgrade_base(ALL, meta, "custom-html-tiny") == "0.1.0+v1"