engine-major gate: no database-engine pin crosses a MAJOR until Slice 4 (R-448) ships
The rule (CLAUDE.md, operator ruling 2026-09-13): until the Update button takes a verified backup as its precondition, no template may move a mariadb:/postgres: image across a major version. Four MariaDB and eleven PostgreSQL services; the gate finds them by image name, not by a list. scripts/check-engine-major.py — fast (git reads only), diffs each changed template's per-service image: line between the two ends of the push range, refuses a major move naming the rule and its expiry (R-448). Fourth row of catalog_gates.py; .githooks/pre-push now hands the push range through as --range=<remote sha>..<local sha>. HONEST LIMIT: it needs a parent commit and CI fetches at --depth 1 (the R-452 gap, not re-filed), so on a shallow clone the runner SKIPS it out loud instead of reddening every CI push. The hook, which has the full clone, is where it bites. Red-proof (scripts/test_gate_decoys.py, 7 cases, all seen to judge correctly): mariadb 11.6->12.3 REFUSED, postgres 16->17 REFUSED, mariadb:lts INCONCLUSIVE; 11.6->11.8 PASSES; the major moving only in a comment / kimai's serverVersion env / README / the app's own image PASSES. COVERS literal registered for felhom.eu's decoy_coverage_gate (which now reads 1 covered, 3 exempt, 0 unaccounted). test_catalog_gates.py pins the four-gate table and the announced shallow-clone skip. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -5,14 +5,21 @@
|
||||
python3 scripts/catalog_gates.py # every AVAILABLE app, all three gates
|
||||
python3 scripts/catalog_gates.py papra wishlist # only these app dirs (the normal case)
|
||||
python3 scripts/catalog_gates.py --all # include hidden/abandoned apps too
|
||||
python3 scripts/catalog_gates.py --fast # gate 1 only — no network, no containers;
|
||||
# this is what .githooks/pre-push runs
|
||||
python3 scripts/catalog_gates.py --fast # static gates only — no network, no
|
||||
# containers; this is what .githooks/pre-push runs
|
||||
python3 scripts/catalog_gates.py --fast --range=<A>..<B> # the hook passes the push range
|
||||
# through to the gate that diffs commits
|
||||
|
||||
Gates, in order (all must pass; **non-zero exit on any failure**):
|
||||
|
||||
1. image-pins static, instant, whole repo — no :latest / untagged / floating alias
|
||||
2. image-resolvable network — every pinned tag still EXISTS upstream
|
||||
3. volume-persistence RUNTIME — the folder a template preserves is the folder the app writes to
|
||||
4. engine-major static, needs GIT HISTORY — no database engine pin crosses a MAJOR version
|
||||
(operator ruling 2026-09-13; expires when Slice 4 / R-448 ships). Runs in the
|
||||
pre-push hook, which has the full clone; on a SHALLOW clone (CI fetches at
|
||||
--depth 1 — the R-452 gap) it is SKIPPED and the skip is printed, because a
|
||||
gate that reddens every CI push gets bypassed within a week.
|
||||
|
||||
WHY THIS FILE EXISTS (operator ruling, 2026-08-02 — R-161).
|
||||
|
||||
@@ -51,7 +58,11 @@ import sys
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SCRIPTS = os.path.join(ROOT, "scripts")
|
||||
|
||||
# (label, filename, accepts_app_scope, fast)
|
||||
# (label, filename, accepts_app_scope, fast, takes_range)
|
||||
#
|
||||
# `takes_range` = the gate diffs two commits and is handed `--range=<A>..<B>` when the runner was
|
||||
# given one (the pre-push hook computes it from the refs git feeds it). Without a range the gate
|
||||
# defaults to origin/main..HEAD. It cannot run on a shallow clone — see the skip in main().
|
||||
#
|
||||
# `fast` = touches NO network and NO container runtime, so it is safe to run on every push.
|
||||
# image-resolvable talks to registries and volume-persistence deploys containers for minutes per
|
||||
@@ -60,9 +71,10 @@ SCRIPTS = os.path.join(ROOT, "scripts")
|
||||
# catalog campaign, before a publish train that vouches the catalog, whenever a template's
|
||||
# volumes: block or image tag changes) — on a scratch host, never a customer box.
|
||||
GATES = [
|
||||
("image-pins", "check-image-pins.py", False, True),
|
||||
("image-resolvable", "check-image-resolvable.py", True, False),
|
||||
("volume-persistence", "check-volume-persistence.py", True, False),
|
||||
("image-pins", "check-image-pins.py", False, True, False),
|
||||
("image-resolvable", "check-image-resolvable.py", True, False, False),
|
||||
("volume-persistence", "check-volume-persistence.py", True, False, False),
|
||||
("engine-major", "check-engine-major.py", False, True, True),
|
||||
]
|
||||
|
||||
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
||||
@@ -81,11 +93,22 @@ def run_gate(label, script, args):
|
||||
return subprocess.call([sys.executable, path] + args, cwd=ROOT)
|
||||
|
||||
|
||||
def is_shallow():
|
||||
p = subprocess.run(["git", "rev-parse", "--is-shallow-repository"], cwd=ROOT,
|
||||
capture_output=True, text=True)
|
||||
return p.returncode == 0 and p.stdout.strip() == "true"
|
||||
|
||||
|
||||
def main(argv):
|
||||
include_hidden = "--all" in argv
|
||||
fast = "--fast" in argv
|
||||
rng = ""
|
||||
for a in argv:
|
||||
if a.startswith("--range="):
|
||||
rng = a[len("--range="):]
|
||||
apps = [a for a in argv if not a.startswith("-")]
|
||||
unknown = [a for a in argv if a.startswith("-") and a not in ("--all", "--fast")]
|
||||
unknown = [a for a in argv if a.startswith("-") and a not in ("--all", "--fast")
|
||||
and not a.startswith("--range=")]
|
||||
if unknown:
|
||||
print("unknown option(s): %s" % " ".join(unknown))
|
||||
print(__doc__.strip().splitlines()[0])
|
||||
@@ -99,6 +122,20 @@ def main(argv):
|
||||
|
||||
selected = [g for g in GATES if g[3] or not fast]
|
||||
skipped = [g[0] for g in GATES if not (g[3] or not fast)]
|
||||
# engine-major needs a parent commit. The CI runner fetches at --depth 1 (the R-452 gap), so on
|
||||
# a shallow clone it is skipped OUT LOUD rather than convicting every push it cannot judge — the
|
||||
# pre-push hook, which has the full clone, is where it bites. A silent skip would be the R-421
|
||||
# shape (a gate named in the table that never runs), so the skip is announced and pinned by
|
||||
# test_catalog_gates.py.
|
||||
shallow = is_shallow()
|
||||
if shallow:
|
||||
needs_history = [g[0] for g in selected if g[4]]
|
||||
selected = [g for g in selected if not g[4]]
|
||||
if needs_history:
|
||||
print(" SHALLOW CLONE — SKIPPED: %s — it diffs an image: line against the parent commit\n"
|
||||
" and this clone has none (the CI runner fetches at --depth 1; R-452). It is\n"
|
||||
" enforced by .githooks/pre-push, which runs on the full clone. NOT a pass — a\n"
|
||||
" cross-major engine move is caught at push time, not here." % ", ".join(needs_history))
|
||||
if skipped:
|
||||
print(" --fast SKIPPED: %s — they need network and a container runtime and take minutes\n"
|
||||
" per app, so they are NEVER in a hook. They remain deliberate periodic runs: start\n"
|
||||
@@ -106,12 +143,14 @@ def main(argv):
|
||||
" changes. Run them with no --fast, on a scratch host." % ", ".join(skipped))
|
||||
|
||||
results = []
|
||||
for label, script, scoped, _f in selected:
|
||||
for label, script, scoped, _f, takes_range in selected:
|
||||
args = []
|
||||
if include_hidden:
|
||||
args.append("--all")
|
||||
if scoped and apps:
|
||||
args += apps
|
||||
if takes_range and rng:
|
||||
args.append("--range=" + rng)
|
||||
results.append((label, run_gate(label, script, args)))
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
|
||||
Reference in New Issue
Block a user