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
72 changes: 65 additions & 7 deletions .github/bump_version.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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}")
Expand All @@ -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__":
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/push.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions changelog.d/versioning-highest-release.fixed.md
Original file line number Diff line number Diff line change
@@ -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`.
56 changes: 56 additions & 0 deletions tests/test_bump_version.py
Original file line number Diff line number Diff line change
@@ -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()
Loading