22e1c95e6a
gates / gates (push) Failing after 17s
R-410. golden_currency_gate.py matched EVIDENCE_RE against os.listdir and read nothing inside, so `mkdir documentation/tests/golden-9.9.9-2026-01-01` turned it green with no bake behind it - noticed while the 0.230.0 bake was running, when the evidence directory existed before the bake finished. It now reads the GOLDEN_SHA256= line out of that directory's bake log: a directory name is a label, that line is a fact only a completed publish produces. Still offline, still --fast, one file read. Directories that look right and hold nothing are printed by name rather than silently ignored, so a half-finished bake is visible. test_golden_currency_gate.py ships the red-proof with a POSITIVE CONTROL, without which "it fails on an empty directory" would be satisfied by a gate that fails on everything: CASE 1 empty directory -> rejected and named CASE 2 log with no GOLDEN_SHA256 -> rejected CASE 3 real bake log -> counted, and its sha read <- the control CASE 4 the tree is left byte-identical Red-proofed: reverting the gate to name-matching fails cases 1, 2 and 3. R-406. Citations MEASURED before choosing, which is what the row asked for: hub-uniqueness had 3 references (all inside one audit doc), plaintext-break-glass had 5 (CONTEXT.md, break-glass.md, hub/CHANGELOG.md, the capability map, a spike). The FEWER-cited one moved - hub uniqueness is now R-415 - and all three citations were rewritten to "R-415 (was R-133)" rather than silently swapped. THIS IS THE OPPOSITE OF THE TASK'S LITERAL INSTRUCTION, which said renumber the second row on the stated ground that "the older number has the longer reference trail". Measured, that ground points the other way. The principle was followed and the letter was not, and the row says so rather than leaving an unexplained diff. R-416 filed: the within-register duplicate rule was deliberately NOT added in the same commit that removed its only subject - a guard whose red-proof can only be a planted fixture is not this project's standard. Now that the register is clean it can ship with the next real duplicate as its first subject.
240 lines
13 KiB
Python
240 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Golden-currency gate (R-242) — a controller release is not DELIVERED until a golden carries it.
|
|
|
|
Run from the repo root: python3 scripts/golden_currency_gate.py
|
|
Exit 0 clean · 1 convicted (a released controller has no golden) · 2 inconclusive.
|
|
|
|
WHY THIS EXISTS, and why a rule was not enough.
|
|
|
|
R-242 was filed on 2026-08-07 as a mechanism-less rule: *a controller release that changes
|
|
customer-visible behaviour is not finished until a golden carries it, and nothing enforces that.*
|
|
It was deliberately recorded and not built. **It recurred the next day** — controller v0.206.0 shipped
|
|
the R-241 fixes while the vouched golden still carried 0.205.0, so a machine installed that morning
|
|
would have received neither. That is the second occurrence in two days (the first, R-239, went
|
|
unnoticed until a walk measured it from the customer's side), and it is what a rule without a
|
|
mechanism does.
|
|
|
|
The failure mode is FORGETTING, not lying — nobody ever decided to ship a stale golden. So this gate
|
|
is built to catch a missed step, and it is not, and does not pretend to be, an adversarial control.
|
|
|
|
⚠ WHAT IT CHECKS, AND WHAT IT DELIBERATELY DOES NOT — read this before trusting a green.
|
|
|
|
It checks that a golden has been **BAKED** for the newest released controller, by looking for that
|
|
version's bake-evidence directory in this repo. It does **NOT** check that the golden was **VOUCHED**,
|
|
because the vouched version lives ONLY in the hub's `hub_settings` table — there is no copy in git.
|
|
|
|
That limit is forced, not chosen, and the reasoning is recorded so nobody re-derives it:
|
|
|
|
* Both the pre-push hook AND CI run `repo_gates.py --fast`, which by contract selects only gates
|
|
that touch **no network**. A hub-reading gate could therefore be registered as non-fast and would
|
|
then run in NEITHER place — a check that does not run where it applies is precisely the R-29
|
|
census failure this repo's runner was built to end. A gate nobody runs is worse than no gate,
|
|
because it reads as coverage.
|
|
* Recording the vouched version in a tracked file instead would create a second source of truth
|
|
that can drift from the hub, and a green gate over a false claim is the worst outcome available.
|
|
|
|
**So a bake without a vouch still passes this gate.** The bake is the step that happens in this repo
|
|
and is therefore the step this repo can see; the vouch is an operator act against the hub and needs a
|
|
different mechanism. That gap is real and is recorded as R-242's remaining half, NOT papered over
|
|
here. In practice the two are minutes apart in the same session, and the recurrence this gate is
|
|
built for was a missing BAKE.
|
|
|
|
WHY VERSION AND NOT BEHAVIOUR. It compares version numbers, so a controller release that changed
|
|
nothing a customer can see also trips it. That is accepted deliberately: deciding "customer-visible"
|
|
mechanically is not possible, judging it by hand is what already failed twice, and the cost of a
|
|
false trip is one bake — which is the operation the project wants to be routine anyway. **A gate that
|
|
cries wolf is one people learn to bypass, and `--no-verify` exists**, so the tolerance is stated
|
|
rather than assumed: if this ever fires on a release nobody wants a golden for, the honest fix is a
|
|
recorded waiver in the register, never a habit of bypassing.
|
|
|
|
FAIL-CLOSED, BUT HONEST ABOUT NOT KNOWING. An absent controller clone, or a CHANGELOG whose top
|
|
header cannot be parsed, exits **2 (INCONCLUSIVE)** — never 0. The runner reports 2 distinctly for
|
|
exactly this reason: an undetermined result is not a pass, and it is not a conviction either.
|
|
|
|
── THE SECOND BLINDNESS, R-385 (added 2026-08-23) ────────────────────────────────────────────
|
|
|
|
Until this change the gate asked ONE question — *is the golden BEHIND the record?* — and so it could
|
|
only ever catch a forgotten bake. It said nothing when the golden was **AHEAD** of the record, and
|
|
that is not a harmless direction: a golden ahead of every CHANGELOG heading was built from something
|
|
**never written down**.
|
|
|
|
That is not a hypothetical. Controller **0.221.1** was built, baked AND vouched on 2026-08-23 while
|
|
the newest heading in the controller CHANGELOG still read v0.221.0 — the fix had been written inside
|
|
the v0.221.0 entry instead of getting its own. Every gate was green throughout, including this one,
|
|
measured: `newest released 0.221.0 / newest golden baked 0.221.1 → OK`. The fleet ran a version the
|
|
record did not name.
|
|
|
|
So the test is no longer "behind?" but "**is the version we are shipping WRITTEN DOWN?**". The gate
|
|
now looks for the baked version's own `## vX.Y.Z` heading anywhere in the CHANGELOG — not merely at
|
|
the top, because an entry may legitimately be overtaken by later ones; what may never happen is that
|
|
it is absent. An unrecorded golden is convicted (exit 1) exactly like a stale one.
|
|
|
|
**Why membership and not `baked > released`.** A comparison against the newest heading alone would go
|
|
green again the moment ANY later entry was written, leaving 0.221.1 permanently unrecorded and the
|
|
gate permanently silent about it. Membership cannot be satisfied by an unrelated later release.
|
|
"""
|
|
import io
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
# The controller clone sits beside this one. The same sibling assumption reuse_refs_check.py and
|
|
# instructions_gate.py already make — an absent sibling is INCONCLUSIVE, never a silent pass.
|
|
CONTROLLER_CHANGELOG = os.path.join(os.path.dirname(ROOT), "felhom-controller", "CHANGELOG.md")
|
|
EVIDENCE_DIR = os.path.join(ROOT, "documentation", "tests")
|
|
|
|
# `## v0.206.0 — …` on the FIRST such line: the CHANGELOG is newest-first by convention.
|
|
RELEASED_RE = re.compile(r"^##\s+v(\d+)\.(\d+)\.(\d+)\b")
|
|
# `golden-0.205.0-2026-08-07/` — the bake-evidence directory the runbook's §4.1 produces.
|
|
EVIDENCE_RE = re.compile(r"^golden-(\d+)\.(\d+)\.(\d+)-\d{4}-\d{2}-\d{2}$")
|
|
|
|
|
|
def released_versions():
|
|
"""(newest_tuple, note, set_of_all_tuples) of the controller releases, or (None, reason, set()).
|
|
|
|
R-385: the whole set is returned, not only the newest. The newest answers "is the golden behind?";
|
|
membership answers "is the version we are shipping written down at all?" — and only the second
|
|
question could have caught 0.221.1, whose heading was missing while a NEWER heading existed.
|
|
"""
|
|
if not os.path.isfile(CONTROLLER_CHANGELOG):
|
|
return None, "controller clone not found at %s" % CONTROLLER_CHANGELOG, set()
|
|
newest = None
|
|
note = ""
|
|
every = set()
|
|
with open(CONTROLLER_CHANGELOG, encoding="utf-8") as fh:
|
|
for line in fh:
|
|
m = RELEASED_RE.match(line)
|
|
if m:
|
|
v = tuple(int(g) for g in m.groups())
|
|
every.add(v)
|
|
if newest is None:
|
|
# newest-first by convention: the FIRST heading is the newest release.
|
|
newest, note = v, line.strip()[:90]
|
|
if newest is None:
|
|
return None, "no '## vX.Y.Z' header found in %s" % CONTROLLER_CHANGELOG, set()
|
|
return newest, note, every
|
|
|
|
|
|
# GOLDEN_SHA_RE — the line `build-golden.sh` prints when it has actually published a bake.
|
|
# Reading THIS, rather than the directory's name, is R-410's whole fix.
|
|
GOLDEN_SHA_RE = re.compile(r"^GOLDEN_SHA256=([0-9a-f]{64})\s*$", re.MULTILINE)
|
|
|
|
# The bake log, as the runbook's §4.1 teardown copies it out. Two names are accepted because the
|
|
# 0.230.0 bake wrote `06-bake.log` alongside its README while 0.229.0 wrote `bake.log`; both are real
|
|
# bakes and neither should be called a fake.
|
|
BAKE_LOG_NAMES = ("bake.log", "06-bake.log", "bake-clean.log", "06-bake-clean.log")
|
|
|
|
|
|
def bake_sha_in(dirpath):
|
|
"""The GOLDEN_SHA256 a bake log in dirpath records, or None with the reason it could not be read.
|
|
|
|
R-410. Until 2026-09-01 this gate matched EVIDENCE_RE against `os.listdir` and nothing else, so
|
|
`mkdir documentation/tests/golden-9.9.9-2026-01-01` turned it green with no bake behind it —
|
|
noticed while the 0.230.0 bake was running, when the evidence directory was created BEFORE the
|
|
bake finished and the gate would have passed at that moment.
|
|
|
|
A directory NAME is a label; `GOLDEN_SHA256=<64 hex>` is a fact only a completed publish produces.
|
|
Reading it keeps the gate offline and `--fast`: it is one file read, no network.
|
|
"""
|
|
for n in BAKE_LOG_NAMES:
|
|
p = os.path.join(dirpath, n)
|
|
if not os.path.isfile(p):
|
|
continue
|
|
try:
|
|
with io.open(p, encoding="utf-8", errors="replace") as fh:
|
|
m = GOLDEN_SHA_RE.search(fh.read())
|
|
except OSError as e:
|
|
return None, "bake log %s could not be read (%s)" % (n, e)
|
|
if m:
|
|
return m.group(1), n
|
|
return None, "bake log %s carries no GOLDEN_SHA256= line" % n
|
|
return None, "no bake log (looked for %s)" % ", ".join(BAKE_LOG_NAMES)
|
|
|
|
|
|
def newest_baked():
|
|
"""(tuple, str) of the newest golden bake recorded here, or (None, reason).
|
|
|
|
A directory counts ONLY if it holds a bake log with a GOLDEN_SHA256 line (R-410). Directories that
|
|
look right and hold nothing are reported by name, so a half-finished bake is visible rather than
|
|
silently ignored.
|
|
"""
|
|
if not os.path.isdir(EVIDENCE_DIR):
|
|
return None, "bake-evidence directory not found at %s" % EVIDENCE_DIR
|
|
found, rejected = [], []
|
|
for name in os.listdir(EVIDENCE_DIR):
|
|
m = EVIDENCE_RE.match(name)
|
|
if not m:
|
|
continue
|
|
sha, why = bake_sha_in(os.path.join(EVIDENCE_DIR, name))
|
|
if sha is None:
|
|
rejected.append("%s (%s)" % (name, why))
|
|
continue
|
|
found.append((tuple(int(g) for g in m.groups()), "%s [sha %s…]" % (name, sha[:12])))
|
|
if rejected:
|
|
print(" NOT counted as bakes — a directory name is not a bake (R-410):")
|
|
for r in sorted(rejected):
|
|
print(" %s" % r)
|
|
if not found:
|
|
return None, ("no golden-<VER>-<DATE>/ directory under %s holds a bake log with a "
|
|
"GOLDEN_SHA256 line" % EVIDENCE_DIR)
|
|
found.sort()
|
|
return found[-1]
|
|
|
|
|
|
def vstr(v):
|
|
return ".".join(str(p) for p in v)
|
|
|
|
|
|
def main():
|
|
released, rel_note, every_released = released_versions()
|
|
if released is None:
|
|
print("GOLDEN CURRENCY GATE INCONCLUSIVE: %s" % rel_note)
|
|
sys.exit(2)
|
|
baked, bake_note = newest_baked()
|
|
if baked is None:
|
|
print("GOLDEN CURRENCY GATE INCONCLUSIVE: %s" % bake_note)
|
|
sys.exit(2)
|
|
|
|
# Print the evidence unconditionally — a gate that only speaks when it fails teaches nobody what
|
|
# it is watching, and this one is watching the thing two releases already slipped through.
|
|
print(" newest released controller : %s (%s)" % (vstr(released), rel_note))
|
|
print(" newest golden baked : %s (documentation/tests/%s)" % (vstr(baked), bake_note))
|
|
|
|
# R-385 — UNRECORDED, checked before "behind". A golden whose version has no heading of its own
|
|
# was built from something never written down, and that is a different (worse) fault than a
|
|
# forgotten bake: there is nothing to read to find out what the fleet is running.
|
|
if baked not in every_released:
|
|
print("")
|
|
print("GOLDEN CURRENCY GATE FAILED: golden %s is baked but UNRECORDED — the controller "
|
|
"CHANGELOG has no '## v%s' heading." % (vstr(baked), vstr(baked)))
|
|
print("The newest heading is %s. A golden ahead of the record was built from a version "
|
|
"nobody wrote down, so no one can read what the fleet is running." % vstr(released))
|
|
print("Fix: give v%s its own '## v%s — <what changed>' heading in "
|
|
"felhom-controller/CHANGELOG.md, above the entries it supersedes. If its fix is "
|
|
"currently described inside another version's entry, MOVE that text — do not "
|
|
"duplicate it, and do not delete the reasoning." % (vstr(baked), vstr(baked)))
|
|
print("If this bake was a throwaway that must never be delivered, delete its "
|
|
"documentation/tests/golden-<VER>-<DATE>/ directory — never leave it to read as "
|
|
"shipped.")
|
|
sys.exit(1)
|
|
|
|
if released > baked:
|
|
print("")
|
|
print("GOLDEN CURRENCY GATE FAILED: controller v%s is released and NO golden carries it "
|
|
"(newest bake is %s)." % (vstr(released), vstr(baked)))
|
|
print("A machine installed right now would receive v%s — the release is written, tested and "
|
|
"pushed, and NOT delivered." % vstr(baked))
|
|
print("Fix: bake a golden per documentation/runbooks/RUNBOOK-manual-build.md §4.1, then vouch "
|
|
"it (a THREE-field change: golden_version + agent_version + min_agent).")
|
|
print("If this release deliberately needs no golden, record a waiver in "
|
|
"documentation/backlog/OPEN-ITEMS.md — never a bypass.")
|
|
sys.exit(1)
|
|
|
|
print("golden currency gate OK — the newest released controller has a golden "
|
|
"(NOTE: this checks the BAKE, not the vouch — see the module docstring)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|