All checks were successful
continuous-integration/drone/push Build is passing
Gate satisfied — live: with the pin removed, plausible's upgrade tier resolves base 3.0.1+v2.0.0 via the same-version step-back (canonical 3.1.0 == head 3.1.0 → newest-older = 3.0.1, NOT the broken 3.0.0) and passes install+upgrade green (level 5/5). The pin is redundant, so removed everywhere: - meta.py KEYS entry (RecipeMeta field auto-drops; 15→14 keys). - run_recipe_ci.resolve_upgrade_base override branch + docstrings. - tests/unit/test_meta.py (count 15→14, dropped None-assert), test_upgrade_base.py (override test). - docs/recipe-customization.md (regenerated table + mentions), docs/testing.md. - tests/plausible/recipe_meta.py (pin removed), tests/bluesky-pds (re-enable note → dynamic base). 294 unit tests pass; lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
8.6 KiB
Python
185 lines
8.6 KiB
Python
"""Unit tests for `run_recipe_ci.resolve_upgrade_base` — the DYNAMIC upgrade-base decision (phase prevb).
|
|
|
|
Resolution order: upgrade∉stages / EXPECTED_NA[upgrade] (declared skip) → last-green (warm canonical,
|
|
with same-version step-back) → target-branch (`main`) tip → skip (no predecessor). The result is a
|
|
`BasePlan(kind, version, ref, reason)`: kind ∈ {"version", "ref", "skip"}; `.runs` is True for
|
|
version/ref (the upgrade tier runs). Replaces the old static `recipe_versions[-2]` default; the old
|
|
explicit `UPGRADE_BASE_VERSION` override knob was removed in phase canon (§2.G).
|
|
"""
|
|
|
|
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
|
|
import warm_reconcile # noqa: E402
|
|
from harness import canonical, lifecycle # noqa: E402
|
|
|
|
ALL = {"install", "upgrade", "backup", "restore", "custom"}
|
|
HEAD = "aaaa1111head"
|
|
MAIN = "bbbb2222main"
|
|
|
|
|
|
def _meta(expected_na=None):
|
|
return SimpleNamespace(EXPECTED_NA=expected_na)
|
|
|
|
|
|
def _no_canonical(monkeypatch):
|
|
monkeypatch.setattr(canonical, "read_registry", lambda r: None)
|
|
|
|
|
|
def _no_main(monkeypatch):
|
|
monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": None)
|
|
|
|
|
|
def test_upgrade_not_in_stages_skip(monkeypatch):
|
|
# never consults canonical/main when upgrade isn't requested
|
|
monkeypatch.setattr(
|
|
canonical, "read_registry", lambda r: (_ for _ in ()).throw(AssertionError("not consulted"))
|
|
)
|
|
plan = run_recipe_ci.resolve_upgrade_base(ALL - {"upgrade"}, _meta(), "somerecipe")
|
|
assert plan.kind == "skip" and not plan.runs
|
|
|
|
|
|
def test_expected_na_upgrade_skip_even_with_canonical(monkeypatch):
|
|
# EXPECTED_NA[upgrade] is the strongest, declared fact — short-circuits before canonical.
|
|
monkeypatch.setattr(
|
|
canonical, "read_registry", lambda r: {"version": "9.9.9", "status": "warm"}
|
|
)
|
|
declared = {"upgrade": "no deployable upgrade base (moving tag republished)"}
|
|
plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(expected_na=declared), "bluesky-pds")
|
|
assert plan.kind == "skip" and "EXPECTED_NA" in plan.reason
|
|
|
|
|
|
def test_last_green_warm_canonical_is_primary(monkeypatch):
|
|
# no override → last-green (warm canonical version) is the PRIMARY base; main is not consulted.
|
|
monkeypatch.setattr(
|
|
canonical, "read_registry", lambda r: {"version": "0.6.0+3.1.1", "status": "idle"}
|
|
)
|
|
monkeypatch.setattr(
|
|
lifecycle,
|
|
"recipe_branch_commit",
|
|
lambda r, b="main": (_ for _ in ()).throw(AssertionError("not consulted")),
|
|
)
|
|
plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "keycloak", head_ref=HEAD)
|
|
assert plan.kind == "version" and plan.version == "0.6.0+3.1.1" and "last-green" in plan.reason
|
|
|
|
|
|
def test_main_tip_fallback_when_no_last_green(monkeypatch):
|
|
_no_canonical(monkeypatch)
|
|
monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": MAIN)
|
|
plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "discourse", head_ref=HEAD)
|
|
assert plan.kind == "ref" and plan.ref == MAIN and plan.version is None and plan.runs
|
|
|
|
|
|
def test_head_equals_main_tip_skip(monkeypatch):
|
|
# the PR head IS the main tip (empty PR / LATEST run) → no real predecessor → declared skip.
|
|
_no_canonical(monkeypatch)
|
|
monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": HEAD)
|
|
plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "discourse", head_ref=HEAD)
|
|
assert plan.kind == "skip" and "main tip" in plan.reason and not plan.runs
|
|
|
|
|
|
def test_no_canonical_no_main_skip(monkeypatch):
|
|
_no_canonical(monkeypatch)
|
|
_no_main(monkeypatch)
|
|
plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "brandnew", head_ref=HEAD)
|
|
assert plan.kind == "skip" and "no predecessor" in plan.reason
|
|
|
|
|
|
# --- phase samever: step back to an older base when canonical == head version ---
|
|
|
|
# A realistic keycloak tag list (coop-cloud "<recipe-semver>+<app-version>"), out of order on purpose
|
|
# so the test exercises the ordering, not list position.
|
|
KC_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"]
|
|
|
|
|
|
def test_canonical_equals_head_steps_back_to_newest_older(monkeypatch):
|
|
# nightly steady state: a green cold-on-latest run promoted canonical→latest, so the next night
|
|
# finds canonical == head version. The resolver must NOT deploy the same version — it steps back
|
|
# to the newest published version STRICTLY OLDER than the head.
|
|
head_v = "10.8.0+26.6.3" # == canonical == latest tag
|
|
monkeypatch.setattr(canonical, "read_registry", lambda r: {"version": head_v, "status": "warm"})
|
|
monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: KC_TAGS)
|
|
# main must never be consulted on the step-back (canonical path owns the decision)
|
|
monkeypatch.setattr(
|
|
lifecycle,
|
|
"recipe_branch_commit",
|
|
lambda r, b="main": (_ for _ in ()).throw(AssertionError("not consulted")),
|
|
)
|
|
plan = run_recipe_ci.resolve_upgrade_base(
|
|
ALL, _meta(), "keycloak", head_ref=HEAD, head_version=head_v
|
|
)
|
|
assert plan.kind == "version" and plan.runs
|
|
assert plan.version == "10.7.1+26.6.2" # newest tag strictly older than 10.8.0+26.6.3
|
|
# strictly older than head (the load-bearing invariant)
|
|
assert warm_reconcile.version_key(plan.version) < warm_reconcile.version_key(head_v)
|
|
assert "step-back" in plan.reason
|
|
|
|
|
|
def test_canonical_differs_from_head_uses_canonical_unchanged(monkeypatch):
|
|
# the common version-bump case: canonical (older) ≠ head version → primary base unchanged; the
|
|
# step-back never triggers, recipe_tags never consulted.
|
|
monkeypatch.setattr(
|
|
canonical, "read_registry", lambda r: {"version": "10.7.1+26.6.2", "status": "warm"}
|
|
)
|
|
monkeypatch.setattr(
|
|
warm_reconcile,
|
|
"recipe_tags",
|
|
lambda r: (_ for _ in ()).throw(AssertionError("step-back not taken")),
|
|
)
|
|
plan = run_recipe_ci.resolve_upgrade_base(
|
|
ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
|
|
)
|
|
assert plan.kind == "version" and plan.version == "10.7.1+26.6.2"
|
|
assert "last-green" in plan.reason
|
|
|
|
|
|
def test_canonical_equals_head_no_older_published_skips(monkeypatch):
|
|
# canonical == head and it is the ONLY (oldest) published version → genuinely no predecessor →
|
|
# declared skip with the samever reason (never a same-version no-op).
|
|
head_v = "1.0.0+3.5.3"
|
|
monkeypatch.setattr(canonical, "read_registry", lambda r: {"version": head_v, "status": "warm"})
|
|
monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: [head_v]) # only the head version
|
|
plan = run_recipe_ci.resolve_upgrade_base(
|
|
ALL, _meta(), "discourse", head_ref=HEAD, head_version=head_v
|
|
)
|
|
assert plan.kind == "skip" and not plan.runs
|
|
assert "no older published predecessor" in plan.reason
|
|
|
|
|
|
def test_no_head_version_preserves_canonical_primary(monkeypatch):
|
|
# head_version unreadable (None) → cannot compare → preserve prevb behavior: canonical is primary,
|
|
# never a step-back (no regression for callers that don't pass head_version).
|
|
monkeypatch.setattr(
|
|
canonical, "read_registry", lambda r: {"version": "10.8.0+26.6.3", "status": "warm"}
|
|
)
|
|
monkeypatch.setattr(
|
|
warm_reconcile,
|
|
"recipe_tags",
|
|
lambda r: (_ for _ in ()).throw(AssertionError("step-back not taken")),
|
|
)
|
|
plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "keycloak", head_ref=HEAD)
|
|
assert plan.kind == "version" and plan.version == "10.8.0+26.6.3"
|
|
|
|
|
|
def test_newest_older_version_ordering():
|
|
# the ordering helper picks the correct strictly-older tag and excludes the equal one.
|
|
assert warm_reconcile.newest_older_version(KC_TAGS, "10.8.0+26.6.3") == "10.7.1+26.6.2"
|
|
assert warm_reconcile.newest_older_version(KC_TAGS, "10.7.0+26.6.0") == "10.6.0+26.5.0"
|
|
assert warm_reconcile.newest_older_version(KC_TAGS, "10.6.0+26.5.0") is None # oldest → none
|
|
assert warm_reconcile.newest_older_version(KC_TAGS, None) is None
|
|
|
|
|
|
def test_expected_na_other_rung_does_not_suppress_upgrade(monkeypatch):
|
|
# an EXPECTED_NA for a DIFFERENT rung (backup_restore) must NOT short-circuit the upgrade base —
|
|
# resolution proceeds to last-green/main-tip (custom-html-tiny shape).
|
|
_no_canonical(monkeypatch)
|
|
monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": MAIN)
|
|
meta = _meta(expected_na={"backup_restore": "stateless"})
|
|
plan = run_recipe_ci.resolve_upgrade_base(ALL, meta, "custom-html-tiny", head_ref=HEAD)
|
|
assert plan.kind == "ref" and plan.ref == MAIN
|