Files
goodwe-addon/goodwe_controller/test_version_gate.py
adminandClaude Opus 5 cb3f686555
checks / unit (pull_request) Successful in 7m51s
PKG-14: cut 0.10.0, and say what an upgrade from 0.7.0 crosses
`config.yaml` 0.9.0 -> 0.10.0, and the top `## Unreleased` block is closed
into `## 0.10.0` with a preamble that names the backward-incompatible
changes and who they reach.

MINOR, and NOT because eighteen tickets landed. A stored options blob the
previous version started on is REFUSED on this one:

  * TARIFF-07 retires three `price_provider` ids; a site set to one is
    refused BY NAME at startup. Measured on the reference blob, with a
    control on an accepted id so the refusals are about the ID.
  * TARIFF-18 moves `price_taxes_eur_kwh` 0.06 -> 0.11736. A site that
    STORED 0.06 is untouched, and - the non-obvious half - a taxes period
    table beside that stored 0.06 does NOT become a startup refusal on
    upgrade, because `SUPERSEDED_TAXES_DEFAULTS_EUR_KWH` reads it as
    untouched. Measured, with the counterfactual that the rung is not
    weakened (ORES's 0.1198 is still refused).
  * SAFETY-10 adds `shadow_mode`, shipped `auto`. An existing CONTROLLING
    install does not change by one watt and a FRESH one comes up observing.
    Measured cross-revision, 8f82dd4 -> e6da478, same stimulus through the
    shipped `Controller.cycle()`: byte-identical write streams, 13/13/25
    writes. Positive control: the same driver at `shadow_mode: on` gets 13
    writes to 5000 W on the older tree and one 0 W write on the newer.

And the one a reader of the newest section alone would miss: `ha addons
update` does not walk the versions, so a 0.7.0 install meets 0.8.0's and
0.9.0's rules at the same moment as 0.10.0's. Of those, SAFETY-39's rule 10
refuses 0.7.0's OWN shipped default `slew_w: 1000` against a 555.6 W
ceiling on a 5000 W rail. Measured red on this tree; the reference
install's `slew_w: 400` is measured green.

SCOPE, and it is drawn honestly rather than tidily. The top block's 18
entries all merged after the 0.9.0 bump `86bdf49`, so the block is closed
as-is and its `_this branch_` placeholders are resolved to merge shas.
⚠️ 0.10.0 SHIPS THREE MORE RUNTIME TICKETS THAT ARE NOT IN THAT TABLE -
SAFETY-43, TEST-21 and SAFETY-44 - and they are NAMED in the preamble
rather than moved, because re-filing this file's misplaced entries is
PKG-08. SAFETY-44 (`app/main.py`, `app/store.py`) is in the changelog
nowhere at all; that is said in both places. The second `## Unreleased`
block below `## 0.9.0` is ANNOTATED, not rewritten or emptied - UX-02 in it
shipped in 0.9.0, the other three in 0.10.0. No released section is
rewritten.

`test_version_gate.py` gains part 11: the bump and the closed section are
one act. PKG-09 refuses a deploy whose version did not move; nothing graded
the mirror failure, in which the version moves and the block is never
closed. Two sides from two files, a slicer with a synthetic positive
control run on every invocation, the retired ids read off `app/price.py` by
AST rather than transcribed, and the count of still-misfiled blocks frozen
so it can only shrink.

Mutations, all purged, tree bind-mounted :ro, backups at mktemp paths and
the revert fingerprinted:

  M1  `## 0.10.0` -> `## Unreleased`       +4 B    RED 2
  M2  version 0.10.0 -> 0.10.1             +0 B    RED 1   (ZERO-BYTE)
  M3  an id stripped from the NEWEST       +10 B   RED 1
      section only, left below
  M4  a 4th retired provider in price.py   +62 B   RED 1 here, 4 in
                                                   test_price.py
  M5  `## 0.9.0` heading lost              -3 B    RED 1
  M6  a 2nd `## Unreleased` left standing  +27 B   RED 1
  M7  the slicer never closes              -32 B   RED 3
  M8  SUPERSEDED_TAXES... emptied          -5 B    RED 2 on the evidence
                                                   instrument, plus
                                                   test_validate/test_price

⚠️ M5 WAS A SURVIVOR ON THE FIRST DRAFT. The slicer control was a sentinel
(`## 0.8.0`) that only a slicer running to EOF could reach, and deleting one
heading never gets there - ARCH-02's fifteenth instance, in a fresh costume.
11a now asserts the invariant instead: exactly one contents table per
section, which is what a lost heading breaks.

29/29 test files green. `tools/predeploy_check.py --deployed-version 0.7.0
--options <live blob>` CLEARED, exit 0, before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-16 02:48:27 +02:00

413 lines
20 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 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: <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()
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")