diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f0cebf..c78353c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,23 @@ jobs: - name: Verify pyproject.toml and package version agree run: python scripts/check_version.py + version-bump: + # Every pull request must raise __version__ above the base branch, so no + # change merges without a version bump and main is always publishable. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Ensure the version was bumped + run: | + git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}" + python scripts/check_version_bump.py --base-ref "${{ github.event.pull_request.base.sha }}" + lint: runs-on: ubuntu-latest steps: diff --git a/scripts/check_version_bump.py b/scripts/check_version_bump.py new file mode 100644 index 0000000..bfd8546 --- /dev/null +++ b/scripts/check_version_bump.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Ensure a pull request bumps the package version. + +The single source of truth for the version is ``warpedpinball/__init__.py`` +(``__version__``). This gate compares the version on the current checkout +("head") against the version on the base branch and fails unless head is +strictly greater, so no change ever merges without a version bump — which is +what keeps every release publishable straight from ``main``. + +The base version is read from git (``--base-ref``, default ``origin/main``); +if the base has no ``__init__.py`` yet (a brand-new package) the check passes. +Exit status is 0 when head > base (or the base is unavailable), 1 otherwise, so +it works as a CI gate. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +INIT_REL = "warpedpinball/__init__.py" +INIT_PY = REPO_ROOT / INIT_REL + +_INIT_VERSION_RE = re.compile( + r"""^__version__\s*=\s*["']([^"']+)["']""", re.MULTILINE +) + + +def parse_version_text(text: str) -> str | None: + """Extract the ``__version__`` string from an ``__init__.py`` body.""" + m = _INIT_VERSION_RE.search(text) + return m.group(1) if m else None + + +def version_key(version: str) -> tuple: + """A comparable key for a simple ``X.Y.Z[...]`` version. + + Numeric release segments compare numerically; any trailing pre-release + (e.g. ``rc1`` in ``0.3.0rc1``) sorts *before* the same release without it, + matching PEP 440 ordering for the cases this project uses. Prefers + ``packaging`` when available for full correctness. + """ + try: + from packaging.version import Version + + return (0, Version(version)) + except Exception: + pass + # Lightweight fallback: split the leading numeric release from any suffix. + m = re.match(r"^(\d+(?:\.\d+)*)(.*)$", version.strip()) + if not m: + raise ValueError(f"Unrecognized version string: {version!r}") + release = tuple(int(p) for p in m.group(1).split(".")) + suffix = m.group(2) + # No suffix sorts after a pre-release suffix on the same release. + return (1, release, 1 if suffix == "" else 0, suffix) + + +def is_bump(base: str, head: str) -> bool: + """True when ``head`` is a strictly higher version than ``base``.""" + return version_key(head) > version_key(base) + + +def head_version() -> str: + text = INIT_PY.read_text(encoding="utf-8") + version = parse_version_text(text) + if version is None: + raise SystemExit(f"Could not find __version__ in {INIT_PY}") + return version + + +def base_version(base_ref: str) -> str | None: + """Read ``__version__`` from ``base_ref`` via git, or None if unavailable.""" + try: + text = subprocess.check_output( + ["git", "show", f"{base_ref}:{INIT_REL}"], + cwd=REPO_ROOT, + stderr=subprocess.DEVNULL, + text=True, + ) + except subprocess.CalledProcessError: + return None # base branch has no package file yet (new package) + return parse_version_text(text) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--base-ref", + default="origin/main", + help="Git ref of the base branch to compare against (default origin/main).", + ) + args = parser.parse_args(argv) + + head = head_version() + base = base_version(args.base_ref) + + if base is None: + print( + f"Version bump check OK: no base version found at {args.base_ref}; " + f"head is {head}." + ) + return 0 + + if not is_bump(base, head): + print( + "Version bump check FAILED: this branch must raise the package " + "version.\n" + f" base ({args.base_ref}) = {base}\n" + f" head = {head}\n" + "Bump __version__ in warpedpinball/__init__.py above the base " + "version before merging.", + file=sys.stderr, + ) + return 1 + + print(f"Version bump check OK: {base} -> {head}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_version_bump.py b/tests/test_check_version_bump.py new file mode 100644 index 0000000..64fc514 --- /dev/null +++ b/tests/test_check_version_bump.py @@ -0,0 +1,39 @@ +"""Tests for scripts/check_version_bump.py version comparison logic.""" + +import importlib.util +from pathlib import Path + +import pytest + +_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "check_version_bump.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("check_version_bump", _SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +cvb = _load() + + +@pytest.mark.parametrize( + "base,head,expected", + [ + ("0.2.1", "0.2.2", True), + ("0.2.1", "0.3.0", True), + ("0.2.1", "1.0.0", True), + ("0.2.1", "0.2.1", False), # unchanged: no bump + ("0.2.2", "0.2.1", False), # downgrade + ("0.2.0rc1", "0.2.0", True), # release supersedes its pre-release + ("0.2.0", "0.2.0rc1", False), # pre-release does not supersede release + ], +) +def test_is_bump(base, head, expected): + assert cvb.is_bump(base, head) is expected + + +def test_parse_version_text(): + assert cvb.parse_version_text('__version__ = "1.2.3"\n') == "1.2.3" + assert cvb.parse_version_text("x = 1\n") is None diff --git a/warpedpinball/__init__.py b/warpedpinball/__init__.py index 176d13a..f2efd58 100644 --- a/warpedpinball/__init__.py +++ b/warpedpinball/__init__.py @@ -32,7 +32,7 @@ from .machine import GameEvent, Machine from .transports.http import HttpTransport -__version__ = "0.2.1" +__version__ = "0.2.2" __all__ = [ "connect",