#!/usr/bin/env python3 # -*- coding: utf-8 -*- """catalog_gates.py — THE entry point for this repo's gates. Run from the repo root: 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 # static gates only — no network, no # containers; this is what .githooks/pre-push runs python3 scripts/catalog_gates.py --fast --range=.. # 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 5. catalog-since static, needs GIT HISTORY — an image: move bumps that app's catalog_since (R-452) 7. probe-matches-compose static, instant, whole repo — the .felhom.yml health probe dials the port/path the app's own compose healthcheck dials (R-618). Runs in the hook: a wrong probe stops a WORKING app at the end of a successful update. 8. test-record static, instant, whole repo — every update_ladder is well-formed, continuous, and its newest step IS the compose's images (runs in CI too) 9. test-record-move git history + the registry for MOVED refs only — an image move adds a PROVEN ladder entry whose digests the registry still serves (hook; skipped on CI) 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). The volume-persistence gate was built because papra's backup completed, verified, and contained an empty directory. The obvious enforcement points were both rejected, each for a measured reason: - **Controller-side, at template load: rejected because it would PASS on the defect it exists to catch.** A check at load time can only read the file, and papra's compose is well-formed — a static audit of all 53 templates reports the catalog clean, papra included. The property is only decidable at runtime (see `check-volume-persistence.py`'s header). - **CI: rejected for now** — neither repo has any CI to build on, and there are no users yet. What was chosen instead is the shape that demonstrably works in this project. Of every gate written here, **the only ones 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 orphaned gates are named nowhere and have stopped nothing. So this copies that shape rather than adding a fourth gate nobody invokes. It is mandated in `CLAUDE.md` the way `site_gates.py` is. **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. EXIT CODES. Each gate returns 0 clean / 1 convicted / 2 inconclusive. This runner exits **non-zero if any gate is non-zero**, and reports 2 distinctly as INCONCLUSIVE — an undetermined result is never a pass (an app that wrote nothing has not been shown correct; a throttled registry has not shown an image alive), but it is also not a conviction, and the operator reading the summary needs to know which they have. SCOPE. With app names, every gate that accepts scoping is scoped to them — that is the normal after-a-template-change run and it is fast. With no names the runtime gate deploys **every** template, which takes minutes per app and **belongs on a scratch host, never a customer box** (see CLAUDE.md). """ import os import subprocess 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, takes_range) # # `takes_range` = the gate diffs two commits and is handed `--range=..` 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 # app — neither belongs in a hook. A push that pulls images and starts containers gets bypassed # within a week, and the bypass becomes the habit; both stay deliberate periodic runs (start of a # 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, 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), # R-452 (2026-09-13): an image: move must bump that app's catalog_since. Same shape as # engine-major — git history, fast, skipped out loud on a shallow clone. ("catalog-since", "check-catalog-since.py", False, True, True), # R-560 (2026-09-20): the Hungarian copy is frozen byte for byte and the English `i18n:` block # is structurally sound and actually English. Static, instant, no git history. It accepts app # scope for the LANGUAGE checks only — the Hungarian freeze always runs on all 53, because a # scoped push that quietly edits a neighbour's copy is precisely what a freeze is for. ("copy-i18n", "check-copy-i18n.py", True, True, False), # R-618 (2026-09-22): the `.felhom.yml` probe must dial the port — and, where the probe can # actually fail on it, the path — that the SAME service's compose healthcheck dials on # loopback. Static, instant, no git history, no network. It is `--fast` deliberately: the # defect it catches does not merely mis-colour a badge, it makes a SUCCESSFUL update stop a # working app (the `verifying` phase waits on this probe), so it must bite at push time. ("probe-matches-compose", "check-probe-matches-compose.py", True, True, False), # 2026-09-23 (`09` §3 decision 13, §6.4 part 4): THE TEST RECORD. The static half needs no # history and no network, so it bites in CI too: a ladder must be well-formed and its newest step # must BE the compose's images. The move half needs history (skipped out loud on CI's shallow # clone, like engine-major) and asks the registry ONLY for refs that moved in the range: an image # move must add a PROVEN entry whose digests the registry still serves. ("test-record", "check-test-record.py", True, True, False), ("test-record-move", "check-test-record-move.py", False, True, True), ] VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} def run_gate(label, script, args): path = os.path.join(SCRIPTS, script) if not os.path.exists(path): print("FAIL: %s — %s is missing from scripts/" % (label, script)) return 1 print("\n" + "=" * 78) print("== gate: %s (%s%s)" % (label, script, (" " + " ".join(args)) if args else "")) print("=" * 78, flush=True) # stream the gate's own output rather than capturing it — its diagnostics are the point, # and a runner that swallows them makes a conviction unreadable. 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") and not a.startswith("--range=")] if unknown: print("unknown option(s): %s" % " ".join(unknown)) print(__doc__.strip().splitlines()[0]) return 2 scope_note = ("apps: " + ", ".join(apps)) if apps else ( "static gate only" if fast else "ALL apps (runtime gate deploys every template — scratch host only)") print("catalog_gates — %s%s%s" % (scope_note, " [--fast]" if fast else "", " [--all: incl. hidden/abandoned]" if include_hidden else "")) 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" " of a catalog campaign, before a publish train, or when a template's volumes:/image\n" " changes. Run them with no --fast, on a scratch host." % ", ".join(skipped)) results = [] 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) print("== summary") print("=" * 78) worst = 0 for label, rc in results: print(" %-20s %-13s (exit %d)" % (label, VERDICT.get(rc, "ERROR"), rc)) # 1 (a conviction) outranks 2 (undetermined) in what it tells the operator to do if rc != 0: worst = 1 if rc == 1 or worst == 1 else 2 if worst == 0: print("\nall catalog gates OK") return 0 convicted = [l for l, rc in results if rc == 1] undecided = [l for l, rc in results if rc not in (0, 1)] if convicted: print("\nCONVICTED: %s" % ", ".join(convicted)) if undecided: print("UNDETERMINED (never a pass): %s" % ", ".join(undecided)) return worst if __name__ == "__main__": sys.exit(main(sys.argv[1:]))