gates: catalog_gates --fast + pre-push hook

--fast selects only gates that touch no network and no container runtime: gate 1
(check-image-pins) runs, image-resolvable and volume-persistence do NOT. Default behaviour with
no flag is unchanged. The skip is ANNOUNCED with the reason and with what still owes a periodic
run — a silently narrowed run reads as 'covered everything' when it did not.

Why the runtime gates are never in a hook: a push that pulls images and starts containers gets
bypassed within a week, and the bypass becomes the habit. They stay deliberate periodic runs at
the start of a catalog campaign, before a publish train, and when a template's volumes: block or
image tag changes — on a scratch host, never a customer box.

.githooks/pre-push runs catalog_gates.py --fast and refuses the push. Per-clone and
--no-verify-able, both stated in the hook itself.

test_catalog_gates.py pins --fast's CONTENT, not just its exit code: the runtime gates must not
run, the skip must be announced, and the no-flag path must still select all three. Red-proofed:
an inert run_gate turns it red.
This commit is contained in:
2026-08-02 15:23:08 +02:00
parent fd7747d129
commit c3e4bb18c7
4 changed files with 146 additions and 8 deletions
+27 -7
View File
@@ -5,6 +5,8 @@
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
Gates, in order (all must pass; **non-zero exit on any failure**):
@@ -49,11 +51,18 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS = os.path.join(ROOT, "scripts")
# (label, filename, accepts_app_scope)
# (label, filename, accepts_app_scope, fast)
#
# `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),
("image-resolvable", "check-image-resolvable.py", True),
("volume-persistence", "check-volume-persistence.py", True),
("image-pins", "check-image-pins.py", False, True),
("image-resolvable", "check-image-resolvable.py", True, False),
("volume-persistence", "check-volume-persistence.py", True, False),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
@@ -74,19 +83,30 @@ def run_gate(label, script, args):
def main(argv):
include_hidden = "--all" in argv
fast = "--fast" in argv
apps = [a for a in argv if not a.startswith("-")]
unknown = [a for a in argv if a.startswith("-") and a != "--all"]
unknown = [a for a in argv if a.startswith("-") and a not in ("--all", "--fast")]
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" % (scope_note, " [--all: incl. hidden/abandoned]" if include_hidden else ""))
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)]
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 in GATES:
for label, script, scoped, _f in selected:
args = []
if include_hidden:
args.append("--all")
+66
View File
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""Seam test for scripts/catalog_gates.py --fast.
Run: python3 scripts/test_catalog_gates.py
WHY THIS EXISTS. An entry point is a seam by definition: a runner that LISTS a gate but never
executes it is inert and fully green. So the assertion is on the member gate's OWN distinctive
stdout — never on the runner's summary line — plus the exit code.
The second and third tests pin --fast's CONTENT, not just its exit code: the two runtime gates
must NOT run (a push that pulls images and starts containers gets bypassed within a week, and
the bypass becomes the habit), and the skip must be ANNOUNCED — a silently narrowed run reads as
"covered everything" when it did not.
"""
import os
import subprocess
import sys
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ENTRY = os.path.join(ROOT, "scripts", "catalog_gates.py")
class CatalogGatesFastTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
p = subprocess.run([sys.executable, ENTRY, "--fast"], cwd=ROOT,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
cls.rc = p.returncode
cls.out = p.stdout.decode("utf-8", "replace")
def test_exit_code_is_zero(self):
self.assertEqual(self.rc, 0, self.out)
def test_static_gate_actually_ran(self):
self.assertIn("image-pin gate", self.out,
"check-image-pins is listed but its own output never appeared — an inert "
"runner prints the summary without calling anything:\n%s" % self.out)
def test_runtime_gates_did_not_run(self):
for fingerprint in ("resolvability gate", "volume-persistence gate", "canary"):
self.assertNotIn(fingerprint, self.out,
"a runtime gate ran under --fast (%r) — --fast must touch no network "
"and no container runtime:\n%s" % (fingerprint, self.out))
self.assertNotIn("image-resolvable OK", self.out)
self.assertNotIn("volume-persistence OK", self.out)
def test_skip_is_announced(self):
self.assertIn("--fast SKIPPED", self.out)
self.assertIn("image-resolvable", self.out)
self.assertIn("volume-persistence", self.out)
def test_default_run_still_selects_all_three(self):
"""--fast must not change the no-flag behaviour. Asserted on the GATES table rather than
by running it — the default run deploys every template and takes minutes per app."""
import importlib.util
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"])
if __name__ == "__main__":
unittest.main(verbosity=2)