feat(bsky): EXPECTED_NA['upgrade'] suppresses the upgrade-tier base deploy — single deploy = PR head; bluesky-pds declares it (no deployable base: every published tag pins the republished moving :0.4). upgrade_base() extracted pure + 6 unit tests; meta-key doc regenerated. 253 unit tests + repo lint PASS
This commit is contained in:
@ -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. |
|
| `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. |
|
| `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. |
|
| `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}`. |
|
| `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]`). |
|
| `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. |
|
| `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. |
|
||||||
|
|||||||
@ -76,7 +76,7 @@ KEYS: tuple[Key, ...] = (
|
|||||||
"EXPECTED_NA",
|
"EXPECTED_NA",
|
||||||
"dict",
|
"dict",
|
||||||
None,
|
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(
|
Key(
|
||||||
"READY_PROBE",
|
"READY_PROBE",
|
||||||
|
|||||||
@ -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
|
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:
|
def _truthy(v: str | None) -> bool:
|
||||||
return str(v or "").strip().lower() in ("1", "true", "yes", "on")
|
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)
|
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
|
prev = upgrade_base(stages, meta, recipe)
|
||||||
# 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
|
|
||||||
)
|
|
||||||
base = prev or target
|
base = prev or target
|
||||||
backup_cap = generic.backup_capable(recipe, meta)
|
backup_cap = generic.backup_capable(recipe, meta)
|
||||||
hook = discovery.install_steps(recipe, repo_local)
|
hook = discovery.install_steps(recipe, repo_local)
|
||||||
@ -1091,7 +1114,7 @@ def main() -> int:
|
|||||||
junit_dir=junit_dir,
|
junit_dir=junit_dir,
|
||||||
)
|
)
|
||||||
if prev
|
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) ----
|
# ---- BACKUP + RESTORE tiers (backup-capable only; else clean N/A) ----
|
||||||
if "backup" in stages:
|
if "backup" in stages:
|
||||||
@ -1274,7 +1297,7 @@ def main() -> int:
|
|||||||
records=records,
|
records=records,
|
||||||
results=results,
|
results=results,
|
||||||
backup_capable=backup_cap,
|
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)
|
lint=lint_result, # L5 rung (phase lvl5)
|
||||||
clean_teardown=clean_teardown,
|
clean_teardown=clean_teardown,
|
||||||
no_secret_leak=True, # narrowed below by an actual scan of the serialised artifact
|
no_secret_leak=True, # narrowed below by an actual scan of the serialised artifact
|
||||||
|
|||||||
@ -6,3 +6,17 @@ HEALTH_PATH = "/xrpc/_health" # PDS health endpoint; returns {"version": ...} o
|
|||||||
HEALTH_OK = (200,)
|
HEALTH_OK = (200,)
|
||||||
DEPLOY_TIMEOUT = 600
|
DEPLOY_TIMEOUT = 600
|
||||||
HTTP_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",
|
||||||
|
}
|
||||||
|
|||||||
79
tests/unit/test_upgrade_base.py
Normal file
79
tests/unit/test_upgrade_base.py
Normal file
@ -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"
|
||||||
Reference in New Issue
Block a user