GRM-37: feat: Smart CI and release skipping for workflow-only changes
This commit is contained in:
+35
-4
@@ -26,19 +26,41 @@ jobs:
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/check_test_speed.py --max-seconds 10
|
||||
- name: Release dry-run validation
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PYTHONPATH=src python3 scripts/release.py --dry-run || true
|
||||
- name: Documentation coverage check
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PYTHONPATH=src python3 scripts/doc_coverage.py
|
||||
|
||||
release-dry-run:
|
||||
needs: [quality, detect-changes]
|
||||
if: needs.detect-changes.outputs.user-facing-changed == 'true'
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- 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"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
curl -sL "$URL" | tar xz -C "$TMPDIR"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
|
||||
chmod +x "$HOME/.local/bin/git-cliff"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Release dry-run validation
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PYTHONPATH=src python3 scripts/release.py --dry-run || true
|
||||
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
outputs:
|
||||
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
|
||||
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -62,6 +84,15 @@ jobs:
|
||||
echo "ansible-changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Ansible files changed — skipping molecule tests."
|
||||
fi
|
||||
# Check if any user-facing files changed (src/, ansible/, pyproject.toml)
|
||||
USER_FACING=$(git diff --name-only "$BASE" "$HEAD" -- src/gitea_runner_manager/ ansible/ pyproject.toml 2>/dev/null | head -1)
|
||||
if [ -n "$USER_FACING" ]; then
|
||||
echo "user-facing-changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "User-facing files changed — release dry-run will run."
|
||||
else
|
||||
echo "user-facing-changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No user-facing files changed — skipping release dry-run."
|
||||
fi
|
||||
|
||||
molecule-tests:
|
||||
needs: [quality, detect-changes]
|
||||
|
||||
@@ -125,19 +125,53 @@ After a PR is merged to master, the release pipeline runs automatically:
|
||||
1. **Release workflow** (`.gitea/workflows/release.yml`):
|
||||
- Triggers on push to master
|
||||
- Sets up full dev environment (`make setup`) so lint and tests can run
|
||||
- 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)
|
||||
- Update `CHANGELOG.md` with the new version section
|
||||
- **Run `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
|
||||
- If lint or tests fail, **abort immediately** — no commit, no tag
|
||||
- Commit with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
|
||||
- Create an annotated tag `vX.Y.Z` on the release commit
|
||||
- Push both the commit and tag to master
|
||||
- Runs `scripts/release.py` which:
|
||||
- **Checks for user-facing changes** via `scripts/classify_changes.py` — if only
|
||||
workflow/infrastructure files changed (`.gitea/`, `scripts/`, `docs/`, `tests/`,
|
||||
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version
|
||||
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
|
||||
- Uses **git-cliff** to calculate the next semver version from conventional commits
|
||||
- Updates `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
|
||||
- Updates `CHANGELOG.md` with the new version section
|
||||
- **Runs `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
|
||||
- If lint or tests fail, **aborts immediately** — no commit, no tag
|
||||
- Commits with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
|
||||
- Creates an annotated tag `vX.Y.Z` on the release commit
|
||||
- Pushes both the commit and tag to master
|
||||
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
|
||||
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
|
||||
- On failure, creates a Gitea issue via `scripts/notify_failure.py`
|
||||
|
||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||
|
||||
Not all changes require the full CI pipeline or a new release. The project
|
||||
classifies changes into two categories:
|
||||
|
||||
**User-facing paths** (tool changes → release needed):
|
||||
- `src/gitea_runner_manager/**` — Python CLI source
|
||||
- `ansible/**` — Ansible role
|
||||
- `pyproject.toml` — Package metadata
|
||||
|
||||
**Workflow-only paths** (infrastructure → no release needed):
|
||||
- `.gitea/workflows/**` — Gitea Actions workflows
|
||||
- `scripts/**` — CI/CD automation scripts
|
||||
- `docs/**` — Documentation
|
||||
- `tests/**` — Test files
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md` — Project docs
|
||||
- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml`, `.ansible-lint` — Config
|
||||
- `hooks/**` — Git hooks
|
||||
|
||||
**CI behavior based on classification:**
|
||||
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
||||
- **Release dry-run**: Only runs when user-facing files change
|
||||
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
|
||||
- **Release workflow**: Skips entirely when no user-facing files changed since last tag
|
||||
|
||||
**AI agents must follow these rules:**
|
||||
- When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes
|
||||
- Do NOT bump the version or create tags for workflow-only changes
|
||||
- The `classify_changes.py` script enforces this automatically — no manual intervention needed
|
||||
|
||||
2. **Publish workflow** (`.gitea/workflows/publish.yml`):
|
||||
- Triggers on tag push (`v*`)
|
||||
- Validates `PYPI_TOKEN` is set (warns if missing)
|
||||
|
||||
@@ -181,6 +181,28 @@ After a PR is merged to master, the release pipeline runs automatically.
|
||||
- Runs `scripts/post_merge.py` with the latest commit message and commit SHA
|
||||
- Marks the corresponding Vikunja task as done
|
||||
|
||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||
|
||||
Not all changes require the full CI pipeline or a new release. The project uses
|
||||
`scripts/classify_changes.py` to classify changed files into two categories:
|
||||
|
||||
**User-facing paths** (tool changes → release needed):
|
||||
- `src/gitea_runner_manager/**` — Python CLI source
|
||||
- `ansible/**` — Ansible role
|
||||
- `pyproject.toml` — Package metadata
|
||||
|
||||
**Workflow-only paths** (infrastructure → no release needed):
|
||||
- `.gitea/workflows/**`, `scripts/**`, `docs/**`, `tests/**`
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`, etc.
|
||||
|
||||
**CI behavior based on classification:**
|
||||
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
||||
- **Release dry-run**: Only runs when user-facing files change (separate `release-dry-run` job)
|
||||
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
|
||||
- **Release workflow**: `release.py` calls `classify_changes.py` to check if any
|
||||
user-facing files changed since the last tag. If not, the release is skipped
|
||||
entirely — no version bump, no tag, no publish.
|
||||
|
||||
## git-cliff Commit Preprocessing
|
||||
|
||||
Merge commits on master have the format `GRM-N <conventional commit>`. The `GRM-N ` prefix is not a valid conventional commit prefix, so `cliff.toml` includes a `commit_preprocessors` entry that strips it before parsing:
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify git changes as user-facing or workflow-only.
|
||||
|
||||
Determines whether changes between two git refs (e.g., last tag and HEAD)
|
||||
affect the GRM tool itself (user-facing) or only the CI/CD infrastructure
|
||||
(workflow-only). This is used by:
|
||||
|
||||
- **release.py** — skips release when only workflow files changed
|
||||
- **CI workflow** — skips molecule tests and release dry-run when only
|
||||
workflow files changed
|
||||
|
||||
Classification rules:
|
||||
|
||||
User-facing paths (tool changes → release needed):
|
||||
- src/gitea_runner_manager/** — Python CLI source
|
||||
- ansible/** — Ansible role
|
||||
- pyproject.toml — Package metadata
|
||||
|
||||
Workflow-only paths (infrastructure → no release needed):
|
||||
- .gitea/workflows/** — Gitea Actions workflows
|
||||
- scripts/** — CI/CD automation scripts
|
||||
- docs/** — Documentation
|
||||
- tests/** — Test files
|
||||
- hooks/** — Git hooks
|
||||
- AGENTS.md — Agent conventions
|
||||
- README.md — README (lean, links to wiki)
|
||||
- CHANGELOG.md — Changelog (generated)
|
||||
- TROUBLESHOOTING.md — Troubleshooting guide
|
||||
- cliff.toml — git-cliff config
|
||||
- Makefile — Build automation
|
||||
- .pre-commit-config.yaml — Pre-commit config
|
||||
- .ansible-lint — Ansible lint config
|
||||
- .env.example — Environment template
|
||||
- .gitignore — Git ignore rules
|
||||
|
||||
Usage:
|
||||
python3 scripts/classify_changes.py [--base <ref>] [--head <ref>]
|
||||
python3 scripts/classify_changes.py --base v0.3.0 --head HEAD
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
# Paths that count as user-facing (tool changes)
|
||||
USER_FACING_PATTERNS = frozenset(
|
||||
[
|
||||
"src/gitea_runner_manager/",
|
||||
"ansible/",
|
||||
"pyproject.toml",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def run_git(args: list[str]) -> str:
|
||||
"""Run a git command and return stdout."""
|
||||
result = subprocess.run( # nosec B603
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_("git command failed ({cmd}): {stderr}", cmd=" ".join(args), stderr=result.stderr.strip())
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_changed_files(base: str, head: str) -> list[str]:
|
||||
"""Get list of files changed between base and head refs."""
|
||||
output = run_git(["git", "diff", "--name-only", base, head])
|
||||
if not output:
|
||||
return []
|
||||
return output.split("\n")
|
||||
|
||||
|
||||
def is_user_facing(file_path: str) -> bool:
|
||||
"""Check if a file path is user-facing (affects the GRM tool)."""
|
||||
return any(file_path.startswith(pattern) or file_path == pattern for pattern in USER_FACING_PATTERNS)
|
||||
|
||||
|
||||
def classify_changes(files: list[str]) -> dict[str, list[str]]:
|
||||
"""Classify changed files into user-facing and workflow-only.
|
||||
|
||||
Returns a dict with keys "user_facing" and "workflow_only".
|
||||
"""
|
||||
user_facing: list[str] = []
|
||||
workflow_only: list[str] = []
|
||||
for f in files:
|
||||
if is_user_facing(f):
|
||||
user_facing.append(f)
|
||||
else:
|
||||
workflow_only.append(f)
|
||||
return {"user_facing": user_facing, "workflow_only": workflow_only}
|
||||
|
||||
|
||||
def has_user_facing_changes(base: str, head: str) -> bool:
|
||||
"""Check if any user-facing files changed between base and head."""
|
||||
files = get_changed_files(base, head)
|
||||
return any(is_user_facing(f) for f in files)
|
||||
|
||||
|
||||
def get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "describe", "--tags", "--abbrev=0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--base", default=None, help="Base ref (default: latest tag).")
|
||||
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
|
||||
@click.option("--quiet", is_flag=True, default=False, help="Only output true/false.")
|
||||
def main(base: str | None, head: str, quiet: bool) -> None:
|
||||
if base is None:
|
||||
base = get_latest_tag()
|
||||
if not base:
|
||||
if quiet:
|
||||
click.echo("true")
|
||||
else:
|
||||
click.echo(_("No tags found — treating all changes as user-facing."))
|
||||
return
|
||||
|
||||
files = get_changed_files(base, head)
|
||||
if not files:
|
||||
if quiet:
|
||||
click.echo("false")
|
||||
else:
|
||||
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
|
||||
return
|
||||
|
||||
result = classify_changes(files)
|
||||
has_user = bool(result["user_facing"])
|
||||
|
||||
if quiet:
|
||||
click.echo("true" if has_user else "false")
|
||||
return
|
||||
|
||||
click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files)))
|
||||
click.echo(_("\nUser-facing changes ({count}):", count=len(result["user_facing"])))
|
||||
for f in result["user_facing"]:
|
||||
click.echo(f" {f}")
|
||||
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result["workflow_only"])))
|
||||
for f in result["workflow_only"]:
|
||||
click.echo(f" {f}")
|
||||
if has_user:
|
||||
status = "USER-FACING changes detected — release needed"
|
||||
else:
|
||||
status = "Workflow-only changes — no release needed"
|
||||
click.echo(_("\nResult: {status}", status=status))
|
||||
|
||||
if not has_user:
|
||||
sys.exit(2) # Exit code 2 = workflow-only (used by CI to skip release)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -42,6 +42,7 @@ REQUIRED_SCRIPTS = [
|
||||
"review_pr.py",
|
||||
"notify_failure.py",
|
||||
"post_merge.py",
|
||||
"classify_changes.py",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
from scripts.classify_changes import has_user_facing_changes
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
@@ -256,6 +257,18 @@ def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
if branch != "master":
|
||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||
|
||||
# Check if any user-facing files changed since the last tag.
|
||||
# If only workflow/infra files changed, skip the release entirely.
|
||||
latest_tag = get_latest_tag()
|
||||
if latest_tag and not has_user_facing_changes(latest_tag, "HEAD"):
|
||||
click.echo(
|
||||
_(
|
||||
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
tag=latest_tag,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Calculate next version (single git-cliff call — Gap 7 fix)
|
||||
new_version = get_bumped_version()
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Unit tests for scripts/classify_changes.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.classify_changes import (
|
||||
classify_changes,
|
||||
get_changed_files,
|
||||
get_latest_tag,
|
||||
has_user_facing_changes,
|
||||
is_user_facing,
|
||||
main,
|
||||
run_git,
|
||||
)
|
||||
|
||||
|
||||
class TestIsUserFacing:
|
||||
def test_src_is_user_facing(self) -> None:
|
||||
assert is_user_facing("src/gitea_runner_manager/cli.py") is True
|
||||
|
||||
def test_ansible_is_user_facing(self) -> None:
|
||||
assert is_user_facing("ansible/roles/gitea-runner/tasks/main.yml") is True
|
||||
|
||||
def test_pyproject_is_user_facing(self) -> None:
|
||||
assert is_user_facing("pyproject.toml") is True
|
||||
|
||||
def test_workflow_is_not_user_facing(self) -> None:
|
||||
assert is_user_facing(".gitea/workflows/ci.yml") is False
|
||||
|
||||
def test_scripts_are_not_user_facing(self) -> None:
|
||||
assert is_user_facing("scripts/release.py") is False
|
||||
|
||||
def test_docs_are_not_user_facing(self) -> None:
|
||||
assert is_user_facing("docs/user/getting-started.md") is False
|
||||
|
||||
def test_tests_are_not_user_facing(self) -> None:
|
||||
assert is_user_facing("tests/unit/test_cli.py") is False
|
||||
|
||||
def test_agents_md_is_not_user_facing(self) -> None:
|
||||
assert is_user_facing("AGENTS.md") is False
|
||||
|
||||
def test_makefile_is_not_user_facing(self) -> None:
|
||||
assert is_user_facing("Makefile") is False
|
||||
|
||||
|
||||
class TestClassifyChanges:
|
||||
def test_all_user_facing(self) -> None:
|
||||
files = ["src/gitea_runner_manager/cli.py", "ansible/roles/gitea-runner/tasks/main.yml"]
|
||||
result = classify_changes(files)
|
||||
assert result["user_facing"] == files
|
||||
assert result["workflow_only"] == []
|
||||
|
||||
def test_all_workflow_only(self) -> None:
|
||||
files = [".gitea/workflows/ci.yml", "docs/index.md", "AGENTS.md"]
|
||||
result = classify_changes(files)
|
||||
assert result["user_facing"] == []
|
||||
assert result["workflow_only"] == files
|
||||
|
||||
def test_mixed(self) -> None:
|
||||
files = [
|
||||
"src/gitea_runner_manager/cli.py",
|
||||
".gitea/workflows/ci.yml",
|
||||
"pyproject.toml",
|
||||
"docs/index.md",
|
||||
]
|
||||
result = classify_changes(files)
|
||||
assert "src/gitea_runner_manager/cli.py" in result["user_facing"]
|
||||
assert "pyproject.toml" in result["user_facing"]
|
||||
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
|
||||
assert "docs/index.md" in result["workflow_only"]
|
||||
|
||||
def test_empty(self) -> None:
|
||||
result = classify_changes([])
|
||||
assert result == {"user_facing": [], "workflow_only": []}
|
||||
|
||||
|
||||
class TestGetChangedFiles:
|
||||
@patch("scripts.classify_changes.run_git")
|
||||
def test_returns_file_list(self, mock_run_git: MagicMock) -> None:
|
||||
mock_run_git.return_value = "file1.py\nfile2.py\nfile3.md"
|
||||
result = get_changed_files("v0.1.0", "HEAD")
|
||||
assert result == ["file1.py", "file2.py", "file3.md"]
|
||||
|
||||
@patch("scripts.classify_changes.run_git")
|
||||
def test_empty_when_no_changes(self, mock_run_git: MagicMock) -> None:
|
||||
mock_run_git.return_value = ""
|
||||
result = get_changed_files("v0.1.0", "HEAD")
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestHasUserFacingChanges:
|
||||
@patch("scripts.classify_changes.get_changed_files")
|
||||
def test_true_when_user_facing(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = ["src/gitea_runner_manager/cli.py", "docs/index.md"]
|
||||
assert has_user_facing_changes("v0.1.0", "HEAD") is True
|
||||
|
||||
@patch("scripts.classify_changes.get_changed_files")
|
||||
def test_false_when_workflow_only(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
|
||||
assert has_user_facing_changes("v0.1.0", "HEAD") is False
|
||||
|
||||
@patch("scripts.classify_changes.get_changed_files")
|
||||
def test_false_when_no_changes(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = []
|
||||
assert has_user_facing_changes("v0.1.0", "HEAD") is False
|
||||
|
||||
|
||||
class TestGetLatestTag:
|
||||
@patch("subprocess.run")
|
||||
def test_returns_tag(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="v0.3.0\n", stderr="")
|
||||
assert get_latest_tag() == "v0.3.0"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_returns_empty_when_no_tags(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
assert get_latest_tag() == ""
|
||||
|
||||
|
||||
class TestRunGit:
|
||||
@patch("scripts.classify_changes.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="file1.py\n", stderr="")
|
||||
result = run_git(["git", "diff", "--name-only", "v0.1.0", "HEAD"])
|
||||
assert result == "file1.py"
|
||||
|
||||
@patch("scripts.classify_changes.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="git error")
|
||||
with pytest.raises(click.ClickException):
|
||||
run_git(["git", "bad-command"])
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("scripts.classify_changes.get_latest_tag", return_value="")
|
||||
def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "true" in result.output
|
||||
|
||||
@patch("scripts.classify_changes.get_changed_files", return_value=[])
|
||||
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_no_changes_outputs_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "false" in result.output
|
||||
|
||||
@patch("scripts.classify_changes.get_changed_files")
|
||||
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 2
|
||||
assert "no release needed" in result.output
|
||||
|
||||
@patch("scripts.classify_changes.get_changed_files")
|
||||
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_user_facing_exits_0(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", "docs/index.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "release needed" in result.output
|
||||
|
||||
@patch("scripts.classify_changes.get_latest_tag", return_value="")
|
||||
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
|
||||
"""Non-quiet mode with no tags prints user-facing message."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No tags found" in result.output
|
||||
|
||||
@patch("scripts.classify_changes.get_changed_files", return_value=[])
|
||||
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_no_changes_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
"""Non-quiet mode with no changes prints message."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No changes" in result.output
|
||||
|
||||
@patch("scripts.classify_changes.get_changed_files")
|
||||
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
"""Quiet mode with user-facing changes outputs true."""
|
||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "true" in result.output
|
||||
|
||||
@patch("scripts.classify_changes.get_changed_files")
|
||||
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
"""Quiet mode with workflow-only changes outputs false."""
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "false" in result.output
|
||||
|
||||
@patch("scripts.classify_changes.get_changed_files")
|
||||
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
"""Explicit --base overrides latest tag."""
|
||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"])
|
||||
assert result.exit_code == 0
|
||||
assert "release needed" in result.output
|
||||
@@ -76,7 +76,7 @@ class TestMain:
|
||||
)
|
||||
# Write ci-cd-workflow.md with all scripts
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(
|
||||
"auto_merge.py release.py publish.py review_pr.py notify_failure.py post_merge.py"
|
||||
"auto_merge.py release.py publish.py review_pr.py notify_failure.py post_merge.py classify_changes.py"
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
|
||||
@@ -303,10 +303,17 @@ class TestMain:
|
||||
assert "master" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=False)
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_no_unreleased_changes(self, mock_run_cmd: MagicMock, mock_bumped: MagicMock, mock_has: MagicMock) -> None:
|
||||
def test_no_unreleased_changes(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
@@ -314,6 +321,7 @@ class TestMain:
|
||||
assert "No unreleased changes" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.release.create_and_push_tag")
|
||||
@patch("scripts.release.commit_release_changes")
|
||||
@patch("scripts.release.update_changelog")
|
||||
@@ -334,6 +342,7 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -342,6 +351,7 @@ class TestMain:
|
||||
assert "empty changelog" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.release.create_and_push_tag")
|
||||
@patch("scripts.release.commit_release_changes")
|
||||
@patch("scripts.release.update_changelog")
|
||||
@@ -362,6 +372,7 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -374,6 +385,25 @@ class TestMain:
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.3.0")
|
||||
@patch("scripts.release.has_user_facing_changes", return_value=False)
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_skips_release_when_workflow_only(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_user_facing: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
) -> None:
|
||||
"""Release is skipped when only workflow/infra files changed."""
|
||||
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 user-facing changes" in result.output
|
||||
assert "Skipping release" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.release.run_tests")
|
||||
@patch("scripts.release.create_and_push_tag", return_value=True)
|
||||
@patch("scripts.release.commit_release_changes", return_value=True)
|
||||
@@ -396,6 +426,7 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -409,6 +440,7 @@ class TestMain:
|
||||
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.release.run_tests")
|
||||
@patch("scripts.release.create_and_push_tag", return_value=False)
|
||||
@patch("scripts.release.commit_release_changes", return_value=False)
|
||||
@@ -431,6 +463,7 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""When tag already exists, still update files but report existing tag."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -441,6 +474,7 @@ class TestMain:
|
||||
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.release.create_and_push_tag", return_value=True)
|
||||
@patch("scripts.release.commit_release_changes", return_value=True)
|
||||
@patch("scripts.release.update_changelog")
|
||||
@@ -461,6 +495,7 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""--skip-tests bypasses test verification."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -473,6 +508,7 @@ class TestMain:
|
||||
assert make_calls == []
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.release.create_and_push_tag")
|
||||
@patch("scripts.release.commit_release_changes")
|
||||
@patch("scripts.release.update_changelog")
|
||||
@@ -493,6 +529,7 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""If tests fail, release aborts — no commit, no tag."""
|
||||
# First call: git rev-parse (master), then make lint-ruff (success),
|
||||
@@ -510,6 +547,7 @@ class TestMain:
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.release.create_and_push_tag")
|
||||
@patch("scripts.release.commit_release_changes")
|
||||
@patch("scripts.release.update_changelog")
|
||||
@@ -530,6 +568,7 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""If lint fails, release aborts — no commit, no tag."""
|
||||
mock_run_cmd.side_effect = [
|
||||
|
||||
Reference in New Issue
Block a user