#!/usr/bin/env python3 # -*- coding: utf-8 -*- """check-engine-major.py — refuse a push that moves a DATABASE ENGINE across a MAJOR version. Run from the repo root: python3 scripts/check-engine-major.py # diff origin/main..HEAD python3 scripts/check-engine-major.py --range .. # what .githooks/pre-push passes Exit 0 no engine crosses a major · 1 REFUSED · 2 INCONCLUSIVE (no parent to diff against, or a pin whose major cannot be read). THE RULE THIS ENFORCES (app-catalog `CLAUDE.md`, operator ruling 2026-09-13): Until the Update button takes a VERIFIED BACKUP as its precondition (update arc Slice 4, felhom.eu OPEN-ITEMS.md R-448), no template may move a database-engine image across a major version. WHY IT EXISTS. On 2026-09-13 every `mariadb:` sidecar gained `MARIADB_AUTO_UPGRADE=1`, so the day a MariaDB pin moves a major, the engine will CONVERT the customer's datadir on the next deliberate Update (~7 s, own backup of the system tables first — SPIKE-r459-mariadb-upgrade-2026-09-06.md). That is the right behaviour and it was ruled so. But the Update button still takes no backup of the app's data (09-update-architecture.md §8.6), and PostgreSQL's image performs no conversion at all — it REFUSES to start on an older major's datadir (R-463). Either way a cross-major pin is a customer-data event, and until Slice 4 puts a verified backup in front of the button, the catalog must not offer one. WHY A GATE AND NOT A SENTENCE IN CLAUDE.md. This project's most-repeated finding is that a rule with no instrument is a wish. The rule's own expiry condition is the tell: "until Slice 4 ships" is exactly the kind of clause nobody revisits. The gate carries the expiry in its refusal text, and removing the gate is a diff someone reviews. WHAT IT COMPARES. For every `templates//docker-compose.yml` changed between the two ends of the range, the `image:` line of each SERVICE on both sides — per service, on that service's own line, never a blind string replace (the same discipline as `upgrade-test.py`'s `render`). Only images whose repository name is a database engine are judged (`ENGINES`); the app's own image may move as it likes. The engine set is a NAME MATCH on the repository's last path component, so a registry prefix (`docker.io/library/postgres:16`) or a digest suffix does not hide one. Scope is the glob, not a hand-kept list of the four MariaDB and eleven PostgreSQL services — a list would need maintaining, and the 2026-09-01 decoy sweep's rule is that scope is a fact too. HONEST LIMIT — CI CANNOT RUN THIS YET. It needs a PARENT commit to diff against, and the CI runner fetches at `--depth 1` (`.gitea/workflows/gates.yml`), which is the very gap R-452 recorded for the `catalog_since` gate. So this runs in the pre-push hook, which has the full clone, and `catalog_gates.py` SKIPS it — out loud — on a shallow clone rather than turning CI red on every push. That is one gate short of enforcement, stated here so nobody mistakes the hook for CI. Fixing it is R-452's fix (a deeper fetch), not a second row. FAIL-CLOSED WHERE IT CAN BE. A range that cannot be resolved, a compose file at either end that cannot be read, or an engine tag whose major cannot be parsed (`mariadb:lts`) is INCONCLUSIVE (2), never 0. The pin gate already forbids floating tags, so an unparseable engine tag is a defect in its own right. """ import re import subprocess import sys # Repository basenames that are database engines. A match here means "judge this service's major". ENGINES = ("mariadb", "mysql", "postgres", "postgresql") SERVICE_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$") IMAGE_RE = re.compile(r"^\s+image:\s*[\"']?(\S+?)[\"']?\s*$") TEMPLATE_RE = re.compile(r"^templates/[^/]+/docker-compose\.ya?ml$") ZERO_SHA_RE = re.compile(r"^0{40}$") def git(*args): p = subprocess.run(["git"] + list(args), capture_output=True, text=True) return p.returncode, p.stdout, p.stderr def images_in(text): """{service: image} — per service, from that service's OWN `image:` line.""" out, cur = {}, None for line in text.splitlines(): m = SERVICE_RE.match(line) if m: cur = m.group(1) continue mi = IMAGE_RE.match(line) if mi and cur and cur not in out: out[cur] = mi.group(1) return out def engine_of(ref): """(engine_name, tag) if the image's repository is a database engine, else None. `docker.io/library/postgres:16-alpine@sha256:…` -> ("postgres", "16-alpine"). A registry with a port (`host:5000/postgres:16`) is handled by taking the LAST path component before splitting on ':'. """ ref = ref.split("@", 1)[0] last = ref.rsplit("/", 1)[-1] if ":" in last: name, tag = last.rsplit(":", 1) else: name, tag = last, "" if name.lower() in ENGINES: return name.lower(), tag return None def major_of(tag): m = re.match(r"^v?(\d+)", tag) return int(m.group(1)) if m else None def resolve_range(spec): """'A..B' -> (A, B, note). An all-zero A (a new remote ref) falls back to origin/main.""" if not spec or ".." not in spec: return None, None, "range must be .. (got %r)" % spec a, b = spec.split("..", 1) if ZERO_SHA_RE.match(a): a = "origin/main" for r in (a, b): rc, _, err = git("rev-parse", "--verify", "-q", r + "^{commit}") if rc != 0: return None, None, "cannot resolve %r (%s)" % (r, err.strip() or "not a commit") return a, b, "" def main(argv): spec = "origin/main..HEAD" for arg in argv: if arg.startswith("--range="): spec = arg[len("--range="):] elif arg == "--range" or arg.startswith("-"): pass if "--range" in argv: i = argv.index("--range") if i + 1 < len(argv): spec = argv[i + 1] rc, shallow, _ = git("rev-parse", "--is-shallow-repository") if rc == 0 and shallow.strip() == "true": print("ENGINE-MAJOR GATE INCONCLUSIVE: this clone is SHALLOW — there is no parent commit to " "diff an image: line against (the R-452 gap; the CI runner fetches at --depth 1). " "This gate is enforced by the pre-push hook, which has the full clone.") return 2 a, b, why = resolve_range(spec) if a is None: print("ENGINE-MAJOR GATE INCONCLUSIVE: %s" % why) return 2 rc, names, err = git("diff", "--name-only", a, b, "--", "templates") if rc != 0: print("ENGINE-MAJOR GATE INCONCLUSIVE: git diff %s %s failed: %s" % (a, b, err.strip())) return 2 files = [n for n in names.split("\n") if TEMPLATE_RE.match(n)] compared, refused, unparseable = 0, [], [] for path in files: rc_b, before_text, _ = git("show", "%s:%s" % (a, path)) rc_a, after_text, _ = git("show", "%s:%s" % (b, path)) if rc_a != 0: continue # deleted at B: nothing is being offered before = images_in(before_text) if rc_b == 0 else {} after = images_in(after_text) for svc, img_after in after.items(): eng_after = engine_of(img_after) if eng_after is None or svc not in before: continue # not an engine, or a NEW service (nothing to move across) eng_before = engine_of(before[svc]) if eng_before is None: continue # became an engine — there is no engine datadir to convert compared += 1 if before[svc] == img_after: continue mb, ma = major_of(eng_before[1]), major_of(eng_after[1]) if mb is None or ma is None: unparseable.append("%s %s: %s -> %s" % (path, svc, before[svc], img_after)) continue if mb != ma: refused.append((path, svc, eng_after[0], mb, ma, before[svc], img_after)) print("engine-major gate — range %s..%s: %d compose file(s) changed, %d engine pin(s) compared" % (a, b, len(files), compared)) if refused: print("") for path, svc, eng, mb, ma, ib, ia in refused: print("ENGINE-MAJOR GATE FAILED: %s service %s moves %s %d -> %d (%s -> %s)." % (path, svc, eng, mb, ma, ib, ia)) print("RULE (app-catalog CLAUDE.md, operator ruling 2026-09-13): until the Update button takes " "a VERIFIED BACKUP as its precondition (Slice 4, felhom.eu OPEN-ITEMS.md R-448), no " "template may move a database-engine image across a MAJOR version.") print("WHY: MariaDB sidecars now carry MARIADB_AUTO_UPGRADE=1 and WILL convert the customer's " "datadir on the next Update; PostgreSQL's image refuses to start on an older major's " "datadir (R-463). Either way this is a customer-data event with no backup in front of it.") print("EXPIRY: this rule is removed DELIBERATELY when R-448 ships — the removal is its own " "register row, not a silent edit. Until then, keep the engine within its major.") return 1 if unparseable: print("") for u in unparseable: print("ENGINE-MAJOR GATE INCONCLUSIVE: cannot read a MAJOR from %s" % u) print("An engine tag without a leading number cannot be judged and the pin gate already " "forbids floating tags — pin a numbered tag.") return 2 print("engine-major gate OK — no database engine crosses a major version" " (rule: CLAUDE.md, until Slice 4 / R-448 ships)") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))