Skip to content

Commit ebcee07

Browse files
authored
ci: gate the stated RomM minimum against the enforced one (#1568)
The README badge, the requirements list and the trap note in `CLAUDE.md` each restate the minimum RomM version. The number that actually matters is `Plugin._MIN_REQUIRED_VERSION` in `main.py` — the floor `test_connection()` rejects servers against, below which the plugin is inert. Three restatements are three chances to forget one, and a badge promising a version the code does not enforce is worse than no badge. ## How `scripts/check_romm_min_version.py` reads the constant out of `main.py` with `ast` rather than importing it, which would need Decky's runtime present, and compares it against each claim. `--fix` rewrites them. It runs in the `lint` job and in `mise run gate`, and it is listed in the invariant register. Bumping the floor stays a one-line change to `main.py`; CI enforces the rest. ## Why not a live badge Shields cannot read Python, so a genuinely dynamic badge would mean moving the floor into a machine-readable file. The obvious candidate is `package.json`, which is already read at runtime — but by an adapter that falls back to a default when the file is missing or malformed. That fallback is right for a version string and wrong for a gate: it would either lock everyone out or let old servers through. The value only ever changes by editing this repository, so a live fetch adds an external dependency and a failure mode without removing any drift the gate does not already catch. ## Scope ADRs are excluded. They record the floor as it stood when each decision was taken, and rewriting them would falsify the record. Verified by temporarily setting the constant to `5.1.2`: all three sites were reported, `--fix` corrected them, and the check went green. docs: N/A — tooling only; the invariant register in `CLAUDE.md` gains the new gate's entry.
1 parent cccf717 commit ebcee07

4 files changed

Lines changed: 121 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ jobs:
163163

164164
- name: Check local markdown links resolve (file + anchor)
165165
run: python scripts/check_markdown_links.py
166+
167+
- name: Check stated RomM minimum matches the enforced one
168+
run: python scripts/check_romm_min_version.py
166169
# The compiled gavel core ships in the plugin zip and has no source in
167170
# this repo — a silently swapped binary must fail CI. Verify the vendored
168171
# .so against its checksum (bumped only by a deliberate native/README.md

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ Format: **invariant** — tier — enforced by.
147147
`scripts/check_lock_sync.py`
148148
- **Every local markdown link in tracked docs resolves (file target + heading/attr-list anchor)** — check —
149149
`scripts/check_markdown_links.py`
150+
- **Every stated RomM minimum version matches the enforced `Plugin._MIN_REQUIRED_VERSION`** — check —
151+
`scripts/check_romm_min_version.py` (ADRs excluded: frozen history)
150152
- **Server-supplied path components pass `safe_join` (`lib/path_safety.py`)** — test + prompt-only — traversal tests per
151153
path builder; new call sites are prompt-only
152154
- **No sentinel objects on the wire — explicit JSON-representable tagged values only** — prompt-only — mechanize with

mise.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ run = [
6161
"python scripts/check_sync_lifecycle_owner.py",
6262
"python scripts/check_lock_sync.py",
6363
"python scripts/check_markdown_links.py",
64+
"python scripts/check_romm_min_version.py",
6465
"cd py_modules/native && sha256sum -c libgavel-x86_64-linux.so.sha256",
6566
"cd defaults && sha256sum -c bios_registry.json.sha256",
6667
]

scripts/check_romm_min_version.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python3
2+
"""Keep every stated RomM minimum version equal to the one the plugin enforces.
3+
4+
``Plugin._MIN_REQUIRED_VERSION`` in ``main.py`` is the single source of truth:
5+
it is the floor ``test_connection()`` rejects servers against, so the plugin is
6+
inert below it. Several places restate that number for humans — a badge, the
7+
requirements list, the trap note in CLAUDE.md — and a restated number drifts.
8+
9+
The constant is read with ``ast`` rather than by importing ``main``, which would
10+
need Decky's runtime present.
11+
12+
Frozen history is deliberately out of scope: ADRs record the floor as it stood
13+
when the decision was taken and must not be rewritten.
14+
15+
Usage:
16+
python scripts/check_romm_min_version.py # report drift, exit 1
17+
python scripts/check_romm_min_version.py --fix # rewrite the claims
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import argparse
23+
import ast
24+
import pathlib
25+
import re
26+
import sys
27+
28+
ROOT = pathlib.Path(__file__).resolve().parent.parent
29+
SOURCE = ROOT / "main.py"
30+
CONSTANT = "_MIN_REQUIRED_VERSION"
31+
32+
# Each claim site: file, a regex with the version as group 1, and a label.
33+
# The regexes are deliberately narrow — a loose one would rewrite unrelated
34+
# version numbers.
35+
CLAIMS = [
36+
(
37+
"README.md",
38+
re.compile(r"(?<=badge/RomM-%E2%89%A5%20)(\d+\.\d+\.\d+)(?=-)"),
39+
"readme badge",
40+
),
41+
(
42+
"README.md",
43+
re.compile(r"(?<=\*\*version )(\d+\.\d+\.\d+)(?= or newer\*\*)"),
44+
"readme requirements",
45+
),
46+
(
47+
"CLAUDE.md",
48+
re.compile(r"(?<=Requires RomM >= )(\d+\.\d+\.\d+)"),
49+
"claude.md trap note",
50+
),
51+
]
52+
53+
54+
def enforced_version() -> str:
55+
"""The floor as ``main.py`` states it, e.g. ``4.9.0``."""
56+
tree = ast.parse(SOURCE.read_text(encoding="utf-8"), filename=str(SOURCE))
57+
for node in ast.walk(tree):
58+
if not isinstance(node, ast.Assign):
59+
continue
60+
names = [t.id for t in node.targets if isinstance(t, ast.Name)]
61+
if CONSTANT not in names:
62+
continue
63+
value = ast.literal_eval(node.value)
64+
if not isinstance(value, tuple) or not all(isinstance(p, int) for p in value):
65+
raise SystemExit(f"{CONSTANT} is not a tuple of ints: {value!r}")
66+
return ".".join(str(p) for p in value)
67+
raise SystemExit(f"{CONSTANT} not found in {SOURCE.name}")
68+
69+
70+
def main() -> int:
71+
parser = argparse.ArgumentParser(description=__doc__)
72+
parser.add_argument("--fix", action="store_true", help="rewrite the claims instead of reporting")
73+
args = parser.parse_args()
74+
75+
expected = enforced_version()
76+
drift: list[str] = []
77+
fixed: list[str] = []
78+
79+
for filename, pattern, label in CLAIMS:
80+
path = ROOT / filename
81+
text = path.read_text(encoding="utf-8")
82+
found = pattern.findall(text)
83+
if not found:
84+
drift.append(f"{filename}: no RomM version found for the {label}")
85+
continue
86+
wrong = [v for v in found if v != expected]
87+
if not wrong:
88+
continue
89+
if args.fix:
90+
path.write_text(pattern.sub(expected, text), encoding="utf-8")
91+
fixed.append(f"{filename}: {label} {', '.join(wrong)} -> {expected}")
92+
else:
93+
drift.append(f"{filename}: {label} says {', '.join(wrong)}, {SOURCE.name} enforces {expected}")
94+
95+
for line in fixed:
96+
print(f"fixed {line}")
97+
if drift:
98+
print(
99+
f"ERROR: the stated RomM minimum has drifted from {SOURCE.name}'s {CONSTANT} ({expected}):",
100+
file=sys.stderr,
101+
)
102+
for line in drift:
103+
print(f" {line}", file=sys.stderr)
104+
print(
105+
"\nRun: python scripts/check_romm_min_version.py --fix",
106+
file=sys.stderr,
107+
)
108+
return 1
109+
if not fixed:
110+
print(f"OK: every stated RomM minimum matches {CONSTANT} ({expected})")
111+
return 0
112+
113+
114+
if __name__ == "__main__":
115+
raise SystemExit(main())

0 commit comments

Comments
 (0)