"""Unit tests for scripts/ci/release.py.""" from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner from scripts.ci.release import ( commit_release_changes, create_and_push_tag, get_bumped_version, get_changelog, get_latest_tag, has_unreleased_changes, main, run_cmd, run_tests, tag_exists, update_changelog, update_init_version, ) class TestRunCmd: @patch("scripts.ci.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.ci.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.ci.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.ci.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.ci.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 TestTagExists: @patch("scripts.ci.release.run_cmd") def test_exists(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n") assert tag_exists("v0.1.0") is True @patch("scripts.ci.release.run_cmd") def test_not_exists(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="") assert tag_exists("v0.2.0") is False class TestGetBumpedVersion: @patch("scripts.ci.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.ci.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.ci.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.ci.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.ci.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.ci.release.get_latest_tag") def test_no_tags_has_changes(self, mock_latest: MagicMock) -> None: mock_latest.return_value = "" assert has_unreleased_changes() is True @patch("scripts.ci.release.get_latest_tag") @patch("scripts.ci.release.run_cmd") def test_no_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="") mock_latest.return_value = "v0.2.0" assert has_unreleased_changes() is False @patch("scripts.ci.release.get_latest_tag") @patch("scripts.ci.release.run_cmd") def test_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123 fix: bug\n") mock_latest.return_value = "v0.2.0" assert has_unreleased_changes() is True @patch("scripts.ci.release.get_latest_tag") @patch("scripts.ci.release.run_cmd") def test_git_log_fails_returns_false(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=1, stdout="") mock_latest.return_value = "v0.2.0" 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.ci.release.INIT_FILE", str(init_file)) update_init_version("0.2.0") assert '__version__ = "0.2.0"' in init_file.read_text() def test_same_version_ok(self, tmp_path, monkeypatch) -> None: """Updating to the same version should not raise.""" init_file = tmp_path / "__init__.py" init_file.write_text('__version__ = "0.1.0"\n') monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file)) update_init_version("0.1.0") assert '__version__ = "0.1.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.ci.release.INIT_FILE", str(init_file)) with pytest.raises(click.ClickException): update_init_version("0.2.0") class TestUpdateChangelog: def test_creates_new_file(self, tmp_path, monkeypatch) -> None: changelog_file = tmp_path / "CHANGELOG.md" monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file)) update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing") content = changelog_file.read_text() assert "## [0.2.0]" in content assert "new thing" in content def test_prepends_to_existing(self, tmp_path, monkeypatch) -> None: changelog_file = tmp_path / "CHANGELOG.md" changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n") monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file)) update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing") content = changelog_file.read_text() assert "# Changelog" in content # New version should be before old version assert content.index("0.2.0") < content.index("0.1.0") assert "new thing" in content assert "old thing" in content def test_appends_when_no_version_sections(self, tmp_path, monkeypatch) -> None: changelog_file = tmp_path / "CHANGELOG.md" changelog_file.write_text("# Changelog\n\nSome intro text.\n") monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file)) update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing") content = changelog_file.read_text() assert "Some intro text" in content assert "## [0.2.0]" in content def test_strips_git_cliff_header(self, tmp_path, monkeypatch) -> None: """git-cliff output includes a header — should be stripped before inserting.""" changelog_file = tmp_path / "CHANGELOG.md" changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n") monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file)) # Simulate git-cliff output with header cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing" update_changelog(cliff_output) content = changelog_file.read_text() # Header should appear only once (from the existing file) assert content.count("# Changelog") == 1 assert "## [0.2.0]" in content assert "new thing" in content def test_strips_header_when_creating_new_file(self, tmp_path, monkeypatch) -> None: """When creating a new file, strip the git-cliff header.""" changelog_file = tmp_path / "CHANGELOG.md" monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file)) cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing" update_changelog(cliff_output) content = changelog_file.read_text() assert "# Changelog" not in content assert "## [0.2.0]" in content class TestCommitReleaseChanges: @patch("scripts.ci.release.run_cmd") def test_commits_when_changes(self, mock_run_cmd: MagicMock) -> None: # git diff --cached --quiet returns 1 (changes exist) mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="") result = commit_release_changes("0.2.0") assert result is True calls = [c.args[0] for c in mock_run_cmd.call_args_list] assert ["git", "add", "src/gitea_runner_manager/__init__.py", "CHANGELOG.md"] in calls assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls @patch("scripts.ci.release.run_cmd") def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None: # git diff --cached --quiet returns 0 (no changes) mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") result = commit_release_changes("0.1.0") assert result is False calls = [c.args[0] for c in mock_run_cmd.call_args_list] assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0"] not in calls class TestCreateAndPushTag: @patch("scripts.ci.release.tag_exists", return_value=False) @patch("scripts.ci.release.run_cmd") def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: create_and_push_tag("0.2.0", "changelog", dry_run=False) calls = [c.args[0] for c in mock_run_cmd.call_args_list] assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls assert ["git", "push", "origin", "v0.2.0"] in calls @patch("scripts.ci.release.tag_exists", return_value=False) @patch("scripts.ci.release.run_cmd") def test_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: result = create_and_push_tag("0.2.0", "changelog", dry_run=True) assert result is True for call in mock_run_cmd.call_args_list: assert call.args[0][0:2] != ["git", "push"] assert call.args[0][0:2] != ["git", "tag"] @patch("scripts.ci.release.tag_exists", return_value=True) @patch("scripts.ci.release.run_cmd") def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: result = create_and_push_tag("0.1.0", "changelog", dry_run=False) assert result is False # Should not create tag, but should ensure it's pushed calls = [c.args[0] for c in mock_run_cmd.call_args_list] assert ["git", "tag", "-a"] not in [c[:3] for c in calls] assert ["git", "push", "origin", "v0.1.0"] in calls @patch("scripts.ci.release.tag_exists", return_value=True) @patch("scripts.ci.release.run_cmd") def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: result = create_and_push_tag("0.1.0", "changelog", dry_run=True) assert result is False # No git commands at all in dry-run when tag exists mock_run_cmd.assert_not_called() class TestRunTests: @patch("scripts.ci.release.run_cmd") def test_lint_and_tests_pass(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") run_tests() # should not raise @patch("scripts.ci.release.run_cmd") def test_lint_fails_raises(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="lint error") with pytest.raises(click.ClickException, match="Lint failed"): run_tests() @patch("scripts.ci.release.run_cmd") def test_tests_fail_raises(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="", stderr=""), # lint passes MagicMock(returncode=1, stdout="", stderr="test failure"), # tests fail ] with pytest.raises(click.ClickException, match="Tests failed"): run_tests() class TestMain: @patch.dict("os.environ", {}) @patch("scripts.ci.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.ci.release.has_user_facing_changes", return_value=False) @patch("scripts.ci.release.run_cmd") def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None: """Dry-run mode should not fail on non-master branches.""" mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="") runner = CliRunner() result = runner.invoke(main, ["--dry-run"]) assert result.exit_code == 0 assert "Dry-run mode" in result.output @patch.dict("os.environ", {}) @patch("scripts.ci.release.has_user_facing_changes", return_value=True) @patch("scripts.ci.release.run_cmd") def test_release_lock_skips_when_head_is_release_commit(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None: """If HEAD is already a release commit, should skip to prevent duplicate releases.""" # First call: git rev-parse (master), second: git log -1 (release commit) mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""), ] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "already a release commit" in result.output assert "Skipping" in result.output @patch.dict("os.environ", {}) @patch("scripts.ci.release.has_user_facing_changes", return_value=True) @patch("scripts.ci.release.has_unreleased_changes", return_value=False) @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") @patch("scripts.ci.release.run_cmd") 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, []) assert result.exit_code == 0 assert "No unreleased changes" in result.output @patch.dict("os.environ", {}) @patch("scripts.ci.release.has_user_facing_changes", return_value=True) @patch("scripts.ci.release.create_and_push_tag") @patch("scripts.ci.release.commit_release_changes") @patch("scripts.ci.release.update_changelog") @patch("scripts.ci.release.update_init_version") @patch("scripts.ci.release.get_changelog", return_value="") @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") @patch("scripts.ci.release.has_unreleased_changes", return_value=True) @patch("scripts.ci.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_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() result = runner.invoke(main, ["--dry-run"]) assert result.exit_code == 0 assert "empty changelog" in result.output @patch.dict("os.environ", {}) @patch("scripts.ci.release.has_user_facing_changes", return_value=True) @patch("scripts.ci.release.create_and_push_tag") @patch("scripts.ci.release.commit_release_changes") @patch("scripts.ci.release.update_changelog") @patch("scripts.ci.release.update_init_version") @patch("scripts.ci.release.get_changelog", return_value="changelog") @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") @patch("scripts.ci.release.has_unreleased_changes", return_value=True) @patch("scripts.ci.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_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() result = runner.invoke(main, ["--dry-run"]) assert result.exit_code == 0 assert "[dry-run]" in result.output mock_update_init.assert_not_called() mock_update_changelog.assert_not_called() mock_commit.assert_not_called() mock_tag.assert_not_called() @patch.dict("os.environ", {}) @patch("scripts.ci.release.get_latest_tag", return_value="v0.3.0") @patch("scripts.ci.release.has_user_facing_changes", return_value=False) @patch("scripts.ci.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.ci.release.has_user_facing_changes", return_value=True) @patch("scripts.ci.release.run_tests") @patch("scripts.ci.release.create_and_push_tag", return_value=True) @patch("scripts.ci.release.commit_release_changes", return_value=True) @patch("scripts.ci.release.update_changelog") @patch("scripts.ci.release.update_init_version") @patch("scripts.ci.release.get_changelog", return_value="changelog") @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") @patch("scripts.ci.release.has_unreleased_changes", return_value=True) @patch("scripts.ci.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_update_changelog: MagicMock, 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() 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_update_changelog.assert_called_once_with("changelog") mock_run_tests.assert_called_once() mock_commit.assert_called_once_with("0.2.0") mock_tag.assert_called_once_with("0.2.0", "changelog", False) @patch.dict("os.environ", {}) @patch("scripts.ci.release.has_user_facing_changes", return_value=True) @patch("scripts.ci.release.run_tests") @patch("scripts.ci.release.create_and_push_tag", return_value=False) @patch("scripts.ci.release.commit_release_changes", return_value=False) @patch("scripts.ci.release.update_changelog") @patch("scripts.ci.release.update_init_version") @patch("scripts.ci.release.get_changelog", return_value="changelog") @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") @patch("scripts.ci.release.get_bumped_version", return_value="0.1.0") @patch("scripts.ci.release.has_unreleased_changes", return_value=True) @patch("scripts.ci.release.run_cmd") def test_full_flow_tag_exists( self, mock_run_cmd: MagicMock, mock_has: MagicMock, mock_bumped: MagicMock, mock_latest: MagicMock, mock_changelog: MagicMock, mock_update_init: MagicMock, mock_update_changelog: MagicMock, 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="") runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "already existed" in result.output mock_tag.assert_called_once_with("0.1.0", "changelog", False) @patch.dict("os.environ", {}) @patch("scripts.ci.release.has_user_facing_changes", return_value=True) @patch("scripts.ci.release.create_and_push_tag", return_value=True) @patch("scripts.ci.release.commit_release_changes", return_value=True) @patch("scripts.ci.release.update_changelog") @patch("scripts.ci.release.update_init_version") @patch("scripts.ci.release.get_changelog", return_value="changelog") @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") @patch("scripts.ci.release.has_unreleased_changes", return_value=True) @patch("scripts.ci.release.run_cmd") def test_full_flow_skip_tests( self, mock_run_cmd: MagicMock, mock_has: MagicMock, mock_bumped: MagicMock, mock_latest: MagicMock, mock_changelog: MagicMock, mock_update_init: MagicMock, 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="") runner = CliRunner() result = runner.invoke(main, ["--skip-tests"]) assert result.exit_code == 0 assert "WARNING: --skip-tests" in result.output # run_tests should NOT be called — verify no "make lint-ruff" or "make pytest-cov" calls make_calls = [c.args[0] for c in mock_run_cmd.call_args_list if c.args[0][:1] == ["make"]] assert make_calls == [] @patch.dict("os.environ", {}) @patch("scripts.ci.release.has_user_facing_changes", return_value=True) @patch("scripts.ci.release.create_and_push_tag") @patch("scripts.ci.release.commit_release_changes") @patch("scripts.ci.release.update_changelog") @patch("scripts.ci.release.update_init_version") @patch("scripts.ci.release.get_changelog", return_value="changelog") @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") @patch("scripts.ci.release.has_unreleased_changes", return_value=True) @patch("scripts.ci.release.run_cmd") def test_tests_fail_aborts_before_tag( self, mock_run_cmd: MagicMock, mock_has: MagicMock, mock_bumped: MagicMock, mock_latest: MagicMock, mock_changelog: MagicMock, mock_update_init: MagicMock, mock_update_changelog: MagicMock, mock_commit: MagicMock, mock_tag: MagicMock, mock_user: MagicMock, ) -> None: """If tests fail, release aborts — no commit, no tag.""" # Calls: git rev-parse (master), git log -1 (release lock check), # make lint-ruff (success), make pytest-cov (failure) mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="GRM-50 fix: something\n", stderr=""), MagicMock(returncode=0, stdout="", stderr=""), MagicMock(returncode=1, stdout="", stderr="test failure"), ] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code != 0 assert "Tests failed" in result.output mock_commit.assert_not_called() mock_tag.assert_not_called() @patch.dict("os.environ", {}) @patch("scripts.ci.release.has_user_facing_changes", return_value=True) @patch("scripts.ci.release.create_and_push_tag") @patch("scripts.ci.release.commit_release_changes") @patch("scripts.ci.release.update_changelog") @patch("scripts.ci.release.update_init_version") @patch("scripts.ci.release.get_changelog", return_value="changelog") @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") @patch("scripts.ci.release.has_unreleased_changes", return_value=True) @patch("scripts.ci.release.run_cmd") def test_lint_fail_aborts_before_tag( self, mock_run_cmd: MagicMock, mock_has: MagicMock, mock_bumped: MagicMock, mock_latest: MagicMock, mock_changelog: MagicMock, mock_update_init: MagicMock, mock_update_changelog: MagicMock, mock_commit: MagicMock, mock_tag: MagicMock, mock_user: MagicMock, ) -> None: """If lint fails, release aborts — no commit, no tag.""" # Calls: git rev-parse (master), git log -1 (release lock check), # make lint-ruff (failure) mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="GRM-50 fix: something\n", stderr=""), MagicMock(returncode=1, stdout="", stderr="lint error"), ] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code != 0 assert "Lint failed" in result.output mock_commit.assert_not_called() mock_tag.assert_not_called()