Files
felhom-controller/controller/scripts/controller_gates.py
T
admin f946b0d0ca
gates / gates (push) Successful in 12s
gates: the newest release header must state its MinAgent; backfill v0.233.0-v0.236.0 (R-470, R-472)
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.
2026-09-13 17:09:33 +02:00

174 lines
9.6 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""controller_gates.py — THE entry point for this repo's gates. Run from `controller/`:
python3 scripts/controller_gates.py # every gate
python3 scripts/controller_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. template-id every template id/handle referenced by the dashboard resolves
2. emoji no emoji in the Hungarian UI (design-system v2)
3. native-confirm no native confirm()/prompt() — they freeze browser automation
4. offbox-rename the persisted `offbox` key is never re-guessed as `offbox_target`
5. app-row-dedup no duplicated app-row markup in the dashboard templates
6. mojibake no double-encoded UTF-8 in Hungarian copy
7. docker-v every `docker … -v` mount is a named volume or a proven host path
8. debug-routes every debug-page control resolves to a handler, and back (R-400)
9. reuse-refs every path cited by this repo's REUSE.md still resolves
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.** Of the seven gates above, this repo's CLAUDE.md named exactly two; four
were reachable only through a line in REUSE.md, and `docker_run_volume_path_gate.py` — RED at the
time of the census — through one line in REUSE.md and nothing else. 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.
THE SHARED CHECKER. `reuse_refs_check.py` lives in ONE place — `felhom.eu/scripts/` — and is
invoked here across the workspace at `<repo-root>/../felhom.eu/scripts/`. It is deliberately NOT
copied into this repo: duplicating it would recreate exactly the drift it exists to detect. If the
sibling clone is absent the gate FAILS and prints the path it tried — fail-closed, because a
runner that quietly skips 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.
"""
import os
import subprocess
import sys
SCRIPTS = os.path.dirname(os.path.abspath(__file__))
CTRL = os.path.dirname(SCRIPTS) # <repo>/controller — every gate's cwd
REPO = os.path.dirname(CTRL) # <repo> — the root REUSE.md lives here
SHARED_REUSE = os.path.join(os.path.dirname(REPO), "felhom.eu", "scripts", "reuse_refs_check.py")
SHARED_INSTRUCTIONS = os.path.join(
os.path.dirname(REPO), "felhom.eu", "scripts", "instructions_gate.py")
# R-389 — shared, like the two above: it lives in felhom.eu/scripts/ and is never copied.
SHARED_OBSERVATIONS = os.path.join(
os.path.dirname(REPO), "felhom.eu", "scripts", "observations_gate.py")
# R-404 — the golden NOTICE. Lives here, beside the runner, because it is about THIS repo's
# releases; it imports the felhom.eu gate rather than copying its comparison.
GOLDEN_NOTICE = os.path.join(SCRIPTS, "golden_notice.py")
# (label, absolute script path, args, fast, blocking)
#
# `blocking` — R-404, 2026-09-01. FALSE means this gate REPORTS and never changes the runner's exit
# code. Before this the runner could not express such a gate at all: every registered gate's
# non-zero exit failed the run, so the only way to add a notice was to give it the power to refuse
# a push. That was the wrong trade for the golden notice, whose whole point is that it fires at the
# moment a release is committed — when the golden legitimately does not exist yet and refusing
# would be absurd. The capability was added rather than the notice compromised.
#
# It is FALSE for exactly one gate. Everything else blocks, as it always has.
GATES = [
("template-id", os.path.join(SCRIPTS, "template_id_gate.py"), [], True, True),
("emoji", os.path.join(SCRIPTS, "emoji_gate.py"), [], True, True),
("native-confirm", os.path.join(SCRIPTS, "native_confirm_gate.py"), [], True, True),
("offbox-rename", os.path.join(SCRIPTS, "offbox_rename_gate.py"), [], True, True),
("app-row-dedup", os.path.join(SCRIPTS, "app_row_dedup_gate.py"), [], True, True),
("mojibake", os.path.join(SCRIPTS, "mojibake_gate.py"), [], True, True),
("docker-v", os.path.join(SCRIPTS, "docker_run_volume_path_gate.py"), [], True, True),
("secret-markup", os.path.join(SCRIPTS, "secret_in_markup_gate.py"), [], True, True),
("retrieval-promise", os.path.join(SCRIPTS, "retrieval_promise_gate.py"), [], True, True),
# R-400 — every debug-page control resolves to a handler, and every handler is reachable.
# Registered AFTER the seven dead controls were implemented or deleted: a registered-but-failing
# gate refuses every push, so the order matters here exactly as it did for instructions_gate.
("debug-routes", os.path.join(SCRIPTS, "debug_route_gate.py"), [], True, True),
("reuse-refs", SHARED_REUSE, [REPO], True, True),
("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),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
ADVISORY = "ADVISORY" # R-404: a non-blocking gate — it reports, it never refuses
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). The reuse-refs")
print(" checker is shared and lives in the felhom.eu sibling clone; it is never copied.")
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.
return subprocess.call([sys.executable, path] + args, cwd=CTRL)
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/controller_gates.py [--fast] (run from controller/)")
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("controller_gates — %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(REPO)
results = [(label, run_gate(label, path, args), blocking)
for label, path, args, _f, blocking in selected]
print("\n" + "=" * 78)
print("== summary")
print("=" * 78)
worst = 0
for label, rc, blocking in results:
if not blocking:
# A non-blocking gate's exit code is INFORMATION, never a verdict on the push.
print(" %-18s %-13s (exit %d, advisory)" % (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 worst == 0:
print("\nall controller gates OK")
return 0
convicted = [l for l, rc, b in results if rc == 1 and b]
undecided = [l for l, rc, b in results if rc not in (0, 1) and b]
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:]))