From 06fcd7a10f5bfed337b91379347a5786e0f1ba85 Mon Sep 17 00:00:00 2001 From: Abdussamet Kocak Date: Tue, 20 Oct 2026 04:31:13 +0000 Subject: [PATCH] feat: Add version tag bump script --- bump_version.py | 68 +++++++++++++++++++++++++++++++++++++++++++ bump_version_test.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100755 bump_version.py create mode 100755 bump_version_test.py diff --git a/bump_version.py b/bump_version.py new file mode 100755 index 0000000..8faebeb --- /dev/null +++ b/bump_version.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +import argparse +import logging +import re +import subprocess +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create a tag at HEAD by bumping the highest local X.Y.Z or vX.Y.Z tag. " + "Preserves the latest tag's prefix; starts from v0.0.0 when no version tags exist.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + group = parser.add_mutually_exclusive_group(required=True) + for part in ("major", "minor", "patch"): + group.add_argument(f"--{part}", dest="part", action="store_const", const=part, + help=f"Bump the {part} version and reset lower components") + parser.add_argument("--cwd", type=Path, default=Path.cwd(), help="Git repository directory") + parser.add_argument("--push", action="store_true", help="Push only the new tag to origin") + return parser.parse_args() + + +def git(cwd: Path, arguments: list[str]) -> str: + result = subprocess.run( + args=["git", *arguments], cwd=cwd, check=True, capture_output=True, text=True, + ) + return result.stdout.strip() + + +def next_tag(tags: list[str], part: str) -> str: + versions = [] + for tag in tags: + match = re.fullmatch(r"(v?)(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", tag) + if match: + versions.append((tuple(int(value) for value in match.groups()[1:]), match[1])) + version, prefix = max(versions, default=((0, 0, 0), "v")) + components = list(version) + index = ("major", "minor", "patch").index(part) + components[index] += 1 + components[index + 1:] = [0] * (2 - index) + return f"{prefix}{'.'.join(str(value) for value in components)}" + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") + arguments = parse_args() + try: + tags = git(cwd=arguments.cwd, arguments=["tag", "--list"]).splitlines() + tag = next_tag(tags=tags, part=arguments.part) + git(cwd=arguments.cwd, arguments=["tag", "--", tag, "HEAD"]) + logging.info(f"Created {tag} at HEAD") + if arguments.push: + git(cwd=arguments.cwd, arguments=["push", "origin", f"refs/tags/{tag}:refs/tags/{tag}"]) + logging.info(f"Pushed {tag} to origin") + except subprocess.CalledProcessError as error: + logging.error(f"Git failed: {(error.stderr or str(error)).strip()}") + return 1 + except OSError as error: + logging.error(f"{error}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bump_version_test.py b/bump_version_test.py new file mode 100755 index 0000000..cd4134a --- /dev/null +++ b/bump_version_test.py @@ -0,0 +1,69 @@ +#!/usr/bin/env -S uv run --script +# /// script +# dependencies = ["pytest"] +# /// + +from pathlib import Path +import subprocess +import sys + +import pytest + +import bump_version as script + + +@pytest.mark.parametrize("tags, part, expected", [ + ([], "patch", "v0.0.1"), + ([], "minor", "v0.1.0"), + ([], "major", "v1.0.0"), + (["v1.9.9", "v1.10.2", "v1.2.3"], "patch", "v1.10.3"), + (["v1.9.9", "v1.10.2"], "minor", "v1.11.0"), + (["v1.9.9", "v1.10.2"], "major", "v2.0.0"), + (["1.2.3", "v1.0.0"], "patch", "1.2.4"), + (["release", "v9.0.0-rc.1", "v01.0.0", "v1.2.3+build", "v1.2.3"], "patch", "v1.2.4"), +]) +def test_next_tag(tags: list[str], part: str, expected: str) -> None: + assert script.next_tag(tags=tags, part=part) == expected + + +@pytest.mark.parametrize("push", [False, True]) +def test_create_and_push_tag(tmp_path: Path, push: bool) -> None: + repository = tmp_path / "repository" + remote = tmp_path / "remote.git" + subprocess.run(args=["git", "init", "--initial-branch=master", str(repository)], check=True, capture_output=True) + subprocess.run(args=["git", "init", "--bare", str(remote)], check=True, capture_output=True) + script.git(cwd=repository, arguments=["-c", "user.name=Test", "-c", "user.email=test@example.com", + "-c", "commit.gpgsign=false", "commit", "--allow-empty", "--message=Initial"]) + script.git(cwd=repository, arguments=["remote", "add", "origin", str(remote)]) + script.git(cwd=repository, arguments=["tag", "v1.9.9"]) + script.git(cwd=repository, arguments=["tag", "v1.10.2"]) + command = [sys.executable, str(Path(script.__file__).resolve()), "--minor", "--cwd", str(repository)] + if push: + command.append("--push") + subprocess.run(args=command, check=True, capture_output=True, text=True) + assert script.git(cwd=repository, arguments=["rev-parse", "v1.11.0"]) == script.git( + cwd=repository, arguments=["rev-parse", "HEAD"], + ) + assert script.git(cwd=remote, arguments=["tag", "--list"]) == ("v1.11.0" if push else "") + assert script.git(cwd=remote, arguments=["for-each-ref", "refs/heads"]) == "" + + +@pytest.mark.parametrize("arguments", [[], ["--major", "--patch"]]) +def test_requires_exactly_one_bump(arguments: list[str]) -> None: + result = subprocess.run( + args=[sys.executable, str(Path(script.__file__).resolve()), *arguments], capture_output=True, + ) + assert result.returncode == 2 + + +def test_missing_repository(tmp_path: Path) -> None: + result = subprocess.run( + args=[sys.executable, str(Path(script.__file__).resolve()), "--patch", "--cwd", str(tmp_path)], + capture_output=True, text=True, + ) + assert result.returncode == 1 + assert "Git failed:" in result.stderr + + +if __name__ == "__main__": + sys.exit(pytest.main(args=[str(Path(__file__).resolve()), *sys.argv[1:]]))