From 63fb259bacbc15d752695c31fddd802442a2788b Mon Sep 17 00:00:00 2001 From: emil Date: Sun, 21 Jun 2026 09:53:56 +0000 Subject: [PATCH] GRM-34: feat: add automated semver versioning, tagging, and releases with git-cliff --- .gitea/workflows/publish.yml | 10 ++ .gitea/workflows/release.yml | 35 +++++ AGENTS.md | 34 ++++- CHANGELOG.md | 5 + README.md | 21 ++- cliff.toml | 62 +++++++++ pyproject.toml | 5 +- scripts/publish.py | 32 ++++- scripts/release.py | 195 ++++++++++++++++++++++++++ tests/unit/test_publish.py | 65 ++++++++- tests/unit/test_release.py | 262 +++++++++++++++++++++++++++++++++++ 11 files changed, 718 insertions(+), 8 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 cliff.toml create mode 100644 scripts/release.py create mode 100644 tests/unit/test_release.py diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 91d7bd4..13c1381 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -10,6 +10,16 @@ jobs: runs-on: docker steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install git-cliff + run: | + GIT_CLIFF_VERSION="2.13.0" + URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + curl -sL "$URL" | tar xz -C /tmp + mv "/tmp/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" /usr/local/bin/git-cliff + chmod +x /usr/local/bin/git-cliff + git-cliff --version - name: Install build tools run: | python3 -m pip install --break-system-packages build twine requests python-dotenv click diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..07acdf5 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +on: + push: + branches: [master] + +jobs: + release: + runs-on: docker + # Skip release commits to avoid infinite loops + if: !startsWith(github.event.head_commit.message, 'chore(release):') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.REPO_TOKEN }} + - name: Install git-cliff + run: | + GIT_CLIFF_VERSION="2.13.0" + URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + curl -sL "$URL" | tar xz -C /tmp + mv "/tmp/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" /usr/local/bin/git-cliff + chmod +x /usr/local/bin/git-cliff + git-cliff --version + - name: Install Python dependencies + run: python3 -m pip install --break-system-packages requests python-dotenv click + - name: Configure git + run: | + git config user.name "grm-ci-bot" + git config user.email "grm-ci-bot@oblachno.fyi" + - name: Run release + env: + PYTHONPATH: src + run: | + python3 scripts/release.py diff --git a/AGENTS.md b/AGENTS.md index 431a7dd..ba742b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,8 @@ make test-all # pytest-cov + molecule - **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible - **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup -- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, publishing, molecule distribution, PR reviews +- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, release, publishing, molecule distribution, PR reviews +- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits ## PR Workflow (Mandatory) @@ -94,6 +95,37 @@ Then add the `ready-to-merge` label. The auto-merge workflow will: 1. Wait for all CI checks to pass 2. Squash-merge with title: `GRM-N ` (space-separated) 3. The post-merge workflow marks the Vikunja task as done +4. The release workflow automatically versions, tags, and publishes (see below) + +### Automated Release Pipeline + +After a PR is merged to master, the release pipeline runs automatically: + +1. **Release workflow** (`.gitea/workflows/release.yml`): + - Triggers on push to master (skips `chore(release):` commits to avoid loops) + - Runs `scripts/release.py` which uses **git-cliff** to: + - Calculate the next semver version from conventional commits since the last tag + - Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth) + - Create a `chore(release): prepare for vX.Y.Z` commit + - Create an annotated tag `vX.Y.Z` with the changelog as the tag message + - Push the commit and tag to master + +2. **Publish workflow** (`.gitea/workflows/publish.yml`): + - Triggers on tag push (`v*`) + - Builds the Python package + - Optionally publishes to PyPI (if `PYPI_TOKEN` is set) + - Creates a Gitea release with git-cliff-generated release notes + +### Version Bumping Rules (git-cliff) + +| Commit type | Version bump | +|-------------|-------------| +| `feat:` | minor (0.X.0) | +| `fix:` | patch (0.0.X) | +| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0: major would be 1.0.0) | +| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) | + +The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, read by setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release script only updates `__init__.py` — no need to touch `pyproject.toml`. `grm --version` reports this version. ### Title Format Summary diff --git a/CHANGELOG.md b/CHANGELOG.md index bde6616..4d9f6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to this project will be documented in this file. ### Added +- **Automated semver versioning and releases**: `scripts/release.py` — CI script that uses git-cliff to calculate the next version from conventional commits, update version files, create a release commit, tag, and push. +- `cliff.toml` — git-cliff configuration for conventional commit parsing, semver bumping, and changelog generation. +- Release workflow (`.gitea/workflows/release.yml`) — triggers on push to master, runs `scripts/release.py` to automatically version and tag releases. +- `publish.py` now uses git-cliff to generate release notes for Gitea releases (falls back to generic message if git-cliff is not available). +- `pyproject.toml` now uses `dynamic = ["version"]` with setuptools `attr` to source version from `__init__.py` (single source of truth — release script only updates `__init__.py`). - **Mandatory PR review step**: `scripts/review_pr.py` — CLI to post Gitea PR reviews (COMMENT, APPROVE, REQUEST_CHANGES) with inline comments via `--comments-json` or `--comments-stdin`. - `GiteaClient.get_pr_files`, `GiteaClient.get_pr_commits`, `GiteaClient.create_review` — API methods for PR review workflow. - `VikunjaClient.get_task` — fetch a single task by numeric ID. diff --git a/README.md b/README.md index 1dc8896..991bbbb 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,26 @@ Every change to master goes through a mandatory review workflow: 6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `scripts/review_pr.py`. 7. **Address comments** — fix each comment, commit, push, re-review 8. **Approve** — post an `APPROVE` review via `scripts/review_pr.py` -9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N `, post-merge workflow marks the Vikunja task as done +9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N `, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags + +### Automated Versioning & Releases + +Versioning is fully automated using [git-cliff](https://git-cliff.org): + +1. **After merge to master** — the release workflow runs `scripts/release.py` +2. **git-cliff calculates the next version** from conventional commits since the last tag +3. **Version file is updated** (`__init__.py`) and a `chore(release): prepare for vX.Y.Z` commit is created +4. **An annotated tag `vX.Y.Z`** is pushed with the changelog as the tag message +5. **The publish workflow triggers** on the tag — builds the package, optionally publishes to PyPI, and creates a Gitea release with generated release notes + +| Commit type | Version bump | +|-------------|-------------| +| `feat:` | minor | +| `fix:` | patch | +| `feat!:` / `BREAKING CHANGE` | minor (pre-1.0) | +| `chore:`, `ci:`, `docs:` | no bump | + +`grm --version` reports the current version from `__init__.py`. ## Features diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..3693fb7 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,62 @@ +# git-cliff configuration for GRM +# https://git-cliff.org/docs/configuration + +[changelog] +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %} +""" +trim = true +render_always = true + +[git] +conventional_commits = true +filter_unconventional = true +require_conventional = false +split_commits = false +protect_breaking_commits = false +filter_commits = false +fail_on_unmatched_commit = false +use_branch_tags = false +topo_order = false +topo_order_commits = true +sort_commits = "oldest" +recurse_submodules = false + +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "Miscellaneous Tasks" }, + { body = ".*security", group = "Security" }, + { message = "^revert", group = "Revert" }, + { message = ".*", group = "Other" }, +] + +[bump] +features_always_bump_minor = true +breaking_always_bump_major = false +initial_tag = "0.1.0" diff --git a/pyproject.toml b/pyproject.toml index 30f7e8a..dcb12e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gitea-runner-manager" -version = "0.1.0" +dynamic = ["version"] description = "Lean CLI to manage Gitea Actions runners" readme = "README.md" license = {text = "GPL-3.0"} @@ -23,6 +23,9 @@ dependencies = [ [project.scripts] grm = "gitea_runner_manager.cli:cli" +[tool.setuptools.dynamic] +version = {attr = "gitea_runner_manager.__version__"} + [project.optional-dependencies] dev = [ "pytest>=9.1.0", diff --git a/scripts/publish.py b/scripts/publish.py index a3fdf77..98803e2 100644 --- a/scripts/publish.py +++ b/scripts/publish.py @@ -1,11 +1,14 @@ #!/usr/bin/env python3 """Build package, optionally publish to PyPI, and create Gitea release. +Uses git-cliff to generate the release notes from conventional commits. + Usage: REPO_TOKEN= [PYPI_TOKEN=] python3 scripts/publish.py """ import os +import shutil import subprocess # nosec B404 import sys @@ -19,6 +22,30 @@ from gitea_runner_manager.i18n import _ load_dotenv(override=True) +CLIFF_CONFIG = "cliff.toml" + + +def generate_release_notes(tag: str) -> str: + """Generate release notes for the given tag using git-cliff. + + Falls back to a generic message if git-cliff is not available. + """ + cliff_bin = shutil.which("git-cliff") + if not cliff_bin: + return f"Release {tag}\n\nSee CHANGELOG.md for details." + try: + result = subprocess.run( # nosec B603 + [cliff_bin, "--config", CLIFF_CONFIG, "--latest", "--strip", "header"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except FileNotFoundError: + pass + return f"Release {tag}\n\nSee CHANGELOG.md for details." + def build_package() -> None: """Build the Python package using python -m build.""" @@ -84,10 +111,13 @@ def main(tag: str, repo: str) -> None: owner, repo_name = repo.split("/") client = GiteaClient(GITEA_API_URL, gitea_token, owner, repo_name) + + release_body = generate_release_notes(tag) + try: client.create_release( tag=tag, - body=f"Release {tag}\n\nSee CHANGELOG.md for details.", + body=release_body, ) except APIError as e: raise click.ClickException( diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..357af3f --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,195 @@ +#!/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() diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index 21cdf8a..bf92872 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -9,11 +9,57 @@ from click.testing import CliRunner from scripts.publish import ( build_package, + generate_release_notes, main, publish_to_pypi, ) +class TestGenerateReleaseNotes: + @patch("scripts.publish.subprocess.run") + @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") + def test_generates_from_git_cliff(self, mock_which: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout="## v1.0.0\n- feat: x") + result = generate_release_notes("v1.0.0") + assert "## v1.0.0" in result + assert "feat: x" in result + + @patch("scripts.publish.subprocess.run") + @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") + def test_strips_whitespace(self, mock_which: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout=" changelog \n") + result = generate_release_notes("v1.0.0") + assert result == "changelog" + + @patch("scripts.publish.subprocess.run") + @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") + def test_falls_back_on_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=1, stdout="") + result = generate_release_notes("v1.0.0") + assert "Release v1.0.0" in result + assert "CHANGELOG.md" in result + + @patch("scripts.publish.subprocess.run") + @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") + def test_falls_back_on_empty_output(self, mock_which: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout=" ") + result = generate_release_notes("v1.0.0") + assert "Release v1.0.0" in result + + @patch("scripts.publish.subprocess.run", side_effect=FileNotFoundError) + @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") + def test_falls_back_on_file_not_found(self, mock_which: MagicMock, mock_run: MagicMock) -> None: + result = generate_release_notes("v1.0.0") + assert "Release v1.0.0" in result + assert "CHANGELOG.md" in result + + @patch("scripts.publish.shutil.which", return_value=None) + def test_falls_back_when_not_installed(self, mock_which: MagicMock) -> None: + result = generate_release_notes("v1.0.0") + assert "Release v1.0.0" in result + assert "CHANGELOG.md" in result + + class TestBuildPackage: @patch("scripts.publish.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: @@ -50,6 +96,7 @@ class TestPublishToPypi: class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") @@ -58,6 +105,7 @@ class TestMain: mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, + mock_notes: MagicMock, ) -> None: runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) @@ -66,14 +114,19 @@ class TestMain: mock_build.assert_called_once() mock_publish.assert_called_once_with("pypi-tok") mock_client_cls.return_value.create_release.assert_called_once() + # Verify release body uses git-cliff notes + call_args = mock_client_cls.return_value.create_release.call_args + assert call_args.kwargs["body"] == "Release notes" @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) + @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.build_package") def test_without_pypi( self, mock_build: MagicMock, mock_client_cls: MagicMock, + mock_notes: MagicMock, ) -> None: runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) @@ -90,11 +143,12 @@ class TestMain: assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") def test_build_failure_raises_click( - self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock + self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_build.side_effect = click.ClickException("build failed") runner = CliRunner() @@ -103,11 +157,12 @@ class TestMain: assert "build" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") def test_publish_failure_raises_click( - self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock + self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_publish.side_effect = click.ClickException("publish failed") runner = CliRunner() @@ -116,11 +171,12 @@ class TestMain: assert "publish" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") def test_release_failure_raises_click( - self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock + self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_client = MagicMock() from gitea_runner_manager.exceptions import APIError @@ -133,11 +189,12 @@ class TestMain: assert "HTTP" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") def test_release_json_parse_failure( - self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock + self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_client = MagicMock() from gitea_runner_manager.exceptions import APIError diff --git a/tests/unit/test_release.py b/tests/unit/test_release.py new file mode 100644 index 0000000..7a3e17e --- /dev/null +++ b/tests/unit/test_release.py @@ -0,0 +1,262 @@ +"""Unit tests for scripts/release.py.""" + +from unittest.mock import MagicMock, patch + +import click +import pytest +from click.testing import CliRunner + +from scripts.release import ( + create_and_push_tag, + create_release_commit, + get_bumped_version, + get_changelog, + get_latest_tag, + has_unreleased_changes, + main, + run_cmd, + update_init_version, +) + + +class TestRunCmd: + @patch("scripts.release.subprocess.run") + def test_success(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="") + result = run_cmd(["echo", "hi"]) + assert result.returncode == 0 + mock_run.assert_called_once() + + @patch("scripts.release.subprocess.run") + def test_failure_raises(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="") + with pytest.raises(click.ClickException): + run_cmd(["false"]) + + @patch("scripts.release.subprocess.run") + def test_check_false_no_raise(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="") + result = run_cmd(["false"], check=False) + assert result.returncode == 1 + + +class TestGetLatestTag: + @patch("scripts.release.run_cmd") + def test_returns_tag(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n") + assert get_latest_tag() == "v0.1.0" + + @patch("scripts.release.run_cmd") + def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=1, stdout="") + assert get_latest_tag() == "" + + +class TestGetBumpedVersion: + @patch("scripts.release.run_cmd") + def test_returns_version(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="0.2.0\n") + assert get_bumped_version() == "0.2.0" + + @patch("scripts.release.run_cmd") + def test_strips_v_prefix(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n") + assert get_bumped_version() == "0.2.0" + + @patch("scripts.release.run_cmd") + def test_empty_raises(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="") + with pytest.raises(click.ClickException): + get_bumped_version() + + +class TestGetChangelog: + @patch("scripts.release.run_cmd") + def test_returns_changelog(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="## 0.2.0\n- fix\n") + assert get_changelog("0.2.0") == "## 0.2.0\n- fix" + + @patch("scripts.release.run_cmd") + def test_strips_whitespace(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout=" text \n") + assert get_changelog("0.2.0") == "text" + + +class TestHasUnreleasedChanges: + @patch("scripts.release.get_latest_tag") + @patch("scripts.release.run_cmd") + def test_no_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n") + mock_latest.return_value = "v0.2.0" + assert has_unreleased_changes() is False + + @patch("scripts.release.get_latest_tag") + @patch("scripts.release.run_cmd") + def test_has_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\n") + mock_latest.return_value = "v0.2.0" + assert has_unreleased_changes() is True + + @patch("scripts.release.get_latest_tag") + @patch("scripts.release.run_cmd") + def test_no_tags_returns_true(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n") + mock_latest.return_value = "" + assert has_unreleased_changes() is True + + @patch("scripts.release.run_cmd") + def test_cliff_fails_returns_false(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=1, stdout="") + assert has_unreleased_changes() is False + + +class TestUpdateInitVersion: + def test_updates_version(self, tmp_path, monkeypatch) -> None: + init_file = tmp_path / "__init__.py" + init_file.write_text('__version__ = "0.1.0"\n') + monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file)) + update_init_version("0.2.0") + assert '__version__ = "0.2.0"' in init_file.read_text() + + def test_no_version_raises(self, tmp_path, monkeypatch) -> None: + init_file = tmp_path / "__init__.py" + init_file.write_text('"""module"""\n') + monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file)) + with pytest.raises(click.ClickException): + update_init_version("0.2.0") + + +class TestCreateReleaseCommit: + @patch("scripts.release.run_cmd") + def test_commits(self, mock_run_cmd: MagicMock) -> None: + create_release_commit("0.2.0") + calls = [c.args[0] for c in mock_run_cmd.call_args_list] + assert ["git", "add", "src/gitea_runner_manager/__init__.py"] in calls + assert ["git", "commit", "-m", "chore(release): prepare for v0.2.0"] in calls + + +class TestCreateAndPushTag: + @patch("scripts.release.run_cmd") + def test_creates_tag(self, mock_run_cmd: MagicMock) -> None: + create_and_push_tag("0.2.0", "changelog", dry_run=True) + first_call = mock_run_cmd.call_args_list[0].args[0] + assert first_call[:3] == ["git", "tag", "-a"] + assert first_call[3] == "v0.2.0" + assert "Release v0.2.0" in first_call[5] + + @patch("scripts.release.run_cmd") + def test_dry_run_no_push(self, mock_run_cmd: MagicMock) -> None: + create_and_push_tag("0.2.0", "changelog", dry_run=True) + for call in mock_run_cmd.call_args_list: + assert call.args[0][0:2] != ["git", "push"] + + @patch("scripts.release.run_cmd") + def test_pushes_tag(self, mock_run_cmd: MagicMock) -> None: + create_and_push_tag("0.2.0", "changelog", dry_run=False) + last_call = mock_run_cmd.call_args_list[-1].args[0] + assert last_call == ["git", "push", "origin", "v0.2.0"] + + +class TestMain: + @patch.dict("os.environ", {}) + @patch("scripts.release.run_cmd") + def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="") + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code != 0 + assert "master" in result.output + + @patch.dict("os.environ", {}) + @patch("scripts.release.has_unreleased_changes", return_value=False) + @patch("scripts.release.run_cmd") + def test_no_unreleased_changes(self, mock_run_cmd: MagicMock, mock_has: MagicMock) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert "No unreleased changes" in result.output + + @patch.dict("os.environ", {}) + @patch("scripts.release.create_and_push_tag") + @patch("scripts.release.create_release_commit") + @patch("scripts.release.update_init_version") + @patch("scripts.release.get_changelog", return_value="") + @patch("scripts.release.get_latest_tag", return_value="v0.1.0") + @patch("scripts.release.get_bumped_version", return_value="0.2.0") + @patch("scripts.release.has_unreleased_changes", return_value=True) + @patch("scripts.release.run_cmd") + def test_dry_run_empty_changelog( + self, + mock_run_cmd: MagicMock, + mock_has: MagicMock, + mock_bumped: MagicMock, + mock_latest: MagicMock, + mock_changelog: MagicMock, + mock_update_init: MagicMock, + mock_commit: MagicMock, + mock_tag: MagicMock, + ) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") + runner = CliRunner() + result = runner.invoke(main, ["--dry-run"]) + assert result.exit_code == 0 + assert "empty changelog" in result.output + + @patch.dict("os.environ", {}) + @patch("scripts.release.create_and_push_tag") + @patch("scripts.release.create_release_commit") + @patch("scripts.release.update_init_version") + @patch("scripts.release.get_changelog", return_value="changelog") + @patch("scripts.release.get_latest_tag", return_value="v0.1.0") + @patch("scripts.release.get_bumped_version", return_value="0.2.0") + @patch("scripts.release.has_unreleased_changes", return_value=True) + @patch("scripts.release.run_cmd") + def test_dry_run( + self, + mock_run_cmd: MagicMock, + mock_has: MagicMock, + mock_bumped: MagicMock, + mock_latest: MagicMock, + mock_changelog: MagicMock, + mock_update_init: MagicMock, + mock_commit: MagicMock, + mock_tag: MagicMock, + ) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") + runner = CliRunner() + result = runner.invoke(main, ["--dry-run"]) + assert result.exit_code == 0 + assert "[dry-run]" in result.output + mock_update_init.assert_not_called() + mock_commit.assert_not_called() + mock_tag.assert_not_called() + + @patch.dict("os.environ", {}) + @patch("scripts.release.create_and_push_tag") + @patch("scripts.release.create_release_commit") + @patch("scripts.release.update_init_version") + @patch("scripts.release.get_changelog", return_value="changelog") + @patch("scripts.release.get_latest_tag", return_value="v0.1.0") + @patch("scripts.release.get_bumped_version", return_value="0.2.0") + @patch("scripts.release.has_unreleased_changes", return_value=True) + @patch("scripts.release.run_cmd") + def test_full_flow( + self, + mock_run_cmd: MagicMock, + mock_has: MagicMock, + mock_bumped: MagicMock, + mock_latest: MagicMock, + mock_changelog: MagicMock, + mock_update_init: MagicMock, + mock_commit: MagicMock, + mock_tag: MagicMock, + ) -> None: + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert "Bumping version" in result.output + mock_update_init.assert_called_once_with("0.2.0") + mock_commit.assert_called_once_with("0.2.0") + mock_tag.assert_called_once_with("0.2.0", "changelog", False)