diff --git a/.github/bump_version.py b/.github/bump_version.py index 779a82e3..2ac79193 100644 --- a/.github/bump_version.py +++ b/.github/bump_version.py @@ -1,11 +1,21 @@ """Infer semver bump from towncrier fragment types and update version.""" import re +import subprocess import sys from pathlib import Path +SEMVER_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") -def get_current_version(pyproject_path: Path) -> str: + +def parse_version(version: str) -> tuple[int, int, int]: + match = SEMVER_PATTERN.match(version) + if not match: + raise ValueError(f"Invalid semver: {version}") + return tuple(int(part) for part in match.groups()) + + +def get_pyproject_version(pyproject_path: Path) -> str: text = pyproject_path.read_text() match = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', text, re.MULTILINE) if not match: @@ -17,6 +27,45 @@ def get_current_version(pyproject_path: Path) -> str: return match.group(1) +def get_changelog_versions(changelog_path: Path) -> list[str]: + if not changelog_path.exists(): + return [] + return re.findall( + r"^## \[(\d+\.\d+\.\d+)\]", changelog_path.read_text(), re.MULTILINE + ) + + +def get_git_tag_versions(repo_root: Path) -> list[str]: + try: + result = subprocess.run( + ["git", "tag"], + cwd=repo_root, + capture_output=True, + text=True, + check=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return [] + + versions = [] + for tag in result.stdout.splitlines(): + normalized = tag.removeprefix("v").strip() + if SEMVER_PATTERN.match(normalized): + versions.append(normalized) + return versions + + +def get_current_version( + pyproject_path: Path, + changelog_path: Path, + repo_root: Path, +) -> str: + candidates = [get_pyproject_version(pyproject_path)] + candidates.extend(get_changelog_versions(changelog_path)) + candidates.extend(get_git_tag_versions(repo_root)) + return max(candidates, key=parse_version) + + def infer_bump(changelog_dir: Path) -> str: fragments = [ f for f in changelog_dir.iterdir() if f.is_file() and f.name != ".gitkeep" @@ -48,12 +97,20 @@ def bump_version(version: str, bump: str) -> str: return f"{major}.{minor}.{patch + 1}" -def update_file(path: Path, old_version: str, new_version: str): +def update_file(path: Path, new_version: str): text = path.read_text() - updated = text.replace( - f'version = "{old_version}"', - f'version = "{new_version}"', + updated, replacements = re.subn( + r'(^version\s*=\s*")(\d+\.\d+\.\d+)(")', + rf"\g<1>{new_version}\g<3>", + text, + flags=re.MULTILINE, ) + if replacements == 0: + print( + f"Could not update version in {path}", + file=sys.stderr, + ) + sys.exit(1) if updated != text: path.write_text(updated) print(f" Updated {path}") @@ -62,15 +119,16 @@ def update_file(path: Path, old_version: str, new_version: str): def main(): root = Path(__file__).resolve().parent.parent pyproject = root / "pyproject.toml" + changelog = root / "CHANGELOG.md" changelog_dir = root / "changelog.d" - current = get_current_version(pyproject) + current = get_current_version(pyproject, changelog, root) bump = infer_bump(changelog_dir) new = bump_version(current, bump) print(f"Version: {current} -> {new} ({bump})") - update_file(pyproject, current, new) + update_file(pyproject, new) if __name__ == "__main__": diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 6db5fec2..43bec76f 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -108,6 +108,9 @@ jobs: uses: actions/checkout@v4 with: token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 + - name: Fetch tags + run: git fetch --tags --force - name: Setup Python uses: actions/setup-python@v5 with: diff --git a/changelog.d/versioning-highest-release.fixed.md b/changelog.d/versioning-highest-release.fixed.md new file mode 100644 index 00000000..be1458e6 --- /dev/null +++ b/changelog.d/versioning-highest-release.fixed.md @@ -0,0 +1 @@ +Fix the release versioning workflow so it bumps from the highest known released version instead of regressing to a stale version from `pyproject.toml`. diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py new file mode 100644 index 00000000..79461b01 --- /dev/null +++ b/tests/test_bump_version.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = REPO_ROOT / ".github" / "bump_version.py" + +spec = importlib.util.spec_from_file_location("bump_version", MODULE_PATH) +bump_version = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(bump_version) + + +def test_get_current_version_prefers_highest_seen_version(tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nversion = "3.4.1"\n') + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + "## [3.4.1] - 2026-04-13\n\n" + "### Changed\n\n" + "- Current change.\n\n" + "## [3.4.2] - 2026-04-12\n\n" + "### Changed\n\n" + "- Prior release.\n" + ) + + current = bump_version.get_current_version(pyproject, changelog, tmp_path) + + assert current == "3.4.2" + + +def test_get_current_version_uses_git_tags_when_available(tmp_path, monkeypatch): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nversion = "3.4.1"\n') + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("## [3.4.1] - 2026-04-13\n") + + monkeypatch.setattr( + bump_version, + "get_git_tag_versions", + lambda _repo_root: ["3.4.3"], + ) + + current = bump_version.get_current_version(pyproject, changelog, tmp_path) + + assert current == "3.4.3" + + +def test_update_file_replaces_stale_version_field(tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nversion = "3.4.1"\n') + + bump_version.update_file(pyproject, "3.4.3") + + assert 'version = "3.4.3"' in pyproject.read_text()