Add a safe git tag release script

This commit is contained in:
ItsDrike 2025-08-13 19:59:49 +02:00
parent aeef67ba58
commit 99a61b1bc5
Signed by: ItsDrike
GPG key ID: FA2745890B7048C0
2 changed files with 138 additions and 0 deletions

View file

@ -62,6 +62,8 @@
lg = "log --all --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit --" lg = "log --all --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit --"
loog = "log --format=fuller --show-signature --all --color --decorate --graph" loog = "log --format=fuller --show-signature --all --color --decorate --graph"
release = "!~/.config/git/release.py"
make-patch = "diff --no-prefix --relative" make-patch = "diff --no-prefix --relative"
set-upstream = "!git branch --set-upstream-to=origin/`git symbolic-ref --short HEAD`" set-upstream = "!git branch --set-upstream-to=origin/`git symbolic-ref --short HEAD`"

136
home/.config/git/release.py Executable file
View file

@ -0,0 +1,136 @@
#!/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.
"""
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."""
return f"v{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()