diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..02e8f79 --- /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 [felhom.eu]: running scripts/repo_gates.py --fast ..." +python3 "scripts/repo_gates.py" --fast +rc=$? +if [ "$rc" -ne 0 ]; then + echo "pre-push [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 [felhom.eu]: gates OK - push proceeding." +fi +exit $rc diff --git a/CLAUDE.md b/CLAUDE.md index 9d8ffb7..d612a26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,11 +174,33 @@ Steps: commit+push code → `cd /mnt/5_hdd/felhom.eu/build/felhom-hub && ./build (local) → bump `manifests/hub.yaml` tag + push → ArgoCD hard-refresh + sync (kubectl-patch method in the skill, now local `sudo kubectl`) → verify Synced/Healthy + rollout + image + startup log. +## Gates — ONE entry point + +**Run `python3 scripts/repo_gates.py` after ANY change in this repo.** It is the one entry point +and runs every gate — `site_gates.py`, `hostinstall_gates.py`, `hub_confirm_gate.py`, +`manifest_bearer_gate.py` and `reuse_refs_check.py` on this root — streaming each gate's own output +and exiting non-zero if any fails. `--fast` selects only the gates that touch no network and no +container runtime; today that is all of them. A missing gate script is a FAILURE, never a skip. + +**Why a runner and not five invocations** (2026-08-02, R-29): a census of all thirteen gates across +the four repos found that every check a `CLAUDE.md` names was passing, and two of the four nobody +is told to run were failing — one since 14 July. The single-entry-point shape is the only one that +demonstrably gets run here; `app-catalog-felhom.eu/scripts/catalog_gates.py` is the canonical +version of it (R-161) and `repo_gates.py` copies it. `site_gates.py` is a *gate*, not a runner — +do not model new work on it. + +**The pre-push hook.** `.githooks/pre-push` runs `repo_gates.py --fast` and refuses the push if it +fails. It is **per-clone** and switched on once with `git config core.hooksPath .githooks` — a +clone does not carry it, and any manual `repo_gates.py` run WARNS when this clone is unarmed. +`git push --no-verify` bypasses it deliberately; **say so in the session report when you use it**. +Both facts are why continuous integration is still owed (`OPEN-ITEMS.md` R-168) — this hook is +local and skippable, and only CI is neither. + ## Build & deploy — Website / Manifests -- **Website** auto-deploys via git-sync; just push to `main` (live in 1–2 min). **Run - `python3 scripts/site_gates.py` after ANY website change**; new pages go into its `PAGES` list. - Emergency edits: https://files.felhom.eu. All `website/` HTML is **UTF-8 with BOM** — preserve it. +- **Website** auto-deploys via git-sync; just push to `main` (live in 1–2 min). Website changes go + through `repo_gates.py` above (it runs `site_gates.py`); new pages go into that gate's `PAGES` + list. Emergency edits: https://files.felhom.eu. All `website/` HTML is **UTF-8 with BOM** — preserve it. - **Manifests** are GitOps via the `felhom` app — commit to `main`, then deliberate sync. ## Key patterns diff --git a/scripts/repo_gates.py b/scripts/repo_gates.py new file mode 100644 index 0000000..8ab4989 --- /dev/null +++ b/scripts/repo_gates.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""repo_gates.py — THE entry point for this repo's gates. Run from the repo root: + + python3 scripts/repo_gates.py # every gate + python3 scripts/repo_gates.py --fast # only gates that touch no network and no container + # runtime (what .githooks/pre-push runs) + +Gates, in order (all must pass; **non-zero exit on any failure**): + + 1. site website HTML: BOM, emoji, nav/footer, analytics, CDN, tokens, cache-busting + 2. hostinstall felhom-host-install.sh's five drill-swept invariants (+ R-94's absent-version) + 3. hub-confirm no native confirm()/prompt() in hub templates + 4. manifest-bearer no bearer-shaped literal anywhere in manifests/ + 5. reuse-refs every path cited by this repo's REUSE.md still resolves + +WHY THIS FILE EXISTS (2026-08-02, closing R-29 leg (a) and half of leg (b)). + +A census of all thirteen gate scripts across the four felhom repos found one clean correlation: +**every check a CLAUDE.md tells a person to run was passing, and two of the four nobody is told +to run were failing** — one since 14 July. Neither failure was harmful in effect, which was +checked line by line; nothing would have said so if they had been. The fix is not more gates, it +is one place to run them from. `app-catalog-felhom.eu/scripts/catalog_gates.py` is the canonical +shape (R-161) and this copies it deliberately rather than inventing a second one. + +`site_gates.py` is a GATE — eight assertions in one file — and is NOT the model for this file. A +runner that invokes separate gates is the shape that survives; copying site_gates would just add +a ninth monolith. + +FAIL-CLOSED. A gate script that is missing is a FAILURE, never a skip, and the exact path tried +is printed. A runner that quietly drops a gate is the inert-seam failure this project has shipped +four times. + +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, but it is not a conviction either, and the operator needs to know which they have. +""" +import os +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPTS = os.path.join(ROOT, "scripts") + +# (label, absolute script path, args, fast) +GATES = [ + ("site", os.path.join(SCRIPTS, "site_gates.py"), [], True), + ("hostinstall", os.path.join(SCRIPTS, "hostinstall_gates.py"), [], True), + ("hub-confirm", os.path.join(SCRIPTS, "hub_confirm_gate.py"), [], True), + ("manifest-bearer", os.path.join(SCRIPTS, "manifest_bearer_gate.py"), [], True), + ("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True), +] + +VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} + + +def hooks_armed_note(root): + """Print a WARNING (never a failure) when this clone's pre-push hook is not switched on. + + core.hooksPath is local config and a clone does not carry it, so an unarmed clone is silent + by construction — this is the only place it becomes visible. + """ + try: + val = subprocess.check_output(["git", "config", "--get", "core.hooksPath"], + cwd=root, stderr=subprocess.DEVNULL).decode().strip() + except Exception: + val = "" + norm = val.replace("\\", "/").rstrip("/") + if norm == ".githooks" or norm.endswith("/.githooks"): + return + print("WARNING: this clone is UNARMED — core.hooksPath is %s, so the pre-push hook will not\n" + " run here. Switch it on once with: git config core.hooksPath .githooks" + % (("'" + val + "'") if val else "unset")) + + +def run_gate(label, path, args): + if not os.path.exists(path): + print("\nFAIL: gate '%s' is MISSING — tried %s" % (label, path)) + print(" A missing gate is a failure, never a skip (fail-closed).") + return 1 + print("\n" + "=" * 78) + print("== gate: %s (%s%s)" % (label, os.path.basename(path), + (" " + " ".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 main(argv): + fast = "--fast" in argv + unknown = [a for a in argv if a != "--fast"] + if unknown: + print("unknown argument(s): %s" % " ".join(unknown)) + print("usage: python3 scripts/repo_gates.py [--fast]") + return 2 + + 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)] + print("repo_gates (felhom.eu) — %d gate(s)%s" % (len(selected), " [--fast]" if fast else "")) + if skipped: + print(" --fast SKIPPED (deliberate periodic runs, never in a hook): %s" % ", ".join(skipped)) + hooks_armed_note(ROOT) + + results = [(label, run_gate(label, path, args)) for label, path, args, _f in selected] + + print("\n" + "=" * 78) + print("== summary") + print("=" * 78) + worst = 0 + for label, rc in results: + print(" %-18s %-13s (exit %d)" % (label, VERDICT.get(rc, "ERROR"), rc)) + if rc != 0: + worst = 1 if rc == 1 or worst == 1 else 2 + if worst == 0: + print("\nall felhom.eu 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:])) diff --git a/scripts/test_repo_gates.py b/scripts/test_repo_gates.py new file mode 100644 index 0000000..1f8b27d --- /dev/null +++ b/scripts/test_repo_gates.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""Seam test for scripts/repo_gates.py. + +Run: python3 scripts/test_repo_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, and this project has shipped an inert seam four times. So +the assertion is on each member gate's OWN distinctive stdout — never on the runner's summary +line, which the runner can print without ever calling anything — plus the exit code, which is a +runner's actual effect. + +Red-proofed 2026-08-02: replacing run_gate's body with `return 0` (the inert runner) turns +test_every_member_gate_actually_ran red while the summary still prints "all felhom.eu gates OK". +""" +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", "repo_gates.py") + +# (label, a substring only THAT gate can print) +FINGERPRINTS = [ + ("site", "site gates OK"), + ("hostinstall", "hostinstall gates: ALL PASS"), + ("hub-confirm", "hub confirm gate"), + ("manifest-bearer", "manifest bearer gate"), + ("reuse-refs", "cited paths — exact"), +] + + +class RepoGatesTest(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_every_member_gate_actually_ran(self): + for label, fingerprint in FINGERPRINTS: + self.assertIn(fingerprint, self.out, + "gate %r is listed but its own output never appeared — an inert runner " + "prints the summary without calling anything:\n%s" % (label, self.out)) + + def test_unknown_argument_is_rejected(self): + p = subprocess.run([sys.executable, ENTRY, "--nope"], cwd=ROOT, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + self.assertEqual(p.returncode, 2, p.stdout.decode("utf-8", "replace")) + + +if __name__ == "__main__": + unittest.main(verbosity=2)