diff --git a/.github/scripts/compute_release_version.py b/.github/scripts/compute_release_version.py new file mode 100644 index 0000000..af7d330 --- /dev/null +++ b/.github/scripts/compute_release_version.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Literal + +REPO_ROOT = Path(__file__).resolve().parents[2] +RC_IGNORE_TAGS = r"^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$" +RC_VERSION_RE = re.compile(r"^(\d+\.\d+\.\d+)-rc\.(\d+)$") +ReleaseKind = Literal["stable", "rc"] + + +def bumped_version(release_kind: ReleaseKind) -> str: + try: + raw_version = run_command(*build_git_cliff_args(release_kind)) + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + if "No releases found" in stderr: + return "0.1.0" + raise RuntimeError(stderr or "git-cliff failed while computing the next version.") from exc + + return raw_version + + +def build_git_cliff_args(release_kind: ReleaseKind) -> list[str]: + args = ["git-cliff"] + if release_kind == "stable": + args.extend(["--ignore-tags", RC_IGNORE_TAGS]) + args.append("--bumped-version") + return args + + +def build_git_cliff_context_args(release_kind: ReleaseKind) -> list[str]: + args = ["git-cliff", "--unreleased", "--bump", "--context"] + if release_kind == "stable": + args.extend(["--ignore-tags", RC_IGNORE_TAGS]) + return args + + +def run_command(*args: str) -> str: + completed = subprocess.run( + args, + cwd=REPO_ROOT, + check=True, + text=True, + capture_output=True, + ) + return completed.stdout.strip() + + +def normalize_version(raw_version: str) -> str: + version = raw_version.strip() + if version.startswith("v"): + version = version[1:] + return version + + +def compute_next_tag(raw_version: str, release_kind: ReleaseKind) -> str: + version = normalize_version(raw_version) + if not version: + raise RuntimeError("git-cliff did not return a version.") + + if release_kind == "stable": + if RC_VERSION_RE.fullmatch(version): + raise RuntimeError("git-cliff returned a prerelease version for a stable release.") + return f"v{version}" + + if RC_VERSION_RE.fullmatch(version): + return f"v{version}" + return f"v{version}-rc.1" + + +def release_commit_count(release_kind: ReleaseKind) -> int: + try: + raw_context = run_command(*build_git_cliff_context_args(release_kind)) + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + raise RuntimeError(stderr or "git-cliff failed while checking for new commits.") from exc + + try: + context = json.loads(raw_context) + except json.JSONDecodeError as exc: + raise RuntimeError("git-cliff returned invalid JSON while checking for new commits.") from exc + + if not context: + return 0 + + statistics = context[0].get("statistics", {}) + return int(statistics.get("commit_count", 0)) + + +def ensure_new_commits(release_kind: ReleaseKind) -> None: + if release_commit_count(release_kind) == 0: + raise RuntimeError("nothing to release") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="compute_release_version.py", + description="Compute the next release tag for stable or rc workflows.", + ) + parser.add_argument( + "--release-kind", + required=True, + choices=("stable", "rc"), + help="Release line to compute the next tag for.", + ) + parser.add_argument( + "--require-new-commits", + action="store_true", + help="Fail if there are no unreleased commits for the selected release line.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.require_new_commits: + ensure_new_commits(args.release_kind) + print(compute_next_tag(bumped_version(args.release_kind), args.release_kind)) + except (RuntimeError, subprocess.CalledProcessError) as exc: + print(str(exc), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml new file mode 100644 index 0000000..9399758 --- /dev/null +++ b/.github/workflows/preview-release.yml @@ -0,0 +1,68 @@ +name: preview-release + +on: + workflow_dispatch: + inputs: + release_kind: + description: "Release line to preview." + required: true + default: stable + type: choice + options: + - stable + - rc + +permissions: + contents: read + +jobs: + preview: + runs-on: ubuntu-latest + env: + RELEASE_KIND: ${{ inputs.release_kind }} + + steps: + - name: Checkout repository + if: ${{ !env.ACT }} + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + filter: blob:none + + - name: Install git-cliff + uses: taiki-e/install-action@git-cliff + + - name: Compute release tag + id: compute_release_version + shell: bash + run: | + set -euo pipefail + + next_tag="$(python3 .github/scripts/compute_release_version.py --release-kind "${RELEASE_KIND}")" + echo "next_tag=${next_tag}" >> "$GITHUB_OUTPUT" + + - name: Generate release preview + shell: bash + env: + NEXT_TAG: ${{ steps.compute_release_version.outputs.next_tag }} + run: | + set -euo pipefail + + ignore_rc_tags='^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' + notes_path="${RUNNER_TEMP}/release-notes.md" + if [[ "${RELEASE_KIND}" == "stable" ]]; then + git-cliff --ignore-tags "${ignore_rc_tags}" --unreleased --bump --tag "${NEXT_TAG}" --output "${notes_path}" + else + git-cliff --unreleased --bump --tag "${NEXT_TAG}" --output "${notes_path}" + fi + + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..86b7b69 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,122 @@ +name: release + +on: + workflow_dispatch: + inputs: + release_kind: + description: "Release line to publish." + required: true + default: stable + type: choice + options: + - stable + - rc + +permissions: + contents: write + +concurrency: + group: release-pipeline + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + env: + RELEASE_KIND: ${{ inputs.release_kind }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + filter: blob:none + + - name: Install git-cliff + uses: taiki-e/install-action@git-cliff + + - name: Create annotated tag + id: create_tag + shell: bash + run: | + set -euo pipefail + + next_tag="$(python3 .github/scripts/compute_release_version.py --release-kind "${RELEASE_KIND}" --require-new-commits)" + release_name="${next_tag#v}" + + if git ls-remote --exit-code --tags origin "refs/tags/${next_tag}" >/dev/null 2>&1; then + echo "Tag ${next_tag} already exists in origin." >&2 + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git tag -a "${next_tag}" -m "Release ${next_tag#v}" + git push origin "${next_tag}" + + commit_sha="$(git rev-parse HEAD)" + + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_OUTPUT" + echo "release_name=${release_name}" >> "$GITHUB_OUTPUT" + + - name: Generate release notes + shell: bash + env: + NEXT_TAG: ${{ steps.create_tag.outputs.next_tag }} + run: | + set -euo pipefail + + notes_path="${RUNNER_TEMP}/release-notes.md" + ignore_rc_tags='^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' + + if [[ "${NEXT_TAG}" =~ -rc\.[0-9]+$ ]]; then + git-cliff \ + --current \ + --output "${notes_path}" + else + git-cliff \ + --current \ + --ignore-tags "${ignore_rc_tags}" \ + --output "${notes_path}" + fi + + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_STEP_SUMMARY" + + - name: Determine release flags + id: release_flags + shell: bash + env: + NEXT_TAG: ${{ steps.create_tag.outputs.next_tag }} + run: | + set -euo pipefail + + if [[ "${NEXT_TAG}" =~ -rc\.[0-9]+$ ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + echo "make_latest=true" >> "$GITHUB_OUTPUT" + fi + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.create_tag.outputs.next_tag }} + name: ${{ steps.create_tag.outputs.release_name }} + body_path: ${{ runner.temp }}/release-notes.md + prerelease: ${{ steps.release_flags.outputs.prerelease }} + make_latest: ${{ steps.release_flags.outputs.make_latest }} diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..e660ac1 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,36 @@ +# https://git-cliff.org/docs/configuration + +[changelog] + +body = """ +{% for group, commits in commits | group_by(attribute="group") %} +## {{ group | striptags | trim }} + +{% for commit in commits -%} +- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} +{% endfor -%} + +{% endfor %} +""" + +trim = true + +[git] +conventional_commits = true +tag_pattern = "^v[0-9]+\\.[0-9]+\\.[0-9]+(?:-rc\\.[0-9]+)?$" + +commit_parsers = [ + { message = "^feat", group = "New Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^perf", group = "Performance" }, + { message = "^ci", group = "CI" }, + { message = "^test", group = "Tests" }, + { message = "^(build|chore)", group = "Chores" }, + { message = ".*", group = "Other" }, +] + +filter_unconventional = true +sort_commits = "oldest" +topo_order = false diff --git a/tests/test_compute_release_version.py b/tests/test_compute_release_version.py new file mode 100644 index 0000000..7bd6734 --- /dev/null +++ b/tests/test_compute_release_version.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import io +import subprocess +import sys +import unittest +from pathlib import Path +from contextlib import redirect_stderr +from unittest.mock import patch + + +SCRIPT_ROOT = Path(__file__).resolve().parents[1] / ".github/scripts" +if str(SCRIPT_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPT_ROOT)) + +import compute_release_version as release_version + + +class ComputeReleaseVersionTests(unittest.TestCase): + def test_normalize_version_strips_leading_v(self) -> None: + self.assertEqual(release_version.normalize_version("v1.2.3"), "1.2.3") + self.assertEqual(release_version.normalize_version("1.2.3"), "1.2.3") + + def test_build_git_cliff_args_for_stable_adds_ignore_tags(self) -> None: + self.assertEqual( + release_version.build_git_cliff_args("stable"), + ["git-cliff", "--ignore-tags", release_version.RC_IGNORE_TAGS, "--bumped-version"], + ) + + def test_build_git_cliff_args_for_rc_omits_ignore_tags(self) -> None: + self.assertEqual(release_version.build_git_cliff_args("rc"), ["git-cliff", "--bumped-version"]) + + def test_build_git_cliff_context_args_for_stable_adds_ignore_tags(self) -> None: + self.assertEqual( + release_version.build_git_cliff_context_args("stable"), + [ + "git-cliff", + "--unreleased", + "--bump", + "--context", + "--ignore-tags", + release_version.RC_IGNORE_TAGS, + ], + ) + + def test_build_git_cliff_context_args_for_rc_omits_ignore_tags(self) -> None: + self.assertEqual( + release_version.build_git_cliff_context_args("rc"), + ["git-cliff", "--unreleased", "--bump", "--context"], + ) + + def test_bumped_version_returns_raw_git_cliff_output(self) -> None: + with patch.object(release_version, "run_command", return_value="v1.2.4"): + self.assertEqual(release_version.bumped_version("stable"), "v1.2.4") + + def test_bumped_version_falls_back_without_releases_for_stable(self) -> None: + error = subprocess.CalledProcessError( + returncode=1, + cmd=("git-cliff", "--bumped-version"), + stderr="No releases found, using 0.1.0 as the next version.", + ) + + with patch.object(release_version, "run_command", side_effect=error): + self.assertEqual(release_version.bumped_version("stable"), "0.1.0") + + def test_bumped_version_falls_back_without_releases_for_rc(self) -> None: + error = subprocess.CalledProcessError( + returncode=1, + cmd=("git-cliff", "--bumped-version"), + stderr="No releases found, using 0.1.0 as the next version.", + ) + + with patch.object(release_version, "run_command", side_effect=error): + self.assertEqual(release_version.bumped_version("rc"), "0.1.0") + + def test_release_commit_count_parses_json_context(self) -> None: + with patch.object( + release_version, + "run_command", + return_value='[{"statistics":{"commit_count":2}}]', + ): + self.assertEqual(release_version.release_commit_count("stable"), 2) + + def test_release_commit_count_handles_empty_context(self) -> None: + with patch.object(release_version, "run_command", return_value="[]"): + self.assertEqual(release_version.release_commit_count("rc"), 0) + + def test_ensure_new_commits_raises_for_empty_release(self) -> None: + with patch.object(release_version, "release_commit_count", return_value=0): + with self.assertRaisesRegex(RuntimeError, "nothing to release"): + release_version.ensure_new_commits("stable") + + def test_compute_next_tag_for_stable_prefixes_version(self) -> None: + self.assertEqual(release_version.compute_next_tag("v1.2.4", "stable"), "v1.2.4") + + def test_compute_next_tag_for_rc_keeps_existing_rc_suffix(self) -> None: + self.assertEqual(release_version.compute_next_tag("v1.2.4-rc.2", "rc"), "v1.2.4-rc.2") + + def test_compute_next_tag_for_rc_adds_first_suffix_for_plain_version(self) -> None: + self.assertEqual(release_version.compute_next_tag("v1.2.4", "rc"), "v1.2.4-rc.1") + + def test_compute_next_tag_for_stable_rejects_prerelease_version(self) -> None: + with self.assertRaisesRegex( + RuntimeError, + "git-cliff returned a prerelease version for a stable release.", + ): + release_version.compute_next_tag("v1.2.4-rc.2", "stable") + + def test_compute_next_tag_for_empty_version_rejects_empty_output(self) -> None: + with self.assertRaisesRegex(RuntimeError, "git-cliff did not return a version."): + release_version.compute_next_tag(" ", "stable") + + def test_main_fails_on_empty_release_when_required(self) -> None: + stderr = io.StringIO() + with ( + patch.object(release_version, "ensure_new_commits", side_effect=RuntimeError("nothing to release")), + patch.object(release_version, "bumped_version") as bumped_version, + redirect_stderr(stderr), + ): + self.assertEqual( + release_version.main(["--release-kind", "stable", "--require-new-commits"]), + 1, + ) + bumped_version.assert_not_called() + self.assertEqual(stderr.getvalue().strip(), "nothing to release") + + +if __name__ == "__main__": + unittest.main()