Public Access
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / vikunja (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 50s
Post-merge / publish (push) Successful in 32s
Post-merge / badges (push) Failing after 36s
- Fix badge system: clean .badges dir from orphan branch, add version verification, make badges job depend on release (avoids stale version badge race condition) - Add check_doc_versions.py: lint tool that verifies docs version references match current __version__, with --fix for auto-update - Integrate check_doc_versions into release process (auto-updates docs on every release commit) - Add Vale prose linter integration: .vale.ini, custom styles for terminology and code block language, CI step, make target - Fix stale version references in docs (0.27.0 → 0.33.4) - Fix e.g. → for example in docs (Google.Latin Vale rule) - Add CI steps for check_doc_versions and Vale to quality workflow - Add make targets: devx-check-doc-versions, devx-vale Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1585 lines
67 KiB
Python
1585 lines
67 KiB
Python
"""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.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.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
|
|
) -> 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.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,
|
|
) -> 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.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,
|
|
) -> 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.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,
|
|
) -> 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.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.run_cmd")
|
|
def test_no_unreleased_changes(
|
|
self,
|
|
mock_run_cmd: MagicMock,
|
|
mock_bumped: MagicMock,
|
|
mock_has: MagicMock,
|
|
mock_user: MagicMock,
|
|
mock_ft: MagicMock,
|
|
mock_vtc: 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.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,
|
|
) -> 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.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,
|
|
) -> 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.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,
|
|
) -> 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.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.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,
|
|
mock_ft: MagicMock,
|
|
mock_vtc: 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.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.2.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,
|
|
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.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.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.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_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,
|
|
mock_ft: MagicMock,
|
|
mock_vtc: 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.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.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.time.sleep")
|
|
@patch("devx.ci.release.run_cmd")
|
|
def test_push_fails_after_all_retries(
|
|
self,
|
|
mock_run_cmd: MagicMock,
|
|
mock_sleep: 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,
|
|
mock_ft: MagicMock,
|
|
mock_vtc: 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.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.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.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_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,
|
|
mock_ft: MagicMock,
|
|
mock_vtc: 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.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,
|
|
) -> 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.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,
|
|
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.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,
|
|
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.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,
|
|
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.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,
|
|
) -> 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.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
|
|
) -> 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
|