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