GRM-37: feat: Smart CI and release skipping for workflow-only changes

This commit is contained in:
emil
2026-06-21 20:15:28 +00:00
parent 6b3f2a5866
commit 56eb241b77
9 changed files with 540 additions and 15 deletions
+216
View File
@@ -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
+1 -1
View File
@@ -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)])
+40 -1
View File
@@ -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 = [