R-404: block the push that can create the golden debt, notify the one that cannot
push_scope.py classifies a push as code or documents from an ALLOW-LIST of document paths - everything else, including any new top-level directory, is code. Every uncertainty (first push, force-push, merge commit, empty range, unreadable stdin) answers code: guessing 'documents' would hand out the exemption by accident. repo_gates.py gains a fifth GATES field and --scope=code|docs. On a documents-only push a golden-currency CONVICTION prints as ADVISORY in its own block and does not refuse; every other gate still refuses every push, and golden-currency still refuses a push touching code. The gate itself is UNCHANGED - its verdict, exit codes and wording are byte-identical. What changed is who is refused. Measured on git 2.47.3: a pre-push hook receives <local ref> <local sha> <remote ref> <remote sha> on stdin, one line per ref; a first push carries an all-zero remote sha and a deletion an all-zero local sha. Both land on code.
This commit is contained in:
+112
-28
@@ -87,36 +87,49 @@ 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)
|
||||
# (label, absolute script path, args, fast, exemptible)
|
||||
#
|
||||
# `exemptible` — R-404, 2026-09-01. TRUE means: on a DOCUMENTS-ONLY push this gate's CONVICTION is
|
||||
# reported as ADVISORY and does not refuse the push. It is TRUE for exactly ONE gate and the list is
|
||||
# meant to stay that length.
|
||||
#
|
||||
# WHY ONLY golden-currency. It is the only gate here that does not examine the push at all: it
|
||||
# compares the controller's newest CHANGELOG heading against this repo's bake evidence and returns
|
||||
# the same verdict whatever you are pushing. Every other gate convicts on something IN the change,
|
||||
# so a documents push that trips one has a broken document and must be refused.
|
||||
#
|
||||
# The gate's own verdict is UNCHANGED — it still runs on every push and still says the same thing.
|
||||
# What changed is who is refused. See scripts/push_scope.py and OPEN-ITEMS.md R-404.
|
||||
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),
|
||||
("site", os.path.join(SCRIPTS, "site_gates.py"), [], True, False),
|
||||
("hostinstall", os.path.join(SCRIPTS, "hostinstall_gates.py"), [], True, False),
|
||||
("hub-confirm", os.path.join(SCRIPTS, "hub_confirm_gate.py"), [], True, False),
|
||||
("manifest-bearer", os.path.join(SCRIPTS, "manifest_bearer_gate.py"), [], True, False),
|
||||
("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True, False),
|
||||
("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True, False),
|
||||
("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True, True),
|
||||
("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True, False),
|
||||
# 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),
|
||||
("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True, False),
|
||||
# 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),
|
||||
("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], True, False),
|
||||
# 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),
|
||||
("one-register", os.path.join(SCRIPTS, "one_register_gate.py"), [], True, False),
|
||||
# R-405 — the 2026-08-22 compression sweep moved R-87 into CLOSED-ITEMS.md while its own state
|
||||
# cell read READY; R-378 caught six siblings in the same session and missed this one, so it sat
|
||||
# in the wrong file for nine days while the ranking paragraph pointed at nothing. Fast: two
|
||||
# file reads.
|
||||
("closed-register", os.path.join(SCRIPTS, "closed_register_gate.py"), [], True),
|
||||
("closed-register", os.path.join(SCRIPTS, "closed_register_gate.py"), [], True, False),
|
||||
# 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),
|
||||
("observations", os.path.join(SCRIPTS, "observations_gate.py"), [ROOT], True, False),
|
||||
]
|
||||
|
||||
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
||||
ADVISORY = "ADVISORY" # R-404: a conviction that is reported, loudly, and does not refuse
|
||||
|
||||
|
||||
def hooks_armed_note(root):
|
||||
@@ -142,46 +155,90 @@ 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
|
||||
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)
|
||||
# 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. It is ALSO collected, so the advisory
|
||||
# block below can quote the gate's own words instead of re-deriving the version itself. Printing
|
||||
# happens line by line as it arrives, so this is a tee and not a capture.
|
||||
proc = subprocess.Popen([sys.executable, path] + args, cwd=ROOT,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
lines = []
|
||||
for raw in iter(proc.stdout.readline, b""):
|
||||
line = raw.decode("utf-8", "replace").rstrip("\n")
|
||||
lines.append(line)
|
||||
print(line, flush=True)
|
||||
proc.stdout.close()
|
||||
return proc.wait(), "\n".join(lines)
|
||||
|
||||
|
||||
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]")
|
||||
scope = "code"
|
||||
rest = []
|
||||
for a in argv:
|
||||
if a == "--fast":
|
||||
continue
|
||||
if a.startswith("--scope="):
|
||||
scope = a.split("=", 1)[1].strip()
|
||||
continue
|
||||
rest.append(a)
|
||||
if rest:
|
||||
print("unknown argument(s): %s" % " ".join(rest))
|
||||
print("usage: python3 scripts/repo_gates.py [--fast] [--scope=code|docs]")
|
||||
return 2
|
||||
if scope not in ("code", "docs"):
|
||||
# Fail closed: an unrecognised scope is never quietly treated as 'docs'.
|
||||
print("unrecognised --scope=%s — expected 'code' or 'docs'." % scope)
|
||||
print("An unrecognised scope is REFUSED rather than assumed, because the only assumption")
|
||||
print("that could be wrong in a dangerous direction is 'docs'.")
|
||||
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 ""))
|
||||
print("repo_gates (felhom.eu) — %d gate(s)%s%s"
|
||||
% (len(selected), " [--fast]" if fast else "",
|
||||
" [scope=docs]" if scope == "docs" 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]
|
||||
results = []
|
||||
for label, path, args, _f, exemptible in selected:
|
||||
rc, text = run_gate(label, path, args)
|
||||
results.append((label, rc, exemptible, text))
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
print("== summary")
|
||||
print("=" * 78)
|
||||
worst = 0
|
||||
for label, rc in results:
|
||||
advisories = []
|
||||
for label, rc, exemptible, text in results:
|
||||
# An ADVISORY is a CONVICTION (rc == 1) on a documents-only push, for a gate registered as
|
||||
# exemptible. INCONCLUSIVE (rc == 2) is deliberately NOT exemptible: it was never a
|
||||
# conviction, and treating "we could not tell" as "we forgive it" is a different decision
|
||||
# that nobody made.
|
||||
advisory = (scope == "docs" and rc == 1 and exemptible)
|
||||
if advisory:
|
||||
advisories.append((label, text))
|
||||
print(" %-18s %-13s (exit %d)" % (label, ADVISORY, rc))
|
||||
continue
|
||||
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 advisories:
|
||||
_print_advisory_block(advisories)
|
||||
|
||||
if worst == 0:
|
||||
print("\nall felhom.eu gates OK")
|
||||
print("\nall felhom.eu gates OK" + (" (with %d advisory — see above)" % len(advisories)
|
||||
if advisories else ""))
|
||||
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)]
|
||||
convicted = [l for l, rc, _e, _t in results if rc == 1 and not (scope == "docs" and _e)]
|
||||
undecided = [l for l, rc, _e, _t in results if rc not in (0, 1)]
|
||||
if convicted:
|
||||
print("\nCONVICTED: %s" % ", ".join(convicted))
|
||||
if undecided:
|
||||
@@ -189,5 +246,32 @@ def main(argv):
|
||||
return worst
|
||||
|
||||
|
||||
def _print_advisory_block(advisories):
|
||||
"""Its own block, after the table, because a line inside a table is easy to miss.
|
||||
|
||||
R-404's whole premise is that the warning must NOT go quiet — only its consequence changes. If
|
||||
this block ever stops printing, the change has become a silencing and Scenario A has failed.
|
||||
"""
|
||||
print("\n" + "!" * 78)
|
||||
for label, text in advisories:
|
||||
print("!! ADVISORY — %s convicted, and this push is NOT refused for it." % label)
|
||||
# Quote the gate's own numbers rather than re-deriving them; a second implementation of
|
||||
# "which version owes a golden" is a second thing that can be wrong.
|
||||
for line in text.splitlines():
|
||||
t = line.strip()
|
||||
if t.startswith("newest released controller") or t.startswith("newest golden baked"):
|
||||
print("!! %s" % t)
|
||||
print("!!")
|
||||
print("!! This push touches DOCUMENTS ONLY, so it can neither create this debt nor clear")
|
||||
print("!! it — and the push that DOES clear it (a bake record under documentation/tests/)")
|
||||
print("!! is itself documents-only. Blocking here blocked the cure.")
|
||||
print("!!")
|
||||
print("!! WHAT CLEARS IT: bake a golden per documentation/runbooks/RUNBOOK-manual-build.md")
|
||||
print("!! section 4.1, then vouch it (a THREE-field change: golden_version + agent_version")
|
||||
print("!! + min_agent). The debt stays visible in STATUS.md and in the controller repo's")
|
||||
print("!! own golden-notice until then.")
|
||||
print("!" * 78)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
|
||||
Reference in New Issue
Block a user