GRM-34: feat: add automated semver versioning, tagging, and releases with git-cliff
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automated release: calculate next version, update files, tag, and push.
|
||||
|
||||
Uses git-cliff to determine the next semver version from conventional commits
|
||||
since the last tag. Updates ``__version__`` in ``__init__.py`` (the single
|
||||
source of truth, read by setuptools via ``dynamic = ["version"]``), creates a
|
||||
release commit, tags it with the changelog as the tag message, and pushes the
|
||||
tag to trigger the publish workflow.
|
||||
|
||||
This script is idempotent: if there are no new conventional commits since the
|
||||
last tag, it exits with a message and does nothing.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/release.py [--dry-run]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
INIT_FILE = "src/gitea_runner_manager/__init__.py"
|
||||
CLIFF_CONFIG = "cliff.toml"
|
||||
|
||||
|
||||
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process."""
|
||||
result = subprocess.run( # nosec B603
|
||||
args,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_bumped_version() -> str:
|
||||
"""Use git-cliff to calculate the next version from conventional commits."""
|
||||
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
|
||||
version = result.stdout.strip()
|
||||
if not version:
|
||||
raise click.ClickException(_("git-cliff returned empty version."))
|
||||
# git-cliff may return with or without 'v' prefix
|
||||
return version.lstrip("v")
|
||||
|
||||
|
||||
def get_changelog(new_version: str) -> str:
|
||||
"""Generate changelog content for the new version using git-cliff."""
|
||||
result = run_cmd(
|
||||
[
|
||||
"git-cliff",
|
||||
"--config",
|
||||
CLIFF_CONFIG,
|
||||
"--tag",
|
||||
f"v{new_version}",
|
||||
"--unreleased",
|
||||
"--bump",
|
||||
]
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def has_unreleased_changes() -> bool:
|
||||
"""Check if there are conventional commits since the last tag."""
|
||||
result = run_cmd(
|
||||
["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
latest = get_latest_tag()
|
||||
if not latest:
|
||||
return True
|
||||
bumped = result.stdout.strip().lstrip("v")
|
||||
current = latest.lstrip("v")
|
||||
return bumped != current
|
||||
|
||||
|
||||
def update_init_version(new_version: str) -> None:
|
||||
"""Update __version__ in __init__.py."""
|
||||
with open(INIT_FILE) as f:
|
||||
content = f.read()
|
||||
updated = re.sub(
|
||||
r'^__version__\s*=\s*"[^"]*"',
|
||||
f'__version__ = "{new_version}"',
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if updated == content:
|
||||
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
|
||||
with open(INIT_FILE, "w") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
def create_release_commit(new_version: str) -> None:
|
||||
"""Stage version file and create a release commit."""
|
||||
run_cmd(["git", "add", INIT_FILE])
|
||||
run_cmd(["git", "commit", "-m", f"chore(release): prepare for v{new_version}"])
|
||||
|
||||
|
||||
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> None:
|
||||
"""Create an annotated tag with the changelog as message and push it."""
|
||||
tag = f"v{new_version}"
|
||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] Would push tag {tag}", tag=tag))
|
||||
return
|
||||
run_cmd(["git", "push", "origin", tag])
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
||||
def main(dry_run: bool) -> None:
|
||||
# Ensure we're on master
|
||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
||||
if branch != "master":
|
||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||
|
||||
# Check for unreleased changes
|
||||
if not has_unreleased_changes():
|
||||
click.echo(_("No unreleased changes found. Nothing to release."))
|
||||
return
|
||||
|
||||
# Calculate next version
|
||||
new_version = get_bumped_version()
|
||||
current_tag = get_latest_tag()
|
||||
click.echo(
|
||||
_(
|
||||
"Bumping version: {current} -> v{new_version}",
|
||||
current=current_tag or "(none)",
|
||||
new_version=new_version,
|
||||
)
|
||||
)
|
||||
|
||||
# Generate changelog
|
||||
changelog = get_changelog(new_version)
|
||||
if not changelog:
|
||||
click.echo(_("Warning: git-cliff generated empty changelog."))
|
||||
|
||||
if dry_run:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
|
||||
click.echo(_("[dry-run] Would create commit: chore(release): prepare for v{version}", version=new_version))
|
||||
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
|
||||
return
|
||||
|
||||
# Update version file
|
||||
update_init_version(new_version)
|
||||
click.echo(_("Updated version in {init}", init=INIT_FILE))
|
||||
|
||||
# Create release commit
|
||||
create_release_commit(new_version)
|
||||
click.echo(_("Created release commit."))
|
||||
|
||||
# Push commit to master
|
||||
run_cmd(["git", "push", "origin", "master"])
|
||||
click.echo(_("Pushed release commit to master."))
|
||||
|
||||
# Create and push tag
|
||||
create_and_push_tag(new_version, changelog, dry_run)
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
version=new_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
Reference in New Issue
Block a user