347 lines
12 KiB
Python
347 lines
12 KiB
Python
#!/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=<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()
|