Files
LEDMatrix/test/test_version_consistency.py
T
f2b246ef03 fix(version): make the core version have exactly one answer (#428)
* 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(version): address review — regex strictness, OSError, stale doc claim

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:21:47 -04:00

114 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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