Files
devx/tests/unit/test_release.py
T
emil 6ebb48f9e8
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / vikunja (push) Successful in 21s
Post-merge / configure-repo (push) Successful in 12s
Post-merge / release (push) Failing after 1m5s
Post-merge / sync-wiki (push) Successful in 1m0s
Post-merge / badges (push) Failing after 31s
DEVX-1: fix: fix post-merge job failures (configure-repo, badges, notify-failure)
2026-06-22 16:08:27 +00:00

617 lines
27 KiB
Python

"""Unit tests for scripts/ci/release.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.ci.release.get_latest_tag")
@patch("devx.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("devx.ci.release.get_latest_tag")
@patch("devx.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("devx.ci.release.get_latest_tag")
@patch("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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("devx.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/devx/__init__.py", "CHANGELOG.md"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
@patch("devx.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("devx.ci.release.tag_exists", return_value=False)
@patch("devx.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("devx.ci.release.tag_exists", return_value=False)
@patch("devx.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("devx.ci.release.tag_exists", return_value=True)
@patch("devx.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("devx.ci.release.tag_exists", return_value=True)
@patch("devx.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("devx.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("devx.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("devx.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()
@patch("devx.ci.release.run_cmd")
def test_tests_fail_stdout_fallback(self, mock_run_cmd: MagicMock) -> None:
"""When stderr is empty, test output from stdout is used in the error message."""
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""), # lint passes
MagicMock(returncode=1, stdout="test failure on stdout", stderr=""), # tests fail, stderr empty
]
with pytest.raises(click.ClickException, match="test failure on stdout"):
run_tests()
class TestMain:
@patch.dict("os.environ", {})
@patch("devx.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("devx.ci.release.has_user_facing_changes", return_value=False)
@patch("devx.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("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.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("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.has_unreleased_changes", return_value=False)
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.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("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.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("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.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("devx.ci.release.get_latest_tag", return_value="v0.3.0")
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
@patch("devx.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("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.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("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=False)
@patch("devx.ci.release.commit_release_changes", return_value=False)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.1.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.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("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.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("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.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="DEVX-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("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.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="DEVX-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()