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:
2026-09-13 09:45:35 +02:00
parent eec1228dc8
commit bd328307d4
7 changed files with 508 additions and 15 deletions
+47 -8
View File
@@ -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)