"""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 twelve 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: /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() print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED") # ⚠️ EVERY FIXTURE ABOVE BUILDS A FRESH REPOSITORY WHOSE `version:` LINE IS # INTRODUCED ONCE AND NEVER EDITED. That is precisely the shape in which the # original `git log -S 'version: "'` looked correct - the pickaxe finds commits # that change a string's COUNT, and introducing the line changes it from 0 to 1. # A real repository edits the line IN PLACE, the count never moves again, and # the lookup silently returns the commit that first added it. # # MEASURED on admin/goodwe-addon before the fix: `-S` named `0a1e61d` # (2026-08-23, the original packaging commit) as "the last bump", so a correctly # bumped tree was reported as 87 commits behind an unbumped version - with # PKG-06's and PKG-07's own bump commits listed among the offenders. The tool # cried wolf on every run, and none of the fixtures above could see it, because # a test that builds its own one-bump history is a test asserting against a # structure it built itself. MUTATION.md habit 4, in the file written to # prevent a recurrence. from predeploy_check import last_version_change # noqa: E402 root4 = tempfile.mkdtemp(prefix="test_version_gate.hist.") tmpdirs.append(root4) addon4 = os.path.join(root4, "goodwe_controller") os.makedirs(os.path.join(addon4, "app")) write(addon4, "config.yaml", 'name: t\nversion: "0.5.0"\n') write(addon4, "app/control.py", "X = 1\n") git(root4, "init", "-q", ".") git(root4, "add", "-A") git(root4, "commit", "-q", "-m", "initial at 0.5.0") _first = git(root4, "rev-parse", "HEAD").stdout.strip() write(addon4, "app/control.py", "X = 2\n") git(root4, "commit", "-qam", "runtime change between bumps") write(addon4, "config.yaml", 'name: t\nversion: "0.6.0"\n') git(root4, "commit", "-qam", "bump to 0.6.0") write(addon4, "app/control.py", "X = 3\n") git(root4, "commit", "-qam", "another runtime change") write(addon4, "config.yaml", 'name: t\nversion: "0.7.0"\n') git(root4, "commit", "-qam", "bump to 0.7.0") _last_bump = git(root4, "rev-parse", "HEAD").stdout.strip() check("⭐ THE LAST BUMP IS FOUND, NOT THE FIRST. An in-place edit of the " "version line does not change how many times the string occurs, so a " "pickaxe search returns the commit that introduced it and every bump " "after that is invisible", last_version_change(root4) == _last_bump) check("...and it is emphatically not the commit that introduced the line", last_version_change(root4) != _first) r6 = run_gate(addon4, root4, "0.7.0", opts_file(VALID)) check("a tree bumped with nothing after it clears, even with bumps in history", r6.returncode == 0 and "CLEARED for deploy" in r6.stdout) write(addon4, "app/control.py", "X = 4\n") git(root4, "commit", "-qam", "SAFETY-98: runtime change after the last bump") r7 = run_gate(addon4, root4, "0.7.0", opts_file(VALID)) check("and one runtime commit after that last bump is refused", r7.returncode == 1) check("...naming only the commit since the LAST bump, not since the first", "SAFETY-98" in r7.stdout and "runtime change between bumps" not in r7.stdout) # ⚠️ AND THE FIXTURE ABOVE STILL COULD NOT TELL TWO IMPLEMENTATIONS APART. # Review swapped `last_version_change` for "-G's newest raw hit, skip the value # comparison entirely" and THE WHOLE SUITE STAYED GREEN, including the section # above - because a linear history of real bumps never produces a commit that # touches the version LINE without changing the version VALUE. So the docstring's # central claim ("the VALUE is compared, and that is the only thing that answers # it") was not pinned by anything, and `-G` alone would have shipped as if it # were the fix. That is the third distinct shape of habit 4 in this one file. # # A cosmetic touch of the version line is the discriminator: `-G '^version: "'` # matches it, and the value comparison does not. write(addon4, "config.yaml", 'name: t\nversion: "0.7.0" # pinned deliberately\n') git(root4, "commit", "-qam", "COSMETIC: annotate the version line, same value") _cosmetic = git(root4, "rev-parse", "HEAD").stdout.strip() check("⭐ A COMMIT THAT TOUCHES THE VERSION LINE WITHOUT CHANGING THE VERSION " "IS NOT A BUMP. `-G` matches it and a filter-only lookup would name it; " "only comparing the VALUE against the parent's answers the question the " "tool is actually asking", last_version_change(root4) == _last_bump) check("...and it is emphatically not the cosmetic commit", last_version_change(root4) != _cosmetic) r8 = run_gate(addon4, root4, "0.7.0", opts_file(VALID)) check("...and because config.yaml IS a runtime path, that cosmetic commit is " "still counted as something unshipped - conservative, and correct: the " "Supervisor rebuilds on the version string alone, so a changed file " "behind an unchanged version is a no-op whatever changed in it", r8.returncode == 1 and "COSMETIC" in r8.stdout) print() print("⭐ A GIT LOOKUP THAT GENUINELY FAILS - THE SHAPE NO FIXTURE COULD REACH") # ⚠️ EVERY FIXTURE ABOVE CREATES ITS OWN REPOSITORY AS ITS OWN UID, so none of # them could ever meet the failure that actually happened: the documented # invocation runs the container as uid 0 against a checkout owned by the # operator, git refuses with "detected dubious ownership", `_git` returns # non-zero, and the no-op half reports SKIPPED. It degraded honestly - exit 2, # never a false clearance - but the check this file exists for had never once # run in its documented form, and nothing here could see that. # # `_git` now sets `safe.directory`, so ownership no longer breaks it. This # section grades the REMAINING failure mode: git answering not at all. root5 = tempfile.mkdtemp(prefix="test_version_gate.nogit.") tmpdirs.append(root5) addon5 = os.path.join(root5, "goodwe_controller") os.makedirs(addon5) write(addon5, "config.yaml", 'name: t\nversion: "0.7.0"\n') # NOT a git repo check("a repository git cannot answer for reports no last-bump commit", last_version_change(root5) is None) r9 = run_gate(addon5, root5, "0.7.0", opts_file(VALID)) check("⭐ AND THE GATE DOES NOT CLEAR ON IT. An unanswerable git lookup is " "INCONCLUSIVE, not clean - the one thing it must never do is call a " "tree it could not inspect deployable", r9.returncode != 0 and "CLEARED for deploy" not in r9.stdout) check("...it says INCONCLUSIVE in those words", "INCONCLUSIVE" in r9.stdout) # ⚠️ AND THE EXIT CODES ARE PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0` # is satisfied by collapsing NOT-CLEARED into REFUSED, which review found the # earlier draft could not tell apart. 1 and 2 mean different things to an # operator: 1 says "this deploy is wrong", 2 says "nobody checked". check("NOT CLEARED is exactly 2, not merely non-zero", r9.returncode == 2) r10 = run_gate(addon3, root3, "0.7.0", opts_file(dict(VALID, slew_w=1000))) check("REFUSED is exactly 1, not merely non-zero", r10.returncode == 1 and "REFUSED" in r10.stdout) print("\nthe shipped tree's own version is readable") check("shipped_version() finds a version", bool(shipped_version(HERE))) # --------------------------------------------------------------------------- # # PKG-14 - THE OTHER HALF OF THE NO-OP, AND NOTHING GRADED IT. # # PKG-09 refuses a deploy whose `version:` did not move. It cannot see the # mirror failure, which this repository has now produced twice: the version # DOES move and the changelog's `## Unreleased` block is never closed, so the # operator who opens the file to find out what they are about to install reads # "THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE" about a # tree that is being installed. `## 0.8.0` records one such block that # accumulated across three releases; `## 0.9.0` was cut with a second one left # standing underneath it. # # ⚠️ THE TWO SIDES COME FROM DIFFERENT FILES ON PURPOSE (MUTATION.md habit 4). # config.yaml's `version:` and CHANGELOG.md's newest heading are written by # different hands at different times, and the whole defect is that they drift. # A check that read the number once and compared it with itself would be the # DEV-02 fixture again: a number against itself. # # ⚠️ AND IT IS NOT A SUBSTRING SCAN OVER THE WHOLE FILE. Every id this section # looks for occurs dozens of times lower down, so a scan of the file would pass # on any tree whatever the newest section said - CFG-04's defect, which asserted # against the TEXT of the thing under test. The section is SLICED first, and # part 11a grades the slicer against a sentinel that exists only below it. print("\n11. PKG-14 - the bump and the closed section are one act") _CHANGELOG = os.path.join(HERE, "CHANGELOG.md") with open(_CHANGELOG, encoding="utf-8") as fh: _cl_lines = fh.read().splitlines() _heads = [(i, l[3:].strip()) for i, l in enumerate(_cl_lines) if l.startswith("## ")] check(f"precondition: the file has {len(_heads)} `## ` headings, so the " f"quantifiers below are not over an empty set", len(_heads) >= 8) # 11a. THE SLICE, AND TWO CONTROLS ON THE SLICER. # ARCH-02's fifteenth instance: a splitter that never closes attributes # everything to the last thing it opened, and every per-section check # downstream silently becomes a total over the whole file. Both controls below # exist because 11b/11c would go on passing for a tree in which nothing they # name is true. def slice_newest(lines): """(heading text, the lines of the FIRST `## ` section) - closing at the next `## `, which is the half that has to be graded rather than assumed.""" heads = [i for i, l in enumerate(lines) if l.startswith("## ")] if not heads: return None, [] start = heads[0] end = heads[1] if len(heads) > 1 else len(lines) return lines[start][3:].strip(), lines[start:end] # CONTROL ONE, run on every invocation rather than attested in a commit # message: a synthetic document whose token appears ONLY below the first # section. A slicer that runs to the end of the file finds it; this one must # not. test_defaults_pin.py part 5 plants a literal for the same reason. _SYNTH = ["# Changelog", "", "## 9.9.9", "", "the newest section.", "", "## 9.9.8", "", "PLANTED-ONLY-BELOW", ""] _syn_name, _syn_body = slice_newest(_SYNTH) check("11a. the slicer CLOSES - on a synthetic document it excludes a token " "that exists only in the section BELOW the newest one", _syn_name == "9.9.9" and "PLANTED-ONLY-BELOW" in "\n".join(_SYNTH) and "PLANTED-ONLY-BELOW" not in "\n".join(_syn_body)) _top_name, _top_body = slice_newest(_cl_lines) _newest = "\n".join(_top_body) _SENTINEL = "## 0.8.0" check(f"...and on the shipped file too: it does not contain {_SENTINEL!r}, " f"which the file does", _SENTINEL in "\n".join(_cl_lines) and _SENTINEL not in _newest) check(f"...and it is not empty: {len(_top_body)} lines under {_top_name!r}", len(_top_body) > 20) # CONTROL TWO - and this is the one that catches a LOST HEADING rather than a # loose slicer. Every version section in this file carries exactly one # "in merge order since" contents table. Delete a released section's `## ` # heading and its table is swallowed by the section above, which is a released # record disappearing into an unreleased one - the shape `## 0.7.0` already # records for 0.6.0, and the one PKG-08 is left holding. _tables = [l for l in _top_body if "in merge order since" in l] check(f"...and it carries EXACTLY ONE contents table, not {len(_tables)} - a " f"second one means a released section below lost its `## ` heading and " f"was absorbed into this one", len(_tables) == 1) # 11b. THE BLOCK IS CLOSED, AND IT CARRIES THE SHIPPED NUMBER. check("11b. the newest `## ` heading is a VERSION and not `Unreleased` - the " "block was closed by whoever bumped the version", _top_name.lower() != "unreleased") _shipped = shipped_version(HERE) check(f"...and it is exactly config.yaml's `version:` ({_shipped!r} in " f"config.yaml, {_top_name!r} in CHANGELOG.md) - two files, so a bump " f"without a section, or a section without a bump, is red", _top_name == _shipped) # 11c. A WITHDRAWN price_provider IS NAMED IN THE SECTION THAT WITHDRAWS IT. # ⚠️ READ OFF app/price.py BY AST, NOT TYPED HERE. A list transcribed into this # file would go on passing after a fourth id was retired and documented # nowhere, which is the exact failure TARIFF-07 exists to prevent: an id the # add-on refuses at startup and no release note mentions. import ast # noqa: E402 _price_src = open(os.path.join(HERE, "app", "price.py"), encoding="utf-8").read() _retired = [] for _node in ast.walk(ast.parse(_price_src)): if isinstance(_node, ast.AnnAssign) and getattr( _node.target, "id", None) == "RETIRED_PROVIDERS": _retired = [k.value for k in _node.value.keys] elif isinstance(_node, ast.Assign) and any( getattr(t, "id", None) == "RETIRED_PROVIDERS" for t in _node.targets): _retired = [k.value for k in _node.value.keys] check(f"11c. precondition: RETIRED_PROVIDERS was read off app/price.py and is " f"not empty - {sorted(_retired)}", len(_retired) > 0) _unnamed = sorted(p for p in _retired if p not in _newest) check(f"...and every one of the {len(_retired)} withdrawn id(s) is named in " f"`## {_top_name}` - an id the validator refuses at startup that no " f"release note mentions is the TARIFF-07 defect with the sign flipped", _unnamed == []) # 11d. THE LEDGER OF BLOCKS STILL MISFILED, FROZEN RATHER THAN FIXED. # PKG-08 owns splitting them; this file owns the promise that the number can # only go DOWN. A release that cuts its section and leaves a second # `## Unreleased` standing is red here on the commit that does it. _stale = [n for _, n in _heads[1:] if n.lower() == "unreleased"] check(f"11d. exactly {len(_stale)} `## Unreleased` block(s) remain BELOW the " f"newest section, and the count is pinned EXACTLY so the ledger can " f"only shrink - splitting them is PKG-08, not this file", len(_stale) == 1) 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")