"""Unit tests for scripts/ci/release.py.""" import os from pathlib import Path 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, fetch_tags, get_all_tags, get_bumped_version, get_changelog, get_changelog_versions, get_commit_version, get_head_commit, get_init_version, get_latest_tag, get_tag_commit, has_unreleased_changes, main, run_cmd, run_tests, tag_exists, update_changelog, update_doc_versions, update_init_version, verify_alignment, verify_tag_consistency, ) class TestRunCmd: @patch("devx.ci._shared.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._shared.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._shared.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._shared.subprocess.run") def test_returns_tag(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout="v0.1.0\n") assert get_latest_tag() == "v0.1.0" @patch("devx.ci._shared.subprocess.run") def test_no_tags_returns_empty(self, mock_run: MagicMock) -> None: mock_run.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() @patch("devx.ci.release.run_cmd") def test_invalid_version_format_raises(self, mock_run_cmd: MagicMock) -> None: """Non-semver version from git-cliff should raise.""" mock_run_cmd.return_value = MagicMock(returncode=0, stdout="not-a-version\n", stderr="") with pytest.raises(click.ClickException, match="invalid version format"): 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 TestGetTagCommit: @patch("devx.ci.release.run_cmd") def test_returns_commit(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123\n", stderr="") assert get_tag_commit("v0.1.0") == "abc123" @patch("devx.ci.release.run_cmd") def test_returns_empty_on_failure(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err") assert get_tag_commit("v0.1.0") == "" class TestGetHeadCommit: @patch("devx.ci.release.run_cmd") def test_returns_head(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="def456\n", stderr="") assert get_head_commit() == "def456" class TestFetchTags: @patch("devx.ci.release.run_cmd") def test_success(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") fetch_tags() @patch("devx.ci.release.run_cmd") def test_failure_warns(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err") # Should not raise fetch_tags() class TestGetAllTags: @patch("devx.ci.release.run_cmd") def test_returns_tags(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\nv0.2.0\nv0.1.0\n", stderr="") tags = get_all_tags() assert tags == ["v0.3.0", "v0.2.0", "v0.1.0"] @patch("devx.ci.release.run_cmd") def test_empty(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="\n", stderr="") assert get_all_tags() == [] @patch("devx.ci.release.run_cmd") def test_failure_returns_empty(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err") assert get_all_tags() == [] class TestGetCommitVersion: @patch("devx.ci.release.run_cmd") def test_release_commit(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="release: v0.4.4 [skip ci]\n", stderr="") assert get_commit_version("abc123") == "0.4.4" @patch("devx.ci.release.run_cmd") def test_non_release_commit(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="DEVX-9 feat: add thing\n", stderr="") assert get_commit_version("abc123") is None class TestVerifyTagConsistency: @patch("devx.ci.release.get_commit_version") @patch("devx.ci.release.get_all_tags") def test_all_consistent(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None: mock_tags.return_value = ["v0.2.0", "v0.1.0"] mock_cv.side_effect = ["0.2.0", "0.1.0"] errors = verify_tag_consistency() assert errors == [] @patch("devx.ci.release.get_commit_version") @patch("devx.ci.release.get_all_tags") def test_tag_on_non_release_commit(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None: # v0.1.0 is first (exempt), v0.2.0 is non-release (should error) mock_tags.return_value = ["v0.2.0", "v0.1.0"] mock_cv.side_effect = [None, "0.1.0"] # v0.2.0 non-release, v0.1.0 ok errors = verify_tag_consistency() assert len(errors) == 1 assert "non-release commit" in errors[0] @patch("devx.ci.release.get_commit_version") @patch("devx.ci.release.get_all_tags") def test_first_tag_exempt_from_release_check(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None: """The first (oldest) tag is allowed to point to a non-release commit.""" mock_tags.return_value = ["v0.1.0"] mock_cv.return_value = None # non-release commit errors = verify_tag_consistency() assert errors == [] # no error — first tag is exempt @patch("devx.ci.release.get_commit_version") @patch("devx.ci.release.get_all_tags") def test_tag_version_mismatch(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None: mock_tags.return_value = ["v0.2.0"] mock_cv.return_value = "0.1.0" errors = verify_tag_consistency() assert len(errors) == 1 assert "0.1.0" in errors[0] assert "0.2.0" in errors[0] @patch("devx.ci.release.get_all_tags") def test_no_tags(self, mock_tags: MagicMock) -> None: mock_tags.return_value = [] assert verify_tag_consistency() == [] @patch("devx.ci.release.get_commit_version") @patch("devx.ci.release.get_all_tags") def test_non_version_tags_ignored(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None: """Non-version tags like 'master' should be skipped, not crash.""" mock_tags.return_value = ["v0.2.0", "master", "v0.1.0"] mock_cv.side_effect = ["0.2.0", "0.1.0"] # only version tags get checked errors = verify_tag_consistency() assert errors == [] class TestGetInitVersion: def test_returns_version(self, tmp_path, monkeypatch) -> None: init_file = tmp_path / "__init__.py" init_file.write_text('__version__ = "0.4.4"\n') monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file)) assert get_init_version() == "0.4.4" def test_file_not_found(self, monkeypatch) -> None: monkeypatch.setattr("devx.ci.release.INIT_FILE", "/nonexistent/path/__init__.py") assert get_init_version() is None def test_no_version_string(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)) assert get_init_version() is None class TestGetChangelogVersions: def test_returns_versions(self, tmp_path, monkeypatch) -> None: changelog = tmp_path / "CHANGELOG.md" changelog.write_text( "# Changelog\n\n## [0.4.4] - 2026-06-21\n\n### Features\n- new\n\n" "## [0.4.3] - 2026-06-20\n\n### Fixes\n- fix\n\n## [0.4.2] - 2026-06-19\n" ) monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog)) versions = get_changelog_versions() assert versions == ["0.4.4", "0.4.3", "0.4.2"] def test_file_not_found(self, monkeypatch) -> None: monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", "/nonexistent/CHANGELOG.md") assert get_changelog_versions() == [] class TestVerifyAlignment: @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_all_aligned( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify alignment passes when everything is consistent.""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4", "v0.4.3"] mock_vtc.return_value = [] # no tag errors mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.4.4", "0.4.3"] # run_cmd is called for untagged release commits check mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 0 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_misaligned_tags( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify alignment fails when tags are misaligned.""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [" v0.1.0 → bad"] mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.4.4"] mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 1 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_version_mismatch( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify alignment fails when __version__ != latest tag.""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [] mock_iv.return_value = "0.4.3" # mismatch mock_cv.return_value = ["0.4.4"] mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 1 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_changelog_duplicates( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify alignment fails when CHANGELOG has duplicate versions.""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [] mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.4.4", "0.4.4"] # duplicate mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 1 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_changelog_out_of_order( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify alignment fails when CHANGELOG versions are not descending.""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [] mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.4.3", "0.4.4"] # out of order mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 1 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_changelog_latest_mismatch( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify alignment fails when CHANGELOG latest != latest tag.""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [] mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.4.3"] # doesn't match tag mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 1 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_changelog_unreleased_section( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify passes when CHANGELOG has one unreleased section ahead of tag.""" mock_lt.return_value = "v0.6.3" mock_tags.return_value = ["v0.6.3", "v0.6.2"] mock_vtc.return_value = [] mock_iv.return_value = "0.6.3" mock_cv.return_value = ["0.6.4", "0.6.3"] # 0.6.4 is unreleased mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 0 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_changelog_tag_at_wrong_position( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify fails when latest tag is deep in CHANGELOG (not at position 0 or 1).""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [] mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.5.0", "0.4.5", "0.4.4"] # tag at position 2 mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 1 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_no_latest_tag_skips_changelog_tag_check( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """When there is no latest tag, the CHANGELOG/tag match check is skipped.""" mock_lt.return_value = None # no tags mock_tags.return_value = [] mock_vtc.return_value = [] mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.4.4"] # changelog has versions but no tag to compare mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 0 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_duplicate_release_commits_info( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify reports duplicate release commits as info, not error.""" mock_lt.return_value = "v0.6.1" mock_tags.return_value = ["v0.6.1"] # tag for 0.6.1 exists mock_vtc.return_value = [] mock_iv.return_value = "0.6.1" mock_cv.return_value = ["0.6.1"] # git log finds 2 release commits for v0.6.1, neither has tag pointing at it # (the tag points to a third commit) commits = "abc123 release: v0.6.1 [skip ci]\ndef456 release: v0.6.1 [skip ci]\n" mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout=commits, stderr=""), MagicMock(returncode=0, stdout="", stderr=""), # no tag at abc123 MagicMock(returncode=0, stdout="", stderr=""), # no tag at def456 ] # Should return 0 — duplicates are informational, not errors assert verify_alignment() == 0 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_many_duplicate_release_commits( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify handles >5 duplicate release commits (truncation message).""" mock_lt.return_value = "v0.6.1" mock_tags.return_value = ["v0.6.1"] mock_vtc.return_value = [] mock_iv.return_value = "0.6.1" mock_cv.return_value = ["0.6.1"] # Generate 7 duplicate release commits for v0.6.1 commits = "\n".join(f"abc{i:03d} release: v0.6.1 [skip ci]" for i in range(7)) mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout=commits + "\n", stderr=""), ] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(7)] assert verify_alignment() == 0 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_untagged_release_commits( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify alignment fails when there are untagged release commits.""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [] mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.4.4"] # git log finds release commits, then tag --points-at finds nothing mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="abc123 release: v0.3.0 [skip ci]\n", stderr=""), MagicMock(returncode=0, stdout="", stderr=""), # no tags at abc123 ] assert verify_alignment() == 1 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_no_init_version( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify alignment fails when __version__ is not found.""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [] mock_iv.return_value = None # not found mock_cv.return_value = ["0.4.4"] mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 1 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_all_release_commits_tagged( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify passes when all release commits have tags.""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [] mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.4.4"] # git log finds release commit, tag --points-at finds the tag mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="abc123 release: v0.4.4 [skip ci]\n", stderr=""), MagicMock(returncode=0, stdout="v0.4.4\n", stderr=""), # tag found ] assert verify_alignment() == 0 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_no_release_commits_found( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify handles case with no release commits at all.""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [] mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.4.4"] mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="") assert verify_alignment() == 0 @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @patch("devx.ci.release.verify_tag_consistency") @patch("devx.ci.release.get_all_tags") @patch("devx.ci.release.get_latest_tag") def test_many_untagged_release_commits( self, mock_lt: MagicMock, mock_tags: MagicMock, mock_vtc: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_run_cmd: MagicMock, ) -> None: """Verify handles >10 untagged release commits (truncation message).""" mock_lt.return_value = "v0.4.4" mock_tags.return_value = ["v0.4.4"] mock_vtc.return_value = [] mock_iv.return_value = "0.4.4" mock_cv.return_value = ["0.4.4"] # Generate 15 untagged release commits commits = "\n".join(f"abc{i:03d} release: v0.1.{i} [skip ci]" for i in range(15)) # First call returns all commits, subsequent calls return empty (no tags) mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout=commits + "\n", stderr=""), ] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(15)] assert verify_alignment() == 1 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 def test_no_version_section_in_changelog(self, tmp_path, monkeypatch) -> None: """Changelog input without any ## [ version section is inserted as-is.""" 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)) # No ## [ section in the cliff output — should not be stripped update_changelog("Some raw text without version header") content = changelog_file.read_text() assert "Some raw text without version header" 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", "README.md", "docs/"] 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 [skip ci]"] not in calls class TestUpdateDocVersions: @patch("subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") update_doc_versions("0.33.4") assert mock_run.called @patch("subprocess.run") def test_failure_warns(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="some error") # Should not raise update_doc_versions("0.33.4") assert mock_run.called 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, tmp_path: Path) -> None: github_output = tmp_path / "output.txt" with patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output)}): 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", "refs/tags/v0.2.0"] in calls assert github_output.read_text() == "tag=v0.2.0\n" @patch("devx.ci.release.tag_exists", return_value=False) @patch("devx.ci.release.run_cmd") def test_no_github_output_skips_write(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: with patch.dict(os.environ, {}, clear=True): create_and_push_tag("0.2.0", "changelog", dry_run=False) # Should still create tag, just not write GITHUB_OUTPUT 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 @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.get_head_commit", return_value="abc123") @patch("devx.ci.release.get_tag_commit", return_value="abc123") @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, mock_tag_commit: MagicMock, mock_head_commit: 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", "refs/tags/v0.1.0"] in calls @patch("devx.ci.release.get_head_commit", return_value="def456") @patch("devx.ci.release.get_tag_commit", return_value="abc123") @patch("devx.ci.release.tag_exists", return_value=True) @patch("devx.ci.release.run_cmd") def test_tag_exists_mismatch_raises( self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock, mock_tag_commit: MagicMock, mock_head_commit: MagicMock, ) -> None: """Tag exists but points to different commit than HEAD → error.""" with pytest.raises(click.ClickException, match="misalignment"): create_and_push_tag("0.1.0", "changelog", dry_run=False) @patch("devx.ci.release.get_head_commit", return_value="abc123") @patch("devx.ci.release.get_tag_commit", return_value="abc123") @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, mock_tag_commit: MagicMock, mock_head_commit: MagicMock, ) -> None: result = create_and_push_tag("0.1.0", "changelog", dry_run=True) assert result is False # No push in dry-run when tag exists, but alignment check still runs 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"] 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: """Tests for the main release command. All tests mock fetch_tags and verify_tag_consistency since these are pre-flight checks that call git commands. Tests that need to verify specific git call sequences mock run_cmd with side_effect. """ @patch("devx.ci.release.has_user_facing_changes", return_value=False) @patch("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.run_cmd") def test_not_on_master_exits( self, mock_run_cmd: MagicMock, mock_update_docs: MagicMock, mock_ufc: 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("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.get_latest_tag", return_value="v0.5.0") @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @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, mock_vtc: MagicMock, mock_glt: MagicMock, mock_update_docs: 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("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.get_head_commit", return_value="abc123") @patch("devx.ci.release.get_tag_commit", return_value="abc123") @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @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_and_tag_exists( self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock, mock_tc: MagicMock, mock_hc: MagicMock, mock_update_docs: MagicMock, ) -> None: """If HEAD is a release commit and the tag exists, skip.""" mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""), MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag ] 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("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.get_head_commit", return_value="def456") @patch("devx.ci.release.get_tag_commit", return_value="abc123") @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.run_cmd") def test_release_lock_tag_points_elsewhere( self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock, mock_tc: MagicMock, mock_hc: MagicMock, mock_update_docs: MagicMock, ) -> None: """If HEAD is a release commit but tag points elsewhere, error.""" mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""), MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag ] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code != 0 assert "misalignment" in result.output @patch("devx.ci.release.has_user_facing_changes", return_value=False) @patch("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @patch("devx.ci.release.get_changelog", return_value="## changelog") @patch("devx.ci.release.create_and_push_tag", return_value=True) @patch("devx.ci.release.run_cmd") def test_release_lock_recovers_when_tag_missing( self, mock_run_cmd: MagicMock, mock_create_tag: MagicMock, mock_changelog: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock, mock_update_docs: MagicMock, mock_ufc: MagicMock, ) -> None: """If HEAD is a release commit but the tag is missing, create the tag.""" mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""), MagicMock(returncode=0, stdout="", stderr=""), # tag -l finds nothing ] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "tag v0.5.0 is missing" in result.output assert "Recovering" in result.output mock_create_tag.assert_called_once_with("0.5.0", "## changelog", False) @patch("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @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.get_latest_tag", return_value="v0.1.0") @patch("devx.ci.release.run_cmd") def test_no_unreleased_changes( self, mock_run_cmd: MagicMock, mock_latest: MagicMock, mock_bumped: MagicMock, mock_has: MagicMock, mock_user: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock, mock_update_docs: 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("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @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_fails( 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, mock_vtc: MagicMock, mock_update_docs: MagicMock, ) -> None: """Empty changelog should fail, not warn.""" 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.lower() @patch("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @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, mock_vtc: MagicMock, mock_update_docs: 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("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @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, mock_ft: MagicMock, mock_vtc: MagicMock, mock_update_docs: MagicMock, ) -> None: """Release is skipped when only workflow/infrastructure 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("devx.ci.release.run_tests") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @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.update_doc_versions") @patch("devx.ci.release.run_cmd") def test_full_flow( self, mock_run_cmd: MagicMock, mock_update_docs: 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, mock_ft: MagicMock, mock_vtc: MagicMock, mock_run_tests: 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.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @patch("devx.ci.release.has_user_facing_changes", return_value=True) @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.2.0") @patch("devx.ci.release.has_unreleased_changes", return_value=True) @patch("devx.ci.release.update_doc_versions") @patch("devx.ci.release.run_cmd") def test_full_flow_tag_exists( self, mock_run_cmd: MagicMock, mock_update_docs: 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, mock_ft: MagicMock, mock_vtc: 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.2.0", "changelog", False) @patch("devx.ci.release.run_tests") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @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.update_doc_versions") @patch("devx.ci.release.time.sleep") @patch("devx.ci.release.run_cmd") def test_push_retry_succeeds_after_rebase_failure( self, mock_run_cmd: MagicMock, mock_sleep: MagicMock, mock_update_docs: 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, mock_ft: MagicMock, mock_vtc: MagicMock, mock_run_tests: MagicMock, ) -> None: """Push should retry after rebase failure and succeed on second attempt.""" ok = MagicMock(returncode=0, stdout="master\n", stderr="") rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict") rebase_abort = MagicMock(returncode=0, stdout="", stderr="") push_ok = MagicMock(returncode=0, stdout="", stderr="") # git rev-parse → ok, git log -1 → ok (non-release msg) # pull --rebase → fail, rebase --abort → ok # pull --rebase → ok, push → ok mock_run_cmd.side_effect = [ok, ok, rebase_fail, rebase_abort, ok, push_ok] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "Rebase attempt 1/3 failed" in result.output assert "Pushed release commit to master" in result.output @patch("devx.ci.release.run_tests") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @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.update_doc_versions") @patch("devx.ci.release.time.sleep") @patch("devx.ci.release.run_cmd") def test_push_fails_after_all_retries( self, mock_run_cmd: MagicMock, mock_sleep: MagicMock, mock_update_docs: 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, mock_ft: MagicMock, mock_vtc: MagicMock, mock_run_tests: MagicMock, ) -> None: """Push should fail after 3 unsuccessful rebase attempts.""" ok = MagicMock(returncode=0, stdout="master\n", stderr="") rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict") rebase_abort = MagicMock(returncode=0, stdout="", stderr="") # git rev-parse → ok, git log -1 → ok # 3 attempts: pull --rebase → fail, rebase --abort → ok mock_run_cmd.side_effect = [ ok, ok, rebase_fail, rebase_abort, # attempt 1 rebase_fail, rebase_abort, # attempt 2 rebase_fail, rebase_abort, # attempt 3 ] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code != 0 assert "Failed to push release commit after 3 attempts" in result.output @patch("devx.ci.release.run_tests") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @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.update_doc_versions") @patch("devx.ci.release.time.sleep") @patch("devx.ci.release.run_cmd") def test_push_retry_succeeds_after_push_failure( self, mock_run_cmd: MagicMock, mock_sleep: MagicMock, mock_update_docs: 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, mock_ft: MagicMock, mock_vtc: MagicMock, mock_run_tests: MagicMock, ) -> None: """Push should retry after push rejection and succeed on second attempt.""" ok = MagicMock(returncode=0, stdout="master\n", stderr="") rebase_ok = MagicMock(returncode=0, stdout="", stderr="") push_fail = MagicMock(returncode=1, stdout="", stderr="non-fast-forward") push_ok = MagicMock(returncode=0, stdout="", stderr="") # git rev-parse → ok, git log -1 → ok # attempt 1: pull --rebase → ok, push → fail # attempt 2: pull --rebase → ok, push → ok mock_run_cmd.side_effect = [ok, ok, rebase_ok, push_fail, rebase_ok, push_ok] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "Push attempt 1/3 failed" in result.output assert "Pushed release commit to master" in result.output @patch("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @patch("devx.ci.release.has_user_facing_changes", return_value=True) @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_skips_when_version_doesnt_bump( self, mock_run_cmd: MagicMock, mock_has: MagicMock, mock_bumped: MagicMock, mock_latest: MagicMock, mock_user: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock, mock_update_docs: MagicMock, ) -> None: """Release is skipped when git-cliff doesn't bump the version.""" 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 version bump" in result.output assert "Skipping" in result.output @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @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.update_doc_versions") @patch("devx.ci.release.run_cmd") def test_full_flow_skip_tests( self, mock_run_cmd: MagicMock, mock_update_docs: 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, mock_ft: MagicMock, mock_vtc: 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.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @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.update_doc_versions") @patch("devx.ci.release.run_cmd") def test_tests_fail_aborts_before_tag( self, mock_run_cmd: MagicMock, mock_update_docs: 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, mock_ft: MagicMock, mock_vtc: 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.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags") @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.update_doc_versions") @patch("devx.ci.release.run_cmd") def test_lint_fail_aborts_before_tag( self, mock_run_cmd: MagicMock, mock_update_docs: 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, mock_ft: MagicMock, mock_vtc: 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() @patch("devx.ci.release.has_user_facing_changes", return_value=False) @patch("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.get_changelog_versions", return_value=[]) @patch("devx.ci.release.get_init_version", return_value="0.1.0") @patch("devx.ci.release.get_all_tags", return_value=[]) @patch("devx.ci.release.get_latest_tag", return_value="") @patch("devx.ci.release.run_cmd") def test_verify_mode_no_tags( self, mock_run_cmd: MagicMock, mock_lt: MagicMock, mock_tags: MagicMock, mock_iv: MagicMock, mock_cv: MagicMock, mock_update_docs: MagicMock, mock_ufc: MagicMock, ) -> None: """--verify checks alignment and exits without releasing.""" mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") runner = CliRunner() result = runner.invoke(main, ["--verify"]) assert result.exit_code == 0 assert "Release Alignment Verification" in result.output @patch("devx.ci.release.has_user_facing_changes", return_value=False) @patch("devx.ci.release.update_doc_versions") @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[" v0.1.0 → bad"]) @patch("devx.ci.release.fetch_tags") @patch("devx.ci.release.run_cmd") def test_preflight_tag_consistency_fails( self, mock_run_cmd: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock, mock_update_docs: MagicMock, mock_ufc: MagicMock, ) -> None: """Pre-flight tag consistency check aborts if tags are misaligned.""" mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code != 0 assert "Tag consistency check failed" in result.output