Release version 0.7.0

This commit is contained in:
Kevin Veen-Birkenbach
2025-12-09 15:21:06 +01:00
parent e00b1a7b69
commit 44ff0a6cd9
16 changed files with 1210 additions and 205 deletions

View File

@@ -133,6 +133,36 @@ class TestIntegrationReleaseCommand(unittest.TestCase):
"close must be True when --close is given",
)
def test_release_help_runs_without_error(self) -> None:
"""
Running `pkgmgr release --help` should succeed without touching the
release helper and print a usage message for the release subcommand.
This test intentionally does not mock anything to exercise the real
CLI parser wiring in main.py.
"""
import io
import contextlib
original_argv = list(sys.argv)
buf = io.StringIO()
try:
sys.argv = ["pkgmgr", "release", "--help"]
# argparse will call sys.exit(), so we expect a SystemExit here.
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
with self.assertRaises(SystemExit) as cm:
runpy.run_module("main", run_name="__main__")
finally:
sys.argv = original_argv
# Help exit code is usually 0 (or sometimes None, which also means "no error")
self.assertIn(cm.exception.code, (0, None))
output = buf.getvalue()
# Sanity checks: release help text should be present
self.assertIn("usage:", output)
self.assertIn("pkgmgr release", output)
if __name__ == "__main__":
unittest.main()

View File

@@ -6,70 +6,16 @@ import textwrap
import unittest
from unittest.mock import patch
from pkgmgr.core.version.semver import SemVer
from pkgmgr.actions.release import (
_determine_current_version,
_bump_semver,
from pkgmgr.actions.release.files import (
update_pyproject_version,
update_flake_version,
update_pkgbuild_version,
update_spec_version,
update_changelog,
update_debian_changelog,
release,
)
class TestDetermineCurrentVersion(unittest.TestCase):
@patch("pkgmgr.actions.release.get_tags", return_value=[])
def test_determine_current_version_no_tags_returns_zero(
self,
mock_get_tags,
) -> None:
ver = _determine_current_version()
self.assertIsInstance(ver, SemVer)
self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0))
mock_get_tags.assert_called_once()
@patch("pkgmgr.actions.release.find_latest_version")
@patch("pkgmgr.actions.release.get_tags")
def test_determine_current_version_uses_latest_semver_tag(
self,
mock_get_tags,
mock_find_latest_version,
) -> None:
mock_get_tags.return_value = ["v0.1.0", "v1.2.3"]
mock_find_latest_version.return_value = ("v1.2.3", SemVer(1, 2, 3))
ver = _determine_current_version()
self.assertEqual((ver.major, ver.minor, ver.patch), (1, 2, 3))
mock_get_tags.assert_called_once()
mock_find_latest_version.assert_called_once_with(["v0.1.0", "v1.2.3"])
class TestBumpSemVer(unittest.TestCase):
def test_bump_semver_major(self) -> None:
base = SemVer(1, 2, 3)
bumped = _bump_semver(base, "major")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (2, 0, 0))
def test_bump_semver_minor(self) -> None:
base = SemVer(1, 2, 3)
bumped = _bump_semver(base, "minor")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 3, 0))
def test_bump_semver_patch(self) -> None:
base = SemVer(1, 2, 3)
bumped = _bump_semver(base, "patch")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 2, 4))
def test_bump_semver_invalid_type_raises(self) -> None:
base = SemVer(1, 2, 3)
with self.assertRaises(ValueError):
_bump_semver(base, "invalid-type")
class TestUpdatePyprojectVersion(unittest.TestCase):
def test_update_pyproject_version_replaces_version_line(self) -> None:
original = textwrap.dedent(
@@ -364,151 +310,5 @@ class TestUpdateDebianChangelog(unittest.TestCase):
self.assertEqual(content, original)
class TestReleaseOrchestration(unittest.TestCase):
@patch("pkgmgr.actions.release.sys.stdin.isatty", return_value=False)
@patch("pkgmgr.actions.release._run_git_command")
@patch("pkgmgr.actions.release.update_debian_changelog")
@patch("pkgmgr.actions.release.update_spec_version")
@patch("pkgmgr.actions.release.update_pkgbuild_version")
@patch("pkgmgr.actions.release.update_flake_version")
@patch("pkgmgr.actions.release.get_current_branch", return_value="develop")
@patch("pkgmgr.actions.release.update_changelog")
@patch("pkgmgr.actions.release.update_pyproject_version")
@patch("pkgmgr.actions.release._bump_semver")
@patch("pkgmgr.actions.release._determine_current_version")
def test_release_happy_path_uses_helpers_and_git(
self,
mock_determine_current_version,
mock_bump_semver,
mock_update_pyproject,
mock_update_changelog,
mock_get_current_branch,
mock_update_flake,
mock_update_pkgbuild,
mock_update_spec,
mock_update_debian_changelog,
mock_run_git_command,
mock_isatty,
) -> None:
mock_determine_current_version.return_value = SemVer(1, 2, 3)
mock_bump_semver.return_value = SemVer(1, 2, 4)
release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type="patch",
message="Test release",
preview=False,
)
# Current version + bump
mock_determine_current_version.assert_called_once()
mock_bump_semver.assert_called_once()
args, kwargs = mock_bump_semver.call_args
self.assertEqual(args[0], SemVer(1, 2, 3))
self.assertEqual(args[1], "patch")
self.assertEqual(kwargs, {})
# pyproject update
mock_update_pyproject.assert_called_once()
args, kwargs = mock_update_pyproject.call_args
self.assertEqual(args[0], "pyproject.toml")
self.assertEqual(args[1], "1.2.4")
self.assertEqual(kwargs.get("preview"), False)
# changelog update
mock_update_changelog.assert_called_once()
args, kwargs = mock_update_changelog.call_args
self.assertEqual(args[0], "CHANGELOG.md")
self.assertEqual(args[1], "1.2.4")
self.assertEqual(kwargs.get("message"), "Test release")
self.assertEqual(kwargs.get("preview"), False)
# repo root is derived from pyproject path; we don't care about
# exact paths here, only that helpers are called with preview=False.
mock_update_flake.assert_called_once()
self.assertEqual(mock_update_flake.call_args[1].get("preview"), False)
mock_update_pkgbuild.assert_called_once()
self.assertEqual(mock_update_pkgbuild.call_args[1].get("preview"), False)
mock_update_spec.assert_called_once()
self.assertEqual(mock_update_spec.call_args[1].get("preview"), False)
mock_update_debian_changelog.assert_called_once()
self.assertEqual(
mock_update_debian_changelog.call_args[1].get("preview"),
False,
)
# Git operations
mock_get_current_branch.assert_called_once()
self.assertEqual(mock_get_current_branch.return_value, "develop")
git_calls = [c.args[0] for c in mock_run_git_command.call_args_list]
self.assertIn('git commit -am "Release version 1.2.4"', git_calls)
self.assertIn('git tag -a v1.2.4 -m "Test release"', git_calls)
self.assertIn("git push origin develop", git_calls)
self.assertIn("git push origin --tags", git_calls)
@patch("pkgmgr.actions.release.sys.stdin.isatty", return_value=False)
@patch("pkgmgr.actions.release._run_git_command")
@patch("pkgmgr.actions.release.update_debian_changelog")
@patch("pkgmgr.actions.release.update_spec_version")
@patch("pkgmgr.actions.release.update_pkgbuild_version")
@patch("pkgmgr.actions.release.update_flake_version")
@patch("pkgmgr.actions.release.get_current_branch", return_value="develop")
@patch("pkgmgr.actions.release.update_changelog")
@patch("pkgmgr.actions.release.update_pyproject_version")
@patch("pkgmgr.actions.release._bump_semver")
@patch("pkgmgr.actions.release._determine_current_version")
def test_release_preview_mode_skips_git_and_uses_preview_flag(
self,
mock_determine_current_version,
mock_bump_semver,
mock_update_pyproject,
mock_update_changelog,
mock_get_current_branch,
mock_update_flake,
mock_update_pkgbuild,
mock_update_spec,
mock_update_debian_changelog,
mock_run_git_command,
mock_isatty,
) -> None:
mock_determine_current_version.return_value = SemVer(1, 2, 3)
mock_bump_semver.return_value = SemVer(1, 2, 4)
release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type="patch",
message="Preview release",
preview=True,
)
# All update helpers must be called with preview=True
mock_update_pyproject.assert_called_once()
self.assertTrue(mock_update_pyproject.call_args[1].get("preview"))
mock_update_changelog.assert_called_once()
self.assertTrue(mock_update_changelog.call_args[1].get("preview"))
mock_update_flake.assert_called_once()
self.assertTrue(mock_update_flake.call_args[1].get("preview"))
mock_update_pkgbuild.assert_called_once()
self.assertTrue(mock_update_pkgbuild.call_args[1].get("preview"))
mock_update_spec.assert_called_once()
self.assertTrue(mock_update_spec.call_args[1].get("preview"))
mock_update_debian_changelog.assert_called_once()
self.assertTrue(mock_update_debian_changelog.call_args[1].get("preview"))
# In preview mode no git commands must be executed
mock_run_git_command.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from pkgmgr.core.git import GitError
from pkgmgr.actions.release.git_ops import (
run_git_command,
sync_branch_with_remote,
update_latest_tag,
)
class TestRunGitCommand(unittest.TestCase):
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
def test_run_git_command_success(self, mock_run) -> None:
# No exception means success
run_git_command("git status")
mock_run.assert_called_once()
args, kwargs = mock_run.call_args
self.assertIn("git status", args[0])
self.assertTrue(kwargs.get("check"))
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
def test_run_git_command_failure_raises_git_error(self, mock_run) -> None:
from subprocess import CalledProcessError
mock_run.side_effect = CalledProcessError(
returncode=1,
cmd="git status",
output="stdout",
stderr="stderr",
)
with self.assertRaises(GitError):
run_git_command("git status")
class TestSyncBranchWithRemote(unittest.TestCase):
@patch("pkgmgr.actions.release.git_ops.run_git_command")
def test_sync_branch_with_remote_skips_non_main_master(
self,
mock_run_git_command,
) -> None:
sync_branch_with_remote("feature/my-branch", preview=False)
mock_run_git_command.assert_not_called()
@patch("pkgmgr.actions.release.git_ops.run_git_command")
def test_sync_branch_with_remote_preview_on_main_does_not_run_git(
self,
mock_run_git_command,
) -> None:
sync_branch_with_remote("main", preview=True)
mock_run_git_command.assert_not_called()
@patch("pkgmgr.actions.release.git_ops.run_git_command")
def test_sync_branch_with_remote_main_runs_fetch_and_pull(
self,
mock_run_git_command,
) -> None:
sync_branch_with_remote("main", preview=False)
calls = [c.args[0] for c in mock_run_git_command.call_args_list]
self.assertIn("git fetch origin", calls)
self.assertIn("git pull origin main", calls)
class TestUpdateLatestTag(unittest.TestCase):
@patch("pkgmgr.actions.release.git_ops.run_git_command")
def test_update_latest_tag_preview_does_not_call_git(
self,
mock_run_git_command,
) -> None:
update_latest_tag("v1.2.3", preview=True)
mock_run_git_command.assert_not_called()
@patch("pkgmgr.actions.release.git_ops.run_git_command")
def test_update_latest_tag_real_calls_git(
self,
mock_run_git_command,
) -> None:
update_latest_tag("v1.2.3", preview=False)
calls = [c.args[0] for c in mock_run_git_command.call_args_list]
self.assertIn("git tag -f latest v1.2.3", calls)
self.assertIn("git push origin latest --force", calls)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from pkgmgr.core.version.semver import SemVer
from pkgmgr.actions.release import release
class TestReleaseOrchestration(unittest.TestCase):
def test_release_happy_path_uses_helpers_and_git(self) -> None:
with patch("pkgmgr.actions.release.sys.stdin.isatty", return_value=False), \
patch("pkgmgr.actions.release.determine_current_version") as mock_determine_current_version, \
patch("pkgmgr.actions.release.bump_semver") as mock_bump_semver, \
patch("pkgmgr.actions.release.update_pyproject_version") as mock_update_pyproject, \
patch("pkgmgr.actions.release.update_changelog") as mock_update_changelog, \
patch("pkgmgr.actions.release.get_current_branch", return_value="develop") as mock_get_current_branch, \
patch("pkgmgr.actions.release.update_flake_version") as mock_update_flake, \
patch("pkgmgr.actions.release.update_pkgbuild_version") as mock_update_pkgbuild, \
patch("pkgmgr.actions.release.update_spec_version") as mock_update_spec, \
patch("pkgmgr.actions.release.update_debian_changelog") as mock_update_debian_changelog, \
patch("pkgmgr.actions.release.run_git_command") as mock_run_git_command, \
patch("pkgmgr.actions.release.sync_branch_with_remote") as mock_sync_branch, \
patch("pkgmgr.actions.release.update_latest_tag") as mock_update_latest_tag:
mock_determine_current_version.return_value = SemVer(1, 2, 3)
mock_bump_semver.return_value = SemVer(1, 2, 4)
release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type="patch",
message="Test release",
preview=False,
)
# Current version + bump
mock_determine_current_version.assert_called_once()
mock_bump_semver.assert_called_once()
args, kwargs = mock_bump_semver.call_args
self.assertEqual(args[0], SemVer(1, 2, 3))
self.assertEqual(args[1], "patch")
self.assertEqual(kwargs, {})
# pyproject update
mock_update_pyproject.assert_called_once()
args, kwargs = mock_update_pyproject.call_args
self.assertEqual(args[0], "pyproject.toml")
self.assertEqual(args[1], "1.2.4")
self.assertEqual(kwargs.get("preview"), False)
# changelog update
mock_update_changelog.assert_called_once()
args, kwargs = mock_update_changelog.call_args
self.assertEqual(args[0], "CHANGELOG.md")
self.assertEqual(args[1], "1.2.4")
self.assertEqual(kwargs.get("message"), "Test release")
self.assertEqual(kwargs.get("preview"), False)
# Additional packaging helpers called with preview=False
mock_update_flake.assert_called_once()
self.assertEqual(mock_update_flake.call_args[1].get("preview"), False)
mock_update_pkgbuild.assert_called_once()
self.assertEqual(mock_update_pkgbuild.call_args[1].get("preview"), False)
mock_update_spec.assert_called_once()
self.assertEqual(mock_update_spec.call_args[1].get("preview"), False)
mock_update_debian_changelog.assert_called_once()
self.assertEqual(
mock_update_debian_changelog.call_args[1].get("preview"),
False,
)
# Git operations
mock_get_current_branch.assert_called_once()
self.assertEqual(mock_get_current_branch.return_value, "develop")
git_calls = [c.args[0] for c in mock_run_git_command.call_args_list]
self.assertIn('git commit -am "Release version 1.2.4"', git_calls)
self.assertIn('git tag -a v1.2.4 -m "Test release"', git_calls)
self.assertIn("git push origin develop", git_calls)
self.assertIn("git push origin --tags", git_calls)
# Branch sync & latest tag update
mock_sync_branch.assert_called_once_with("develop", preview=False)
mock_update_latest_tag.assert_called_once_with("v1.2.4", preview=False)
def test_release_preview_mode_skips_git_and_uses_preview_flag(self) -> None:
with patch("pkgmgr.actions.release.determine_current_version") as mock_determine_current_version, \
patch("pkgmgr.actions.release.bump_semver") as mock_bump_semver, \
patch("pkgmgr.actions.release.update_pyproject_version") as mock_update_pyproject, \
patch("pkgmgr.actions.release.update_changelog") as mock_update_changelog, \
patch("pkgmgr.actions.release.get_current_branch", return_value="develop") as mock_get_current_branch, \
patch("pkgmgr.actions.release.update_flake_version") as mock_update_flake, \
patch("pkgmgr.actions.release.update_pkgbuild_version") as mock_update_pkgbuild, \
patch("pkgmgr.actions.release.update_spec_version") as mock_update_spec, \
patch("pkgmgr.actions.release.update_debian_changelog") as mock_update_debian_changelog, \
patch("pkgmgr.actions.release.run_git_command") as mock_run_git_command, \
patch("pkgmgr.actions.release.sync_branch_with_remote") as mock_sync_branch, \
patch("pkgmgr.actions.release.update_latest_tag") as mock_update_latest_tag:
mock_determine_current_version.return_value = SemVer(1, 2, 3)
mock_bump_semver.return_value = SemVer(1, 2, 4)
release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type="patch",
message="Preview release",
preview=True,
)
# All update helpers must be called with preview=True
mock_update_pyproject.assert_called_once()
self.assertTrue(mock_update_pyproject.call_args[1].get("preview"))
mock_update_changelog.assert_called_once()
self.assertTrue(mock_update_changelog.call_args[1].get("preview"))
mock_update_flake.assert_called_once()
self.assertTrue(mock_update_flake.call_args[1].get("preview"))
mock_update_pkgbuild.assert_called_once()
self.assertTrue(mock_update_pkgbuild.call_args[1].get("preview"))
mock_update_spec.assert_called_once()
self.assertTrue(mock_update_spec.call_args[1].get("preview"))
mock_update_debian_changelog.assert_called_once()
self.assertTrue(mock_update_debian_changelog.call_args[1].get("preview"))
# In preview mode no real git commands must be executed
mock_run_git_command.assert_not_called()
# Branch sync is still invoked (with preview=True internally),
# and latest tag is only announced in preview mode
mock_sync_branch.assert_called_once_with("develop", preview=True)
mock_update_latest_tag.assert_called_once_with("v1.2.4", preview=True)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from pkgmgr.core.version.semver import SemVer
from pkgmgr.actions.release.versioning import (
determine_current_version,
bump_semver,
)
class TestDetermineCurrentVersion(unittest.TestCase):
@patch("pkgmgr.actions.release.versioning.get_tags", return_value=[])
def test_determine_current_version_no_tags_returns_zero(
self,
mock_get_tags,
) -> None:
ver = determine_current_version()
self.assertIsInstance(ver, SemVer)
self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0))
mock_get_tags.assert_called_once()
@patch("pkgmgr.actions.release.versioning.find_latest_version")
@patch("pkgmgr.actions.release.versioning.get_tags")
def test_determine_current_version_uses_latest_semver_tag(
self,
mock_get_tags,
mock_find_latest_version,
) -> None:
mock_get_tags.return_value = ["v0.1.0", "v1.2.3"]
mock_find_latest_version.return_value = ("v1.2.3", SemVer(1, 2, 3))
ver = determine_current_version()
self.assertEqual((ver.major, ver.minor, ver.patch), (1, 2, 3))
mock_get_tags.assert_called_once()
mock_find_latest_version.assert_called_once_with(["v0.1.0", "v1.2.3"])
class TestBumpSemVer(unittest.TestCase):
def test_bump_semver_major(self) -> None:
base = SemVer(1, 2, 3)
bumped = bump_semver(base, "major")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (2, 0, 0))
def test_bump_semver_minor(self) -> None:
base = SemVer(1, 2, 3)
bumped = bump_semver(base, "minor")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 3, 0))
def test_bump_semver_patch(self) -> None:
base = SemVer(1, 2, 3)
bumped = bump_semver(base, "patch")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 2, 4))
def test_bump_semver_invalid_type_raises(self) -> None:
base = SemVer(1, 2, 3)
with self.assertRaises(ValueError):
bump_semver(base, "invalid-type")
if __name__ == "__main__":
unittest.main()