2fc4a15fa3
gates / gates (push) Successful in 16s
The cooldown key was customerID:eventType plus the tier and run suffixes, and none of them names an app, so every app going down inside the same hour collapsed onto one key and only the first was mailed. Measured on demo-hp: bookstack sent 09:27:51, privatebin suppressed 09:31:51 under key=demo-hp:app_start_failed. cooldownStackSuffix is the third sibling of cooldownTierSuffix and cooldownRunSuffix, and separate for the reason the second one's docstring already gives: the existing two keep byte-identical semantics for every type that uses them. It is ALLOW-LISTED to app_start_failed and takes the event type as well as the details, unlike its siblings, and that asymmetry is the safety property. The backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full disk sends one digest rather than one mail per app - and crossdrive_failed is severity error, reaches the operator leg, and carries stack_name through a DIFFERENT struct, so a payload-shape rule would have split it silently. The hour itself does not change. Gate 11 refuses a push whose REPORT.md carries an observation with neither `FILED: R-NNN` nor `NOT-A-FINDING: <reason>`. It deliberately does NOT accept a passing mention of some other R-number: the lost item cited R-182 as an analogy, so "cites a register row" would have passed the very item the gate exists to catch. That discrepancy with the spec is recorded in the gate's docstring. Registered here and in the controller and agent runners. NOT in the catalog runner - it has no shared-gate mechanism and appends --all to every gate; filed as R-391 rather than left as a sentence, which is this session's lesson. PROMPT-TEMPLATE.md §15.9 corrected: "documented, NOT acted on" was the wording that invited the gap, and it now names the markers and points at the gate. R-390 filed for the golden-bake runbook's missing `pveam update`. Hub tests 709 -> 716.
189 lines
11 KiB
Python
189 lines
11 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)
|
|
11. observations a REPORT.md observation with no register row behind it (R-389)
|
|
|
|
WHY 11 IS HERE (2026-08-24, R-389). On 2026-08-23 a session measured on live hardware that only the
|
|
FIRST broken app per hour reaches the operator — the notification cooldown keys on the event type,
|
|
not on the app. It was real, reproducible, and written under `## Observations` in `REPORT.md`. It was
|
|
written NOWHERE ELSE. `REPORT.md` is overwritten every session by this project's own convention, so
|
|
the finding had a lifetime of exactly one session and had to be re-derived the next day. That is
|
|
gate 10's shape one surface over — a commitment recorded in prose that nothing enforces — and the
|
|
instruction invited it: PROMPT-TEMPLATE.md §15 asked for observations "documented, not acted on", and
|
|
"documented" was satisfied by the paragraph. The template was corrected in the same session; **this
|
|
gate is the mechanism that correction points at.** It REFUSES rather than warns, for gate 10's
|
|
reason. It PASSES quietly when there is no observations section, deliberately — a gate that taxes
|
|
every push is one that gets disabled within a week. `--fast` (stdlib file reads only).
|
|
|
|
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),
|
|
# R-369 — two files held open work and only one called itself the source of truth, so a READY
|
|
# finding sat in ROADMAP.md for 25 days invisible to every "grep the register" rule and was
|
|
# rediscovered by an overnight drill. Fast: two file reads.
|
|
("one-register", os.path.join(SCRIPTS, "one_register_gate.py"), [], True),
|
|
# R-389 — a live finding lived in a REPORT.md observations paragraph and nowhere else, and
|
|
# REPORT.md is overwritten every session. Fast: stdlib file reads.
|
|
("observations", os.path.join(SCRIPTS, "observations_gate.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:]))
|