checks / unit (pull_request) Successful in 6m16s
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 six 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. ⚠️ IT CAUGHT A LIVE ONE ON ITS OWN PULL REQUEST. Run against release/1.0 while this branch was in review: version 0.8.0 on both sides with UX-02's and OPT-09's runtime commits already merged behind it. The class this exists to catch had recurred, the same day, unnoticed - which is the argument for PKG-10, a non-required post-merge check, since this script only helps someone who remembers to run it. ⚠️ THE FIRST VERSION WAS BROKEN ON EVERY REAL REPOSITORY AND ITS TESTS COULD NOT SEE IT. The "when did the version last change" lookup was `git log -S`, the pickaxe, which finds commits that change the COUNT of a literal string. A bump edits the line IN PLACE, so the count never moves and it returned only the commit that first INTRODUCED the line. Measured here: -S ->0a1e61d2026-08-23 Packaged as a Home Assistant add-on -G ->06661c52026-09-06 PKG-07: bump 0.7.0 -> 0.8.0 so a correctly bumped tree was reported as 87 COMMITS BEHIND AN UNBUMPED VERSION, PKG-06's and PKG-07's own bump commits among the offenders. A tool whose whole purpose is to be trusted cried wolf on every run. ⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER - it matches a reformat, a quote change, a comment moved onto the line. `-G` narrows the candidates and the VALUE is then compared against the parent's. ⚠️ THREE DISTINCT SHAPES OF HABIT 4 IN ONE FILE, each found by a different means, which is the honest record of how this landed: 1. Fixtures built fresh repositories whose version line is introduced once and never edited - the one shape in which `-S` looks correct. Found by review. Answered with a three-state history; the old lookup goes red on 4 checks. 2. That three-state history still could not tell "value-compared" from "filtered-only": review swapped in `-G`'s newest raw hit and THE WHOLE SUITE STAYED GREEN, so the docstring's central claim was pinned by nothing. Answered with a cosmetic touch of the version line - `-G` matches it, the value comparison does not. That mutant now goes red on 3 checks. 3. The counts below were claimed fixed and two of them had not landed at all. Two git shapes it still gets wrong are recorded in the docstring rather than fixed - a bump resolved inside a merge commit (false refusal) and a bump reverted while the work is kept (false clearance, the dangerous direction). Neither has ever occurred here; both were constructed in review. Card PKG-11. ⚠️ AND THE PROSE COUNTS, WHICH I REPORTED AS FIXED WHEN THEY WERE NOT. Round 1 found "PKG-06 ... four merges" (its own commit message says six) and "recorded eleven times" (MUTATION.md:550 says TWELFTH). I replaced them without asserting the replacement matched, said "all applied", and round 2 found both still in the tree - one fixed in DOCS.md but not in the script, one not fixed anywhere. A claim that a correction landed is itself a claim that needs checking, and this is PROC-01's tenth instance. WHAT IT DOES NOT DO. It does not read the live box: the options blob needs the Supervisor API. Without --options the validator half SKIPS - loudly, exit 2 - and never pretends. On a bare host `app/validate.py` needs aiohttp, so that half skips too and names the container to re-run in. A missing or malformed --options file is a REFUSAL with a sentence, not a traceback. EXIT CODES: 0 cleared, 1 REFUSED, 2 NOT CLEARED. ⚠️ 2 IS NOT A PASS. Suite: 26 files, all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
273 lines
12 KiB
Python
273 lines
12 KiB
Python
"""PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject.
|
|
|
|
python3 tools/predeploy_check.py --deployed-version 0.7.0 --options live-options.json
|
|
|
|
⚠️ RUN IT WHERE THE ADD-ON'S DEPENDENCIES EXIST. `app/validate.py` imports its
|
|
siblings and those need aiohttp, so on a bare host the validator half SKIPS -
|
|
loudly, and exit 2, never a silent pass. To get both halves:
|
|
|
|
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
|
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
|
--deployed-version 0.7.0 --options /w/live-options.json
|
|
|
|
EXIT CODES. 0 cleared; 1 REFUSED, do not deploy; 2 NOT CLEARED - a check did not
|
|
run, and that is not the same as passing.
|
|
|
|
TWO FAILURES THIS ANSWERS, BOTH OF WHICH HAVE HAPPENED.
|
|
|
|
1. THE SILENT NO-OP. The Supervisor keys the built image by `version:` in
|
|
config.yaml. `ha apps update` against an unchanged version does nothing and
|
|
REPORTS SUCCESS. PKG-06 recorded this on 2026-09-05 with SIX merges behind
|
|
an unbumped version - its own commit message says so, and this line said
|
|
"four" through two review rounds; PKG-07 recorded it again on 2026-09-06 with six. Both
|
|
times a human noticed. Nothing else was ever going to.
|
|
|
|
2. 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 by a config that
|
|
started fine on the previous version: it refuses an explicit `slew_w` above
|
|
SLEW_RAIL_FRAC x the site rail, and 0.7.0's own shipped default of 1000
|
|
exceeds the 555.6 W ceiling on a 5000 W rail.
|
|
|
|
⚠️ WHY THIS IS NOT A CI CHECK. The obvious version gate - "a pull request that
|
|
touches runtime code must bump the version" - is the WRONG shape for this
|
|
project, which deliberately batches many merges into one deploy. It would demand
|
|
a bump per pull request, put every branch in conflict over one line of
|
|
config.yaml, and still not prevent the actual failure, which happens at DEPLOY
|
|
and not at merge. The check belongs where the mistake is made.
|
|
|
|
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
|
|
reachable only through the Supervisor API, which needs a supervisor token, so
|
|
the operator exports it and passes `--options`. Without that flag the validator
|
|
half is SKIPPED and says so - a skipped check must never look like a passed one.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
ADDON = os.path.dirname(HERE)
|
|
sys.path.insert(0, ADDON)
|
|
|
|
# Files that end up in the built image and can change what it DOES. A change to
|
|
# any of these needs a new `version:` or the Supervisor will not rebuild.
|
|
# CHANGELOG.md, DOCS.md, MUTATION.md, DECISIONS/, test_*.py, tools/ and
|
|
# scenarios/ are deliberately absent: they ship in the repo but cannot change
|
|
# runtime behaviour, and demanding a bump for a typo fix trains people to bump
|
|
# without reading, which is the habit this file exists to prevent.
|
|
RUNTIME = (
|
|
"goodwe_controller/app/",
|
|
"goodwe_controller/translations/",
|
|
"goodwe_controller/config.yaml",
|
|
"goodwe_controller/Dockerfile",
|
|
"goodwe_controller/run.sh",
|
|
"goodwe_controller/requirements.txt",
|
|
"goodwe_controller/build.yaml",
|
|
)
|
|
|
|
CONFIG_PATH = "goodwe_controller/config.yaml"
|
|
_VER = re.compile(r'^version:\s*"([^"]+)"', re.M)
|
|
|
|
|
|
def shipped_version(tree=ADDON):
|
|
with open(os.path.join(tree, "config.yaml"), encoding="utf-8") as fh:
|
|
m = _VER.search(fh.read())
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def is_runtime(path):
|
|
return any(path == p or path.startswith(p) for p in RUNTIME)
|
|
|
|
|
|
def _git(*args, repo=None):
|
|
return subprocess.run(["git", "-C", repo or os.path.dirname(ADDON), *args],
|
|
capture_output=True, text=True)
|
|
|
|
|
|
def _version_at(sha, repo=None, path=CONFIG_PATH):
|
|
"""`version:` as of one commit, or None if the file or the key is absent."""
|
|
r = _git("show", f"{sha}:{path}", repo=repo)
|
|
if r.returncode != 0:
|
|
return None
|
|
m = _VER.search(r.stdout)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def last_version_change(repo=None):
|
|
"""The newest commit whose `version:` VALUE differs from its parent's.
|
|
|
|
⚠️ THIS USED TO BE `git log -S 'version: "'` AND IT WAS WRONG ON EVERY REAL
|
|
REPOSITORY. `-S` is the pickaxe: it finds commits that change the COUNT of
|
|
a literal string. A bump edits the line in place - `version: "0.7.0"` ->
|
|
`version: "0.8.0"` - so the substring still occurs exactly once before and
|
|
after, and `-S` never sees it. It found only the commit that first
|
|
INTRODUCED the line.
|
|
|
|
Measured on this repository: `-S` returned `0a1e61d` (2026-08-23, the
|
|
original packaging commit) as "the last bump", so the script reported the
|
|
correctly-bumped tree as 87 commits behind an unbumped version - listing
|
|
PKG-06's and PKG-07's own bump commits among the offenders. A tool whose
|
|
entire purpose is to be trusted before a deploy cried wolf on every run.
|
|
|
|
⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER. `-G'^version: "'`
|
|
matches a commit whose diff touches a line with that shape - which includes
|
|
a reformat, a quote change, or a comment moved onto the line, none of which
|
|
are bumps. So `-G` narrows the candidates and then the VALUE is compared
|
|
against the parent's. The question is "did the version change", and that is
|
|
the only thing that answers it.
|
|
|
|
⚠️ TWO GIT SHAPES THIS STILL GETS WRONG, NEITHER OF WHICH HAS EVER OCCURRED
|
|
IN THIS REPOSITORY - every bump here has been a clean, linear, forward-only
|
|
edit. Both were constructed in review rather than found:
|
|
|
|
1. A BUMP RESOLVED INSIDE A MERGE COMMIT IS INVISIBLE. `git log -G -- path`
|
|
does not diff merge commits at all without `-m`, so a version line whose
|
|
final value is decided by a conflict resolution is never a candidate.
|
|
The lookup then names the last ordinary bump, and the merge itself counts
|
|
as a runtime change since it - a FALSE REFUSAL of a tree that is in fact
|
|
correctly bumped.
|
|
2. REVERTING A BUMP WHILE KEEPING THE WORK MASKS THE WORK. Bump, change
|
|
`app/`, then revert only `config.yaml`: the version is back where it
|
|
started, the runtime change is still in the tree, and this reports
|
|
"nothing to deploy" - a FALSE CLEARANCE, which is the dangerous
|
|
direction.
|
|
|
|
Card PKG-11 carries both. They are recorded here rather than fixed because
|
|
a lookup that handles them costs more than the failure they represent, and
|
|
a caveat a reader can check beats a guarantee that is not true.
|
|
"""
|
|
r = _git("log", "--format=%H", "-G", r'^version: "', "--", CONFIG_PATH,
|
|
repo=repo)
|
|
if r.returncode != 0:
|
|
return None
|
|
shas = r.stdout.split()
|
|
for sha in shas: # newest first
|
|
here = _version_at(sha, repo)
|
|
there = _version_at(sha + "^", repo)
|
|
if here is not None and here != there:
|
|
return sha
|
|
return shas[-1] if shas else None
|
|
|
|
|
|
def runtime_commits_since_version_change(repo=None):
|
|
"""Commits touching RUNTIME since `version:` last changed. ([], None) if unknown.
|
|
|
|
Returns (commits, last_bump_sha). An empty list with a sha means the tree is
|
|
clean; an empty list with None means git could not answer and the caller
|
|
must NOT read that as a pass.
|
|
"""
|
|
last = last_version_change(repo)
|
|
if not last:
|
|
return [], None
|
|
r = _git("log", "--format=%H %s", f"{last}..HEAD", repo=repo)
|
|
if r.returncode != 0:
|
|
return [], None
|
|
out = []
|
|
for line in r.stdout.splitlines():
|
|
sha, _, subject = line.partition(" ")
|
|
f = _git("show", "--name-only", "--format=", sha, repo=repo)
|
|
if any(is_runtime(p) for p in f.stdout.split()):
|
|
out.append((sha[:10], subject))
|
|
return out, last[:10]
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--deployed-version", required=True,
|
|
help="the version currently running ON THE BOX")
|
|
ap.add_argument("--options", help="path to the live options blob, exported "
|
|
"from the Supervisor. Omitted = that half is SKIPPED.")
|
|
ap.add_argument("--tree", default=ADDON)
|
|
ap.add_argument("--repo", default=None)
|
|
a = ap.parse_args()
|
|
|
|
problems, skipped = [], []
|
|
shipped = shipped_version(a.tree)
|
|
print(f" deployed on the box : {a.deployed_version}")
|
|
print(f" shipped in this tree: {shipped}")
|
|
|
|
commits, last = runtime_commits_since_version_change(a.repo)
|
|
if shipped is None:
|
|
problems.append("could not read `version:` out of config.yaml")
|
|
elif shipped == a.deployed_version:
|
|
if last is None:
|
|
skipped.append("could not ask git what changed since the last bump; "
|
|
"the no-op check is INCONCLUSIVE, not clean")
|
|
if commits:
|
|
problems.append(
|
|
f"THE DEPLOY WOULD BE A NO-OP. Version is {shipped} on both sides, and "
|
|
f"{len(commits)} commit(s) touching runtime files have landed since the "
|
|
f"version last changed ({last}). `ha apps update` will report success "
|
|
f"and install NOTHING. Bump `version:` first.")
|
|
for sha, subj in commits[:12]:
|
|
print(f" {sha} {subj[:76]}")
|
|
elif last is not None:
|
|
print(" no runtime change since the last bump - nothing to deploy")
|
|
|
|
if a.options:
|
|
# ⚠️ A TOOL RUN IN A HURRY BEFORE A DEPLOY MUST NOT ANSWER WITH A
|
|
# TRACEBACK. A missing or malformed export is the likeliest operator
|
|
# error here, and it is a REFUSAL, not a skip - the config was not
|
|
# checked and the script must not be re-run past it.
|
|
opts = None
|
|
try:
|
|
with open(a.options, encoding="utf-8") as fh:
|
|
opts = json.load(fh)
|
|
except FileNotFoundError:
|
|
problems.append(f"--options file not found: {a.options}")
|
|
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
|
problems.append(f"--options file could not be read as JSON "
|
|
f"({type(exc).__name__}: {exc}); the config was NOT checked")
|
|
if a.options and opts is not None:
|
|
try:
|
|
from app.validate import validate_startup_config
|
|
except Exception as exc: # pragma: no cover
|
|
skipped.append(
|
|
f"could not import the shipped validator ({exc}); the config check "
|
|
f"did NOT run. `app/validate.py` imports its siblings, and those need "
|
|
f"the add-on's runtime dependencies - so THIS SCRIPT ONLY CHECKS A "
|
|
f"CONFIG WHERE THOSE DEPENDENCIES EXIST: inside the add-on container, "
|
|
f"or in the CI image. Re-run it as:\n"
|
|
f" docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \\\n"
|
|
f" python3 /w/goodwe_controller/tools/predeploy_check.py \\\n"
|
|
f" --deployed-version <ver> --options /w/<options.json>")
|
|
else:
|
|
found = validate_startup_config(opts)
|
|
if found:
|
|
problems.append(
|
|
f"THE LIVE CONFIG IS REFUSED BY THIS VERSION'S VALIDATOR "
|
|
f"({len(found)} problem(s)). Deploying it is SystemExit(78): the "
|
|
f"add-on does not start, and the ingress panel goes with it because "
|
|
f"the add-on serving it is not running. Recovery is the Supervisor UI.")
|
|
for p in found:
|
|
print(f" - {p}")
|
|
else:
|
|
print(" live options pass this version's validator")
|
|
else:
|
|
skipped.append("--options was not given, so the live config was NEVER CHECKED "
|
|
"against this version's validator. A config refused at startup is "
|
|
"exit 78. Export the options and re-run before deploying.")
|
|
|
|
print()
|
|
for s in skipped:
|
|
print(f" ⚠️ SKIPPED: {s}")
|
|
if problems:
|
|
for p in problems:
|
|
print(f" ❌ {p}")
|
|
print("\n REFUSED - do not deploy.")
|
|
return 1
|
|
if skipped:
|
|
print(" NOT CLEARED - checks above did not run. Deploying now is a decision "
|
|
"made without them.")
|
|
return 2
|
|
print(" CLEARED for deploy.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|