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:
Executable
+47
@@ -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
|
||||||
@@ -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
|
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
|
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
|
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
|
- **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
|
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`
|
version jump). Digest pins (`@sha256:`) also count. Gate: `python scripts/check-image-pins.py`
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
python3 scripts/catalog_gates.py # every AVAILABLE app, all three gates
|
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 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 --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**):
|
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__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
SCRIPTS = os.path.join(ROOT, "scripts")
|
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 = [
|
GATES = [
|
||||||
("image-pins", "check-image-pins.py", False),
|
("image-pins", "check-image-pins.py", False, True),
|
||||||
("image-resolvable", "check-image-resolvable.py", True),
|
("image-resolvable", "check-image-resolvable.py", True, False),
|
||||||
("volume-persistence", "check-volume-persistence.py", True),
|
("volume-persistence", "check-volume-persistence.py", True, False),
|
||||||
]
|
]
|
||||||
|
|
||||||
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
||||||
@@ -74,19 +83,30 @@ def run_gate(label, script, args):
|
|||||||
|
|
||||||
def main(argv):
|
def main(argv):
|
||||||
include_hidden = "--all" in argv
|
include_hidden = "--all" in argv
|
||||||
|
fast = "--fast" in argv
|
||||||
apps = [a for a in argv if not a.startswith("-")]
|
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:
|
if unknown:
|
||||||
print("unknown option(s): %s" % " ".join(unknown))
|
print("unknown option(s): %s" % " ".join(unknown))
|
||||||
print(__doc__.strip().splitlines()[0])
|
print(__doc__.strip().splitlines()[0])
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
scope_note = ("apps: " + ", ".join(apps)) if apps else (
|
scope_note = ("apps: " + ", ".join(apps)) if apps else (
|
||||||
|
"static gate only" if fast else
|
||||||
"ALL apps (runtime gate deploys every template — scratch host only)")
|
"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 = []
|
results = []
|
||||||
for label, script, scoped in GATES:
|
for label, script, scoped, _f in selected:
|
||||||
args = []
|
args = []
|
||||||
if include_hidden:
|
if include_hidden:
|
||||||
args.append("--all")
|
args.append("--all")
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user