gates: the newest release header must state its MinAgent; backfill v0.233.0-v0.236.0 (R-470, R-472)
gates / gates (push) Successful in 12s

From hub v0.112.0 a floor above the golden is served only with a declared
MinAgent, read from this CHANGELOG's newest `## vX.Y.Z` header. New fast,
blocking gate minagent_header_gate.py: that block must contain a line
starting `**MinAgent: X.Y.Z**`; prose, a code span or a non-bold mention
does not count (decoy test; red-proof F: relaxing the match to a body
mention fails test_decoy_body_mention_is_not_the_line).

Backfill: v0.233.0, v0.234.0, v0.235.0 and v0.236.0 carried no MinAgent
line. Each now reads `**MinAgent: 0.129.0** (unchanged)`, the value of the
nearest earlier header (v0.232.0). Proof it did not move: zero commits
under controller/internal/agentapi since 2026-09-01 (last: bb50e12,
2026-08-14), and the highest featureMinAgent entry is 0.129.0.
This commit is contained in:
2026-09-13 17:09:33 +02:00
parent cf8a371c39
commit f946b0d0ca
6 changed files with 144 additions and 0 deletions
+2
View File
@@ -84,6 +84,8 @@ GATES = [
("instructions", SHARED_INSTRUCTIONS, [REPO], True, True),
# R-389 — a REPORT.md observation with no register row behind it. Fast: stdlib file reads.
("observations", SHARED_OBSERVATIONS, [REPO], True, True),
# R-470/R-472 — the newest release header states its MinAgent; the hub's floor reads it. Fast.
("minagent-header", os.path.join(SCRIPTS, "minagent_header_gate.py"), [], True, True),
# R-404 — ADVISORY. Reports the golden debt where it is created; never refuses.
("golden-notice", GOLDEN_NOTICE, [REPO], True, False),
]
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""minagent_header_gate.py — the newest release header must state its MinAgent (R-470, R-472).
Run from controller/: python3 scripts/minagent_header_gate.py
Exit 0 clean · 1 the newest `## vX.Y.Z` block has no `**MinAgent: X.Y.Z**` line · 2 inconclusive.
WHY THIS EXISTS. From hub v0.112.0 a controller release can reach the fleet by the managed floor
WITHOUT a golden, because the operator declares the release's agent requirement with the floor
(operator ruling 2026-09-13, R-472). That declaration is READ from this file's newest header — the
`**MinAgent: 0.129.0** (unchanged)` line `RUNBOOK-manual-build.md` §4.1 already told the vouch to
read. Four headers (v0.233.0 … v0.236.0) did not carry it (R-470), so the value the hub is now told
to trust would have had to be guessed. A rule with no gate is a wish; this is the gate.
WHAT COUNTS. The block from the newest `## vX.Y.Z` heading to the next `## ` heading must contain a
line that STARTS with `**MinAgent: <X.Y.Z>**`. A mention of the word anywhere else — prose saying
"MinAgent is unchanged", a code span, the heading itself — is the label without the fact (R-421)
and does not count.
"""
import io
import os
import re
import sys
SCRIPTS = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(SCRIPTS))
CHANGELOG = os.environ.get("MINAGENT_GATE_CHANGELOG", os.path.join(REPO, "CHANGELOG.md"))
HEADER_RE = re.compile(r"^##\s+v(\d+\.\d+\.\d+)\b")
MINAGENT_LINE_RE = re.compile(r"^\*\*MinAgent:\s*(\d+\.\d+\.\d+)\*\*")
def newest_block(lines):
"""(version, block_lines) for the newest `## vX.Y.Z` heading, or (None, [])."""
start, version = None, None
for i, line in enumerate(lines):
m = HEADER_RE.match(line)
if m:
start, version = i, m.group(1)
break
if start is None:
return None, []
block = []
for line in lines[start + 1:]:
if line.startswith("## "):
break
block.append(line)
return version, block
def main():
try:
lines = io.open(CHANGELOG, encoding="utf-8").read().splitlines()
except OSError as e:
print("minagent header gate INCONCLUSIVE: cannot read %s (%s)" % (CHANGELOG, e))
return 2
version, block = newest_block(lines)
if version is None:
print("minagent header gate INCONCLUSIVE: no '## vX.Y.Z' heading in %s" % CHANGELOG)
return 2
for line in block:
m = MINAGENT_LINE_RE.match(line.strip())
if m:
print("minagent header gate OK — v%s declares MinAgent %s" % (version, m.group(1)))
return 0
print("minagent header gate FAILED: the newest release header v%s has no '**MinAgent: X.Y.Z**' line." % version)
print("From hub v0.112.0 the floor carries a release past the golden only with a DECLARED MinAgent, and")
print("that value is read from this line (R-472). Add it directly under the heading, e.g.:")
print(" **MinAgent: 0.129.0** (unchanged)")
print("Read the value from internal/agentapi/features.go (the highest featureMinAgent) — never guess it.")
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -32,6 +32,7 @@ FINGERPRINTS = [
("docker-v", "docker -v gate OK"),
("debug-routes", "debug route gate OK"),
("reuse-refs", "cited paths — exact"),
("minagent-header", "minagent header gate OK"),
]
+1
View File
@@ -43,6 +43,7 @@ COVERS = {
"mojibake": "CONTROL: already walked; proves the planted file is really reachable",
"debug-routes": "a live dispatcher case commented out - the button survives, the handler dies",
"golden-notice": "R-410 in the other direction: an empty dir must not count as a bake",
"minagent-header": "the word MinAgent in prose / a code span, not the header line (test_minagent_header_gate.py)",
}
fails = []
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Tests for minagent_header_gate.py (R-470/R-472) — each case writes a CHANGELOG and runs the REAL gate.
Run from controller/: python3 scripts/test_minagent_header_gate.py
"""
import os
import subprocess
import sys
import tempfile
import unittest
SCRIPTS = os.path.dirname(os.path.abspath(__file__))
GATE = os.path.join(SCRIPTS, "minagent_header_gate.py")
def run(body):
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f:
f.write(body)
path = f.name
try:
env = dict(os.environ, MINAGENT_GATE_CHANGELOG=path)
p = subprocess.run([sys.executable, GATE], capture_output=True, text=True, env=env)
return p.returncode, p.stdout + p.stderr
finally:
os.unlink(path)
class MinAgentHeaderGateTest(unittest.TestCase):
def test_header_line_passes(self):
rc, out = run("## v0.239.0 — x (2026-09-13)\n\n**MinAgent: 0.129.0** (unchanged)\n\nbody\n\n## v0.238.1 — y\n")
self.assertEqual(rc, 0, out)
self.assertIn("v0.239.0 declares MinAgent 0.129.0", out)
def test_missing_line_fails_naming_the_header(self):
rc, out = run("## v0.239.0 — x (2026-09-13)\n\nbody\n\n## v0.238.1 — y\n\n**MinAgent: 0.129.0** (unchanged)\n")
self.assertEqual(rc, 1, out)
self.assertIn("v0.239.0", out)
def test_decoy_body_mention_is_not_the_line(self):
"""R-421: the word in prose, a code span, or a non-bold form is the LABEL without the FACT.
COMPANION RED-PROOF F (REPORT.md): relax MINAGENT_LINE_RE to a plain search for 'MinAgent'.
This test then passes the decoy and fails."""
decoy = ("## v0.239.0 — x (2026-09-13)\n\n"
"MinAgent is unchanged from the previous release, see `**MinAgent: 0.129.0**` there.\n"
"The agent requirement (MinAgent: 0.129.0) did not move.\n\n## v0.238.1 — y\n")
rc, out = run(decoy)
self.assertEqual(rc, 1, out)
def test_no_header_is_inconclusive(self):
rc, out = run("# CHANGELOG\n\nnothing released\n")
self.assertEqual(rc, 2, out)
if __name__ == "__main__":
unittest.main()