diff --git a/assets/map.png b/assets/map.png index e03691a..cc59a81 100644 Binary files a/assets/map.png and b/assets/map.png differ diff --git a/src/pkgmgr/actions/release/README.md b/src/pkgmgr/actions/release/README.md new file mode 100644 index 0000000..11f2f1d --- /dev/null +++ b/src/pkgmgr/actions/release/README.md @@ -0,0 +1,218 @@ +# Release Action + +This module implements the `pkgmgr release` workflow. + +It provides a controlled, reproducible release process that: +- bumps the project version +- updates all supported packaging formats +- creates and pushes Git tags +- optionally maintains a floating `latest` tag +- optionally closes the current branch + +The implementation is intentionally explicit and conservative to avoid +accidental releases or broken Git states. + +--- + +## What the Release Command Does + +A release performs the following high-level steps: + +1. Synchronize the current branch with its upstream (fast-forward only) +2. Determine the next semantic version +3. Update all versioned files +4. Commit the release +5. Create and push a version tag +6. Optionally update and push the floating `latest` tag +7. Optionally close the current branch + +All steps support **preview (dry-run)** mode. + +--- + +## Supported Files Updated During a Release + +If present, the following files are updated automatically: + +- `pyproject.toml` +- `CHANGELOG.md` +- `flake.nix` +- `PKGBUILD` +- `package-manager.spec` +- `debian/changelog` + +Missing files are skipped gracefully. + +--- + +## Git Safety Rules + +The release workflow enforces strict Git safety guarantees: + +- A `git pull --ff-only` is executed **before any file modifications** +- No merge commits are ever created automatically +- Only the current branch and the newly created version tag are pushed +- `git push --tags` is intentionally **not** used +- The floating `latest` tag is force-pushed only when required + +--- + +## Semantic Versioning + +The next version is calculated from existing Git tags: + +- Tags must follow the format `vX.Y.Z` +- The release type controls the version bump: + - `patch` + - `minor` + - `major` + +The new tag is always created as an **annotated tag**. + +--- + +## Floating `latest` Tag + +The floating `latest` tag is handled explicitly: + +- `latest` is updated **only if** the new version is the highest existing version +- Version comparison uses natural version sorting (`sort -V`) +- `latest` always points to the commit behind the version tag +- Updating `latest` uses a forced push by design + +This guarantees that `latest` always represents the highest released version, +never an older release. + +--- + +## Preview Mode + +Preview mode (`--preview`) performs a full dry-run: + +- No files are modified +- No Git commands are executed +- All intended actions are printed + +Example preview output includes: +- version bump +- file updates +- commit message +- tag creation +- branch and tag pushes +- `latest` update (if applicable) + +--- + +## Interactive vs Forced Mode + +### Interactive (default) + +1. Run a preview +2. Ask for confirmation +3. Execute the real release + +### Forced (`--force`) + +- Skips preview and confirmation +- Skips branch deletion prompts +- Executes the release immediately + +--- + +## Branch Closing (`--close`) + +When `--close` is enabled: + +- `main` and `master` are **never** deleted +- Other branches: + - prompt for confirmation (`y/N`) + - can be skipped using `--force` +- Branch deletion happens **only after** a successful release + +--- + +## Execution Flow (ASCII Diagram) + +``` + ++---------------------+ +| pkgmgr release | ++----------+----------+ +| +v ++---------------------+ +| Detect branch | ++----------+----------+ +| +v ++------------------------------+ +| git fetch / pull --ff-only | ++----------+-------------------+ +| +v ++------------------------------+ +| Determine next version | ++----------+-------------------+ +| +v ++------------------------------+ +| Update versioned files | ++----------+-------------------+ +| +v ++------------------------------+ +| Commit release | ++----------+-------------------+ +| +v ++------------------------------+ +| Create version tag (vX.Y.Z) | ++----------+-------------------+ +| +v ++------------------------------+ +| Push branch + version tag | ++----------+-------------------+ +| +v ++---------------------------------------+ +| Is this the highest version? | ++----------+----------------------------+ +| +yes | no +| +v ++------------------------------+ +----------------------+ +| Update & push `latest` tag | | Skip `latest` update | ++----------+-------------------+ +----------------------+ +| +v ++------------------------------+ +| Close branch (optional) | ++------------------------------+ + +``` + +--- + +## Design Goals + +- Deterministic and reproducible releases +- No implicit Git side effects +- Explicit tag handling +- Safe defaults for interactive usage +- Automation-friendly forced mode +- Clear separation of concerns: + - `workflow.py` – orchestration + - `git_ops.py` – Git operations + - `prompts.py` – user interaction + - `versioning.py` – SemVer logic + +--- + +## Summary + +`pkgmgr release` is a **deliberately strict** release mechanism. + +It trades convenience for safety, traceability, and correctness — making it +suitable for both interactive development workflows and fully automated CI/CD diff --git a/src/pkgmgr/actions/release/__init__.py b/src/pkgmgr/actions/release/__init__.py index 45f0641..1c9e3e7 100644 --- a/src/pkgmgr/actions/release/__init__.py +++ b/src/pkgmgr/actions/release/__init__.py @@ -1,310 +1,5 @@ -#!/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, - update_spec_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) - - # Determine a single effective_message to be reused across all - # changelog targets (project, Debian, Fedora). - 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" - - # Debian changelog - update_debian_changelog( - debian_changelog_path, - package_name=package_name, - new_version=new_ver_str, - message=effective_message, - preview=preview, - ) - - # Fedora / RPM %changelog - update_spec_changelog( - spec_path=spec_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. This is best-effort and must not break the release. - try: - update_latest_tag(new_tag, preview=False) - except GitError as exc: # pragma: no cover - print( - f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}\n" - "[WARN] The release itself completed successfully; only the " - "'latest' tag was not updated." - ) - - 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, - ) - +from .workflow import release __all__ = ["release"] diff --git a/src/pkgmgr/actions/release/git_ops.py b/src/pkgmgr/actions/release/git_ops.py index 3d72b1a..c02aa06 100644 --- a/src/pkgmgr/actions/release/git_ops.py +++ b/src/pkgmgr/actions/release/git_ops.py @@ -1,16 +1,3 @@ -#!/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 @@ -19,77 +6,84 @@ 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) + subprocess.run( + cmd, + shell=True, + check=True, + text=True, + capture_output=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) + print("\n" + exc.stdout) if exc.stderr: - print("--- stderr ---") - print(exc.stderr) + print("\n" + 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. +def _capture(cmd: str) -> str: + res = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True) + return (res.stdout or "").strip() - Behaviour: - - For main/master: run 'git fetch origin' and 'git pull origin '. - - For all other branches: only log that no automatic sync is performed. + +def ensure_clean_and_synced(preview: bool = False) -> None: """ - if branch not in ("main", "master"): - print( - f"[INFO] Skipping automatic git pull for non-main/master branch " - f"{branch}." - ) + Always run a pull BEFORE modifying anything. + Uses --ff-only to avoid creating merge commits automatically. + If no upstream is configured, we skip. + """ + upstream = _capture("git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null") + if not upstream: + print("[INFO] No upstream configured for current branch. Skipping pull.") 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}") + print("[PREVIEW] Would run: git fetch --prune --tags") + print("[PREVIEW] Would run: git pull --ff-only") return - run_git_command("git fetch origin") - run_git_command(f"git pull origin {branch}") + print("[INFO] Syncing with remote before making any changes...") + run_git_command("git fetch --prune --tags") + run_git_command("git pull --ff-only") + + +def is_highest_version_tag(tag: str) -> bool: + """ + Return True if `tag` is the highest version among all tags matching v*. + Comparison uses `sort -V` for natural version ordering. + """ + all_v = _capture("git tag --list 'v*'") + if not all_v: + return True + + latest = _capture("git tag --list 'v*' | sort -V | tail -n1") + return tag == latest def update_latest_tag(new_tag: str, preview: bool = False) -> None: """ Move the floating 'latest' tag to the newly created release tag. - Implementation details: - - We explicitly dereference the tag object via `^{}` so that - 'latest' always points at the underlying commit, not at another tag. - - We create/update 'latest' as an annotated tag with a short message so - Git configurations that enforce annotated/signed tags do not fail - with "no tag message". + Notes: + - We dereference the tag object via `^{}` so that 'latest' points to the commit. + - 'latest' is forced (floating tag), therefore the push uses --force. """ target_ref = f"{new_tag}^{{}}" print(f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})...") if preview: - print(f"[PREVIEW] Would run: git tag -f -a latest {target_ref} " - f'-m "Floating latest tag for {new_tag}"') + print( + f'[PREVIEW] Would run: git tag -f -a latest {target_ref} ' + f'-m "Floating latest tag for {new_tag}"' + ) print("[PREVIEW] Would run: git push origin latest --force") return run_git_command( - f'git tag -f -a latest {target_ref} ' - f'-m "Floating latest tag for {new_tag}"' + f'git tag -f -a latest {target_ref} -m "Floating latest tag for {new_tag}"' ) run_git_command("git push origin latest --force") diff --git a/src/pkgmgr/actions/release/prompts.py b/src/pkgmgr/actions/release/prompts.py new file mode 100644 index 0000000..ab5c278 --- /dev/null +++ b/src/pkgmgr/actions/release/prompts.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import sys + + +def should_delete_branch(force: bool) -> bool: + """ + Ask whether the current branch should be deleted after a successful release. + + - If force=True: skip prompt and return True. + - If non-interactive stdin: do NOT delete by default. + """ + if force: + return True + if not sys.stdin.isatty(): + return False + answer = input("Delete the current branch after release? [y/N] ").strip().lower() + return answer in ("y", "yes") + + +def confirm_proceed_release() -> bool: + """ + Ask whether to proceed with the REAL release after the preview phase. + """ + try: + answer = input("Proceed with the actual release? [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + return False + return answer in ("y", "yes") diff --git a/src/pkgmgr/actions/release/workflow.py b/src/pkgmgr/actions/release/workflow.py new file mode 100644 index 0000000..32dbcfc --- /dev/null +++ b/src/pkgmgr/actions/release/workflow.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import os +import sys +from typing import Optional + +from pkgmgr.actions.branch import close_branch +from pkgmgr.core.git import get_current_branch, GitError + +from .files import ( + update_changelog, + update_debian_changelog, + update_flake_version, + update_pkgbuild_version, + update_pyproject_version, + update_spec_changelog, + update_spec_version, +) +from .git_ops import ( + ensure_clean_and_synced, + is_highest_version_tag, + run_git_command, + update_latest_tag, +) +from .prompts import confirm_proceed_release, should_delete_branch +from .versioning import bump_semver, determine_current_version + + +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, + force: bool = False, +) -> None: + # Determine current branch early + try: + branch = get_current_branch() or "main" + except GitError: + branch = "main" + print(f"Releasing on branch: {branch}") + + # Pull BEFORE making any modifications + ensure_clean_and_synced(preview=preview) + + 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_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, + ) + + update_spec_changelog( + spec_path=spec_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 + + 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(f"[PREVIEW] Would run: git push origin {new_tag}") + + if is_highest_version_tag(new_tag): + update_latest_tag(new_tag, preview=True) + else: + print(f"[PREVIEW] Skipping 'latest' update (tag {new_tag} is not the highest).") + + if close and branch not in ("main", "master"): + if force: + print(f"[PREVIEW] Would delete branch {branch} (forced).") + else: + print(f"[PREVIEW] Would ask whether to delete branch {branch} after release.") + 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}"') + + # Push branch and ONLY the newly created version tag (no --tags) + run_git_command(f"git push origin {branch}") + run_git_command(f"git push origin {new_tag}") + + # Update 'latest' only if this is the highest version tag + try: + if is_highest_version_tag(new_tag): + update_latest_tag(new_tag, preview=False) + else: + print(f"[INFO] Skipping 'latest' update (tag {new_tag} is not the highest).") + except GitError as exc: + print(f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}") + print("'latest' tag was not updated.") + + print(f"Release {new_ver_str} completed.") + + if close: + if branch in ("main", "master"): + print(f"[INFO] close=True but current branch is {branch}; skipping branch deletion.") + return + + if not should_delete_branch(force=force): + print(f"[INFO] Branch deletion declined. Keeping branch {branch}.") + return + + print(f"[INFO] Deleting branch {branch} after successful release...") + try: + close_branch(name=branch, base_branch="main", cwd=".") + except Exception as exc: + print(f"[WARN] Failed to close branch {branch} automatically: {exc}") + + +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: + if preview: + _release_impl( + pyproject_path=pyproject_path, + changelog_path=changelog_path, + release_type=release_type, + message=message, + preview=True, + close=close, + force=force, + ) + return + + # If force or non-interactive: no preview+confirmation step + if force or (not sys.stdin.isatty()): + _release_impl( + pyproject_path=pyproject_path, + changelog_path=changelog_path, + release_type=release_type, + message=message, + preview=False, + close=close, + force=force, + ) + 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, + force=force, + ) + + if not confirm_proceed_release(): + print() + 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, + force=force, + ) diff --git a/tests/unit/pkgmgr/actions/release/init.py b/tests/unit/pkgmgr/actions/release/init.py new file mode 100644 index 0000000..7fe52ab --- /dev/null +++ b/tests/unit/pkgmgr/actions/release/init.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import unittest + + +class TestReleasePackageInit(unittest.TestCase): + def test_release_is_reexported(self) -> None: + from pkgmgr.actions.release import release # noqa: F401 + + self.assertTrue(callable(release)) + + +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 index 1b53867..86d6e75 100644 --- a/tests/unit/pkgmgr/actions/release/test_git_ops.py +++ b/tests/unit/pkgmgr/actions/release/test_git_ops.py @@ -5,8 +5,9 @@ from unittest.mock import patch from pkgmgr.core.git import GitError from pkgmgr.actions.release.git_ops import ( + ensure_clean_and_synced, + is_highest_version_tag, run_git_command, - sync_branch_with_remote, update_latest_tag, ) @@ -14,12 +15,13 @@ from pkgmgr.actions.release.git_ops import ( 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")) + self.assertTrue(kwargs.get("capture_output")) + self.assertTrue(kwargs.get("text")) @patch("pkgmgr.actions.release.git_ops.subprocess.run") def test_run_git_command_failure_raises_git_error(self, mock_run) -> None: @@ -36,58 +38,138 @@ class TestRunGitCommand(unittest.TestCase): 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() +class TestEnsureCleanAndSynced(unittest.TestCase): + def _fake_run(self, cmd: str, *args, **kwargs): + class R: + def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0): + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode - @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() + # upstream detection + if "git rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd: + return R(stdout="origin/main") - @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) + # fetch/pull should be invoked in real mode + if cmd == "git fetch --prune --tags": + return R(stdout="") + if cmd == "git pull --ff-only": + return R(stdout="Already up to date.") - 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) + return R(stdout="") + + @patch("pkgmgr.actions.release.git_ops.subprocess.run") + def test_ensure_clean_and_synced_preview_does_not_run_git_commands(self, mock_run) -> None: + def fake(cmd: str, *args, **kwargs): + class R: + def __init__(self, stdout: str = ""): + self.stdout = stdout + self.stderr = "" + self.returncode = 0 + + if "git rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd: + return R(stdout="origin/main") + return R(stdout="") + + mock_run.side_effect = fake + + ensure_clean_and_synced(preview=True) + + # In preview mode we still check upstream, but must NOT run fetch/pull + called_cmds = [c.args[0] for c in mock_run.call_args_list] + self.assertTrue(any("git rev-parse" in c for c in called_cmds)) + self.assertFalse(any(c == "git fetch --prune --tags" for c in called_cmds)) + self.assertFalse(any(c == "git pull --ff-only" for c in called_cmds)) + + @patch("pkgmgr.actions.release.git_ops.subprocess.run") + def test_ensure_clean_and_synced_no_upstream_skips(self, mock_run) -> None: + def fake(cmd: str, *args, **kwargs): + class R: + def __init__(self, stdout: str = ""): + self.stdout = stdout + self.stderr = "" + self.returncode = 0 + + if "git rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd: + return R(stdout="") # no upstream + return R(stdout="") + + mock_run.side_effect = fake + + ensure_clean_and_synced(preview=False) + + called_cmds = [c.args[0] for c in mock_run.call_args_list] + self.assertTrue(any("git rev-parse" in c for c in called_cmds)) + self.assertFalse(any(c == "git fetch --prune --tags" for c in called_cmds)) + self.assertFalse(any(c == "git pull --ff-only" for c in called_cmds)) + + @patch("pkgmgr.actions.release.git_ops.subprocess.run") + def test_ensure_clean_and_synced_real_runs_fetch_and_pull(self, mock_run) -> None: + mock_run.side_effect = self._fake_run + + ensure_clean_and_synced(preview=False) + + called_cmds = [c.args[0] for c in mock_run.call_args_list] + self.assertIn("git fetch --prune --tags", called_cmds) + self.assertIn("git pull --ff-only", called_cmds) + + +class TestIsHighestVersionTag(unittest.TestCase): + @patch("pkgmgr.actions.release.git_ops.subprocess.run") + def test_is_highest_version_tag_no_tags_true(self, mock_run) -> None: + def fake(cmd: str, *args, **kwargs): + class R: + def __init__(self, stdout: str = ""): + self.stdout = stdout + self.stderr = "" + self.returncode = 0 + + if cmd == "git tag --list 'v*'": + return R(stdout="") # no tags + return R(stdout="") + + mock_run.side_effect = fake + + self.assertTrue(is_highest_version_tag("v1.0.0")) + + @patch("pkgmgr.actions.release.git_ops.subprocess.run") + def test_is_highest_version_tag_compares_sort_v(self, mock_run) -> None: + def fake(cmd: str, *args, **kwargs): + class R: + def __init__(self, stdout: str = ""): + self.stdout = stdout + self.stderr = "" + self.returncode = 0 + + if cmd == "git tag --list 'v*'": + return R(stdout="v1.0.0\nv1.2.0\nv1.10.0\n") + if cmd == "git tag --list 'v*' | sort -V | tail -n1": + return R(stdout="v1.10.0") + return R(stdout="") + + mock_run.side_effect = fake + + self.assertTrue(is_highest_version_tag("v1.10.0")) + self.assertFalse(is_highest_version_tag("v1.2.0")) 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: + 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_with_dereference_and_message( - self, - mock_run_git_command, - ) -> None: + 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] - # Must dereference the tag object and create an annotated tag with message self.assertIn( 'git tag -f -a latest v1.2.3^{} -m "Floating latest tag for 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_prompts.py b/tests/unit/pkgmgr/actions/release/test_prompts.py new file mode 100644 index 0000000..ee49046 --- /dev/null +++ b/tests/unit/pkgmgr/actions/release/test_prompts.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from pkgmgr.actions.release.prompts import ( + confirm_proceed_release, + should_delete_branch, +) + + +class TestShouldDeleteBranch(unittest.TestCase): + def test_force_true_skips_prompt_and_returns_true(self) -> None: + self.assertTrue(should_delete_branch(force=True)) + + @patch("pkgmgr.actions.release.prompts.sys.stdin.isatty", return_value=False) + def test_non_interactive_returns_false(self, _mock_isatty) -> None: + self.assertFalse(should_delete_branch(force=False)) + + @patch("pkgmgr.actions.release.prompts.sys.stdin.isatty", return_value=True) + @patch("builtins.input", return_value="y") + def test_interactive_yes_returns_true(self, _mock_input, _mock_isatty) -> None: + self.assertTrue(should_delete_branch(force=False)) + + @patch("pkgmgr.actions.release.prompts.sys.stdin.isatty", return_value=True) + @patch("builtins.input", return_value="N") + def test_interactive_no_returns_false(self, _mock_input, _mock_isatty) -> None: + self.assertFalse(should_delete_branch(force=False)) + + +class TestConfirmProceedRelease(unittest.TestCase): + @patch("builtins.input", return_value="y") + def test_confirm_yes(self, _mock_input) -> None: + self.assertTrue(confirm_proceed_release()) + + @patch("builtins.input", return_value="no") + def test_confirm_no(self, _mock_input) -> None: + self.assertFalse(confirm_proceed_release()) + + @patch("builtins.input", side_effect=EOFError) + def test_confirm_eof_returns_false(self, _mock_input) -> None: + self.assertFalse(confirm_proceed_release()) + + @patch("builtins.input", side_effect=KeyboardInterrupt) + def test_confirm_keyboard_interrupt_returns_false(self, _mock_input) -> None: + self.assertFalse(confirm_proceed_release()) + + +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 deleted file mode 100644 index 587f4bb..0000000 --- a/tests/unit/pkgmgr/actions/release/test_release_orchestration.py +++ /dev/null @@ -1,155 +0,0 @@ -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.update_spec_changelog") as mock_update_spec_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 (Projekt) - 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, - ) - - # Fedora / RPM %changelog helper - mock_update_spec_changelog.assert_called_once() - self.assertEqual( - mock_update_spec_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.update_spec_changelog") as mock_update_spec_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")) - - # Fedora / RPM spec changelog helper in preview mode - mock_update_spec_changelog.assert_called_once() - self.assertTrue(mock_update_spec_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_workflow.py b/tests/unit/pkgmgr/actions/release/test_workflow.py new file mode 100644 index 0000000..015ca3f --- /dev/null +++ b/tests/unit/pkgmgr/actions/release/test_workflow.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from pkgmgr.actions.release.workflow import release + + +class TestWorkflowReleaseEntryPoint(unittest.TestCase): + @patch("pkgmgr.actions.release.workflow._release_impl") + def test_release_preview_calls_impl_preview_only(self, mock_impl) -> None: + release(preview=True, force=False, close=False) + + mock_impl.assert_called_once() + kwargs = mock_impl.call_args.kwargs + self.assertTrue(kwargs["preview"]) + self.assertFalse(kwargs["force"]) + + @patch("pkgmgr.actions.release.workflow._release_impl") + @patch("pkgmgr.actions.release.workflow.sys.stdin.isatty", return_value=False) + def test_release_non_interactive_runs_real_without_confirmation(self, _mock_isatty, mock_impl) -> None: + release(preview=False, force=False, close=False) + + mock_impl.assert_called_once() + kwargs = mock_impl.call_args.kwargs + self.assertFalse(kwargs["preview"]) + + @patch("pkgmgr.actions.release.workflow._release_impl") + def test_release_force_runs_real_without_confirmation(self, mock_impl) -> None: + release(preview=False, force=True, close=False) + + mock_impl.assert_called_once() + kwargs = mock_impl.call_args.kwargs + self.assertFalse(kwargs["preview"]) + self.assertTrue(kwargs["force"]) + + @patch("pkgmgr.actions.release.workflow._release_impl") + @patch("pkgmgr.actions.release.workflow.confirm_proceed_release", return_value=False) + @patch("pkgmgr.actions.release.workflow.sys.stdin.isatty", return_value=True) + def test_release_interactive_decline_runs_only_preview(self, _mock_isatty, _mock_confirm, mock_impl) -> None: + release(preview=False, force=False, close=False) + + # interactive path: preview first, then decline => only one call + self.assertEqual(mock_impl.call_count, 1) + self.assertTrue(mock_impl.call_args_list[0].kwargs["preview"]) + + @patch("pkgmgr.actions.release.workflow._release_impl") + @patch("pkgmgr.actions.release.workflow.confirm_proceed_release", return_value=True) + @patch("pkgmgr.actions.release.workflow.sys.stdin.isatty", return_value=True) + def test_release_interactive_accept_runs_preview_then_real(self, _mock_isatty, _mock_confirm, mock_impl) -> None: + release(preview=False, force=False, close=False) + + self.assertEqual(mock_impl.call_count, 2) + self.assertTrue(mock_impl.call_args_list[0].kwargs["preview"]) + self.assertFalse(mock_impl.call_args_list[1].kwargs["preview"]) + + +if __name__ == "__main__": + unittest.main()