diff --git a/runner/run_recipe_ci.py b/runner/run_recipe_ci.py index 83514fc..19d8167 100644 --- a/runner/run_recipe_ci.py +++ b/runner/run_recipe_ci.py @@ -151,8 +151,30 @@ def resolve_upgrade_base( flush=True, ) return BasePlan("skip", None, None, f"declared EXPECTED_NA[upgrade]: {declared}") + # UPGRADE_BASE_FLOOR (phase basefloor): a recipe_meta declaration marking a STRUCTURAL breaking + # boundary — published versions strictly below the floor are not valid in-place upgrade sources + # (e.g. discourse 0.8.x→1.0.0 changed the db family bitnami/pgvector → discourse/postgres; the + # data layout+roles are incompatible, upstream supports no in-place path across it). This is NOT + # the removed UPGRADE_BASE_VERSION pin (§2.G): resolution stays fully dynamic — the floor only + # EXCLUDES structurally-impossible bases, and when no candidate ≥ floor exists the tier records + # a DECLARED skip (never a silent pass). Never weakens: below-floor upgrades were never a + # supported path, so no real coverage is lost. + floor = getattr(meta, "UPGRADE_BASE_FLOOR", None) + + def _below_floor(version: str) -> bool: + return bool(floor) and warm_reconcile.version_key(version) < warm_reconcile.version_key( + floor + ) + skip_canonicals = settings_mod.get().skip_canonicals_for_upgrade rec = canonical.read_registry(recipe) + if rec and rec.get("version") and not skip_canonicals and _below_floor(rec["version"]): + print( + f"== upgrade tier: last-green canonical {rec['version']} is below the declared " + f"UPGRADE_BASE_FLOOR {floor} (structural break) — excluded as a base", + flush=True, + ) + rec = None if rec and rec.get("version") and not skip_canonicals: canon = rec["version"] same = head_version is not None and warm_reconcile.version_key( @@ -168,10 +190,20 @@ def resolve_upgrade_base( f"last-green (warm canonical, status={rec.get('status')})", ) # canonical == head version → deploying it would be a same-version no-op. Step back to the - # newest published version strictly older than the head (phase samever). - older = warm_reconcile.newest_older_version( - warm_reconcile.recipe_tags(recipe), head_version - ) + # newest published version strictly older than the head (phase samever). Candidates below a + # declared UPGRADE_BASE_FLOOR are excluded (phase basefloor — structurally invalid bases). + _tags = warm_reconcile.recipe_tags(recipe) + if floor: + _tags = [t for t in _tags if not _below_floor(t)] + older = warm_reconcile.newest_older_version(_tags, head_version) + if older is None and floor: + return BasePlan( + "skip", + None, + None, + f"declared UPGRADE_BASE_FLOOR {floor}: no published predecessor ≥ floor below " + f"head {head_version} (all older tags cross a structural break)", + ) if older: return BasePlan( "version", @@ -189,10 +221,12 @@ def resolve_upgrade_base( # No canonical in play — none recorded, OR SKIP_CANONICALS_FOR_UPGRADE=true (canonical lookup # bypassed entirely, behaving as if none exists). Improved fallback (phase settings §2.C): prefer # a REAL published predecessor (newest release tag < head) over the raw main-tip. - return _no_canonical_base(recipe, head_ref, head_version) + return _no_canonical_base(recipe, head_ref, head_version, floor=floor) -def _no_canonical_base(recipe: str, head_ref: str | None, head_version: str | None) -> BasePlan: +def _no_canonical_base( + recipe: str, head_ref: str | None, head_version: str | None, floor: str | None = None +) -> BasePlan: """Upgrade base when no canonical is used (none recorded, its promote failed, or SKIP_CANONICALS_FOR_UPGRADE is true). Release-tag-first fallback (phase settings §2.C): 1. most recent release TAG with version strictly older than the PR head — a clean published @@ -202,11 +236,14 @@ def _no_canonical_base(recipe: str, head_ref: str | None, head_version: str | No 3. skip — no predecessor (no older tag and head == main-tip, or no main at all). This replaces the old jump-straight-to-main-tip path, so an un-promoted recipe upgrades from a real release base instead of a possibly-untagged WIP commit.""" - older = ( - warm_reconcile.newest_older_version(warm_reconcile.recipe_tags(recipe), head_version) - if head_version - else None - ) + _tags = warm_reconcile.recipe_tags(recipe) + if floor: + # phase basefloor: exclude structurally-invalid bases below the declared floor; the + # main-tip fallback below remains available (it is post-break by definition of the + # declaration — the floor names the first post-break published version). + _fk = warm_reconcile.version_key(floor) + _tags = [t for t in _tags if warm_reconcile.version_key(t) >= _fk] + older = warm_reconcile.newest_older_version(_tags, head_version) if head_version else None if older: return BasePlan( "version", diff --git a/tests/discourse/recipe_meta.py b/tests/discourse/recipe_meta.py index 0334ad4..92d6c6c 100644 --- a/tests/discourse/recipe_meta.py +++ b/tests/discourse/recipe_meta.py @@ -23,11 +23,21 @@ HTTP_TIMEOUT = 1200 # # UPGRADE-tier BASE (phase prevb — DYNAMIC, no hardcoded UPGRADE_BASE_VERSION): the base the head # upgrades from is resolved at run time — last-green (warm canonical) → fallback target-branch (`main`) -# tip → else skip (run_recipe_ci.resolve_upgrade_base). discourse has no warm canonical, so the base is -# the `main` tip = bitnamilegacy/discourse:3.5.0, which deploys clean (bitnamilegacy exists) with NO -# `previous/` repair needed. The PR head (recipe-maintainers/discourse#4) switches app to the official -# `discourse/discourse:3.5.3` and drops the sidekiq service, so the upgrade tier now exercises the REAL -# bitnamilegacy→official image migration the PR claims to support. +# tip → else skip (run_recipe_ci.resolve_upgrade_base). +# +# UPGRADE_BASE_FLOOR (phase basefloor, 2026-08-04): the 0.8.x→1.0.0 recipe family switched the app +# bitnamilegacy/discourse → official discourse/discourse AND the db pgvector/pgvector:pg17 → +# discourse/postgres:pg18. That db-family change is a structural break: the bitnami cluster has no +# `discourse` role and pg_upgrade preserves-not-creates roles, so an in-place 0.8.x→1.x deploy can +# NEVER converge (app FATALs `role "discourse" does not exist`, swarm rolls back) — upstream ships +# no in-place path across it. Without the floor, the resolver's step-back/fallback selected +# 0.8.1+3.5.0 (newest tag below the head label) and the upgrade tier red'd on this unsupported +# path twice (drone #1165 2026-07-31 diagnosis, #1171/weekly 2026-08-03 — both classified +# stale-test, recipe verified green on the real official→official path). Declaring the floor keeps +# resolution dynamic and only excludes the structurally-impossible bases; when no ≥-floor +# predecessor exists the tier records a DECLARED skip (never a silent pass). No assertion weakened: +# below-floor in-place upgrades were never supported coverage. +UPGRADE_BASE_FLOOR = "1.0.0+3.5.3" # # compose.ccci.yml is now the ENVIRONMENTAL overlay (all deploys): only app.deploy.update_config.order: # stop-first (node memory reality on the upgrade crossover — see its header). The version-specific