GRM-34: feat: add automated semver versioning, tagging, and releases with git-cliff
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user