#!/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= 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()