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
+30 -2
View File
@@ -58,8 +58,36 @@ class CatalogGatesFastTest(unittest.TestCase):
spec = importlib.util.spec_from_file_location("catalog_gates_under_test", ENTRY)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
self.assertEqual(len(mod.GATES), 3)
self.assertEqual([g[0] for g in mod.GATES if g[3]], ["image-pins"])
self.assertEqual(len(mod.GATES), 4)
self.assertEqual([g[0] for g in mod.GATES if g[3]], ["image-pins", "engine-major"])
# exactly one gate needs git history; it is the one the CI half cannot run (R-452)
self.assertEqual([g[0] for g in mod.GATES if g[4]], ["engine-major"])
def test_engine_major_ran_under_fast(self):
"""The 2026-09-13 gate is fast (git reads only) and must be IN --fast, or the hook that
exists to enforce its rule never runs it."""
self.assertIn("engine-major gate", self.out)
def test_shallow_clone_skips_engine_major_out_loud(self):
"""On a --depth 1 clone (what CI has) the runner must SKIP engine-major and SAY so — never
convict every push, never pass silently. Asserted on a real shallow clone of this repo."""
import shutil, tempfile
tmp = tempfile.mkdtemp(prefix="catalog-shallow-")
try:
subprocess.run(["git", "clone", "-q", "--depth", "1", "file://" + ROOT, tmp],
check=True, capture_output=True)
# test the WORKING-TREE runner and gate, not whatever HEAD happens to hold
for fn in ("catalog_gates.py", "check-engine-major.py", "check-image-pins.py"):
shutil.copy(os.path.join(ROOT, "scripts", fn), os.path.join(tmp, "scripts", fn))
p = subprocess.run([sys.executable, os.path.join(tmp, "scripts", "catalog_gates.py"),
"--fast"], cwd=tmp, capture_output=True, text=True)
out = p.stdout + p.stderr
self.assertIn("SHALLOW CLONE", out)
self.assertIn("engine-major", out)
self.assertNotIn("engine-major gate OK", out)
self.assertEqual(p.returncode, 0, out)
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":