#!/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"]``) and ``CHANGELOG.md``, commits them with a ``release:`` prefix, tags the commit with the changelog as the tag message, and pushes both to trigger the publish workflow. The ``release:`` prefix (instead of ``chore(release):``) keeps the history clean while still being descriptive. Loops are prevented by the ``has_unreleased_changes`` check — after a release commit is tagged, the next run finds no unreleased changes and exits. This script is idempotent: if there are no new conventional commits since the last tag, it exits with a message and does nothing. If the tag already exists (e.g., from a partial previous run), it skips tag creation and only pushes. 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" CHANGELOG_FILE = "CHANGELOG.md" 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 tag_exists(tag: str) -> bool: """Check if a git tag already exists.""" result = run_cmd(["git", "tag", "-l", tag], check=False) return bool(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(bumped_version: str | None = None) -> bool: """Check if there are conventional commits since the last tag. If ``bumped_version`` is provided (from a prior git-cliff call), reuses it to avoid a duplicate subprocess invocation. """ if bumped_version is None: result = run_cmd( ["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG], check=False, ) if result.returncode != 0: return False bumped_version = result.stdout.strip().lstrip("v") latest = get_latest_tag() if not latest: return True current = latest.lstrip("v") return bumped_version != current def update_init_version(new_version: str) -> None: """Update __version__ in __init__.py.""" with open(INIT_FILE) as f: content = f.read() if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE): raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE)) updated = re.sub( r'^__version__\s*=\s*"[^"]*"', f'__version__ = "{new_version}"', content, count=1, flags=re.MULTILINE, ) with open(INIT_FILE, "w") as f: f.write(updated) def update_changelog(changelog: str) -> None: """Prepend the new changelog section to CHANGELOG.md. If the file doesn't exist, create it with the changelog as the sole content. If it exists, insert the new version section after the header (before the first existing version section). """ try: with open(CHANGELOG_FILE) as f: existing = f.read() except FileNotFoundError: with open(CHANGELOG_FILE, "w") as f: f.write(changelog + "\n") return # Find the first version section header (## [...] or ## [unreleased]) match = re.search(r"^## \[", existing, flags=re.MULTILINE) if match: # Insert before the first version section pos = match.start() updated = existing[:pos] + changelog + "\n\n" + existing[pos:] else: # No version sections found — append updated = existing.rstrip() + "\n\n" + changelog + "\n" with open(CHANGELOG_FILE, "w") as f: f.write(updated) def commit_release_changes(new_version: str) -> bool: """Stage version file and changelog, then create a release commit. Uses ``release:`` prefix (not ``chore(release):``) for clarity. Returns True if a commit was created, False if there were no staged changes. """ run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE]) status = run_cmd(["git", "diff", "--cached", "--quiet"], check=False) if status.returncode == 0: click.echo(_("No staged changes — version and changelog already up to date.")) return False run_cmd(["git", "commit", "-m", f"release: v{new_version}"]) return True def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool: """Create an annotated tag with the changelog as message and push it. Returns True if the tag was created/pushed, False if it already existed. """ tag = f"v{new_version}" if tag_exists(tag): click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag)) if not dry_run: # Ensure the existing tag is pushed run_cmd(["git", "push", "origin", tag], check=False) return False tag_msg = f"Release v{new_version}\n\n{changelog}" if dry_run: click.echo(_("[dry-run] Would create tag: {tag}", tag=tag)) return True run_cmd(["git", "tag", "-a", tag, "-m", tag_msg]) run_cmd(["git", "push", "origin", tag]) return True @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)) # Calculate next version (single git-cliff call — Gap 7 fix) new_version = get_bumped_version() # Check for unreleased changes (reuses the version we just calculated) if not has_unreleased_changes(bumped_version=new_version): click.echo(_("No unreleased changes found. Nothing to release.")) return 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 update {changelog_file}", changelog_file=CHANGELOG_FILE)) click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version)) click.echo(_("[dry-run] Would push commit to master")) 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)) # Update CHANGELOG.md (Gap 3 fix) update_changelog(changelog) click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE)) # Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):') committed = commit_release_changes(new_version) if committed: click.echo(_("Created release commit.")) run_cmd(["git", "push", "origin", "master"]) click.echo(_("Pushed release commit to master.")) else: click.echo(_("Skipping commit push — no staged changes.")) # Create and push tag (Gap 4: handles existing tag) created = create_and_push_tag(new_version, changelog, dry_run) if created: click.echo( _( "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", version=new_version, ) ) else: click.echo( _( "Tag v{version} already existed. Publish workflow should already have been triggered.", version=new_version, ) ) if __name__ == "__main__": # pragma: no cover main()