Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions scripts/check_release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/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

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
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))

# [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<version>[0-9]+\.[0-9]+\.[0-9]+)[ \t]*$", 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:
"""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


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_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 "
"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())
113 changes: 113 additions & 0 deletions test/test_version_consistency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""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).

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
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"

# [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"^##[ \t]+(?P<version>[0-9]+\.[0-9]+\.[0-9]+)[ \t]*$", 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."
)


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
7 changes: 6 additions & 1 deletion web_interface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Loading