gates: one entry point (scripts/repo_gates.py) + pre-push hook
A census of all thirteen gate scripts across the four felhom repos on 2026-08-02 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 (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. repo_gates.py runs site + hostinstall + hub-confirm + manifest-bearer + reuse-refs, streams each gate's own output, and exits worst-wins non-zero. A missing gate script is a FAILURE and prints the path tried — fail-closed, because a runner that quietly skips a gate is the inert-seam failure this project has shipped four times. It copies catalog_gates.py (R-161), NOT site_gates.py, which is a gate and not a runner. .githooks/pre-push runs it with --fast and refuses the push. Honest limits are written into the hook itself: per-clone (core.hooksPath is local config), and --no-verify bypasses it on purpose. Any manual run WARNS when the clone is unarmed. Measured on git 2.47.3: a relative core.hooksPath resolves correctly and the hook's cwd is the repo root from any subdirectory. test_repo_gates.py is a SEAM test — it asserts each member gate's own distinctive stdout, not the runner's summary line, which an inert runner prints while calling nothing. Red-proofed: replacing run_gate's body with 'return 0' still prints 'all felhom.eu gates OK' and exits 0, and turns the seam test 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 [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
|
||||||
@@ -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
|
(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.
|
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
|
## Build & deploy — Website / Manifests
|
||||||
|
|
||||||
- **Website** auto-deploys via git-sync; just push to `main` (live in 1–2 min). **Run
|
- **Website** auto-deploys via git-sync; just push to `main` (live in 1–2 min). Website changes go
|
||||||
`python3 scripts/site_gates.py` after ANY website change**; new pages go into its `PAGES` list.
|
through `repo_gates.py` above (it runs `site_gates.py`); new pages go into that gate's `PAGES`
|
||||||
Emergency edits: https://files.felhom.eu. All `website/` HTML is **UTF-8 with BOM** — preserve it.
|
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.
|
- **Manifests** are GitOps via the `felhom` app — commit to `main`, then deliberate sync.
|
||||||
|
|
||||||
## Key patterns
|
## Key patterns
|
||||||
|
|||||||
@@ -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:]))
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user