From 4f28d4eb64cae42ab4e358008d45d566729bd32b Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sun, 2 Aug 2026 18:03:47 -0400 Subject: [PATCH 1/2] fix(version): make the core version have exactly one answer Plugin compatibility floors compare against src.__version__, so that string has to be trustworthy. It has not been. v3.1.0 was tagged 2026-05-31 while src/__init__.py still said "1.0.0"; the bump did not land until 2026-07-12. Every device installed from that release reports 1.0.0, which is below the (2, 0, 0) floor in PluginLoader._warn_if_incompatible -- so those users are silently exempt from every plugin compatibility warning. web_interface carried a third answer, a hardcoded "3.0.0" that nothing read and that had drifted two majors from the core. It now re-exports the canonical value, so it cannot disagree again. Adds: - test/test_version_consistency.py (enrolled in the core unit CI job): src.__version__ is parseable semver, matches the newest CHANGELOG heading, the CHANGELOG's headings are unique and descending, and web_interface tracks the core. src.plugin_system.__version__ is deliberately excluded -- it versions the plugin API and moves independently. - scripts/check_release_version.py + a release-version-check workflow that asserts the tag, the CHANGELOG and src.__version__ agree. Runs on pushed v* tags and published releases, and via workflow_dispatch so a tag can be checked *before* it is created: python scripts/check_release_version.py v3.2.0 Verified: 757 core unit tests pass including the four new ones; the script exits 0 for v3.2.0 and non-zero for both a mismatched tag (v3.1.0) and a non-semver one (v2.5); web_interface and web_interface.app still import. Prerequisite for cutting v3.2.0 -- phase B4 in docs/SPORTS_UNIFICATION.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- scripts/check_release_version.py | 99 ++++++++++++++++++++++++++++++++ test/test_version_consistency.py | 91 +++++++++++++++++++++++++++++ web_interface/__init__.py | 7 ++- 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 scripts/check_release_version.py create mode 100644 test/test_version_consistency.py diff --git a/scripts/check_release_version.py b/scripts/check_release_version.py new file mode 100644 index 000000000..1c9bec66d --- /dev/null +++ b/scripts/check_release_version.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Assert that a release tag, the CHANGELOG, and `src.__version__` all agree. + +Run it *before* creating a tag to check yourself: + + python scripts/check_release_version.py v3.2.0 + +CI runs it on every pushed `v*` tag and published release +(`.github/workflows/release-version-check.yml`), so a mismatch shows up as a +red check on the release rather than as a silent wrong answer on user devices. + +Why this exists: `v3.1.0` was tagged 2026-05-31 while `src/__init__.py` still +said `"1.0.0"`; the bump to `"3.1.0"` did not land until 2026-07-12. Devices +installed from that release report `1.0.0`, which is below the `(2, 0, 0)` floor +in `PluginLoader._warn_if_incompatible`, so they are silently exempt from every +plugin compatibility warning. Plugin `ledmatrix_min_version` floors are only as +trustworthy as this agreement. See `docs/SPORTS_UNIFICATION.md`, phase B4. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") +HEADING = re.compile(r"^##\s+(?P\d+\.\d+\.\d+)\s*$", re.MULTILINE) + + +def normalize(tag: str) -> str: + """`v3.2.0` and `3.2.0` are the same release; tags here carry the `v`.""" + return tag[1:] if tag.startswith("v") else tag + + +def newest_changelog_version(changelog: Path) -> str | None: + headings = HEADING.findall(changelog.read_text(encoding="utf-8")) + return headings[0] if headings else None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "tag", + help="Release tag to check, with or without the leading 'v' (e.g. v3.2.0)", + ) + args = parser.parse_args() + + from src import __version__ as core_version + + tag_version = normalize(args.tag) + changelog_version = newest_changelog_version(REPO_ROOT / "CHANGELOG.md") + + problems: list[str] = [] + + if not SEMVER.match(tag_version): + problems.append( + f"tag {args.tag!r} is not vX.Y.Z. Older tags (v2.5) predate this " + "check; new releases must be full semver so floors can parse them." + ) + + if not SEMVER.match(core_version): + problems.append(f"src.__version__ is {core_version!r}, which is not X.Y.Z") + + if tag_version != core_version: + problems.append( + f"tag says {tag_version} but src.__version__ says {core_version}. " + "Bump src/__init__.py to match the tag before releasing — devices " + "report __version__, not the tag, and plugin floors compare " + "against it." + ) + + if changelog_version is None: + problems.append("CHANGELOG.md has no '## X.Y.Z' version heading") + elif changelog_version != core_version: + problems.append( + f"CHANGELOG.md's newest heading is {changelog_version} but " + f"src.__version__ is {core_version}. Plugin authors read the " + "CHANGELOG to pick a ledmatrix_min_version floor." + ) + + if problems: + print(f"Release version check FAILED for tag {args.tag}:", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + return 1 + + print( + f"OK: tag {args.tag}, src.__version__ {core_version}, and the CHANGELOG " + "all agree." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_version_consistency.py b/test/test_version_consistency.py new file mode 100644 index 000000000..e0e2d7b6f --- /dev/null +++ b/test/test_version_consistency.py @@ -0,0 +1,91 @@ +"""Version reporting must have exactly one answer. + +`src.__version__` is the canonical core version. The plugin loader compares +plugin `ledmatrix_min_version` floors against it, and the plugin ecosystem +floors on the number recorded in `CHANGELOG.md` — so if those two disagree, a +plugin can declare a floor that is satisfied by a core which does not actually +ship the module it needs. + +This has already gone wrong once. The `v3.1.0` tag was cut 2026-05-31, but +`src/__init__.py` was not bumped from `"1.0.0"` to `"3.1.0"` until 2026-07-12, +six weeks later. Every device installed from that release reports `1.0.0`, +which is below the `(2, 0, 0)` floor in `PluginLoader._warn_if_incompatible` — +so those users get no compatibility warning at all. See +`docs/SPORTS_UNIFICATION.md` (phase B4). + +The matching tag check runs at release time in +`.github/workflows/release-version-check.yml`; a tag is not available here. + +Note: `src.plugin_system.__version__` is deliberately NOT checked. That module +versions the *plugin API* (it sits beside `__api_version__` and is documented as +such), which moves independently of the core version. +""" + +import re +from pathlib import Path + +import pytest + +import src + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHANGELOG = REPO_ROOT / "CHANGELOG.md" + +SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +# Version headings look like "## 3.2.0". A leading "## Unreleased" section is +# allowed and skipped -- it is where module additions are staged before a bump. +HEADING = re.compile(r"^##\s+(?P\d+\.\d+\.\d+)\s*$", re.MULTILINE) + + +def test_core_version_is_semver(): + """A floor comparison parses this string; it has to be parseable.""" + assert SEMVER.match(src.__version__), ( + f"src.__version__ is {src.__version__!r}, which is not X.Y.Z. " + "The loader's floor comparison cannot parse it." + ) + + +def test_changelog_documents_the_current_version(): + """The newest versioned CHANGELOG heading is the version we claim to be. + + Plugins floor on the version recorded in the CHANGELOG as first shipping a + module. If the code says 3.2.0 and the CHANGELOG's newest entry is 3.1.0, + that record points at the wrong release. + """ + text = CHANGELOG.read_text(encoding="utf-8") + headings = HEADING.findall(text) + assert headings, "CHANGELOG.md has no '## X.Y.Z' version headings" + + newest = headings[0] + assert newest == src.__version__, ( + f"src.__version__ is {src.__version__!r} but the newest CHANGELOG " + f"heading is {newest!r}. Bump one to match the other: the CHANGELOG is " + "what plugin authors read to pick a ledmatrix_min_version floor." + ) + + +def test_changelog_versions_are_ordered_and_unique(): + """A duplicated or out-of-order heading makes 'first release shipping X' + ambiguous, which is exactly the question the sunset rule asks.""" + text = CHANGELOG.read_text(encoding="utf-8") + versions = [tuple(int(p) for p in v.split(".")) for v in HEADING.findall(text)] + + duplicates = {v for v in versions if versions.count(v) > 1} + assert not duplicates, f"CHANGELOG.md has duplicate version headings: {duplicates}" + + assert versions == sorted(versions, reverse=True), ( + "CHANGELOG.md version headings are not in descending order; " + f"got {['.'.join(map(str, v)) for v in versions]}" + ) + + +def test_web_interface_version_tracks_the_core(): + """web_interface used to carry its own hardcoded "3.0.0", a third answer to + 'what version is this'. It now re-exports the canonical one.""" + web_interface = pytest.importorskip( + "web_interface", reason="web_interface needs Flask, which is optional here" + ) + assert getattr(web_interface, "__version__", None) == src.__version__, ( + "web_interface.__version__ has drifted from src.__version__; it should " + "re-export the canonical value rather than hardcode its own." + ) diff --git a/web_interface/__init__.py b/web_interface/__init__.py index 796f00d0b..eb49bf5bf 100644 --- a/web_interface/__init__.py +++ b/web_interface/__init__.py @@ -2,5 +2,10 @@ LED Matrix Web Interface V3 Modern web interface for controlling the LED Matrix display """ -__version__ = "3.0.0" + +# Re-exported, never hardcoded. This used to carry its own "3.0.0", a third +# answer to "what version is this" alongside the tag and src.__version__ — +# and disagreeing version numbers are what made plugin compatibility floors +# untrustworthy (see docs/SPORTS_UNIFICATION.md, phase B4). +from src import __version__ # noqa: F401 From e26ed29385690e902a2de283ebd2c39e1faf5323 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sun, 2 Aug 2026 20:40:06 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(version):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20regex=20strictness,=20OSError,=20stale=20doc=20clai?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From CodeRabbit on #428, all three valid: - The module docstring claimed the tag check "runs at release time in .github/workflows/release-version-check.yml". That workflow is held back to a follow-up PR (the pushing token lacks the `workflow` scope), so the claim was false as written. Both files now describe the script as a manual pre-flight and say the CI wiring is still to come. - `\d` also matches non-ASCII decimal digits, which int() happily parses, and `\s` matches newlines -- so "##\n3.2.0" read as a version heading. Patterns now use [0-9] and [ \t], kept in step across the test and the script, with a regression test pinning both behaviours. - A missing or unreadable CHANGELOG.md raised OSError out of read_text() and printed a traceback. In a release gate that reads as "the tooling is broken"; it now reports the path and a recovery action and exits 1. Verified: v3.2.0 passes, a mismatched tag exits 1, and a missing CHANGELOG exits 1 with the new message instead of a traceback. 5 tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- scripts/check_release_version.py | 35 ++++++++++++++++++++++++++------ test/test_version_consistency.py | 30 +++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/scripts/check_release_version.py b/scripts/check_release_version.py index 1c9bec66d..45dc0eadd 100644 --- a/scripts/check_release_version.py +++ b/scripts/check_release_version.py @@ -5,9 +5,9 @@ python scripts/check_release_version.py v3.2.0 -CI runs it on every pushed `v*` tag and published release -(`.github/workflows/release-version-check.yml`), so a mismatch shows up as a -red check on the release rather than as a silent wrong answer on user devices. +Wiring it into CI (on pushed `v*` tags and published releases) is a follow-up +PR, so for now it is a manual pre-flight: run it before creating the tag and a +mismatch shows up here rather than as a silent wrong answer on user devices. Why this exists: `v3.1.0` was tagged 2026-05-31 while `src/__init__.py` still said `"1.0.0"`; the bump to `"3.1.0"` did not land until 2026-07-12. Devices @@ -27,8 +27,13 @@ REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT)) -SEMVER = re.compile(r"^\d+\.\d+\.\d+$") -HEADING = re.compile(r"^##\s+(?P\d+\.\d+\.\d+)\s*$", re.MULTILINE) +# [0-9] rather than \d, and [ \t] rather than \s: \d also matches non-ASCII +# decimal digits (which int() parses), and \s matches newlines, so "##\n3.2.0" +# would otherwise read as a version heading. Keep these in step with +# test/test_version_consistency.py. +SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") +HEADING = re.compile( + r"^##[ \t]+(?P[0-9]+\.[0-9]+\.[0-9]+)[ \t]*$", re.MULTILINE) def normalize(tag: str) -> str: @@ -37,6 +42,13 @@ def normalize(tag: str) -> str: def newest_changelog_version(changelog: Path) -> str | None: + """Newest version heading, or None when there is none. + + Raises OSError if the file cannot be read; main() turns that into a clear + message rather than a traceback, because this runs as a release gate and a + traceback there reads as "the tooling is broken", not "your CHANGELOG is + missing". + """ headings = HEADING.findall(changelog.read_text(encoding="utf-8")) return headings[0] if headings else None @@ -52,10 +64,21 @@ def main() -> int: from src import __version__ as core_version tag_version = normalize(args.tag) - changelog_version = newest_changelog_version(REPO_ROOT / "CHANGELOG.md") + changelog_path = REPO_ROOT / "CHANGELOG.md" problems: list[str] = [] + try: + changelog_version = newest_changelog_version(changelog_path) + except OSError as e: + print( + f"Release version check FAILED for tag {args.tag}:\n" + f" - could not read {changelog_path}: {e}\n" + f" Restore the file (git checkout -- CHANGELOG.md) and re-run.", + file=sys.stderr, + ) + return 1 + if not SEMVER.match(tag_version): problems.append( f"tag {args.tag!r} is not vX.Y.Z. Older tags (v2.5) predate this " diff --git a/test/test_version_consistency.py b/test/test_version_consistency.py index e0e2d7b6f..03e638a35 100644 --- a/test/test_version_consistency.py +++ b/test/test_version_consistency.py @@ -13,8 +13,12 @@ so those users get no compatibility warning at all. See `docs/SPORTS_UNIFICATION.md` (phase B4). -The matching tag check runs at release time in -`.github/workflows/release-version-check.yml`; a tag is not available here. +A tag is not available here, so the tag half of the check lives in +`scripts/check_release_version.py`. Wiring that script into CI (on pushed `v*` +tags and published releases) is a follow-up PR; until it lands, run it by hand +before tagging: + + python scripts/check_release_version.py v3.2.0 Note: `src.plugin_system.__version__` is deliberately NOT checked. That module versions the *plugin API* (it sits beside `__api_version__` and is documented as @@ -31,10 +35,15 @@ REPO_ROOT = Path(__file__).resolve().parents[1] CHANGELOG = REPO_ROOT / "CHANGELOG.md" -SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +# [0-9] rather than \d: \d also matches non-ASCII decimal digits, which int() +# happily parses, so a heading in Arabic-Indic numerals would pass the pattern +# and then mismatch confusingly. [ \t] rather than \s for the same class of +# reason -- \s matches newlines, so "##\n3.2.0" would read as a heading. +SEMVER = re.compile(r"^([0-9]+)\.([0-9]+)\.([0-9]+)$") # Version headings look like "## 3.2.0". A leading "## Unreleased" section is # allowed and skipped -- it is where module additions are staged before a bump. -HEADING = re.compile(r"^##\s+(?P\d+\.\d+\.\d+)\s*$", re.MULTILINE) +HEADING = re.compile( + r"^##[ \t]+(?P[0-9]+\.[0-9]+\.[0-9]+)[ \t]*$", re.MULTILINE) def test_core_version_is_semver(): @@ -89,3 +98,16 @@ def test_web_interface_version_tracks_the_core(): "web_interface.__version__ has drifted from src.__version__; it should " "re-export the canonical value rather than hardcode its own." ) + + +def test_heading_pattern_is_strict_about_digits_and_whitespace(): + """`\\d` also matches non-ASCII decimal digits and `\\s` matches newlines, + either of which would let a malformed heading through and then fail the + comparison with a confusing message. Pin the tightened patterns.""" + assert HEADING.findall("## 3.2.0\n") == ["3.2.0"] + assert HEADING.findall("##\t3.2.0 \n") == ["3.2.0"] + # A bare "##" whose version sits on the next line is not a heading. + assert HEADING.findall("##\n3.2.0\n") == [] + # Arabic-Indic digits parse via int() but are not our version format. + assert HEADING.findall("## ٣.٢.٠\n") == [] + assert SEMVER.match("٣.٢.٠") is None