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:
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""test_gate_decoys.py — can this repo's gates be fooled by a LABEL? (R-421)
|
||||
|
||||
The same instrument as `felhom.eu/scripts/test_gate_decoys.py`: a decoy is the LABEL without the
|
||||
FACT, and a gate that convicts on the label alone — or fails to convict on the fact — is a live hole.
|
||||
Every case asserts BOTH directions where it can: the genuine article must pass and the decoy must be
|
||||
judged on what it IS, not on what it says.
|
||||
|
||||
Covered here (the `COVERS` literal is AST-read by `felhom.eu/scripts/decoy_coverage_gate.py`):
|
||||
|
||||
engine-major — `check-engine-major.py` refuses a database-engine pin that crosses a MAJOR.
|
||||
Its label is the version string; its fact is the `image:` line of an engine SERVICE. Decoys a
|
||||
real session would produce:
|
||||
* the major moves in a COMMENT and in kimai's `serverVersion=11.6.2-MariaDB` env var, while
|
||||
the image line stays — must PASS (nothing moved);
|
||||
* the APP's own image crosses a major (kimai 2.57 -> 3.0) — must PASS (not an engine);
|
||||
* a `mariadb:12.3` string lands in README.md — must PASS (not a template);
|
||||
* the engine moves WITHIN its major (11.6 -> 11.8) — must PASS (the rule says MAJOR);
|
||||
and the facts:
|
||||
* `mariadb:11.6 -> mariadb:12.3` on `kimai-db` — must be REFUSED (exit 1), naming the rule
|
||||
and its expiry (R-448);
|
||||
* `postgres:16-alpine -> postgres:17-alpine` on `docmost-postgres` — must be REFUSED (the
|
||||
eleven PostgreSQL services are covered by NAME MATCH, not by a list);
|
||||
* `mariadb:11.6 -> mariadb:lts` — INCONCLUSIVE (exit 2), never 0: a major nobody can read is
|
||||
not a pass.
|
||||
|
||||
HOW. The repo is cloned into a scratch directory; the WORKING-TREE gate is run inside the clone
|
||||
(so the file under test is the one being edited, not HEAD's); each case is one commit on top of the
|
||||
clone's HEAD and the gate is run with `--range HEAD~1..HEAD`. The real tree is never touched.
|
||||
|
||||
Run from the repo root: python3 scripts/test_gate_decoys.py
|
||||
Exit 0 every decoy judged correctly · 1 a decoy passed or a genuine article was refused.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# ── WHAT THIS FILE COVERS ────────────────────────────────────────────────────────────────────────
|
||||
# Read by felhom.eu/scripts/decoy_coverage_gate.py, which AST-parses this literal. A gate named here
|
||||
# MUST have a decoy below that has been seen to fail.
|
||||
COVERS = {
|
||||
"engine-major": "the major moved in a comment/env var/README/app image, not on an engine's image: line",
|
||||
}
|
||||
|
||||
fails = []
|
||||
ran = 0
|
||||
|
||||
|
||||
def sh(args, cwd):
|
||||
return subprocess.run(args, cwd=cwd, capture_output=True, text=True)
|
||||
|
||||
|
||||
def make_clone():
|
||||
tmp = tempfile.mkdtemp(prefix="catalog-decoys-")
|
||||
r = sh(["git", "clone", "-q", "file://" + ROOT, tmp], cwd=ROOT)
|
||||
if r.returncode != 0:
|
||||
raise SystemExit("clone failed: " + r.stderr)
|
||||
sh(["git", "config", "user.email", "decoy@gate.invalid"], cwd=tmp)
|
||||
sh(["git", "config", "user.name", "decoy"], cwd=tmp)
|
||||
return tmp
|
||||
|
||||
|
||||
def edit(clone, relpath, fn):
|
||||
p = os.path.join(clone, relpath)
|
||||
text = io.open(p, encoding="utf-8").read() if os.path.exists(p) else ""
|
||||
new = fn(text)
|
||||
if new == text:
|
||||
raise SystemExit("case did not change %s — the case is broken, not the gate" % relpath)
|
||||
with io.open(p, "w", encoding="utf-8") as fh:
|
||||
fh.write(new)
|
||||
|
||||
|
||||
def commit(clone, msg):
|
||||
sh(["git", "add", "-A"], cwd=clone)
|
||||
r = sh(["git", "commit", "-q", "-m", msg], cwd=clone)
|
||||
if r.returncode != 0:
|
||||
raise SystemExit("commit failed: " + r.stderr)
|
||||
|
||||
|
||||
def reset(clone):
|
||||
sh(["git", "reset", "-q", "--hard", "HEAD"], cwd=clone)
|
||||
|
||||
|
||||
def case(name, clone, edits, expect_rc, must_contain=()):
|
||||
"""edits: list of (relpath, fn). Commits them, runs the gate on HEAD~1..HEAD, restores."""
|
||||
global ran
|
||||
ran += 1
|
||||
base = sh(["git", "rev-parse", "HEAD"], cwd=clone).stdout.strip()
|
||||
try:
|
||||
for relpath, fn in edits:
|
||||
edit(clone, relpath, fn)
|
||||
commit(clone, name)
|
||||
# the WORKING-TREE gate, run inside the clone (it reads git from its cwd)
|
||||
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-engine-major.py"),
|
||||
"--range", "HEAD~1..HEAD"], cwd=clone)
|
||||
out = r.stdout + r.stderr
|
||||
ok = r.returncode == expect_rc and all(m in out for m in must_contain)
|
||||
if ok:
|
||||
print(" ok %-52s rc=%d (expected %d)" % (name, r.returncode, expect_rc))
|
||||
else:
|
||||
fails.append("%s: rc=%d expected %d; missing %s\n%s" % (
|
||||
name, r.returncode, expect_rc,
|
||||
[m for m in must_contain if m not in out], out[-900:]))
|
||||
return out
|
||||
finally:
|
||||
sh(["git", "reset", "-q", "--hard", base], cwd=clone)
|
||||
|
||||
|
||||
def swap_image(service, frm, to):
|
||||
"""Change ONLY the named service's own image: line — the same per-service discipline as the
|
||||
gate, so the case moves the fact and nothing else."""
|
||||
def _fn(text):
|
||||
out, cur, done = [], None, False
|
||||
for line in text.splitlines():
|
||||
m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
|
||||
if m:
|
||||
cur = m.group(1)
|
||||
mi = re.match(r"^(\s+image:\s*)(\S+)\s*$", line)
|
||||
if mi and cur == service and mi.group(2) == frm:
|
||||
line = mi.group(1) + to
|
||||
done = True
|
||||
out.append(line)
|
||||
if not done:
|
||||
raise SystemExit("%s does not carry image %s — fixture drifted" % (service, frm))
|
||||
return "\n".join(out) + "\n"
|
||||
return _fn
|
||||
|
||||
|
||||
def main():
|
||||
gate = os.path.join(ROOT, "scripts", "check-engine-major.py")
|
||||
if not os.path.isfile(gate):
|
||||
print("FAIL: scripts/check-engine-major.py is missing — a failure, never a skip")
|
||||
return 1
|
||||
clone = make_clone()
|
||||
try:
|
||||
KIMAI = "templates/kimai/docker-compose.yml"
|
||||
DOCMOST = "templates/docmost/docker-compose.yml"
|
||||
|
||||
# ── THE FACTS: these must be refused ─────────────────────────────────────────────────
|
||||
out = case("FACT: kimai-db mariadb:11.6 -> 12.3 (cross-major)", clone,
|
||||
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:12.3"))],
|
||||
expect_rc=1,
|
||||
must_contain=("ENGINE-MAJOR GATE FAILED", "kimai-db", "mariadb 11 -> 12",
|
||||
"R-448", "EXPIRY"))
|
||||
if "REFUSAL_TEXT" in os.environ:
|
||||
print(out)
|
||||
case("FACT: docmost-postgres postgres:16-alpine -> 17-alpine", clone,
|
||||
[(DOCMOST, swap_image("docmost-postgres", "postgres:16-alpine", "postgres:17-alpine"))],
|
||||
expect_rc=1, must_contain=("docmost-postgres", "postgres 16 -> 17"))
|
||||
case("FACT: kimai-db mariadb:11.6 -> mariadb:lts (major unreadable)", clone,
|
||||
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:lts"))],
|
||||
expect_rc=2, must_contain=("INCONCLUSIVE",))
|
||||
|
||||
# ── THE GENUINE ARTICLES: these must pass ────────────────────────────────────────────
|
||||
case("GENUINE: kimai-db mariadb:11.6 -> 11.8 (within major)", clone,
|
||||
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:11.8"))],
|
||||
expect_rc=0, must_contain=("engine-major gate OK",))
|
||||
|
||||
# ── THE DECOYS: the label moves, the fact does not — these must pass ─────────────────
|
||||
def comment_and_env(text):
|
||||
# the version string moves in a COMMENT and in kimai's serverVersion env, image untouched
|
||||
t = text.replace("serverVersion=11.6.2-MariaDB", "serverVersion=12.3.0-MariaDB")
|
||||
return t.replace("# Database: mariadb", "# Database: mariadb (image: mariadb:12.3 soon)")
|
||||
case("DECOY: major moves only in a comment + serverVersion env", clone,
|
||||
[(KIMAI, comment_and_env)], expect_rc=0, must_contain=("engine-major gate OK",))
|
||||
case("DECOY: the APP image crosses a major (kimai 2.57 -> 3.0)", clone,
|
||||
[(KIMAI, swap_image("kimai", "kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-3.0.0"))],
|
||||
expect_rc=0, must_contain=("engine-major gate OK",))
|
||||
case("DECOY: 'mariadb:12.3' lands in README.md, not a template", clone,
|
||||
[("README.md", lambda t: t + "\nDecoy: mariadb:11.6 -> mariadb:12.3 pending.\n")],
|
||||
expect_rc=0, must_contain=("0 compose file(s) changed",))
|
||||
finally:
|
||||
shutil.rmtree(clone, ignore_errors=True)
|
||||
|
||||
if fails:
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL: %s" % f)
|
||||
return 1
|
||||
print("\ncatalog gate decoys OK — %d case(s), every label judged on its fact (R-421)" % ran)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user