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
+12 -2
View File
@@ -70,8 +70,18 @@ if ! command -v python3 >/dev/null 2>&1; then
exit 1
fi
echo "pre-push [app-catalog-felhom.eu]: running scripts/catalog_gates.py --fast ..."
python3 "scripts/catalog_gates.py" --fast
# ── THE PUSH RANGE (2026-09-13, engine-major gate) ────────────────────────────────────────────────
# git feeds this hook "<local ref> <local sha> <remote ref> <remote sha>" per ref on stdin. The
# engine-major gate diffs image: lines between the two shas, so the range is handed through as
# --range=<remote sha>..<local sha>. An all-zero remote sha (a ref that does not exist yet) is
# resolved inside the gate to origin/main. Read from stdin ONCE — a second read would block.
refs=$(cat)
range_arg=""
first=$(printf '%s\n' "$refs" | awk 'NF>=4 {print $4".."$2; exit}')
[ -n "$first" ] && range_arg="--range=$first"
echo "pre-push [app-catalog-felhom.eu]: running scripts/catalog_gates.py --fast $range_arg ..."
python3 "scripts/catalog_gates.py" --fast $range_arg
rc=$?
if [ "$rc" -ne 0 ]; then
echo "pre-push [app-catalog-felhom.eu]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2
+20 -2
View File
@@ -28,7 +28,7 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details
- No secrets in any committed file; secrets are generated at deploy time via `deploy_fields`
`generate:` specs.
- **Run `python3 scripts/catalog_gates.py <app>` after ANY template change** — it is the ONE entry
point and runs all three gates below, exiting non-zero if any fails. Name the app(s) you touched
point and runs all four gates below, exiting non-zero if any fails. Name the app(s) you touched
and it scopes the two gates that accept scoping, which is fast; with no names the runtime gate
deploys **every** template, so that form belongs **on a scratch host, never a customer box**.
Exit: 0 all clean · 1 convicted · 2 UNDETERMINED, which is never a pass.
@@ -41,7 +41,8 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details
repo has any, and there are no users yet. **R-161 stays open at reduced scope** — this is
convention, run by a person; real automatic enforcement is owed when a second person touches
templates. **Update 2026-08-02:** `.githooks/pre-push` now runs `catalog_gates.py --fast` on every
push, which is gate 1 (`check-image-pins.py`) only — the other two need network and a container
push, which is gate 1 (`check-image-pins.py`) and, since 2026-09-13, the engine-major gate with the
push range — the other two need network and a container
runtime and take minutes per app, and a push that pulls images and starts containers gets bypassed
within a week, after which the bypass is the habit. They stay deliberate periodic runs. The hook is
per-clone (`git config core.hooksPath .githooks`) and `git push --no-verify` bypasses it, which is
@@ -91,6 +92,23 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details
be correct. Why it exists: papra mounted `papra_data:/app/data` while the app wrote its database
to `/app/app-data/db/`, so its backup completed, verified, and contained an empty directory
(R-156, Campaign 10).
- **No template moves a database-engine image across a MAJOR version — until Slice 4 ships.**
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.* It covers the **four MariaDB** services — `bookstack-db`, `kimai-db`,
`nextcloud-db`, `romm-db` — and the **eleven PostgreSQL** ones (`docmost-postgres`,
`paperless-postgres` and the rest; the gate finds them by image name, not by this list). **Why now:**
every `mariadb:` sidecar carries `MARIADB_AUTO_UPGRADE=1` since 2026-09-13, so the day a MariaDB pin
moves a major the engine CONVERTS the customer's datadir on the next Update (measured 7 s, own
backup first — `SPIKE-r459-mariadb-upgrade-2026-09-06.md`); PostgreSQL's image converts nothing and
refuses to start on an older major's datadir (R-463). Either way it is a customer-data event with no
backup in front of it. **Within a major** (`11.6 → 11.8`, `16-alpine → 16.4-alpine`) is allowed.
**Gate: `scripts/check-engine-major.py`** — fourth row of `catalog_gates.py`, `--fast`, run by
`.githooks/pre-push` with the push range. **It needs a parent commit**, and the CI runner fetches at
`--depth 1` (the same gap as R-452 — not re-filed), so on a shallow clone the runner skips it out
loud; the hook is where it bites. **EXPIRY, so it is removed deliberately and not forgotten:** when
R-448 ships, delete this rule, the gate's row in `catalog_gates.py` and the gate — tracked as its
own register row (`OPEN-ITEMS.md`, blocked-on R-448). The rule does not lapse on its own.
- **Taking an app out of circulation — use `lifecycle:`, never a directory move.** `.felhom.yml`
gains an optional `lifecycle:` field: `available` (default; absent/empty means this), `hidden`
(not offered for new installs, no explanation owed), `abandoned` (upstream stopped developing it —
+1 -1
View File
@@ -17,7 +17,7 @@ None — this repo is templates/config, not code. See §2/§5.
| deploy_fields conventions | `templates/paperless-ngx/.felhom.yml` (`deploy_fields:` block) | Every app starts with `DOMAIN` (type `domain`) + `SUBDOMAIN` (type `subdomain`, `locked_after_deploy: true`). Secrets: `type: secret` + `generate:` — dominant generators `password:24` (DB passwords) and `hex:32` (app secret keys); `password:16` for shown admin passwords (`type: password`). HDD apps add `HDD_PATH` (`type: path`, placeholder `/mnt/felhom-drives/hdd_1`, locked). Labels/descriptions in Hungarian. |
| Controller-side health probe | `templates/vaultwarden/.felhom.yml` (`healthcheck:` block) | `healthcheck.checks[]` with `type: http` (port only), `type: api` (port + `path` + `expect.status: 200`), or `type: tcp` (port only — mealie, crafty-controller). Prefer `api` with a real health path when the app has one. |
| App lifecycle (`available`/`hidden`/`abandoned`) | `templates/plant-it/.felhom.yml` (`lifecycle:` block) | Optional top-level `lifecycle:` in `.felhom.yml`. Absent/empty ≡ `available`. `hidden` = not offered for new installs; `abandoned` = same, PLUS a permanent "Nem karbantartott" badge + notice on every box already running it. **Deployed instances keep full function in both states** — lifecycle governs what is OFFERED, never what runs; the controller refuses a deploy of a non-available template server-side (fail-closed, so a stale link or direct POST cannot install one). Unknown value → treated as `available` + one WARN, never a broken template. **Do NOT take an app out of circulation by deleting or moving its directory** — that orphans every customer already running it, which is what the 2026-07-21 `retired/` experiment got wrong. The resolvability gate skips non-available apps, so an abandoned app's dead image is not a standing red. |
| **Catalog gates — THE entry point** | `scripts/catalog_gates.py` | **Run `python3 scripts/catalog_gates.py <app>` after ANY template change** (mandated in `CLAUDE.md`). Runs all three gates below in order — image-pins, image-resolvable, volume-persistence — and exits **non-zero if any fails**; **2 (UNDETERMINED) is reported distinctly and is never a pass**, 1 (convicted) outranks 2 in the summary. Naming app(s) scopes the two gates that accept scoping, which is the normal after-a-change run; with no names the RUNTIME gate deploys every template, so that form is **scratch host only**. **Why a runner** (operator ruling 2026-08-02, R-161): the only gates in this project that ever get run are the ones with a single entry point named in a CLAUDE.md — `felhom.eu/scripts/site_gates.py` is run, R-29's three orphans are named nowhere and have stopped nothing. Controller-side enforcement was rejected because a load-time check reads only the file and a static audit reports the catalog clean **including papra** — it would pass on the very defect it exists to catch; CI was rejected for now (neither repo has any, no users yet). Adding a fourth gate here means adding it to `GATES` in this file — nothing else. |
| **Catalog gates — THE entry point** | `scripts/catalog_gates.py` | **Run `python3 scripts/catalog_gates.py <app>` after ANY template change** (mandated in `CLAUDE.md`). Runs all four gates below in order — image-pins, image-resolvable, volume-persistence, engine-major (2026-09-13; git-history diff, hook-only until CI fetches deeper, R-452) — and exits **non-zero if any fails**; **2 (UNDETERMINED) is reported distinctly and is never a pass**, 1 (convicted) outranks 2 in the summary. Naming app(s) scopes the two gates that accept scoping, which is the normal after-a-change run; with no names the RUNTIME gate deploys every template, so that form is **scratch host only**. **Why a runner** (operator ruling 2026-08-02, R-161): the only gates in this project that ever get run are the ones with a single entry point named in a CLAUDE.md — `felhom.eu/scripts/site_gates.py` is run, R-29's three orphans are named nowhere and have stopped nothing. Controller-side enforcement was rejected because a load-time check reads only the file and a static audit reports the catalog clean **including papra** — it would pass on the very defect it exists to catch; CI was rejected for now (neither repo has any, no users yet). Adding a fourth gate here means adding it to `GATES` in this file — nothing else. |
| Image pinning | ALL `templates/*/docker-compose.yml` (`image:` line) | **Never `:latest` or untagged** (recovery-unit `ImagePins` pins the tag — `:latest` breaks restore fidelity). Pin a concrete version tag; an app deployed anywhere in the fleet pins to the digest it is RUNNING (pin ≠ upgrade); `@sha256:` digest pins also count. Gate: `python scripts/check-image-pins.py` after any compose change (swept 2026-07-12: 5 pins). TRAP: ghcr `tags/list` can be stale/partial — verify tag existence via `docker manifest inspect`, never the tag list. |
| Image RESOLVABILITY (does the pin still exist?) | `scripts/check-image-resolvable.py` + `scripts/test_check_image_resolvable.py` | The complement to the pin gate, which is purely syntactic and cannot see rot. Run it at the START of every catalog campaign and before any publish train that vouches the catalog: `python3 scripts/check-image-resolvable.py [app …]`. Exit **0** all resolve, **1** the registry says an image is GONE, **2** INCONCLUSIVE/harness error. **Two traps it encodes, both live-observed:** (a) `docker manifest inspect` prints `toomanyrequests` and **still exits 0** — never trust the exit code alone (same shape as the ISO `validate-answer` trap); (b) the inverse — the first sweep called 24 of 65 pins dead, `postgres:16-alpine` among them, because Docker Hub throttled it partway. Ambiguity therefore resolves to INCONCLUSIVE, never to an accusation; a gate that cries wolf gets ignored. Unauthenticated Hub lookups WILL throttle on a full 65-pin sweep — `docker login` first, or expect exit 2. |
| Volume PERSISTENCE (does the app write where the template preserves?) | `scripts/check-volume-persistence.py` + `scripts/test_check_volume_persistence.py` | The third gate and the only RUNTIME one — **the two image gates are static and this class is invisible to static analysis**, which was measured, not assumed: a static audit of all 53 composes (every declared volume attached, no anonymous mounts, no stray host binds) reports the catalog clean AND reports papra clean. papra's compose is well-formed; it mounts `papra_data:/app/data` while the app writes `/app/app-data/db/db.sqlite` into the container's **writable layer** and cannot write `/app/data` at all — so `DumpAppVolumes` (`felhom-controller internal/backup/backup.go:543`) tars an empty directory and the backup verifies (R-156, Campaign 10). Run: `python3 scripts/check-volume-persistence.py [app …]` **on a scratch host, never a customer box**. Exit **0** all CLEAN, **1** REFUSED, **2** UNDETERMINED/prober untrustworthy. **Traps it encodes:** (a) `A` vs `C` in `docker diff` — a linuxserver.io entrypoint chowning its app tree produced 1305 `C` entries and called calibre-web BROKEN on the first pass, so DATA is decided from `A` only and a `C` on a DB file is adjudicated by comparing bytes against a pristine container of the same image; (b) no `docker exec` anywhere — Campaign 7 §1.1's OCI-error-to-stdout trap, so uid comes from `/proc/<pid>/status` and writability from a host-side `stat`; (c) `base64key` secrets need the controller's `base64:` prefix (`deploy.go:904`) or bookstack serves 500s and the harness looks like an app defect; (d) it self-tests in BOTH directions against two canary templates before reporting anything — a detector that flags nothing turns an unexamined catalog into a documented-clean one. UNDETERMINED is **never** folded into CLEAN. |
+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)
+206
View File
@@ -0,0 +1,206 @@
#!/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 <A>..<B> # 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/<app>/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 <A>..<B> (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:]))
+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__":
+192
View File
@@ -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())