6446197925
gates / gates (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
211 lines
12 KiB
Python
211 lines
12 KiB
Python
#!/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=<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
|
|
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=<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
|
|
# 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),
|
|
# R-715 (2026-09-29): a setup gate's `setup_done_probe:` must carry its measured before/after answers directly
|
|
# above it. A guessed probe that never flips keeps an app closed to all but the household AND blocks their press.
|
|
("probe-measured", "check-probe-measured.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:]))
|