From 5086b2f8bb85d134397802d4cbe093817c42423d Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Tue, 4 Aug 2026 17:15:37 +0000 Subject: [PATCH 1/3] =?UTF-8?q?test(discourse):=20UPGRADE=5FBASE=5FFLOOR?= =?UTF-8?q?=20=E2=80=94=20exclude=20structurally-invalid=20upgrade=20bases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.8.x->1.0.0 discourse recipe family switched app bitnami->official AND db pgvector/pg17->discourse/postgres:pg18. That db-family change is a structural break (bitnami cluster lacks the discourse role; pg_upgrade preserves-not-creates roles) with NO supported in-place path. The dynamic base resolver's step-back kept selecting 0.8.1+3.5.0 (newest tag below the unbumped 1.0.0+3.5.3 label) and the upgrade tier red'd twice on this unsupported path (drone #1165, #1171 — classified stale-test both times; recipe verified green on the real official->official path). Adds UPGRADE_BASE_FLOOR (phase basefloor) to resolve_upgrade_base: a recipe_meta declaration naming the first post-break published version. Resolution stays fully dynamic (this is NOT the removed sec2.G static pin): the floor only EXCLUDES below-floor candidates (canonical, step-back, no-canonical fallback); when no >=floor predecessor exists the tier records a DECLARED skip, never a silent pass. main-tip fallback unaffected (post-break by construction). tests/discourse/recipe_meta.py declares UPGRADE_BASE_FLOOR = 1.0.0+3.5.3 with the full rationale. Unit-verified: head=1.0.0+3.5.3 -> declared skip (was: 0.8.1+3.5.0 wrong pick); post-release head=2026.x -> base 1.0.0+3.5.3 (the real migration path). No assertion weakened - below-floor in-place upgrades were never supported coverage. --- runner/run_recipe_ci.py | 59 +++++++++++++++++++++++++++------- tests/discourse/recipe_meta.py | 20 +++++++++--- 2 files changed, 63 insertions(+), 16 deletions(-) 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 From ae40545491dd74453111e9a5ceb1fb6041ed9cf5 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Tue, 4 Aug 2026 17:37:02 +0000 Subject: [PATCH 2/3] meta: register UPGRADE_BASE_FLOOR key (phase basefloor) The strict recipe_meta key registry rejected the new declaration (caught by verify run 1). str-typed, default None; full semantics documented on the key. --- runner/harness/meta.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/runner/harness/meta.py b/runner/harness/meta.py index 947af37..19fd451 100644 --- a/runner/harness/meta.py +++ b/runner/harness/meta.py @@ -132,6 +132,17 @@ KEYS: tuple[Key, ...] = ( "Callable `(ctx)` invoked after UPGRADE_EXTRA_ENV env_set but before `abra secret generate --all` in the upgrade path. Use to pre-insert secrets that `generate --all` would produce with wrong format (e.g. when the .env.sample spec is commented out).", hook_params=("ctx",), ), + Key( + "UPGRADE_BASE_FLOOR", + "str", + None, + "Declared STRUCTURAL breaking boundary for the upgrade tier (phase basefloor): the first " + "post-break published version tag. Bases strictly below it are excluded from resolution " + "(canonical / step-back / no-canonical fallback) because an in-place upgrade across the " + "boundary is not supported upstream (e.g. a db-family change). When no ≥-floor predecessor " + "exists the tier records a DECLARED skip. NOT a static base pin (§2.G stays removed) — " + "resolution remains dynamic above the floor.", + ), # (CHAOS_BASE_DEPLOY, OIDC_AT_INSTALL and SKIP_GENERIC were deleted in restructure P2: # compose.ccci.yml is first-class + auto-chaos; install-time deps wiring is the only mode; # the generic floor is suppressible only via the dev-only CCCI_SKIP_GENERIC* env form.) From 877aea3814215759a917ae667f6aee276802b487 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Tue, 4 Aug 2026 17:46:33 +0000 Subject: [PATCH 3/3] test(discourse): version-agnostic official-image assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_head_runs_official_image_not_bitnamilegacy hardcoded the migration-era pin discourse/discourse:3.5.3 and went stale on the first legitimate app bump (2026.7.1, weekly 2026-08-03 — caught by verify run 2: the upgrade converged, head image was discourse/discourse:2026.7.1, only the frozen pin failed). The guarded property is the image FAMILY (official vs bitnamilegacy), not a frozen version — now asserts the discourse/discourse: prefix. Not weakened: the bitnami-leak check + official-prefix check together still assert exactly the migration faithfulness; the concrete head pin is exercised by the deploy. --- tests/discourse/test_upgrade.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/discourse/test_upgrade.py b/tests/discourse/test_upgrade.py index b5bc690..eb1c7b3 100644 --- a/tests/discourse/test_upgrade.py +++ b/tests/discourse/test_upgrade.py @@ -6,7 +6,11 @@ migration was never tested. With the version-specific config removed from the al and the dynamic base (last-green/main = bitnamilegacy:3.5.0) deployed only as the *base*, the upgrade chaos redeploy must land the PR head UNMODIFIED. This overlay asserts exactly that, post-upgrade: - 1. the running `app` service image IS the official discourse/discourse:3.5.3 — NOT bitnamilegacy; + 1. the running `app` service image IS from the official `discourse/discourse` repository — + NOT bitnamilegacy. (Version-agnostic since 2026-08-04: the original assertion hardcoded the + migration-era pin `:3.5.3` and went stale on the first legitimate app bump (2026.7.1, weekly + 2026-08-03). The property this test guards is the IMAGE FAMILY — official vs bitnami — not a + frozen version; the exact head pin is already exercised by the deploy itself.) 2. the `sidekiq` service the PR deletes is GONE from the deployed stack. If either fails, the head did not really run (the overlay leaked onto it) → RED. Assertion-only, @@ -26,8 +30,8 @@ def test_head_runs_official_image_not_bitnamilegacy(live_app): f"app image is {image!r} — the bitnamilegacy base leaked onto the PR head " "(the version-specific overlay was applied to the head, the prevb bug)" ) - assert image.startswith("discourse/discourse:3.5.3"), ( - f"app image is {image!r}, expected the PR head's official discourse/discourse:3.5.3 " + assert image.startswith("discourse/discourse:"), ( + f"app image is {image!r}, expected the PR head's official discourse/discourse image " "— the head's image migration was not exercised" )