DEVX-10: feat: add tag verification, idempotency, and --verify mode to release script
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 1m0s

This commit is contained in:
2026-06-23 01:28:27 +00:00
parent 37772f21a9
commit 19bec24f45
10 changed files with 1594 additions and 174 deletions
+7
View File
@@ -166,6 +166,13 @@ class TestToolsCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.generate_badges", [])
@patch("devx.cli._run_module")
def test_tools_generate_cliff_config(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "generate-cliff-config"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.generate_cliff_config", [])
@patch("devx.cli._run_module")
def test_tools_install_checkmake(self, mock_run: MagicMock) -> None:
runner = CliRunner()
+124
View File
@@ -0,0 +1,124 @@
"""Tests for devx.tools.generate_cliff_config."""
from __future__ import annotations
import tomllib
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from devx.tools.generate_cliff_config import main
class TestGenerateCliffConfig:
"""Tests for the generate_cliff_config tool."""
@pytest.fixture
def runner(self) -> CliRunner:
return CliRunner()
def test_generate_to_new_file(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generate cliff.toml to a new file."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
assert output.exists()
content = output.read_text()
assert "git-cliff configuration for GRM" in content
assert 'pattern = "^GRM-\\\\d+:\\\\s+"' in content
def test_generate_with_default_prefix(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generate with default prefix (DEVX_TASK_PREFIX or 'DEVX')."""
output = tmp_path / "cliff.toml"
with patch("devx.tools.generate_cliff_config.TASK_PREFIX", "DEVX"):
result = runner.invoke(main, ["--output", str(output)])
assert result.exit_code == 0
content = output.read_text()
assert "git-cliff configuration for DEVX" in content
def test_existing_file_without_force(self, runner: CliRunner, tmp_path: Path) -> None:
"""Refuse to overwrite existing file without --force."""
output = tmp_path / "cliff.toml"
output.write_text("# existing")
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code != 0
assert "already exists" in result.output
assert output.read_text() == "# existing"
def test_existing_file_with_force(self, runner: CliRunner, tmp_path: Path) -> None:
"""Overwrite existing file with --force."""
output = tmp_path / "cliff.toml"
output.write_text("# existing")
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output), "--force"])
assert result.exit_code == 0
content = output.read_text()
assert "git-cliff configuration for GRM" in content
assert "# existing" not in content
def test_generated_config_is_valid_toml(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generated config must be valid TOML."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
assert "changelog" in data
assert "git" in data
assert "bump" in data
assert data["bump"]["initial_tag"] == "0.1.0"
assert data["bump"]["features_always_bump_minor"] is True
def test_generated_config_has_correct_preprocessor(self, runner: CliRunner, tmp_path: Path) -> None:
"""Preprocessor pattern must match the given prefix."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "INFRA", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
preprocessors = data["git"]["commit_preprocessors"]
assert len(preprocessors) == 1
pattern = preprocessors[0]["pattern"]
assert "INFRA" in pattern
def test_generated_config_has_commit_parsers(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generated config must have all standard commit parsers."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
parsers = data["git"]["commit_parsers"]
# Should have feat, fix, perf, refactor, doc, test, style, chore, ci, release, security, revert, catch-all
messages = [p["message"] for p in parsers if "message" in p]
assert "^feat" in messages
assert "^fix" in messages
assert "^perf" in messages
assert "^refactor" in messages
assert "^release:" in messages
assert "^revert" in messages
assert ".*" in messages # catch-all
def test_default_output_path(self, runner: CliRunner, tmp_path: Path) -> None:
"""Default output path is cliff.toml in current directory."""
output = tmp_path / "cliff.toml"
# Change to tmp_path so default cliff.toml is created there
import os
old_cwd = os.getcwd()
os.chdir(tmp_path)
try:
result = runner.invoke(main, ["--prefix", "GRM"])
assert result.exit_code == 0
assert output.exists()
finally:
os.chdir(old_cwd)
def test_success_message(self, runner: CliRunner, tmp_path: Path) -> None:
"""Success message includes file and prefix."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
assert "Generated" in result.output
assert "GRM" in result.output
+698 -9
View File
@@ -9,9 +9,16 @@ 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,
@@ -19,6 +26,8 @@ from devx.ci.release import (
tag_exists,
update_changelog,
update_init_version,
verify_alignment,
verify_tag_consistency,
)
@@ -156,6 +165,534 @@ class TestUpdateInitVersion:
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() == []
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_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"
@@ -250,9 +787,17 @@ class TestCreateAndPushTag:
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) -> None:
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
@@ -260,13 +805,38 @@ class TestCreateAndPushTag:
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
assert ["git", "push", "origin", "v0.1.0"] in calls
@patch("devx.ci.release.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_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
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 git commands at all in dry-run when tag exists
mock_run_cmd.assert_not_called()
# 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:
@@ -302,6 +872,13 @@ class TestRunTests:
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:
@@ -312,9 +889,12 @@ class TestMain:
assert "master" 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=False)
@patch("devx.ci.release.run_cmd")
def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
def test_dry_run_on_non_master_warns(
self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_vtc: 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()
@@ -323,13 +903,22 @@ class TestMain:
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
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."""
# git rev-parse, git log -1, git tag -l (tag exists)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
@@ -342,14 +931,47 @@ class TestMain:
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
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."""
# git rev-parse, git log -1, git tag -l (tag NOT found)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
@@ -363,6 +985,8 @@ class TestMain:
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")
@@ -373,6 +997,8 @@ class TestMain:
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()
@@ -381,6 +1007,7 @@ class TestMain:
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")
@@ -403,6 +1030,7 @@ class TestMain:
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="")
@@ -412,6 +1040,7 @@ class TestMain:
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")
@@ -434,6 +1063,7 @@ class TestMain:
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()
@@ -446,6 +1076,8 @@ class TestMain:
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")
@@ -454,6 +1086,8 @@ class TestMain:
mock_run_cmd: MagicMock,
mock_user_facing: MagicMock,
mock_latest: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Release is skipped when only workflow/infra files changed."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
@@ -464,6 +1098,8 @@ class TestMain:
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)
@@ -488,6 +1124,8 @@ class TestMain:
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()
@@ -501,6 +1139,8 @@ class TestMain:
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)
@@ -525,6 +1165,8 @@ class TestMain:
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="")
@@ -535,6 +1177,8 @@ class TestMain:
mock_tag.assert_called_once_with("0.1.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=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@@ -557,6 +1201,8 @@ class TestMain:
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="")
@@ -569,6 +1215,8 @@ class TestMain:
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")
@@ -591,6 +1239,8 @@ class TestMain:
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),
@@ -609,6 +1259,8 @@ class TestMain:
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")
@@ -631,6 +1283,8 @@ class TestMain:
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),
@@ -646,3 +1300,38 @@ class TestMain:
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