0a5e9b14dc
gates / gates (push) Successful in 14s
PART 1+2 — dated checks stop being wishes. R-341 booked two measurements as prose in a register row; nothing read those dates and nothing would have objected when they passed. The dates now live in a DUE-CHECKS block INSIDE OPEN-ITEMS.md (inside, so no sidecar can drift from it) and a new gate reads them. Registered as #10 in repo_gates.py, --fast, so it runs in BOTH the pre-push hook and CI. exit 0 nothing due (prints pending count + nearest date; empty block too) exit 1 a row is due/overdue (due <= today, UTC -- due TODAY counts), or a row names an item with no R-row exit 2 block absent/duplicated/unparseable -- INCONCLUSIVE, never 0 It REFUSES rather than warns, and its docstring states the limitation: it is NOT a scheduler, it fires on the next push, not on the date. 37 tests. BOTH red-proofs run and reverted -- and the first one earned its keep by catching a hollow assertion of MINE rather than confirming the gate: flipping <= to < left a due-today row in neither bucket, min() raised on an empty list, and the TRACEBACK exited 1, so "rc == 1" passed while the boundary was wrong. An exit code cannot tell a verdict from a crash. The test now asserts the conviction banner and the absence of a traceback, and the gate returns 2 rather than crashing if that partition breaks again. PART 3 — the floor raise, and the premise was WRONG. Read back from the store (not the form): min_controller_version = 0.216.0 @ 12:36:58Z, zero per-customer overrides, no "managed floor HELD" line. But read 5 shows the raise was NOT a no-op: demo-felhom had been on 0.214.0 since 12 Aug and auto-updated 0.214.0 -> 0.216.0 at 12:37:07Z -- NINE SECONDS after the save, exactly the immediate action publish-train rule 2 documents. No error events followed; it restarted clean. R-343 is therefore filed OPEN, not CLOSED: the closing condition was all five reads clean and no directive served. It went well, but a record calling it inert when it moved a customer box is what misleads the next reader. The row also states why the floor was behind -- rule 2 policy, not drift, earned by the 2026-07-11 skew onto Peti's box -- and cites ResolveManagedFloor (store.go:2068) plus the two build-felhom-iso.sh facts (build-time at :267, fails open at :78-82) rather than asserting them. Two boxes are below the floor and neither reports: drill-r50 (blocked, powered off) and peti-felhom (host row deleted). peti-felhom was NOT contacted -- its row records that a report from a deleted host 401s and is not persisted, so the raise cannot reach it. PART 4 — R-342 filed READY, quoting stop2-snapshot.txt verbatim: Hetzner server snapshot 421440873 covers /dev/sda only; /mnt/pbs-datastore is a separate Volume that snapshots exclude, so a rollback restores software state and NOT the datastore. Fine for that upgrade; the safeguard for any future procedure that could touch the datastore does not exist and is Viktor's call. Also: CLAUDE.md's gate list named 6 of 10 registered gates -- completed rather than appending a 7th to a wrong list (124 -> 128 effective, ceiling 200). Capability map deliberately unchanged; no row cites a floor or golden version. repo_gates.py fully green, 10/10.
169 lines
9.1 KiB
Python
169 lines
9.1 KiB
Python
#!/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
|
|
6. instructions CLAUDE.md length/versions/TEMPORARY, rule-file scoping, workspace-copy identity
|
|
7. golden-currency a released controller has a golden carrying it (R-242)
|
|
8. wire-contract every emitted field is decodable by its receiver (G-1)
|
|
9. hub-copy the hub's customer-facing words, against the retired-name list (R-324)
|
|
10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341)
|
|
|
|
WHY 10 IS HERE (2026-08-18, R-341). R-341 booked two dated measurements — +24 h and +7 d —
|
|
as a sentence inside a register row. Nothing read those dates, and nothing would have said a word
|
|
when they passed; the row would simply have gone quiet and stayed that way. That is the same shape
|
|
as R-242 (a rule filed without a mechanism, which recurred the next day) and as the R-29 census
|
|
finding below, where the checks nobody was told to run were the ones that had been failing for
|
|
weeks. The dates now live in a machine-readable block INSIDE OPEN-ITEMS.md — inside, so there is no
|
|
sidecar to drift from the register — and this gate refuses the push once one comes due. It REFUSES
|
|
rather than warns, deliberately: a warning is the thing that gets scrolled past, and this repo has
|
|
the census to prove it. It is `--fast` (stdlib file read, no network) so it runs in both the
|
|
pre-push hook and CI. **It is not a scheduler and its docstring says so** — it fires on the next
|
|
push after a date passes, not on the date.
|
|
|
|
WHY 7 IS HERE (2026-08-08, R-242). R-242 was filed as a rule with no mechanism — *a controller
|
|
release is not finished until a golden carries it* — and RECURRED THE NEXT DAY: v0.206.0 shipped
|
|
while the vouched golden still carried 0.205.0, so a machine installed that morning would have got
|
|
neither of the R-241 fixes. Two occurrences in two days, the first (R-239) invisible until a walk
|
|
measured it from the customer's side. It is `--fast` because both the pre-push hook and CI run only
|
|
`--fast`; a non-fast gate would run in neither, which is the R-29 failure this runner ended. That
|
|
constraint is why it checks the BAKE and not the vouch — the full reasoning is in its docstring.
|
|
|
|
WHY 6 IS HERE AND WAS NOT (2026-08-06, R-229 deferred leg). instructions_gate.py LIVES in this
|
|
repo's scripts/ and was registered in the controller and agent runners on the day it was written —
|
|
but not in this one, because this repo's own CLAUDE.md was still 27 lines over the ceiling and a
|
|
registered-but-failing gate refuses every push through .githooks/pre-push. The file was trimmed
|
|
(227 -> 115 effective lines, core + .claude/rules/) and the gate registered in the same session.
|
|
A check that does not run in the place it applies is the exact failure the R-29 gate census found.
|
|
|
|
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),
|
|
("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True),
|
|
("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True),
|
|
("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True),
|
|
# R-324 — the hub composes every customer e-mail and renders the binding pages, and until
|
|
# 2026-08-13 no guard in either repo had ever looked at them. Fast: pure file reads.
|
|
("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True),
|
|
# R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read.
|
|
("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], 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:]))
|