diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..4e8c181 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,47 @@ +#!/bin/sh +# pre-push — refuse a push that carries a broken gate. (2026-08-02, R-29 leg (b) first half.) +# +# Runs this repo's ONE gate entry point in --fast mode: only checks that touch no network and no +# container runtime, so a push stays a push and never pulls images or starts containers. The slow +# gates stay deliberate periodic runs; a hook that takes minutes gets bypassed within a week and +# the bypass becomes the habit. +# +# BOTH LINES BELOW ARE DELIBERATE. An absent log line is not evidence a hook ran — a silent pass is +# equally consistent with "gates green" and "hook never fired", so a passing push says so out loud. +# +# HONEST LIMITS, stated so this is not mistaken for enforcement it cannot provide: +# * per-clone — core.hooksPath is local config and a clone does not carry it. Arm a clone once: +# git config core.hooksPath .githooks +# Any manual entry-point run WARNS when the clone is unarmed. +# * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape +# hatch that cannot be reached is one that gets removed the first time it is +# inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT. +# The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168. +# +# Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd +# is the repo root whether `git push` is issued from the root or from any subdirectory. The +# explicit rev-parse below does not depend on that. +set -u + +root=$(git rev-parse --show-toplevel 2>/dev/null) || { + echo "pre-push: FAIL - cannot resolve the repo root (git rev-parse --show-toplevel)." >&2 + exit 1 +} +cd "$root" || exit 1 + +if ! command -v python3 >/dev/null 2>&1; then + echo "pre-push: FAIL - python3 not found, so the gates CANNOT run. This is a failure, never a" >&2 + echo " pass by default. Install python3, or push with --no-verify and say so." >&2 + exit 1 +fi + +echo "pre-push [app-catalog-felhom.eu]: running scripts/catalog_gates.py --fast ..." +python3 "scripts/catalog_gates.py" --fast +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 + echo " 'git push --no-verify' and state that you did in the session report." >&2 +else + echo "pre-push [app-catalog-felhom.eu]: gates OK - push proceeding." +fi +exit $rc diff --git a/CLAUDE.md b/CLAUDE.md index 9538faa..28296b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,12 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details papra** — it would pass on the exact defect it exists to catch. CI was rejected for now: neither 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. + 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 + 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 + why R-161's automatic half is still owed — it is now tracked as `felhom.eu` `OPEN-ITEMS.md` R-168. - **Never `:latest` or untagged images in templates** — pin a concrete version tag; an app deployed anywhere in the fleet is pinned to the digest it is currently running (a pin must never cause a version jump). Digest pins (`@sha256:`) also count. Gate: `python scripts/check-image-pins.py` diff --git a/scripts/catalog_gates.py b/scripts/catalog_gates.py index 08d6f3d..065e129 100644 --- a/scripts/catalog_gates.py +++ b/scripts/catalog_gates.py @@ -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") diff --git a/scripts/test_catalog_gates.py b/scripts/test_catalog_gates.py new file mode 100644 index 0000000..e26077f --- /dev/null +++ b/scripts/test_catalog_gates.py @@ -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)