Addresses all 12 critical gaps in the automated semantic versioning, tagging, and release workflow. Closes GRM-35
390 lines
17 KiB
Python
390 lines
17 KiB
Python
"""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 (
|
|
commit_release_changes,
|
|
create_and_push_tag,
|
|
get_bumped_version,
|
|
get_changelog,
|
|
get_latest_tag,
|
|
has_unreleased_changes,
|
|
main,
|
|
run_cmd,
|
|
tag_exists,
|
|
update_changelog,
|
|
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 TestTagExists:
|
|
@patch("scripts.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.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.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")
|
|
def test_with_bumped_version_no_changes(self, mock_latest: MagicMock) -> None:
|
|
mock_latest.return_value = "v0.2.0"
|
|
assert has_unreleased_changes(bumped_version="0.2.0") is False
|
|
|
|
@patch("scripts.release.get_latest_tag")
|
|
def test_with_bumped_version_has_changes(self, mock_latest: MagicMock) -> None:
|
|
mock_latest.return_value = "v0.2.0"
|
|
assert has_unreleased_changes(bumped_version="0.3.0") is True
|
|
|
|
@patch("scripts.release.get_latest_tag")
|
|
def test_with_bumped_version_no_tags(self, mock_latest: MagicMock) -> None:
|
|
mock_latest.return_value = ""
|
|
assert has_unreleased_changes(bumped_version="0.1.0") is True
|
|
|
|
@patch("scripts.release.get_latest_tag")
|
|
@patch("scripts.release.run_cmd")
|
|
def test_without_bumped_version_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_without_bumped_version_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.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_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.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.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.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.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.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
|
|
|
|
|
|
class TestCommitReleaseChanges:
|
|
@patch("scripts.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", "-m", "release: v0.2.0"] in calls
|
|
|
|
@patch("scripts.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", "-m", "release: v0.1.0"] not in calls
|
|
|
|
|
|
class TestCreateAndPushTag:
|
|
@patch("scripts.release.tag_exists", return_value=False)
|
|
@patch("scripts.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.release.tag_exists", return_value=False)
|
|
@patch("scripts.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.release.tag_exists", return_value=True)
|
|
@patch("scripts.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.release.tag_exists", return_value=True)
|
|
@patch("scripts.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 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.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:
|
|
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.commit_release_changes")
|
|
@patch("scripts.release.update_changelog")
|
|
@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_update_changelog: 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.commit_release_changes")
|
|
@patch("scripts.release.update_changelog")
|
|
@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_update_changelog: 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_update_changelog.assert_not_called()
|
|
mock_commit.assert_not_called()
|
|
mock_tag.assert_not_called()
|
|
|
|
@patch.dict("os.environ", {})
|
|
@patch("scripts.release.create_and_push_tag", return_value=True)
|
|
@patch("scripts.release.commit_release_changes", return_value=True)
|
|
@patch("scripts.release.update_changelog")
|
|
@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_update_changelog: 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_update_changelog.assert_called_once_with("changelog")
|
|
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.release.create_and_push_tag", return_value=False)
|
|
@patch("scripts.release.commit_release_changes", return_value=False)
|
|
@patch("scripts.release.update_changelog")
|
|
@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.1.0")
|
|
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
|
@patch("scripts.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,
|
|
) -> 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)
|