feat(release): refactor release workflow, tagging logic, and CLI integration
Refactor the release implementation into a dedicated workflow module with clear separation of concerns. Enforce a safe, deterministic Git flow by always syncing with the remote before modifications, pushing only the current branch and the newly created version tag, and updating the floating *latest* tag only when the released version is the highest. Add explicit user prompts for confirmation and optional branch deletion, with a forced mode to skip interaction. Update CLI wiring to pass all relevant flags, add comprehensive unit tests for the new helpers and workflow entry points, and introduce detailed documentation describing the release process, safety rules, and execution flow.
This commit is contained in:
BIN
assets/map.png
BIN
assets/map.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
218
src/pkgmgr/actions/release/README.md
Normal file
218
src/pkgmgr/actions/release/README.md
Normal file
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 <branch>'.
|
||||
- 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 `<tag>^{}` 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 `<tag>^{}` 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")
|
||||
|
||||
29
src/pkgmgr/actions/release/prompts.py
Normal file
29
src/pkgmgr/actions/release/prompts.py
Normal file
@@ -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")
|
||||
230
src/pkgmgr/actions/release/workflow.py
Normal file
230
src/pkgmgr/actions/release/workflow.py
Normal file
@@ -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,
|
||||
)
|
||||
14
tests/unit/pkgmgr/actions/release/init.py
Normal file
14
tests/unit/pkgmgr/actions/release/init.py
Normal file
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
50
tests/unit/pkgmgr/actions/release/test_prompts.py
Normal file
50
tests/unit/pkgmgr/actions/release/test_prompts.py
Normal file
@@ -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()
|
||||
@@ -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()
|
||||
59
tests/unit/pkgmgr/actions/release/test_workflow.py
Normal file
59
tests/unit/pkgmgr/actions/release/test_workflow.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user