From 6be44d93e408080b1463d9895f1bf374b577b7c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 19:32:11 +0000 Subject: [PATCH] Release 1.0.3: fix stale README version + enforce version consistency The README 'Current version' row is hand-maintained and was left at 1.0.1 when 1.0.2 was cut, so the published 1.0.2 PyPI page (README is the project description) shows the wrong version. PyPI versions are immutable, so the fix ships as 1.0.3. - Bump pyproject, nns.__version__, and the README current-version row to 1.0.3. - Add scripts/check_version_consistency.py: fails if pyproject, nns.__version__, and the README current-version row disagree. - Run it as a release gate in release.yml (every release) so a stale version can never be published again, and assert it in tests/tools/test_version_sync.py. --- .github/workflows/release.yml | 4 ++ README.md | 2 +- pyproject.toml | 2 +- scripts/check_version_consistency.py | 80 ++++++++++++++++++++++++++++ src/nns/__init__.py | 2 +- tests/tools/test_version_sync.py | 29 +++++----- 6 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 scripts/check_version_consistency.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09cb406c..4dd72d99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" + - name: Check version consistency + # The README is the PyPI project description; block the release if its + # current-version row, nns.__version__, and pyproject disagree. + run: python scripts/check_version_consistency.py - name: Check release provenance shell: bash run: | diff --git a/README.md b/README.md index 390025cf..886ef172 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ NNS is built around partial moments, the lower and upper components of variance, |---|---| | Distribution package | `ovvo-nns` | | Import package | `nns` | -| Current version | `1.0.1` | +| Current version | `1.0.3` | | Python | `>=3.11` | | Required runtime dependencies | NumPy, SciPy | | R required at runtime | No | diff --git a/pyproject.toml b/pyproject.toml index 4c0f0a07..0036cbd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ovvo-nns" -version = "1.0.2" +version = "1.0.3" description = "Python port of nonlinear nonparametric statistics from R NNS" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/check_version_consistency.py b/scripts/check_version_consistency.py new file mode 100644 index 00000000..198600ff --- /dev/null +++ b/scripts/check_version_consistency.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Fail if the project version is not identical everywhere it is declared. + +The distribution version (``pyproject [project].version``) is the single source +of truth. This asserts that ``nns.__version__`` and the README "Package at a +glance" current-version row match it, so a release can never ship a stale +hard-coded version string -- the README is also the PyPI project description, so +a mismatch there is exactly what shows the wrong version on the package page. + +Run as a release gate (``.github/workflows/release.yml``) and as a unit test. +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] + + +def pyproject_version(root: Path) -> str: + data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + return str(data["project"]["version"]) + + +def init_version(root: Path) -> str | None: + text = (root / "src" / "nns" / "__init__.py").read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', text, re.MULTILINE) + return match.group(1) if match else None + + +def readme_version(root: Path) -> str | None: + text = (root / "README.md").read_text(encoding="utf-8") + match = re.search(r"\|\s*Current version\s*\|\s*`([^`]+)`\s*\|", text) + return match.group(1) if match else None + + +def check(root: Path) -> tuple[str, list[str]]: + version = pyproject_version(root) + problems: list[str] = [] + + init = init_version(root) + if init is None: + problems.append("could not find __version__ in src/nns/__init__.py") + elif init != version: + problems.append(f"nns.__version__ {init!r} != pyproject version {version!r}") + + readme = readme_version(root) + if readme is None: + problems.append("could not find the 'Current version' row in README.md") + elif readme != version: + problems.append( + f"README 'Current version' {readme!r} != pyproject version {version!r}" + ) + + return version, problems + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=_ROOT) + args = parser.parse_args() + + version, problems = check(args.root.resolve()) + if problems: + print("Version consistency check FAILED:") + for problem in problems: + print(f" - {problem}") + print("Bump the version everywhere (pyproject, nns.__version__, README).") + return 1 + + print(f"Version consistency OK: {version} in pyproject, nns.__version__, and README.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/nns/__init__.py b/src/nns/__init__.py index 711284f1..e77d2a41 100644 --- a/src/nns/__init__.py +++ b/src/nns/__init__.py @@ -4,7 +4,7 @@ from nns.pm_matrix import pm_matrix as pm_matrix -__version__ = "1.0.2" +__version__ = "1.0.3" _EXPORTS = { "FactorDesign": ("nns.regression", "FactorDesign"), diff --git a/tests/tools/test_version_sync.py b/tests/tools/test_version_sync.py index e1e93ab4..acfed915 100644 --- a/tests/tools/test_version_sync.py +++ b/tests/tools/test_version_sync.py @@ -1,23 +1,26 @@ from __future__ import annotations -import re -import tomllib +import importlib.util from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / "scripts" / "check_version_consistency.py" -def _pyproject_version() -> str: - data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - return str(data["project"]["version"]) +def _load_module() -> object: + spec = importlib.util.spec_from_file_location("check_version_consistency", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module -def _package_version() -> str: - text = (REPO_ROOT / "src" / "nns" / "__init__.py").read_text(encoding="utf-8") - match = re.search(r'^__version__\s*=\s*"([^"]+)"', text, re.MULTILINE) - assert match is not None, "could not find __version__ in src/nns/__init__.py" - return match.group(1) +def test_version_is_consistent_everywhere() -> None: + """pyproject, nns.__version__, and the README current-version row must agree. - -def test_package_version_matches_pyproject() -> None: - assert _package_version() == _pyproject_version() + The README is the PyPI project description, so a stale version row there + shows the wrong version on the package page. This guards every version bump. + """ + module = _load_module() + _version, problems = module.check(REPO_ROOT) # type: ignore[attr-defined] + assert problems == [], problems