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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
126 changes: 126 additions & 0 deletions scripts/check_version_bump.py
Original file line number Diff line number Diff line change
@@ -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())
39 changes: 39 additions & 0 deletions tests/test_check_version_bump.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion warpedpinball/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from .machine import GameEvent, Machine
from .transports.http import HttpTransport

__version__ = "0.2.1"
__version__ = "0.2.2"

__all__ = [
"connect",
Expand Down
Loading