dotfiles/home/.config/git/release.py

143 lines
4.8 KiB
Python
Executable file

#!/usr/bin/env python3
"""
This is a "safe release tag" script to avoid accidental mistakes when creating version release tags.
It will:
1. Checks that you are on `main`, `master`, `release` or `stable` branch.
2. Validate that your local version matches the remote.
3. Ensures that the provided tag is a valid semantic version bump from the latest release tag.
4. Runs git tag -m "" -as <tag> if valid.
Do note that this does NOT support versions outside of simple X.Y.Z (e.g. a version like 1.2.5rc-1 will not be picked up).
If you're using such versions, this script will block the release and you will need to create the tag for it manually.
That said, due to the nature of these versions, even if there's an alpha/beta/rc/post/dev/... version tag, this will still
correctly identify whether the next stable release is following the previous, so you can use this even if your last version
tag was one of these special forms, just not when creating new tags for them.
"""
from __future__ import annotations
from dataclasses import dataclass
import re
import subprocess
import sys
from typing import ClassVar, final, override
RELEASE_BRANCHES = {"main", "master", "release", "stable"}
@final
@dataclass(frozen=True, order=True)
class SemverTag:
VERSION_TAG_RE: ClassVar[re.Pattern[str]] = re.compile(r"^(v)?(\d+)\.(\d+)\.(\d+)$")
has_v: bool
major: int
minor: int
patch: int
@classmethod
def from_str(cls, s: str) -> SemverTag:
"""Parse a version tag string into a SemverTag instance."""
m = cls.VERSION_TAG_RE.fullmatch(s)
if not m:
raise ValueError(f"Invalid version tag string: {s}")
v, major, minor, patch = m.groups()
return cls(v == "v", int(major), int(minor), int(patch))
def is_next_after(self, other: SemverTag) -> bool:
"""Return True if this tag is the immediate next major, minor, or patch bump after another tag."""
return (
self == SemverTag(other.has_v, other.major + 1, 0, 0)
or self == SemverTag(other.has_v, other.major, other.minor + 1, 0)
or self == SemverTag(other.has_v, other.major, other.minor, other.patch + 1)
)
@override
def __str__(self) -> str:
"""Return tag in standard 'vX.Y.Z' format; the v can be omitted."""
return f"{self.has_v and 'v' or ''}{self.major}.{self.minor}.{self.patch}"
def run_git(*args: str) -> str:
"""Run a git command and return stdout, raising on any error."""
result = subprocess.run(["git", *args], capture_output=True, text=True, check=True)
return result.stdout.strip()
def ensure_branch() -> None:
"""Exit if not on a valid release branch."""
branch = run_git("rev-parse", "--abbrev-ref", "HEAD")
if branch not in RELEASE_BRANCHES:
sys.exit(
f"You must be on a valid release branch (one of: {', '.join(RELEASE_BRANCHES)}), your branch: {branch}"
)
def ensure_remote_match() -> None:
"""Exit if the local version doesn't match the remote version."""
_ = run_git("fetch")
branch = run_git("rev-parse", "--abbrev-ref", "HEAD")
tracking = run_git("rev-parse", "--abbrev-ref", f"{branch}@{{upstream}}")
local_commit = run_git("rev-parse", branch)
remote_commit = run_git("rev-parse", tracking)
if local_commit != remote_commit:
sys.exit(f"Local branch {branch} is not in sync with remote branch {tracking}")
def get_latest_tag() -> SemverTag:
"""Return the latest git version tag."""
# Get all tags sorted by creation name
tags = run_git("tag").splitlines()
version_tags: list[SemverTag] = []
for tag in tags:
try:
version_tag = SemverTag.from_str(tag)
except ValueError:
continue
version_tags.append(version_tag)
if not version_tags:
# Arguably, it might make sense to instead return None and let that pass
# since it's entirely valid to make a new release tag, however, it could
# also indicate a problem with the script, so it's safer to just fail and
# let the user make the first release tag manually.
sys.exit("No version tags found in repository.")
# Sort by semantic version, newest first
return max(version_tags)
def try_make_tag(tag: str) -> None:
ensure_branch()
ensure_remote_match()
try:
new_tag = SemverTag.from_str(tag)
except ValueError as exc:
sys.exit(f"Invalid tag: {exc!s}")
latest_tag = get_latest_tag()
if not new_tag.is_next_after(latest_tag):
sys.exit(f"Given tag isn't a valid version bump after {latest_tag}")
_ = run_git("tag", "-m", "", "-as", str(new_tag))
def main() -> None:
if len(sys.argv) != 2:
sys.exit(f"Usage: {sys.argv[0]} <new_tag>")
new_tag_str = sys.argv[1]
try_make_tag(new_tag_str)
if __name__ == "__main__":
main()