#!/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. **Test enforcement**: Before committing or tagging, the script runs ``make lint-ruff`` and ``make pytest-cov`` to verify the release is healthy. If either fails, the release is aborted — no commit, no tag. This ensures we never release a version that fails tests. Use ``--skip-tests`` only for emergency releases (not recommended). 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] [--skip-tests] """ 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 _ from scripts.ci.classify_changes import has_user_facing_changes 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. The changelog from git-cliff may include a header (e.g., "# Changelog"). This function strips everything before the first ``## [`` version section before inserting, to avoid duplicating the header. """ # Strip git-cliff header — keep only from the first version section section_match = re.search(r"^## \[", changelog, flags=re.MULTILINE) if section_match: changelog = changelog[section_match.start() :] 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. The commit is created with ``--no-verify`` to bypass the commit-msg hook (which requires ``GRM-N:`` prefix for master commits) since release commits are a special case generated by the release script. 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", "--no-verify", "-m", f"release: v{new_version}"]) return True def run_tests() -> None: """Run lint and tests to verify the release is healthy. This is called *after* version files are updated but *before* the tag is created, ensuring we never tag a release that fails tests. """ click.echo(_("Running lint checks...")) lint = run_cmd(["make", "lint-ruff"], check=False) if lint.returncode != 0: raise click.ClickException( _( "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", stderr=lint.stderr.strip() if lint.stderr else lint.stdout.strip(), ) ) click.echo(_("Lint passed.")) click.echo(_("Running tests...")) tests = run_cmd(["make", "pytest-cov"], check=False) if tests.returncode != 0: raise click.ClickException( _( "Tests failed — refusing to release. Fix test failures first.\n{stderr}", stderr=tests.stderr.strip() if tests.stderr else tests.stdout.strip(), ) ) click.echo(_("Tests passed.")) 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.") @click.option( "--skip-tests", is_flag=True, default=False, help="Skip lint and test verification (NOT recommended — only for emergency releases).", ) def main(dry_run: bool, skip_tests: 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 if any user-facing files changed since the last tag. # If only workflow/infra files changed, skip the release entirely. latest_tag = get_latest_tag() if latest_tag and not has_user_facing_changes(latest_tag, "HEAD"): click.echo( _( "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", tag=latest_tag, ) ) return # 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)) # Verify tests pass BEFORE committing or tagging. # This ensures we never release a version that fails tests. if skip_tests: click.echo(_("WARNING: --skip-tests passed — skipping test verification.")) else: run_tests() # 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()