feat(samever): step back to older base when last-green canonical == head version

resolve_upgrade_base now reads the head's published version (abra.head_compose_version,
the coop-cloud.<stack>.version label) and, when the last-green warm-canonical version
equals it, steps back to the newest published version strictly older than head instead
of deploying a same-version no-op. warm_reconcile gains version_key + newest_older_version
(single coop-cloud ordering source; sort_versions refactored onto version_key, no behavior
change). Skip only when no older published predecessor exists. Step-back returns kind=version
so it inherits F1d-2 pinned-tag checkout. Extends tests/unit/test_upgrade_base.py (13 pass).
This commit is contained in:
autonomic-bot
2026-06-17 04:24:14 +00:00
parent 279d84d229
commit b29bb3f804
4 changed files with 175 additions and 9 deletions

View File

@ -11,6 +11,7 @@ from __future__ import annotations
import json
import os
import re
import subprocess
ABRA = "abra"
@ -34,6 +35,29 @@ def recipe_dir(recipe: str) -> str:
return os.path.join(abra_dir(), "recipes", recipe)
_VERSION_LABEL_RE = re.compile(r"coop-cloud\.[^.\s]*\.version=([^\s\"']+)")
def head_compose_version(recipe: str) -> str | None:
"""The published version of the recipe's on-disk compose.yml (the head checkout under test):
the value of the `coop-cloud.<stack>.version` label, e.g. "1.0.0+3.5.3". None if the file is
unreadable or carries no version label.
Used by the upgrade-base resolver (phase samever) to compare the head's declared version against
the last-green warm-canonical version: when they are equal, deploying the canonical as the base
would be a vacuous same-version no-op, so the resolver steps back to an older published version.
`${STACK_NAME}` is a literal in the file (abra interpolates it at deploy time, not on disk), so
the regex matches it as the stack segment without needing interpolation."""
path = os.path.join(recipe_dir(recipe), "compose.yml")
try:
with open(path) as f:
text = f.read()
except OSError:
return None
m = _VERSION_LABEL_RE.search(text)
return m.group(1).strip() if m else None
def _run_pty(
args: list[str], timeout: int = 900, check: bool = True
) -> subprocess.CompletedProcess:

View File

@ -42,6 +42,7 @@ from typing import NamedTuple
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "runner"))
import warm_reconcile # noqa: E402 (runner/ is on sys.path; owns coop-cloud version ordering)
from harness import ( # noqa: E402
abra,
canonical,
@ -108,11 +109,21 @@ class BasePlan(NamedTuple):
return self.kind in ("version", "ref")
def resolve_upgrade_base(stages, meta, recipe: str, head_ref: str | None = None) -> BasePlan:
def resolve_upgrade_base(
stages, meta, recipe: str, head_ref: str | None = None, head_version: str | None = None
) -> BasePlan:
"""Dynamic upgrade-base resolution (phase prevb, replaces the static `recipe_versions[-2]`
default). Order: explicit override → last-green (warm canonical) → target-branch (main) tip →
skip. EXPECTED_NA[upgrade] / upgrade∉stages short-circuit to a declared skip first.
`head_version` is the head checkout's published version (the `coop-cloud.<stack>.version` label;
see abra.head_compose_version). When the last-green warm-canonical version EQUALS it, deploying
the canonical as the base would be a vacuous same-version no-op, so the resolver STEPS BACK to the
newest published version strictly older than the head (phase samever) — the upgrade tier always
crosses a real version delta. This is the nightly STEADY STATE: a green cold-on-latest run promotes
canonical→latest, so the next night finds canonical == head and must step back. Skip only when no
older published predecessor exists.
last-green is the PRIMARY base — the version cc-ci last recorded green for this recipe (the
warm-canonical registry record). main-tip is the FALLBACK: the recipe repo's `main` HEAD, the
real predecessor the PR merges on top of, used when there is no last-green. Else the tier is
@ -135,11 +146,37 @@ def resolve_upgrade_base(stages, meta, recipe: str, head_ref: str | None = None)
return BasePlan("version", override, None, "explicit UPGRADE_BASE_VERSION override")
rec = canonical.read_registry(recipe)
if rec and rec.get("version"):
canon = rec["version"]
same = head_version is not None and warm_reconcile.version_key(
canon
) == warm_reconcile.version_key(head_version)
if not same:
# canonical ≠ head version (the common version-bump PR / nightly-with-new-version case):
# the green-verified primary base, unchanged from prevb.
return BasePlan(
"version",
canon,
None,
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
)
if older:
return BasePlan(
"version",
older,
None,
f"step-back: last-green canonical ({canon}) == head version {head_version}; "
f"newest older published base",
)
return BasePlan(
"version",
rec["version"],
"skip",
None,
f"last-green (warm canonical, status={rec.get('status')})",
None,
f"base == head ({head_version}) and no older published predecessor",
)
main_tip = lifecycle.recipe_branch_commit(recipe, "main")
if main_tip and main_tip != head_ref:
@ -983,7 +1020,10 @@ def main() -> int:
domain = naming.app_domain(recipe, os.environ.get("PR", "0"), ref)
base_plan = resolve_upgrade_base(stages, meta, recipe, head_ref=head_ref)
head_version = abra.head_compose_version(recipe)
base_plan = resolve_upgrade_base(
stages, meta, recipe, head_ref=head_ref, head_version=head_version
)
prev = base_plan.runs # gates the upgrade tier
# base deploy target: a pinned published version (kind=version) or main-tip commit (kind=ref);
# on skip fall back to the run's VERSION/head (target=None → chaos head deploy, as before).

View File

@ -145,14 +145,31 @@ def is_version_tag(tag: str) -> bool:
return bool(_VER_RE.match(tag.strip()))
def version_key(tag: str):
"""Ordering key for a coop-cloud version tag: (recipe-semver tuple, app-version tuple). The single
source of version ordering — sort_versions / newest_older_version both key on this so the
upgrade-base step-back (phase samever) never hand-rolls semver comparison."""
recipe, _, app = tag.partition("+")
return (_numtuple(recipe), _numtuple(app))
def sort_versions(tags) -> list[str]:
"""Sort coop-cloud version tags ascending by (recipe-semver tuple, app-version tuple)."""
return sorted([t for t in tags if is_version_tag(t)], key=version_key)
def key(t: str):
recipe, _, app = t.partition("+")
return (_numtuple(recipe), _numtuple(app))
return sorted([t for t in tags if is_version_tag(t)], key=key)
def newest_older_version(tags, version: str | None) -> str | None:
"""The newest published version tag STRICTLY OLDER than `version` (coop-cloud ordering), or None
when no such tag exists. Used by the upgrade-base resolver (phase samever): when the last-green
warm-canonical version equals the head version, the resolver steps back to this older base so the
upgrade tier always crosses a real version delta instead of deploying a same-version no-op.
"Strictly older" excludes any tag whose ordering key equals the head's (so a re-published or
differently-formatted equal version is never chosen)."""
if not version:
return None
target = version_key(version)
older = [t for t in sort_versions(tags) if version_key(t) < target]
return older[-1] if older else None
def _numtuple(s: str) -> tuple: