checks / unit (pull_request) Successful in 6m11s
Two ways a deploy of this add-on has gone wrong, and the Supervisor reports both as success. THE SILENT NO-OP. The Supervisor keys the built image by `version:`, so `ha apps update` against an unchanged version installs nothing and says it worked. PKG-06 recorded this on 2026-09-05 with four merges behind an unbumped version. PKG-07 recorded it again on 2026-09-06 with six. Both times a human noticed. Nothing else was going to. THE REFUSED CONFIG. A config the shipped validator rejects is SystemExit(78) - the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES WITH IT, because the add-on serving it is not running, so recovery means the Supervisor UI. SAFETY-39's rule 10 made this reachable from a config that started fine on the previous version: 0.7.0's own default `slew_w: 1000` exceeds the 555.6 W ceiling on a 5000 W rail. ⚠️ WHY THIS IS NOT A CI CHECK, WHICH IS THE OBVIOUS IMPLEMENTATION. "A pull request that touches runtime code must bump the version" is the wrong shape for this project. It deliberately batches many merges into one deploy, so a per-PR bump would put every open branch in conflict over one line of config.yaml, and it still would not prevent the actual failure - which happens at DEPLOY, not at merge. The check belongs where the mistake is made, so it is a script the operator runs, not a gate on a pull request. WHAT IT DOES NOT DO. It does not read the live box: the options blob needs the Supervisor API and a supervisor token, and neither the HA REST API nor any entity exposes it. The operator exports the options and passes --options. Without that flag the validator half SKIPS - loudly, exit 2 - and never pretends. `app/validate.py` imports its siblings and those need aiohttp, so on a bare host that half skips too and says which container to re-run it in. EXIT CODES: 0 cleared, 1 REFUSED, 2 NOT CLEARED. ⚠️ 2 IS NOT A PASS. ⚠️ THE TEST'S FIRST DRAFT REPORTED FOUR SILENT SKIPS AS PASSES. It copied the gate into each throwaway fixture, so the script's sibling `app/` was the stub the fixture had built and the validator import failed - every validator assertion skipped, and four of them were written to expect a clearance. Fixed by invoking the gate WHERE IT SHIPS and pointing --tree/--repo at the fixture, which is also the behaviour that matters: the gate must check a config against the validator it ships beside. That is MUTATION.md habit 4 again - a check whose data cannot reach the failure it names - caught this time by the harness positive control rather than in review. Every refusal in test_version_gate.py is DRIVEN: a repository is built in which the failure is really present and the check is required to say so. There is a positive control that the no-op detector does NOT fire on a docs-only change, and a pin that omitting --options can never exit 0. This change needs no version bump, and the gate agrees: `tools/` and `test_*.py` are outside its runtime set, because demanding a bump for a typo fix trains people to bump without reading - the habit it exists to prevent. Suite: 24 files, all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
154 lines
6.1 KiB
Python
154 lines
6.1 KiB
Python
"""PKG-09 - grade the check that refuses a no-op deploy. `python3 test_version_gate.py`
|
|
|
|
⚠️ THE POINT OF THIS FILE IS THE REFUSALS, NOT THE CLEARANCES. A pre-deploy
|
|
check that only ever says "CLEARED" is the defect `MUTATION.md` habit 4 names -
|
|
"a check whose DATA cannot reach the failure it names" - which this repository
|
|
has recorded eleven times. So every refusal below is DRIVEN: a repository is
|
|
built in which the failure is really present, and the check is required to say
|
|
so. A clearance is only meaningful next to a refusal from the same harness.
|
|
|
|
The third property is the one that is easy to lose: a SKIPPED check must not
|
|
look like a PASSED one. Omitting `--options` cannot exit 0.
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
GATE = os.path.join(HERE, "tools", "predeploy_check.py")
|
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
|
|
|
fails = []
|
|
tmpdirs = []
|
|
|
|
|
|
def check(name, ok):
|
|
print(f" {'ok ' if ok else 'FAIL'} {name}")
|
|
if not ok:
|
|
fails.append(name)
|
|
|
|
|
|
def git(repo, *args):
|
|
return subprocess.run(
|
|
["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t", *args],
|
|
capture_output=True, text=True)
|
|
|
|
|
|
def make_repo(version="0.7.0"):
|
|
"""A repo shaped like this one: <root>/goodwe_controller/{config.yaml,app,tools}."""
|
|
root = tempfile.mkdtemp(prefix="test_version_gate.")
|
|
tmpdirs.append(root)
|
|
addon = os.path.join(root, "goodwe_controller")
|
|
os.makedirs(os.path.join(addon, "app"))
|
|
write(addon, "config.yaml", f'name: t\nversion: "{version}"\n')
|
|
write(addon, "app/control.py", "X = 1\n")
|
|
write(addon, "CHANGELOG.md", "# Changelog\n")
|
|
git(root, "init", "-q", ".")
|
|
git(root, "add", "-A")
|
|
git(root, "commit", "-q", "-m", f"initial at {version}")
|
|
return root, addon
|
|
|
|
|
|
def write(addon, rel, text):
|
|
p = os.path.join(addon, rel)
|
|
with open(p, "w", encoding="utf-8") as fh:
|
|
fh.write(text)
|
|
|
|
|
|
def run_gate(addon, root, deployed, options=None):
|
|
"""Run the REAL gate against a throwaway tree.
|
|
|
|
⚠️ The script is invoked where it SHIPS, not copied into the fixture. It
|
|
imports the validator from its own sibling `app/`, which is the behaviour
|
|
that matters: the gate must check a config against the validator it ships
|
|
beside, not against whatever happens to sit next to a copy. Copying it into
|
|
the fixture made every validator assertion silently SKIP - the first draft
|
|
of this file did exactly that and reported four green skips as passes.
|
|
"""
|
|
cmd = [sys.executable, GATE,
|
|
"--deployed-version", deployed, "--tree", addon, "--repo", root]
|
|
if options:
|
|
cmd += ["--options", options]
|
|
return subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
|
|
VALID = {"max_w": 2000, "slew_w": 0, "target_grid_w": -10, "gain": 0.6,
|
|
"inverters": [], "setpoint_entity": "number.goodwe_setpoint_w"}
|
|
|
|
|
|
def opts_file(d):
|
|
fd, p = tempfile.mkstemp(suffix=".json")
|
|
with os.fdopen(fd, "w") as fh:
|
|
json.dump(d, fh)
|
|
return p
|
|
|
|
|
|
print("classification - what forces a rebuild and what does not")
|
|
from predeploy_check import is_runtime, shipped_version # noqa: E402
|
|
|
|
for p in ("goodwe_controller/app/control.py", "goodwe_controller/config.yaml",
|
|
"goodwe_controller/translations/en.yaml", "goodwe_controller/run.sh"):
|
|
check(f"runtime: {p}", is_runtime(p))
|
|
for p in ("goodwe_controller/CHANGELOG.md", "goodwe_controller/DOCS.md",
|
|
"goodwe_controller/test_control.py", "goodwe_controller/tools/x.py",
|
|
"goodwe_controller/DECISIONS/a.md", "README.md"):
|
|
check(f"NOT runtime: {p}", not is_runtime(p))
|
|
|
|
print("\nTHE REFUSAL - a runtime change behind an unchanged version")
|
|
root, addon = make_repo("0.7.0")
|
|
write(addon, "app/control.py", "X = 2\n")
|
|
git(root, "commit", "-qam", "SAFETY-99: change behaviour, bump nothing")
|
|
r = run_gate(addon, root, "0.7.0", opts_file(VALID))
|
|
check("refuses (exit 1)", r.returncode == 1)
|
|
check("...and says the deploy would be a NO-OP", "NO-OP" in r.stdout)
|
|
check("...and names the offending commit", "SAFETY-99" in r.stdout)
|
|
check("...and does NOT say cleared", "CLEARED for deploy" not in r.stdout)
|
|
|
|
print("\nPOSITIVE CONTROL - the same check must NOT fire on a docs-only change")
|
|
root2, addon2 = make_repo("0.7.0")
|
|
write(addon2, "CHANGELOG.md", "# Changelog\n\n## notes\n")
|
|
git(root2, "commit", "-qam", "PKG-99: documentation only")
|
|
r2 = run_gate(addon2, root2, "0.7.0", opts_file(VALID))
|
|
check("does not refuse a docs-only tree", r2.returncode == 0)
|
|
check("...and says there is nothing to deploy",
|
|
"nothing to deploy" in r2.stdout)
|
|
|
|
print("\nTHE CLEARANCE - a bumped version with a valid config")
|
|
root3, addon3 = make_repo("0.7.0")
|
|
write(addon3, "app/control.py", "X = 3\n")
|
|
write(addon3, "config.yaml", 'name: t\nversion: "0.8.0"\n')
|
|
git(root3, "commit", "-qam", "PKG-99: change and bump together")
|
|
r3 = run_gate(addon3, root3, "0.7.0", opts_file(VALID))
|
|
check("clears (exit 0)", r3.returncode == 0)
|
|
check("...and says so", "CLEARED for deploy" in r3.stdout)
|
|
|
|
print("\n⭐ A SKIPPED CHECK MUST NOT LOOK LIKE A PASSED ONE")
|
|
r4 = run_gate(addon3, root3, "0.7.0") # no --options
|
|
check("omitting --options cannot exit 0", r4.returncode != 0)
|
|
check("...says SKIPPED", "SKIPPED" in r4.stdout)
|
|
check("...says the config was NEVER CHECKED", "NEVER CHECKED" in r4.stdout)
|
|
check("...and does NOT claim clearance", "CLEARED for deploy" not in r4.stdout)
|
|
|
|
print("\nTHE OTHER REFUSAL - a config this version's validator rejects")
|
|
bad = dict(VALID, slew_w=1000) # rule 10: > 2/18 x 2000 = 222 W
|
|
r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
|
|
check("refuses a config the validator rejects", r5.returncode == 1)
|
|
check("...and warns it is exit 78", "78" in r5.stdout)
|
|
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
|
|
check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout)
|
|
|
|
print("\nthe shipped tree's own version is readable")
|
|
check("shipped_version() finds a version", bool(shipped_version(HERE)))
|
|
|
|
for d in tmpdirs:
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
|
sys.exit(1)
|
|
print("all checks passed")
|