GRM-34: feat: add automated semver versioning, tagging, and releases with git-cliff

This commit is contained in:
2026-06-21 09:53:56 +00:00
parent 5c1d848311
commit 63fb259bac
11 changed files with 718 additions and 8 deletions
+10
View File
@@ -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
+35
View File
@@ -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
+33 -1
View File
@@ -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 <conventional commit message>` (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
+5
View File
@@ -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.
+20 -1
View File
@@ -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 <conventional commit message>`, post-merge workflow marks the Vikunja task as done
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, 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
+62
View File
@@ -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 = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->Documentation" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->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 = "<!-- 7 -->Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->Security" },
{ message = "^revert", group = "<!-- 9 -->Revert" },
{ message = ".*", group = "<!-- 10 -->Other" },
]
[bump]
features_always_bump_minor = true
breaking_always_bump_major = false
initial_tag = "0.1.0"
+4 -1
View File
@@ -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",
+31 -1
View File
@@ -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=<token> [PYPI_TOKEN=<token>] python3 scripts/publish.py <tag> <repo>
"""
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(
+195
View File
@@ -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=<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()
+61 -4
View File
@@ -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
+262
View File
@@ -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)