From 44ff0a6cd9e4fd2282ade6148e304274cef17b66 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 9 Dec 2025 15:21:06 +0100 Subject: [PATCH] Release version 0.7.0 --- CHANGELOG.md | 5 + PKGBUILD | 2 +- debian/changelog | 6 + flake.nix | 2 +- package-manager.spec | 2 +- pkgmgr/actions/release/__init__.py | 289 ++++++++++++ pkgmgr/actions/release/files.py | 444 ++++++++++++++++++ pkgmgr/actions/release/git_ops.py | 82 ++++ pkgmgr/actions/release/versioning.py | 53 +++ pyproject.toml | 2 +- tests/e2e/test_release_commands.py | 30 ++ tests/unit/pkgmgr/actions/release/__init__.py | 0 .../test_files.py} | 202 +------- .../pkgmgr/actions/release/test_git_ops.py | 90 ++++ .../release/test_release_orchestration.py | 142 ++++++ .../pkgmgr/actions/release/test_versioning.py | 64 +++ 16 files changed, 1210 insertions(+), 205 deletions(-) create mode 100644 pkgmgr/actions/release/__init__.py create mode 100644 pkgmgr/actions/release/files.py create mode 100644 pkgmgr/actions/release/git_ops.py create mode 100644 pkgmgr/actions/release/versioning.py create mode 100644 tests/unit/pkgmgr/actions/release/__init__.py rename tests/unit/pkgmgr/actions/{test_release.py => release/test_files.py} (56%) create mode 100644 tests/unit/pkgmgr/actions/release/test_git_ops.py create mode 100644 tests/unit/pkgmgr/actions/release/test_release_orchestration.py create mode 100644 tests/unit/pkgmgr/actions/release/test_versioning.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f84d3e..40c6a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [0.7.0] - 2025-12-09 + +* Add Git helpers for branch sync and floating 'latest' tag in the release workflow, ensure main/master are updated from origin before tagging, and extend unit/e2e tests including 'pkgmgr release --help' coverage (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff) + + ## [0.6.0] - 2025-12-09 * Expose DISTROS and BASE_IMAGE_* variables as exported Makefile environment variables so all build and test commands can consume them dynamically. By exporting these values, every Make target (e.g., build, build-no-cache, build-missing, test-container, test-unit, test-e2e) and every delegated script in scripts/build/ and scripts/test/ now receives a consistent view of the supported distributions and their base container images. This change removes duplicated definitions across scripts, ensures reproducible builds, and allows build tooling to react automatically when new distros or base images are added to the Makefile. diff --git a/PKGBUILD b/PKGBUILD index e0488af..b1d0842 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Kevin Veen-Birkenbach pkgname=package-manager -pkgver=0.6.0 +pkgver=0.7.0 pkgrel=1 pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." arch=('any') diff --git a/debian/changelog b/debian/changelog index 2058ea3..858e893 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +package-manager (0.7.0-1) unstable; urgency=medium + + * Add Git helpers for branch sync and floating 'latest' tag in the release workflow, ensure main/master are updated from origin before tagging, and extend unit/e2e tests including 'pkgmgr release --help' coverage (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff) + + -- Kevin Veen-Birkenbach Tue, 09 Dec 2025 15:21:03 +0100 + package-manager (0.6.0-1) unstable; urgency=medium * Expose DISTROS and BASE_IMAGE_* variables as exported Makefile environment variables so all build and test commands can consume them dynamically. By exporting these values, every Make target (e.g., build, build-no-cache, build-missing, test-container, test-unit, test-e2e) and every delegated script in scripts/build/ and scripts/test/ now receives a consistent view of the supported distributions and their base container images. This change removes duplicated definitions across scripts, ensures reproducible builds, and allows build tooling to react automatically when new distros or base images are added to the Makefile. diff --git a/flake.nix b/flake.nix index 91473a0..930bd63 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,7 @@ rec { pkgmgr = pyPkgs.buildPythonApplication { pname = "package-manager"; - version = "0.6.0"; + version = "0.7.0"; # Use the git repo as source src = ./.; diff --git a/package-manager.spec b/package-manager.spec index 21f4e4d..57cdd17 100644 --- a/package-manager.spec +++ b/package-manager.spec @@ -1,5 +1,5 @@ Name: package-manager -Version: 0.6.0 +Version: 0.7.0 Release: 1%{?dist} Summary: Wrapper that runs Kevin's package-manager via Nix flake diff --git a/pkgmgr/actions/release/__init__.py b/pkgmgr/actions/release/__init__.py new file mode 100644 index 0000000..328d797 --- /dev/null +++ b/pkgmgr/actions/release/__init__.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Release helper for pkgmgr (public entry point). + +This package provides the high-level `release()` function used by the +pkgmgr CLI to perform versioned releases: + + - Determine the next semantic version based on existing Git tags. + - Update pyproject.toml with the new version. + - Update additional packaging files (flake.nix, PKGBUILD, + debian/changelog, RPM spec) where present. + - Prepend a basic entry to CHANGELOG.md. + - Move the floating 'latest' tag to the newly created release tag so + the newest release is always marked as latest. + +Additional behaviour: + - If `preview=True` (from --preview), no files are written and no + Git commands are executed. Instead, a detailed summary of the + planned changes and commands is printed. + - If `preview=False` and not forced, the release is executed in two + phases: + 1) Preview-only run (dry-run). + 2) Interactive confirmation, then real release if confirmed. + This confirmation can be skipped with the `force=True` flag. + - Before creating and pushing tags, main/master is updated from origin + when the release is performed on one of these branches. + - If `close=True` is used and the current branch is not main/master, + the branch will be closed via branch_commands.close_branch() after + a successful release. +""" + +from __future__ import annotations + +import os +import sys +from typing import Optional + +from pkgmgr.core.git import get_current_branch, GitError +from pkgmgr.actions.branch import close_branch + +from .versioning import determine_current_version, bump_semver +from .git_ops import run_git_command, sync_branch_with_remote, update_latest_tag +from .files import ( + update_pyproject_version, + update_flake_version, + update_pkgbuild_version, + update_spec_version, + update_changelog, + update_debian_changelog, +) + + +# --------------------------------------------------------------------------- +# Internal implementation (single-phase, preview or real) +# --------------------------------------------------------------------------- + + +def _release_impl( + pyproject_path: str = "pyproject.toml", + changelog_path: str = "CHANGELOG.md", + release_type: str = "patch", + message: Optional[str] = None, + preview: bool = False, + close: bool = False, +) -> None: + """ + Internal implementation that performs a single-phase release. + """ + current_ver = determine_current_version() + new_ver = bump_semver(current_ver, release_type) + new_ver_str = str(new_ver) + new_tag = new_ver.to_tag(with_prefix=True) + + mode = "PREVIEW" if preview else "REAL" + print(f"Release mode: {mode}") + print(f"Current version: {current_ver}") + print(f"New version: {new_ver_str} ({release_type})") + + repo_root = os.path.dirname(os.path.abspath(pyproject_path)) + + # Update core project metadata and packaging files + update_pyproject_version(pyproject_path, new_ver_str, preview=preview) + changelog_message = update_changelog( + changelog_path, + new_ver_str, + message=message, + preview=preview, + ) + + flake_path = os.path.join(repo_root, "flake.nix") + update_flake_version(flake_path, new_ver_str, preview=preview) + + pkgbuild_path = os.path.join(repo_root, "PKGBUILD") + update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview) + + spec_path = os.path.join(repo_root, "package-manager.spec") + update_spec_version(spec_path, new_ver_str, preview=preview) + + effective_message: Optional[str] = message + if effective_message is None and isinstance(changelog_message, str): + if changelog_message.strip(): + effective_message = changelog_message.strip() + + debian_changelog_path = os.path.join(repo_root, "debian", "changelog") + package_name = os.path.basename(repo_root) or "package-manager" + update_debian_changelog( + debian_changelog_path, + package_name=package_name, + new_version=new_ver_str, + message=effective_message, + preview=preview, + ) + + commit_msg = f"Release version {new_ver_str}" + tag_msg = effective_message or commit_msg + + # Determine branch and ensure it is up to date if main/master + try: + branch = get_current_branch() or "main" + except GitError: + branch = "main" + print(f"Releasing on branch: {branch}") + + # Ensure main/master are up-to-date from origin before creating and + # pushing tags. For other branches we only log the intent. + sync_branch_with_remote(branch, preview=preview) + + files_to_add = [ + pyproject_path, + changelog_path, + flake_path, + pkgbuild_path, + spec_path, + debian_changelog_path, + ] + existing_files = [p for p in files_to_add if p and os.path.exists(p)] + + if preview: + for path in existing_files: + print(f"[PREVIEW] Would run: git add {path}") + print(f'[PREVIEW] Would run: git commit -am "{commit_msg}"') + print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"') + print(f"[PREVIEW] Would run: git push origin {branch}") + print("[PREVIEW] Would run: git push origin --tags") + + # Also update the floating 'latest' tag to the new highest SemVer. + update_latest_tag(new_tag, preview=True) + + if close and branch not in ("main", "master"): + print( + f"[PREVIEW] Would also close branch {branch} after the release " + "(close=True and branch is not main/master)." + ) + elif close: + print( + f"[PREVIEW] close=True but current branch is {branch}; " + "no branch would be closed." + ) + + print("Preview completed. No changes were made.") + return + + for path in existing_files: + run_git_command(f"git add {path}") + + run_git_command(f'git commit -am "{commit_msg}"') + run_git_command(f'git tag -a {new_tag} -m "{tag_msg}"') + run_git_command(f"git push origin {branch}") + run_git_command("git push origin --tags") + + # Move 'latest' to the new release tag so the newest SemVer is always + # marked as latest. + update_latest_tag(new_tag, preview=False) + + print(f"Release {new_ver_str} completed.") + + if close: + if branch in ("main", "master"): + print( + f"[INFO] close=True but current branch is {branch}; " + "nothing to close." + ) + return + + print( + f"[INFO] Closing branch {branch} after successful release " + "(close=True and branch is not main/master)..." + ) + try: + close_branch(name=branch, base_branch="main", cwd=".") + except Exception as exc: # pragma: no cover + print(f"[WARN] Failed to close branch {branch} automatically: {exc}") + + +# --------------------------------------------------------------------------- +# Public release entry point +# --------------------------------------------------------------------------- + + +def release( + pyproject_path: str = "pyproject.toml", + changelog_path: str = "CHANGELOG.md", + release_type: str = "patch", + message: Optional[str] = None, + preview: bool = False, + force: bool = False, + close: bool = False, +) -> None: + """ + High-level release entry point. + + Modes: + + - preview=True: + * Single-phase PREVIEW only. + + - preview=False, force=True: + * Single-phase REAL release, no interactive preview. + + - preview=False, force=False: + * Two-phase flow (intended default for interactive CLI use). + """ + if preview: + _release_impl( + pyproject_path=pyproject_path, + changelog_path=changelog_path, + release_type=release_type, + message=message, + preview=True, + close=close, + ) + return + + if force: + _release_impl( + pyproject_path=pyproject_path, + changelog_path=changelog_path, + release_type=release_type, + message=message, + preview=False, + close=close, + ) + return + + if not sys.stdin.isatty(): + _release_impl( + pyproject_path=pyproject_path, + changelog_path=changelog_path, + release_type=release_type, + message=message, + preview=False, + close=close, + ) + return + + print("[INFO] Running preview before actual release...\n") + _release_impl( + pyproject_path=pyproject_path, + changelog_path=changelog_path, + release_type=release_type, + message=message, + preview=True, + close=close, + ) + + try: + answer = input("Proceed with the actual release? [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\n[INFO] Release aborted (no confirmation).") + return + + if answer not in ("y", "yes"): + print("Release aborted by user. No changes were made.") + return + + print("\n[INFO] Running REAL release...\n") + _release_impl( + pyproject_path=pyproject_path, + changelog_path=changelog_path, + release_type=release_type, + message=message, + preview=False, + close=close, + ) + + +__all__ = ["release"] diff --git a/pkgmgr/actions/release/files.py b/pkgmgr/actions/release/files.py new file mode 100644 index 0000000..87b43a2 --- /dev/null +++ b/pkgmgr/actions/release/files.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +File and metadata update helpers for the release workflow. + +Responsibilities: + - Update pyproject.toml with the new version. + - Update flake.nix, PKGBUILD, RPM spec files where present. + - Prepend release entries to CHANGELOG.md. + - Maintain debian/changelog entries, including maintainer metadata. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +import tempfile +from datetime import date, datetime +from typing import Optional, Tuple + + +# --------------------------------------------------------------------------- +# Editor helper for interactive changelog messages +# --------------------------------------------------------------------------- + + +def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str: + """ + Open $EDITOR (fallback 'nano') so the user can enter a changelog message. + + The temporary file is pre-filled with commented instructions and an + optional initial_message. Lines starting with '#' are ignored when the + message is read back. + + Returns the final message (may be empty string if user leaves it blank). + """ + editor = os.environ.get("EDITOR", "nano") + + with tempfile.NamedTemporaryFile( + mode="w+", + delete=False, + encoding="utf-8", + ) as tmp: + tmp_path = tmp.name + tmp.write( + "# Write the changelog entry for this release.\n" + "# Lines starting with '#' will be ignored.\n" + "# Empty result will fall back to a generic message.\n\n" + ) + if initial_message: + tmp.write(initial_message.strip() + "\n") + tmp.flush() + + try: + subprocess.call([editor, tmp_path]) + except FileNotFoundError: + print( + f"[WARN] Editor {editor!r} not found; proceeding without " + "interactive changelog message." + ) + + try: + with open(tmp_path, "r", encoding="utf-8") as f: + content = f.read() + finally: + try: + os.remove(tmp_path) + except OSError: + pass + + lines = [ + line for line in content.splitlines() + if not line.strip().startswith("#") + ] + return "\n".join(lines).strip() + + +# --------------------------------------------------------------------------- +# File update helpers (pyproject + extra packaging + changelog) +# --------------------------------------------------------------------------- + + +def update_pyproject_version( + pyproject_path: str, + new_version: str, + preview: bool = False, +) -> None: + """ + Update the version in pyproject.toml with the new version. + + The function looks for a line matching: + + version = "X.Y.Z" + + and replaces the version part with the given new_version string. + """ + try: + with open(pyproject_path, "r", encoding="utf-8") as f: + content = f.read() + except FileNotFoundError: + print(f"[ERROR] pyproject.toml not found at: {pyproject_path}") + sys.exit(1) + + pattern = r'^(version\s*=\s*")([^"]+)(")' + new_content, count = re.subn( + pattern, + lambda m: f'{m.group(1)}{new_version}{m.group(3)}', + content, + flags=re.MULTILINE, + ) + + if count == 0: + print("[ERROR] Could not find version line in pyproject.toml") + sys.exit(1) + + if preview: + print(f"[PREVIEW] Would update pyproject.toml version to {new_version}") + return + + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"Updated pyproject.toml version to {new_version}") + + +def update_flake_version( + flake_path: str, + new_version: str, + preview: bool = False, +) -> None: + """ + Update the version in flake.nix, if present. + """ + if not os.path.exists(flake_path): + print("[INFO] flake.nix not found, skipping.") + return + + try: + with open(flake_path, "r", encoding="utf-8") as f: + content = f.read() + except Exception as exc: + print(f"[WARN] Could not read flake.nix: {exc}") + return + + pattern = r'(version\s*=\s*")([^"]+)(")' + new_content, count = re.subn( + pattern, + lambda m: f'{m.group(1)}{new_version}{m.group(3)}', + content, + ) + + if count == 0: + print("[WARN] No version assignment found in flake.nix, skipping.") + return + + if preview: + print(f"[PREVIEW] Would update flake.nix version to {new_version}") + return + + with open(flake_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"Updated flake.nix version to {new_version}") + + +def update_pkgbuild_version( + pkgbuild_path: str, + new_version: str, + preview: bool = False, +) -> None: + """ + Update the version in PKGBUILD, if present. + + Expects: + pkgver=1.2.3 + pkgrel=1 + """ + if not os.path.exists(pkgbuild_path): + print("[INFO] PKGBUILD not found, skipping.") + return + + try: + with open(pkgbuild_path, "r", encoding="utf-8") as f: + content = f.read() + except Exception as exc: + print(f"[WARN] Could not read PKGBUILD: {exc}") + return + + ver_pattern = r"^(pkgver\s*=\s*)(.+)$" + new_content, ver_count = re.subn( + ver_pattern, + lambda m: f"{m.group(1)}{new_version}", + content, + flags=re.MULTILINE, + ) + + if ver_count == 0: + print("[WARN] No pkgver line found in PKGBUILD.") + new_content = content + + rel_pattern = r"^(pkgrel\s*=\s*)(.+)$" + new_content, rel_count = re.subn( + rel_pattern, + lambda m: f"{m.group(1)}1", + new_content, + flags=re.MULTILINE, + ) + + if rel_count == 0: + print("[WARN] No pkgrel line found in PKGBUILD.") + + if preview: + print(f"[PREVIEW] Would update PKGBUILD to pkgver={new_version}, pkgrel=1") + return + + with open(pkgbuild_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"Updated PKGBUILD to pkgver={new_version}, pkgrel=1") + + +def update_spec_version( + spec_path: str, + new_version: str, + preview: bool = False, +) -> None: + """ + Update the version in an RPM spec file, if present. + """ + if not os.path.exists(spec_path): + print("[INFO] RPM spec file not found, skipping.") + return + + try: + with open(spec_path, "r", encoding="utf-8") as f: + content = f.read() + except Exception as exc: + print(f"[WARN] Could not read spec file: {exc}") + return + + ver_pattern = r"^(Version:\s*)(.+)$" + new_content, ver_count = re.subn( + ver_pattern, + lambda m: f"{m.group(1)}{new_version}", + content, + flags=re.MULTILINE, + ) + + if ver_count == 0: + print("[WARN] No 'Version:' line found in spec file.") + + rel_pattern = r"^(Release:\s*)(.+)$" + + def _release_repl(m: re.Match[str]) -> str: # type: ignore[name-defined] + rest = m.group(2).strip() + match = re.match(r"^(\d+)(.*)$", rest) + if match: + suffix = match.group(2) + else: + suffix = "" + return f"{m.group(1)}1{suffix}" + + new_content, rel_count = re.subn( + rel_pattern, + _release_repl, + new_content, + flags=re.MULTILINE, + ) + + if rel_count == 0: + print("[WARN] No 'Release:' line found in spec file.") + + if preview: + print( + f"[PREVIEW] Would update spec file " + f"{os.path.basename(spec_path)} to Version: {new_version}, Release: 1..." + ) + return + + with open(spec_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print( + f"Updated spec file {os.path.basename(spec_path)} " + f"to Version: {new_version}, Release: 1..." + ) + + +def update_changelog( + changelog_path: str, + new_version: str, + message: Optional[str] = None, + preview: bool = False, +) -> str: + """ + Prepend a new release section to CHANGELOG.md with the new version, + current date, and a message. + """ + today = date.today().isoformat() + + if message is None: + if preview: + message = "Automated release." + else: + print( + "\n[INFO] No release message provided, opening editor for " + "changelog entry...\n" + ) + editor_message = _open_editor_for_changelog() + if not editor_message: + message = "Automated release." + else: + message = editor_message + + header = f"## [{new_version}] - {today}\n" + header += f"\n* {message}\n\n" + + if os.path.exists(changelog_path): + try: + with open(changelog_path, "r", encoding="utf-8") as f: + changelog = f.read() + except Exception as exc: + print(f"[WARN] Could not read existing CHANGELOG.md: {exc}") + changelog = "" + else: + changelog = "" + + new_changelog = header + "\n" + changelog if changelog else header + + print("\n================ CHANGELOG ENTRY ================") + print(header.rstrip()) + print("=================================================\n") + + if preview: + print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md") + return message + + with open(changelog_path, "w", encoding="utf-8") as f: + f.write(new_changelog) + + print(f"Updated CHANGELOG.md with version {new_version}") + + return message + + +# --------------------------------------------------------------------------- +# Debian changelog helpers (with Git config fallback for maintainer) +# --------------------------------------------------------------------------- + + +def _get_git_config_value(key: str) -> Optional[str]: + """ + Try to read a value from `git config --get `. + """ + try: + result = subprocess.run( + ["git", "config", "--get", key], + capture_output=True, + text=True, + check=False, + ) + except Exception: + return None + + value = result.stdout.strip() + return value or None + + +def _get_debian_author() -> Tuple[str, str]: + """ + Determine the maintainer name/email for debian/changelog entries. + """ + name = os.environ.get("DEBFULLNAME") + email = os.environ.get("DEBEMAIL") + + if not name: + name = os.environ.get("GIT_AUTHOR_NAME") + if not email: + email = os.environ.get("GIT_AUTHOR_EMAIL") + + if not name: + name = _get_git_config_value("user.name") + if not email: + email = _get_git_config_value("user.email") + + if not name: + name = "Unknown Maintainer" + if not email: + email = "unknown@example.com" + + return name, email + + +def update_debian_changelog( + debian_changelog_path: str, + package_name: str, + new_version: str, + message: Optional[str] = None, + preview: bool = False, +) -> None: + """ + Prepend a new entry to debian/changelog, if it exists. + """ + if not os.path.exists(debian_changelog_path): + print("[INFO] debian/changelog not found, skipping.") + return + + debian_version = f"{new_version}-1" + now = datetime.now().astimezone() + date_str = now.strftime("%a, %d %b %Y %H:%M:%S %z") + + author_name, author_email = _get_debian_author() + + first_line = f"{package_name} ({debian_version}) unstable; urgency=medium" + body_line = message.strip() if message else f"Automated release {new_version}." + stanza = ( + f"{first_line}\n\n" + f" * {body_line}\n\n" + f" -- {author_name} <{author_email}> {date_str}\n\n" + ) + + if preview: + print( + "[PREVIEW] Would prepend the following stanza to debian/changelog:\n" + f"{stanza}" + ) + return + + try: + with open(debian_changelog_path, "r", encoding="utf-8") as f: + existing = f.read() + except Exception as exc: + print(f"[WARN] Could not read debian/changelog: {exc}") + existing = "" + + new_content = stanza + existing + + with open(debian_changelog_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"Updated debian/changelog with version {debian_version}") diff --git a/pkgmgr/actions/release/git_ops.py b/pkgmgr/actions/release/git_ops.py new file mode 100644 index 0000000..040ef00 --- /dev/null +++ b/pkgmgr/actions/release/git_ops.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Git-related helpers for the release workflow. + +Responsibilities: + - Run Git (or shell) commands with basic error reporting. + - Ensure main/master are synchronized with origin before tagging. + - Maintain the floating 'latest' tag that always points to the newest + release tag. +""" + +from __future__ import annotations + +import subprocess + +from pkgmgr.core.git import GitError + + +def run_git_command(cmd: str) -> None: + """ + Run a Git (or shell) command with basic error reporting. + + The command is executed via the shell, primarily for readability + when printed (as in 'git commit -am "msg"'). + """ + print(f"[GIT] {cmd}") + try: + subprocess.run(cmd, shell=True, check=True) + except subprocess.CalledProcessError as exc: + print(f"[ERROR] Git command failed: {cmd}") + print(f" Exit code: {exc.returncode}") + if exc.stdout: + print("--- stdout ---") + print(exc.stdout) + if exc.stderr: + print("--- stderr ---") + print(exc.stderr) + raise GitError(f"Git command failed: {cmd}") from exc + + +def sync_branch_with_remote(branch: str, preview: bool = False) -> None: + """ + Ensure the local main/master branch is up-to-date before tagging. + + Behaviour: + - For main/master: run 'git fetch origin' and 'git pull origin '. + - For all other branches: only log that no automatic sync is performed. + """ + if branch not in ("main", "master"): + print( + f"[INFO] Skipping automatic git pull for non-main/master branch " + f"{branch}." + ) + return + + print( + f"[INFO] Updating branch {branch} from origin before creating tags..." + ) + + if preview: + print("[PREVIEW] Would run: git fetch origin") + print(f"[PREVIEW] Would run: git pull origin {branch}") + return + + run_git_command("git fetch origin") + run_git_command(f"git pull origin {branch}") + + +def update_latest_tag(new_tag: str, preview: bool = False) -> None: + """ + Move the floating 'latest' tag to the newly created release tag. + """ + print(f"[INFO] Updating 'latest' tag to point at {new_tag}...") + if preview: + print(f"[PREVIEW] Would run: git tag -f latest {new_tag}") + print("[PREVIEW] Would run: git push origin latest --force") + return + + run_git_command(f"git tag -f latest {new_tag}") + run_git_command("git push origin latest --force") diff --git a/pkgmgr/actions/release/versioning.py b/pkgmgr/actions/release/versioning.py new file mode 100644 index 0000000..537bc10 --- /dev/null +++ b/pkgmgr/actions/release/versioning.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Version discovery and bumping helpers for the release workflow. +""" + +from __future__ import annotations + +from pkgmgr.core.git import get_tags +from pkgmgr.core.version.semver import ( + SemVer, + find_latest_version, + bump_major, + bump_minor, + bump_patch, +) + + +def determine_current_version() -> SemVer: + """ + Determine the current semantic version from Git tags. + + Behaviour: + - If there are no tags or no SemVer-compatible tags, return 0.0.0. + - Otherwise, use the latest SemVer tag as current version. + """ + tags = get_tags() + if not tags: + return SemVer(0, 0, 0) + + latest = find_latest_version(tags) + if latest is None: + return SemVer(0, 0, 0) + + _tag, ver = latest + return ver + + +def bump_semver(current: SemVer, release_type: str) -> SemVer: + """ + Bump the given SemVer according to the release type. + + release_type must be one of: "major", "minor", "patch". + """ + if release_type == "major": + return bump_major(current) + if release_type == "minor": + return bump_minor(current) + if release_type == "patch": + return bump_patch(current) + + raise ValueError(f"Unknown release type: {release_type!r}") diff --git a/pyproject.toml b/pyproject.toml index b0bcde6..f08904b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "package-manager" -version = "0.6.0" +version = "0.7.0" description = "Kevin's package-manager tool (pkgmgr)" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/e2e/test_release_commands.py b/tests/e2e/test_release_commands.py index ec045cd..8183cc5 100644 --- a/tests/e2e/test_release_commands.py +++ b/tests/e2e/test_release_commands.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/release/__init__.py b/tests/unit/pkgmgr/actions/release/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/pkgmgr/actions/test_release.py b/tests/unit/pkgmgr/actions/release/test_files.py similarity index 56% rename from tests/unit/pkgmgr/actions/test_release.py rename to tests/unit/pkgmgr/actions/release/test_files.py index 0c7d01b..d6a1e8f 100644 --- a/tests/unit/pkgmgr/actions/test_release.py +++ b/tests/unit/pkgmgr/actions/release/test_files.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/release/test_git_ops.py b/tests/unit/pkgmgr/actions/release/test_git_ops.py new file mode 100644 index 0000000..9220b8d --- /dev/null +++ b/tests/unit/pkgmgr/actions/release/test_git_ops.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/release/test_release_orchestration.py b/tests/unit/pkgmgr/actions/release/test_release_orchestration.py new file mode 100644 index 0000000..dcf9172 --- /dev/null +++ b/tests/unit/pkgmgr/actions/release/test_release_orchestration.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/release/test_versioning.py b/tests/unit/pkgmgr/actions/release/test_versioning.py new file mode 100644 index 0000000..f63a8c0 --- /dev/null +++ b/tests/unit/pkgmgr/actions/release/test_versioning.py @@ -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()