Release version 2.1.0

This commit is contained in:
Kevin Veen-Birkenbach
2025-12-08 20:15:13 +01:00
parent b9b64fed7d
commit bc3ff5b67f
11 changed files with 1401 additions and 200 deletions

4
CHANGELOG.md Normal file
View File

@@ -0,0 +1,4 @@
## [2.1.0] - 2025-12-08
* Implement unified release helper with preview mode, multi-packaging version bumps, and new integration/unit tests (see ChatGPT conversation 2025-12-08: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630)

View File

@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world> # Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager pkgname=package-manager
pkgver=0.1.1 pkgver=2.1.0
pkgrel=1 pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any') arch=('any')

6
debian/changelog vendored
View File

@@ -1,3 +1,9 @@
package-manager (2.1.0-1) unstable; urgency=medium
* Implement unified release helper with preview mode, multi-packaging version bumps, and new integration/unit tests (see ChatGPT conversation 2025-12-08: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630)
-- Kevin Veen-Birkenbach <kevin@veen.world> Mon, 08 Dec 2025 20:15:13 +0100
package-manager (0.1.1-1) unstable; urgency=medium package-manager (0.1.1-1) unstable; urgency=medium
* Initial release. * Initial release.

View File

@@ -31,7 +31,7 @@
rec { rec {
pkgmgr = pyPkgs.buildPythonApplication { pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager"; pname = "package-manager";
version = "0.1.1"; version = "2.1.0";
# Use the git repo as source # Use the git repo as source
src = ./.; src = ./.;

View File

@@ -1,5 +1,5 @@
Name: package-manager Name: package-manager
Version: 0.1.1 Version: 2.1.0
Release: 1%{?dist} Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake Summary: Wrapper that runs Kevin's package-manager via Nix flake

View File

@@ -21,32 +21,56 @@ def handle_release(
Handle the 'release' command. Handle the 'release' command.
Creates a release by incrementing the version and updating the changelog Creates a release by incrementing the version and updating the changelog
in the selected repositories. in a single selected repository.
Important:
- Releases are strictly limited to exactly ONE repository.
- Using --all or specifying multiple identifiers for release does
not make sense and is therefore rejected.
- The --preview flag is respected and passed through to the release
implementation so that no changes are made in preview mode.
""" """
if not selected: if not selected:
print("No repositories selected for release.") print("No repositories selected for release.")
sys.exit(1) sys.exit(1)
if len(selected) > 1:
print(
"[ERROR] Release operations are limited to a single repository.\n"
"Do not use --all or multiple identifiers with 'pkgmgr release'."
)
sys.exit(1)
original_dir = os.getcwd() original_dir = os.getcwd()
for repo in selected: repo = selected[0]
repo_dir: Optional[str] = repo.get("directory")
if not repo_dir:
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo)
pyproject_path = os.path.join(repo_dir, "pyproject.toml") repo_dir: Optional[str] = repo.get("directory")
changelog_path = os.path.join(repo_dir, "CHANGELOG.md") if not repo_dir:
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo)
if not os.path.isdir(repo_dir):
print( print(
f"Releasing repository '{repo.get('repository')}' in '{repo_dir}'..." f"[ERROR] Repository directory does not exist locally: {repo_dir}"
) )
sys.exit(1)
os.chdir(repo_dir) pyproject_path = os.path.join(repo_dir, "pyproject.toml")
changelog_path = os.path.join(repo_dir, "CHANGELOG.md")
print(
f"Releasing repository '{repo.get('repository')}' in '{repo_dir}'..."
)
os.chdir(repo_dir)
try:
rel.release( rel.release(
pyproject_path=pyproject_path, pyproject_path=pyproject_path,
changelog_path=changelog_path, changelog_path=changelog_path,
release_type=args.release_type, release_type=args.release_type,
message=args.message, message=args.message,
preview=getattr(args, "preview", False),
) )
finally:
os.chdir(original_dir) os.chdir(original_dir)

View File

@@ -1,152 +1,766 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
pkgmgr/release.py pkgmgr/release.py
This module defines a 'release' function that: Release helper for pkgmgr.
- Increments the version in pyproject.toml based on the release type (major, minor, patch)
- Updates the CHANGELOG.md with a new release entry (including an optional message) Responsibilities (Milestone 7):
- Executes Git commands to commit, tag, and push the release. - 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.
- Commit, tag, and push the release on the current branch.
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.
""" """
from __future__ import annotations
import argparse
import os
import re import re
import subprocess import subprocess
from datetime import date
import sys import sys
import argparse import tempfile
from datetime import date, datetime
from typing import Optional, Tuple
def bump_version(version_str: str, release_type: str) -> str: from pkgmgr.git_utils import get_tags, get_current_branch, GitError
from pkgmgr.versioning import (
SemVer,
find_latest_version,
bump_major,
bump_minor,
bump_patch,
)
# ---------------------------------------------------------------------------
# Helpers for Git + version discovery
# ---------------------------------------------------------------------------
def _determine_current_version() -> SemVer:
""" """
Parse the version string and return the incremented version. Determine the current semantic version from Git tags.
Parameters: Behaviour:
version_str: The current version in the form "X.Y.Z". - If there are no tags or no SemVer-compatible tags, return 0.0.0.
release_type: One of "major", "minor", or "patch". - Otherwise, use the latest SemVer tag as current version.
"""
Returns: tags = get_tags()
The bumped version string. if not tags:
return SemVer(0, 0, 0)
latest = find_latest_version(tags)
if latest is None:
return SemVer(0, 0, 0)
_tag, ver = latest
return ver
def _bump_semver(current: SemVer, release_type: str) -> SemVer:
"""
Bump the given SemVer according to the release type.
release_type must be one of: "major", "minor", "patch".
""" """
parts = version_str.split('.')
if len(parts) != 3:
raise ValueError("Version format is unexpected. Expected format: X.Y.Z")
major, minor, patch = map(int, parts)
if release_type == "major": if release_type == "major":
major += 1 return bump_major(current)
minor = 0 if release_type == "minor":
patch = 0 return bump_minor(current)
elif release_type == "minor": if release_type == "patch":
minor += 1 return bump_patch(current)
patch = 0
elif release_type == "patch":
patch += 1
else:
raise ValueError("release_type must be 'major', 'minor', or 'patch'.")
return f"{major}.{minor}.{patch}"
def update_pyproject_version(pyproject_path: str, new_version: str): raise ValueError(f"Unknown release type: {release_type!r}")
# ---------------------------------------------------------------------------
# Low-level Git command helper
# ---------------------------------------------------------------------------
def _run_git_command(cmd: str) -> None:
"""
Run a Git (or shell) command with basic error reporting.
The command is executed via the shell, primarily for readability
when printed (as in 'git commit -am "msg"').
"""
print(f"[GIT] {cmd}")
try:
subprocess.run(cmd, shell=True, check=True)
except subprocess.CalledProcessError as exc:
print(f"[ERROR] Git command failed: {cmd}")
print(f" Exit code: {exc.returncode}")
if exc.stdout:
print("--- stdout ---")
print(exc.stdout)
if exc.stderr:
print("--- stderr ---")
print(exc.stderr)
raise GitError(f"Git command failed: {cmd}") from exc
# ---------------------------------------------------------------------------
# Editor helper for interactive changelog messages
# ---------------------------------------------------------------------------
def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
"""
Open $EDITOR (fallback 'nano') so the user can enter a changelog message.
The temporary file is pre-filled with commented instructions and an
optional initial_message. Lines starting with '#' are ignored when the
message is read back.
Returns the final message (may be empty string if user leaves it blank).
"""
editor = os.environ.get("EDITOR", "nano")
with tempfile.NamedTemporaryFile(
mode="w+",
delete=False,
encoding="utf-8",
) as tmp:
tmp_path = tmp.name
# Prefill with instructions as comments
tmp.write(
"# Write the changelog entry for this release.\n"
"# Lines starting with '#' will be ignored.\n"
"# Empty result will fall back to a generic message.\n\n"
)
if initial_message:
tmp.write(initial_message.strip() + "\n")
tmp.flush()
# Open editor
subprocess.call([editor, tmp_path])
# Read back content
try:
with open(tmp_path, "r", encoding="utf-8") as f:
content = f.read()
finally:
try:
os.remove(tmp_path)
except OSError:
pass
# Filter out commented lines and return joined text
lines = [
line for line in content.splitlines()
if not line.strip().startswith("#")
]
return "\n".join(lines).strip()
# ---------------------------------------------------------------------------
# File update helpers (pyproject + extra packaging + changelog)
# ---------------------------------------------------------------------------
def update_pyproject_version(
pyproject_path: str,
new_version: str,
preview: bool = False,
) -> None:
""" """
Update the version in pyproject.toml with the new version. Update the version in pyproject.toml with the new version.
Parameters:
pyproject_path: Path to the pyproject.toml file.
new_version: The new version string.
"""
with open(pyproject_path, "r") as f:
content = f.read()
# Search for the version string in the format: version = "X.Y.Z"
new_content, count = re.subn(r'(version\s*=\s*")([\d\.]+)(")', r'\1' + new_version + r'\3', content)
if count == 0:
print("Could not find version line in pyproject.toml")
sys.exit(1)
with open(pyproject_path, "w") as f:
f.write(new_content)
print(f"Updated pyproject.toml version to {new_version}")
def update_changelog(changelog_path: str, new_version: str, message: str = None): The function looks for a line matching:
"""
Prepend a new release section to CHANGELOG.md with the new version,
todays date and an optional release message.
Parameters:
changelog_path: Path to the CHANGELOG.md file.
new_version: The new version string.
message: An optional release message.
"""
release_date = date.today().isoformat()
header = f"## [{new_version}] - {release_date}\n"
if message:
header += f"{message}\n"
header += "\n"
try:
with open(changelog_path, "r") as f:
changelog = f.read()
except FileNotFoundError:
changelog = ""
new_changelog = header + changelog
with open(changelog_path, "w") as f:
f.write(new_changelog)
print(f"Updated CHANGELOG.md with version {new_version}")
def run_git_command(cmd: str): version = "X.Y.Z"
"""
Execute a shell command via Git and exit if it fails.
Parameters:
cmd: The shell command to run.
"""
print(f"Running: {cmd}")
result = subprocess.run(cmd, shell=True)
if result.returncode != 0:
print(f"Command failed: {cmd}")
sys.exit(result.returncode)
def release(pyproject_path: str = "pyproject.toml", and replaces the version part with the given new_version string.
changelog_path: str = "CHANGELOG.md",
release_type: str = "patch", It does not try to parse the full TOML structure here. This keeps the
message: str = None): implementation small and robust as long as the version line follows
""" the standard pattern.
Perform a release by incrementing the version in pyproject.toml,
updating CHANGELOG.md with the release version and message, then executing Behaviour:
the Git commands to commit, tag, and push the changes. - In normal mode: write the updated content back to the file.
- In preview mode: do NOT write, only report what would change.
Parameters:
pyproject_path: The path to pyproject.toml.
changelog_path: The path to CHANGELOG.md.
release_type: A string indicating the type of release ("major", "minor", "patch").
message: An optional release message to include in CHANGELOG.md and Git tag.
""" """
try: try:
with open(pyproject_path, "r") as f: with open(pyproject_path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
except FileNotFoundError: except FileNotFoundError:
print(f"{pyproject_path} not found.") print(f"[ERROR] pyproject.toml not found at: {pyproject_path}")
sys.exit(1) sys.exit(1)
match = re.search(r'version\s*=\s*"([\d\.]+)"', content)
if not match:
print("Could not find version in pyproject.toml")
sys.exit(1)
current_version = match.group(1)
new_version = bump_version(current_version, release_type)
# Update files.
update_pyproject_version(pyproject_path, new_version)
update_changelog(changelog_path, new_version, message)
# Execute Git commands.
commit_msg = f"Release version {new_version}"
run_git_command(f'git commit -am "{commit_msg}"')
run_git_command(f'git tag -a v{new_version} -m "{commit_msg}"')
run_git_command("git push origin main")
run_git_command("git push origin --tags")
print(f"Release {new_version} completed successfully.")
# Allow the script to be used as a CLI tool. pattern = r'^(version\s*=\s*")([^"]+)(")'
if __name__ == "__main__": new_content, count = re.subn(
parser = argparse.ArgumentParser( pattern,
description="Perform a release by updating version and changelog, then executing Git commands." lambda m: f'{m.group(1)}{new_version}{m.group(3)}',
content,
flags=re.MULTILINE,
)
if count == 0:
print("[ERROR] Could not find version line in pyproject.toml")
sys.exit(1)
if preview:
print(f"[PREVIEW] Would update pyproject.toml version to {new_version}")
return
with open(pyproject_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated pyproject.toml version to {new_version}")
def update_flake_version(
flake_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in flake.nix, if present.
Looks for a line like:
version = "1.2.3";
and replaces the string inside the quotes. If the file does not
exist or no version line is found, this is treated as a non-fatal
condition and only a log message is printed.
"""
if not os.path.exists(flake_path):
print("[INFO] flake.nix not found, skipping.")
return
try:
with open(flake_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read flake.nix: {exc}")
return
pattern = r'(version\s*=\s*")([^"]+)(")'
new_content, count = re.subn(
pattern,
lambda m: f'{m.group(1)}{new_version}{m.group(3)}',
content,
)
if count == 0:
print("[WARN] No version assignment found in flake.nix, skipping.")
return
if preview:
print(f"[PREVIEW] Would update flake.nix version to {new_version}")
return
with open(flake_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated flake.nix version to {new_version}")
def update_pkgbuild_version(
pkgbuild_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in PKGBUILD, if present.
Expects:
pkgver=1.2.3
pkgrel=1
Behaviour:
- Set pkgver to the new_version (e.g. 1.2.3).
- Reset pkgrel to 1.
If the file does not exist, this is non-fatal and only a log
message is printed.
"""
if not os.path.exists(pkgbuild_path):
print("[INFO] PKGBUILD not found, skipping.")
return
try:
with open(pkgbuild_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read PKGBUILD: {exc}")
return
# Update pkgver
ver_pattern = r"^(pkgver\s*=\s*)(.+)$"
new_content, ver_count = re.subn(
ver_pattern,
lambda m: f"{m.group(1)}{new_version}",
content,
flags=re.MULTILINE,
)
if ver_count == 0:
print("[WARN] No pkgver line found in PKGBUILD.")
new_content = content # revert to original if we didn't change anything
# Reset pkgrel to 1
rel_pattern = r"^(pkgrel\s*=\s*)(.+)$"
new_content, rel_count = re.subn(
rel_pattern,
lambda m: f"{m.group(1)}1",
new_content,
flags=re.MULTILINE,
)
if rel_count == 0:
print("[WARN] No pkgrel line found in PKGBUILD.")
if preview:
print(f"[PREVIEW] Would update PKGBUILD to pkgver={new_version}, pkgrel=1")
return
with open(pkgbuild_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated PKGBUILD to pkgver={new_version}, pkgrel=1")
def update_spec_version(
spec_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in an RPM spec file, if present.
Assumes a file like 'package-manager.spec' with lines:
Version: 1.2.3
Release: 1%{?dist}
Behaviour:
- Set 'Version:' to new_version.
- Reset 'Release:' to '1' while preserving any macro suffix,
e.g. '1%{?dist}'.
If the file does not exist, this is non-fatal and only a log
message is printed.
"""
if not os.path.exists(spec_path):
print("[INFO] RPM spec file not found, skipping.")
return
try:
with open(spec_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read spec file: {exc}")
return
# Update Version:
ver_pattern = r"^(Version:\s*)(.+)$"
new_content, ver_count = re.subn(
ver_pattern,
lambda m: f"{m.group(1)}{new_version}",
content,
flags=re.MULTILINE,
)
if ver_count == 0:
print("[WARN] No 'Version:' line found in spec file.")
# Reset Release:
rel_pattern = r"^(Release:\s*)(.+)$"
def _release_repl(m: re.Match[str]) -> str: # type: ignore[name-defined]
rest = m.group(2).strip()
# Reset numeric prefix to "1" and keep any suffix (e.g. % macros).
match = re.match(r"^(\d+)(.*)$", rest)
if match:
suffix = match.group(2)
else:
suffix = ""
return f"{m.group(1)}1{suffix}"
new_content, rel_count = re.subn(
rel_pattern,
_release_repl,
new_content,
flags=re.MULTILINE,
)
if rel_count == 0:
print("[WARN] No 'Release:' line found in spec file.")
if preview:
print(
f"[PREVIEW] Would update spec file "
f"{os.path.basename(spec_path)} to Version: {new_version}, Release: 1..."
)
return
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(
f"Updated spec file {os.path.basename(spec_path)} "
f"to Version: {new_version}, Release: 1..."
)
def update_changelog(
changelog_path: str,
new_version: str,
message: Optional[str] = None,
preview: bool = False,
) -> str:
"""
Prepend a new release section to CHANGELOG.md with the new version,
current date, and a message.
Behaviour:
- If message is None and preview is False:
→ open $EDITOR (fallback 'nano') to let the user enter a message.
- If message is None and preview is True:
→ use a generic automated message.
- The resulting changelog entry is printed to stdout.
- Returns the final message text used.
"""
today = date.today().isoformat()
# Resolve message
if message is None:
if preview:
# Do not open editor in preview mode; keep it non-interactive.
message = "Automated release."
else:
print(
"\n[INFO] No release message provided, opening editor for "
"changelog entry...\n"
)
editor_message = _open_editor_for_changelog()
if not editor_message:
message = "Automated release."
else:
message = editor_message
header = f"## [{new_version}] - {today}\n"
header += f"\n* {message}\n\n"
if os.path.exists(changelog_path):
try:
with open(changelog_path, "r", encoding="utf-8") as f:
changelog = f.read()
except Exception as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
changelog = ""
else:
changelog = ""
new_changelog = header + "\n" + changelog if changelog else header
# Show the entry that will be written
print("\n================ CHANGELOG ENTRY ================")
print(header.rstrip())
print("=================================================\n")
if preview:
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
return message
with open(changelog_path, "w", encoding="utf-8") as f:
f.write(new_changelog)
print(f"Updated CHANGELOG.md with version {new_version}")
return message
# ---------------------------------------------------------------------------
# Debian changelog helpers (with Git config fallback for maintainer)
# ---------------------------------------------------------------------------
def _get_git_config_value(key: str) -> Optional[str]:
"""
Try to read a value from `git config --get <key>`.
Returns the stripped value or None if not set / on error.
"""
try:
result = subprocess.run(
["git", "config", "--get", key],
capture_output=True,
text=True,
check=False,
)
except Exception:
return None
value = result.stdout.strip()
return value or None
def _get_debian_author() -> Tuple[str, str]:
"""
Determine the maintainer name/email for debian/changelog entries.
Priority:
1. DEBFULLNAME / DEBEMAIL
2. GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL
3. git config user.name / user.email
4. Fallback: 'Unknown Maintainer' / 'unknown@example.com'
"""
name = os.environ.get("DEBFULLNAME")
email = os.environ.get("DEBEMAIL")
if not name:
name = os.environ.get("GIT_AUTHOR_NAME")
if not email:
email = os.environ.get("GIT_AUTHOR_EMAIL")
if not name:
name = _get_git_config_value("user.name")
if not email:
email = _get_git_config_value("user.email")
if not name:
name = "Unknown Maintainer"
if not email:
email = "unknown@example.com"
return name, email
def update_debian_changelog(
debian_changelog_path: str,
package_name: str,
new_version: str,
message: Optional[str] = None,
preview: bool = False,
) -> None:
"""
Prepend a new entry to debian/changelog, if it exists.
The first line typically looks like:
package-name (1.2.3-1) unstable; urgency=medium
We generate a new stanza at the top with Debian-style version
'X.Y.Z-1'. If the file does not exist, this function does nothing.
"""
if not os.path.exists(debian_changelog_path):
print("[INFO] debian/changelog not found, skipping.")
return
debian_version = f"{new_version}-1"
now = datetime.now().astimezone()
# Debian-like date string, e.g. "Mon, 08 Dec 2025 12:34:56 +0100"
date_str = now.strftime("%a, %d %b %Y %H:%M:%S %z")
author_name, author_email = _get_debian_author()
first_line = f"{package_name} ({debian_version}) unstable; urgency=medium"
body_line = (
message.strip() if message else f"Automated release {new_version}."
)
stanza = (
f"{first_line}\n\n"
f" * {body_line}\n\n"
f" -- {author_name} <{author_email}> {date_str}\n\n"
)
if preview:
print(
"[PREVIEW] Would prepend the following stanza to debian/changelog:\n"
f"{stanza}"
)
return
try:
with open(debian_changelog_path, "r", encoding="utf-8") as f:
existing = f.read()
except Exception as exc:
print(f"[WARN] Could not read debian/changelog: {exc}")
existing = ""
new_content = stanza + existing
with open(debian_changelog_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated debian/changelog with version {debian_version}")
# ---------------------------------------------------------------------------
# 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,
) -> None:
"""
Perform a release by:
1. Determining the current version from Git tags.
2. Computing the next version (major/minor/patch).
3. Updating pyproject.toml with the new version.
4. Updating CHANGELOG.md with a new entry.
5. Updating additional packaging files where present:
- flake.nix
- PKGBUILD
- debian/changelog
- package-manager.spec
6. Staging all these files.
7. Committing, tagging, and pushing the changes.
If `preview` is True, no files are written and no Git commands
are executed. Instead, the planned actions are printed.
"""
# 1) Determine the current version from Git tags.
current_ver = _determine_current_version()
# 2) Compute the next 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})")
# Determine repository root based on pyproject location
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
# 2) Update files.
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
# Let update_changelog resolve or edit the message; reuse it for debian.
message = update_changelog(
changelog_path,
new_ver_str,
message=message,
preview=preview,
)
# Additional packaging files (non-fatal if missing)
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)
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
# Use repo directory name as a simple default for package name
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=message,
preview=preview,
)
# 3) Git operations: stage, commit, tag, push.
commit_msg = f"Release version {new_ver_str}"
tag_msg = message or commit_msg
try:
branch = get_current_branch() or "main"
except GitError:
branch = "main"
print(f"Releasing on branch: {branch}")
# Stage all relevant packaging files so they are included in the commit
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")
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")
print(f"Release {new_ver_str} completed.")
# ---------------------------------------------------------------------------
# CLI entry point for standalone use
# ---------------------------------------------------------------------------
def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="pkgmgr release helper")
parser.add_argument(
"release_type",
choices=["major", "minor", "patch"],
help="Type of release (major/minor/patch).",
)
parser.add_argument(
"-m",
"--message",
dest="message",
default=None,
help="Release message to use for changelog and tag.",
)
parser.add_argument(
"--pyproject",
dest="pyproject",
default="pyproject.toml",
help="Path to pyproject.toml (default: pyproject.toml)",
)
parser.add_argument(
"--changelog",
dest="changelog",
default="CHANGELOG.md",
help="Path to CHANGELOG.md (default: CHANGELOG.md)",
)
parser.add_argument(
"--preview",
action="store_true",
help="Preview release changes without modifying files or running git.",
)
return parser.parse_args(argv)
if __name__ == "__main__":
args = _parse_args()
release(
pyproject_path=args.pyproject,
changelog_path=args.changelog,
release_type=args.release_type,
message=args.message,
preview=args.preview,
) )
parser.add_argument("release_type", choices=["major", "minor", "patch"],
help="Type of release increment (major, minor, patch).")
parser.add_argument("-m", "--message", help="Optional release message for changelog and tag.", default=None)
args = parser.parse_args()
release(release_type=args.release_type, message=args.message)

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "package-manager" name = "package-manager"
version = "0.1.1" version = "2.1.0"
description = "Kevin's package-manager tool (pkgmgr)" description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -8,33 +8,37 @@ from unittest.mock import patch
class TestIntegrationBranchCommands(unittest.TestCase): class TestIntegrationBranchCommands(unittest.TestCase):
""" """
E2E-style tests for the 'pkgmgr branch' CLI wiring. Integration tests for the `pkgmgr branch` CLI wiring.
We do NOT call real git; instead we patch pkgmgr.branch_commands.open_branch These tests execute the real entry point (main.py) and mock
and verify that the CLI invokes it with the correct parameters. the high-level `open_branch` helper to ensure that argument
parsing and dispatch behave as expected.
""" """
def _run_pkgmgr(self, argv: list[str]) -> None: def _run_pkgmgr(self, extra_args: list[str]) -> None:
""" """
Helper to run 'pkgmgr' via its entry module with a given argv. Run the main entry point with the given extra args, as if called via:
pkgmgr <extra_args...>
We explicitly set sys.argv and execute main.py as __main__ using runpy.
""" """
original_argv = list(sys.argv) original_argv = list(sys.argv)
try: try:
# argv typically looks like: ["pkgmgr", "branch", ...] # argv[0] is the program name; the rest are CLI arguments.
sys.argv = argv sys.argv = ["pkgmgr"] + list(extra_args)
# Run the CLI entry point runpy.run_module("main", run_name="__main__")
runpy.run_module("pkgmgr.cli", run_name="__main__")
finally: finally:
sys.argv = original_argv sys.argv = original_argv
@patch("pkgmgr.branch_commands.open_branch") @patch("pkgmgr.cli_core.commands.branch.open_branch")
def test_branch_open_with_name_and_base(self, mock_open_branch) -> None: def test_branch_open_with_name_and_base(self, mock_open_branch) -> None:
""" """
pkgmgr branch open feature/test --base develop `pkgmgr branch open feature/test --base develop` must forward
should invoke open_branch(name='feature/test', base_branch='develop', cwd='.') the name and base branch to open_branch() with cwd=".".
""" """
self._run_pkgmgr( self._run_pkgmgr(
["pkgmgr", "branch", "open", "feature/test", "--base", "develop"] ["branch", "open", "feature/test", "--base", "develop"]
) )
mock_open_branch.assert_called_once() mock_open_branch.assert_called_once()
@@ -43,14 +47,16 @@ class TestIntegrationBranchCommands(unittest.TestCase):
self.assertEqual(kwargs.get("base_branch"), "develop") self.assertEqual(kwargs.get("base_branch"), "develop")
self.assertEqual(kwargs.get("cwd"), ".") self.assertEqual(kwargs.get("cwd"), ".")
@patch("pkgmgr.branch_commands.open_branch") @patch("pkgmgr.cli_core.commands.branch.open_branch")
def test_branch_open_without_name_uses_default_base(self, mock_open_branch) -> None: def test_branch_open_without_name_uses_default_base(
self,
mock_open_branch,
) -> None:
""" """
pkgmgr branch open `pkgmgr branch open` without a name must still call open_branch(),
should invoke open_branch(name=None, base_branch='main', cwd='.') passing name=None and the default base branch 'main'.
(the branch name will be asked interactively inside open_branch).
""" """
self._run_pkgmgr(["pkgmgr", "branch", "open"]) self._run_pkgmgr(["branch", "open"])
mock_open_branch.assert_called_once() mock_open_branch.assert_called_once()
_, kwargs = mock_open_branch.call_args _, kwargs = mock_open_branch.call_args

View File

@@ -1,63 +1,100 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Integration tests for the `pkgmgr release` command.
We deliberately only test a *negative* path here, to avoid mutating
the real repositories (bumping versions, editing changelogs) during
CI runs.
The test verifies that:
- Calling `pkgmgr release` with a non-existent repository identifier
results in a non-zero exit code and a helpful error.
"""
from __future__ import annotations from __future__ import annotations
import os
import runpy import runpy
import sys import sys
import unittest import unittest
PROJECT_ROOT = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
class TestIntegrationReleaseCommand(unittest.TestCase): class TestIntegrationReleaseCommand(unittest.TestCase):
""" def _run_pkgmgr(
E2E tests for `pkgmgr release`. self,
""" argv: list[str],
expect_success: bool,
def _run_release_expect_failure(self) -> None: ) -> None:
cmd_repr = "pkgmgr release patch does-not-exist-xyz" """
Run the main entry point with the given argv and assert on success/failure.
argv must include the program name as argv[0], e.g. "":
["", "release", "patch", "pkgmgr", "--preview"]
"""
cmd_repr = " ".join(argv[1:])
original_argv = list(sys.argv) original_argv = list(sys.argv)
try: try:
sys.argv = [ sys.argv = argv
"pkgmgr",
"release",
"patch",
"does-not-exist-xyz",
]
try: try:
# Execute main.py as if called via `python main.py ...`
runpy.run_module("main", run_name="__main__") runpy.run_module("main", run_name="__main__")
except SystemExit as exc: except SystemExit as exc:
code = exc.code if isinstance(exc.code, int) else str(exc.code) code = exc.code if isinstance(exc.code, int) else 1
# Hier wirklich verifizieren: if expect_success and code != 0:
assert code != 0, f"{cmd_repr!r} unexpectedly succeeded with exit code 0" print()
print("[TEST] pkgmgr release failed as expected") print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Command : {cmd_repr}") print(f"[TEST] Exit code : {code}")
print(f"[TEST] Exit code : {code}") raise AssertionError(
f"{cmd_repr!r} failed with exit code {code}. "
"Scroll up to inspect the output printed before failure."
) from exc
if not expect_success and code == 0:
print()
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code : {code}")
raise AssertionError(
f"{cmd_repr!r} unexpectedly succeeded with exit code 0."
) from exc
else: else:
# Kein SystemExit -> auf jeden Fall falsch # No SystemExit: treat as success when expect_success is True,
raise AssertionError( # otherwise as a failure (we expected a non-zero exit).
f"{cmd_repr!r} returned normally (expected non-zero exit)." if not expect_success:
) raise AssertionError(
f"{cmd_repr!r} returned normally (expected non-zero exit)."
)
finally: finally:
sys.argv = original_argv sys.argv = original_argv
def test_release_for_unknown_repo_fails_cleanly(self) -> None: def test_release_for_unknown_repo_fails_cleanly(self) -> None:
self._run_release_expect_failure() """
Releasing a non-existent repository identifier must fail
with a non-zero exit code, but without crashing the interpreter.
"""
argv = [
"",
"release",
"patch",
"does-not-exist-xyz",
]
self._run_pkgmgr(argv, expect_success=False)
def test_release_preview_for_pkgmgr_repository(self) -> None:
"""
Sanity-check the happy path for the CLI:
- Runs `pkgmgr release patch pkgmgr --preview`
- Must exit with code 0
- Uses the real configuration + repository selection
- Exercises the new --preview mode end-to-end.
"""
argv = [
"",
"release",
"patch",
"pkgmgr",
"--preview",
]
original_cwd = os.getcwd()
try:
os.chdir(PROJECT_ROOT)
self._run_pkgmgr(argv, expect_success=True)
finally:
os.chdir(original_cwd)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,510 @@
from __future__ import annotations
import os
import tempfile
import textwrap
import unittest
from unittest.mock import patch
from pkgmgr.versioning import SemVer
from pkgmgr.release import (
_determine_current_version,
_bump_semver,
update_pyproject_version,
update_flake_version,
update_pkgbuild_version,
update_spec_version,
update_changelog,
update_debian_changelog,
release,
)
class TestDetermineCurrentVersion(unittest.TestCase):
@patch("pkgmgr.release.get_tags", return_value=[])
def test_determine_current_version_no_tags_returns_zero(
self,
mock_get_tags,
) -> None:
ver = _determine_current_version()
self.assertIsInstance(ver, SemVer)
self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0))
mock_get_tags.assert_called_once()
@patch("pkgmgr.release.find_latest_version")
@patch("pkgmgr.release.get_tags")
def test_determine_current_version_uses_latest_semver_tag(
self,
mock_get_tags,
mock_find_latest_version,
) -> None:
mock_get_tags.return_value = ["v0.1.0", "v1.2.3"]
mock_find_latest_version.return_value = ("v1.2.3", SemVer(1, 2, 3))
ver = _determine_current_version()
self.assertEqual((ver.major, ver.minor, ver.patch), (1, 2, 3))
mock_get_tags.assert_called_once()
mock_find_latest_version.assert_called_once_with(["v0.1.0", "v1.2.3"])
class TestBumpSemVer(unittest.TestCase):
def test_bump_semver_major(self) -> None:
base = SemVer(1, 2, 3)
bumped = _bump_semver(base, "major")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (2, 0, 0))
def test_bump_semver_minor(self) -> None:
base = SemVer(1, 2, 3)
bumped = _bump_semver(base, "minor")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 3, 0))
def test_bump_semver_patch(self) -> None:
base = SemVer(1, 2, 3)
bumped = _bump_semver(base, "patch")
self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 2, 4))
def test_bump_semver_invalid_type_raises(self) -> None:
base = SemVer(1, 2, 3)
with self.assertRaises(ValueError):
_bump_semver(base, "invalid-type")
class TestUpdatePyprojectVersion(unittest.TestCase):
def test_update_pyproject_version_replaces_version_line(self) -> None:
original = textwrap.dedent(
"""
[project]
name = "example"
version = "0.1.0"
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "pyproject.toml")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_pyproject_version(path, "1.2.3", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn('version = "1.2.3"', content)
self.assertNotIn('version = "0.1.0"', content)
def test_update_pyproject_version_preview_does_not_write(self) -> None:
original = textwrap.dedent(
"""
[project]
name = "example"
version = "0.1.0"
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "pyproject.toml")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_pyproject_version(path, "1.2.3", preview=True)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Content must be unchanged in preview mode
self.assertEqual(content, original)
def test_update_pyproject_version_exits_when_no_version_line_found(self) -> None:
original = textwrap.dedent(
"""
[project]
name = "example"
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "pyproject.toml")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
with self.assertRaises(SystemExit) as cm:
update_pyproject_version(path, "1.2.3", preview=False)
self.assertNotEqual(cm.exception.code, 0)
class TestUpdateFlakeVersion(unittest.TestCase):
def test_update_flake_version_normal(self) -> None:
original = 'version = "0.1.0";\n'
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "flake.nix")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_flake_version(path, "1.2.3", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn('version = "1.2.3";', content)
self.assertNotIn('version = "0.1.0";', content)
def test_update_flake_version_preview(self) -> None:
original = 'version = "0.1.0";\n'
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "flake.nix")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_flake_version(path, "1.2.3", preview=True)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
class TestUpdatePkgbuildVersion(unittest.TestCase):
def test_update_pkgbuild_version_normal(self) -> None:
original = textwrap.dedent(
"""
pkgname=example
pkgver=0.1.0
pkgrel=5
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "PKGBUILD")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_pkgbuild_version(path, "1.2.3", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("pkgver=1.2.3", content)
self.assertIn("pkgrel=1", content)
self.assertNotIn("pkgver=0.1.0", content)
def test_update_pkgbuild_version_preview(self) -> None:
original = textwrap.dedent(
"""
pkgname=example
pkgver=0.1.0
pkgrel=5
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "PKGBUILD")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_pkgbuild_version(path, "1.2.3", preview=True)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
class TestUpdateSpecVersion(unittest.TestCase):
def test_update_spec_version_normal(self) -> None:
original = textwrap.dedent(
"""
Name: package-manager
Version: 0.1.0
Release: 5%{?dist}
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "package-manager.spec")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_spec_version(path, "1.2.3", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("Version: 1.2.3", content)
self.assertIn("Release: 1%{?dist}", content)
self.assertNotIn("Version: 0.1.0", content)
self.assertNotIn("Release: 5%{?dist}", content)
def test_update_spec_version_preview(self) -> None:
original = textwrap.dedent(
"""
Name: package-manager
Version: 0.1.0
Release: 5%{?dist}
"""
).strip() + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "package-manager.spec")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_spec_version(path, "1.2.3", preview=True)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
class TestUpdateChangelog(unittest.TestCase):
def test_update_changelog_creates_file_if_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
self.assertFalse(os.path.exists(path))
update_changelog(path, "1.2.3", message="First release", preview=False)
self.assertTrue(os.path.exists(path))
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("## [1.2.3]", content)
self.assertIn("First release", content)
def test_update_changelog_prepends_entry_to_existing_content(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
with open(path, "w", encoding="utf-8") as f:
f.write("## [0.1.0] - 2024-01-01\n\n* Initial content\n")
update_changelog(path, "1.0.0", message=None, preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# New entry must be on top
self.assertTrue(content.startswith("## [1.0.0]"))
self.assertIn("## [0.1.0] - 2024-01-01", content)
def test_update_changelog_preview_does_not_write(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
original = "## [0.1.0] - 2024-01-01\n\n* Initial content\n"
with open(path, "w", encoding="utf-8") as f:
f.write(original)
update_changelog(path, "1.0.0", message="Preview only", preview=True)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
class TestUpdateDebianChangelog(unittest.TestCase):
def test_update_debian_changelog_creates_new_stanza(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "changelog")
# existing content
with open(path, "w", encoding="utf-8") as f:
f.write("existing content\n")
with patch.dict(
os.environ,
{
"DEBFULLNAME": "Test Maintainer",
"DEBEMAIL": "test@example.com",
},
clear=False,
):
update_debian_changelog(
debian_changelog_path=path,
package_name="package-manager",
new_version="1.2.3",
message="Test debian entry",
preview=False,
)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("package-manager (1.2.3-1) unstable; urgency=medium", content)
self.assertIn(" * Test debian entry", content)
self.assertIn("Test Maintainer <test@example.com>", content)
self.assertIn("existing content", content)
def test_update_debian_changelog_preview_does_not_write(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "changelog")
original = "existing content\n"
with open(path, "w", encoding="utf-8") as f:
f.write(original)
with patch.dict(
os.environ,
{
"DEBFULLNAME": "Test Maintainer",
"DEBEMAIL": "test@example.com",
},
clear=False,
):
update_debian_changelog(
debian_changelog_path=path,
package_name="package-manager",
new_version="1.2.3",
message="Test debian entry",
preview=True,
)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
class TestReleaseOrchestration(unittest.TestCase):
@patch("pkgmgr.release._run_git_command")
@patch("pkgmgr.release.update_debian_changelog")
@patch("pkgmgr.release.update_spec_version")
@patch("pkgmgr.release.update_pkgbuild_version")
@patch("pkgmgr.release.update_flake_version")
@patch("pkgmgr.release.get_current_branch", return_value="develop")
@patch("pkgmgr.release.update_changelog")
@patch("pkgmgr.release.update_pyproject_version")
@patch("pkgmgr.release._bump_semver")
@patch("pkgmgr.release._determine_current_version")
def test_release_happy_path_uses_helpers_and_git(
self,
mock_determine_current_version,
mock_bump_semver,
mock_update_pyproject,
mock_update_changelog,
mock_get_current_branch,
mock_update_flake,
mock_update_pkgbuild,
mock_update_spec,
mock_update_debian_changelog,
mock_run_git_command,
) -> None:
mock_determine_current_version.return_value = SemVer(1, 2, 3)
mock_bump_semver.return_value = SemVer(1, 2, 4)
release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type="patch",
message="Test release",
preview=False,
)
# Current version + bump
mock_determine_current_version.assert_called_once()
mock_bump_semver.assert_called_once()
args, kwargs = mock_bump_semver.call_args
self.assertEqual(args[0], SemVer(1, 2, 3))
self.assertEqual(args[1], "patch")
self.assertEqual(kwargs, {})
# pyproject update
mock_update_pyproject.assert_called_once()
args, kwargs = mock_update_pyproject.call_args
self.assertEqual(args[0], "pyproject.toml")
self.assertEqual(args[1], "1.2.4")
self.assertEqual(kwargs.get("preview"), False)
# changelog update
mock_update_changelog.assert_called_once()
args, kwargs = mock_update_changelog.call_args
self.assertEqual(args[0], "CHANGELOG.md")
self.assertEqual(args[1], "1.2.4")
self.assertEqual(kwargs.get("message"), "Test release")
self.assertEqual(kwargs.get("preview"), False)
# repo root is derived from pyproject path; we don't care about
# exact paths here, only that helpers are called with preview=False.
mock_update_flake.assert_called_once()
self.assertEqual(mock_update_flake.call_args[1].get("preview"), False)
mock_update_pkgbuild.assert_called_once()
self.assertEqual(mock_update_pkgbuild.call_args[1].get("preview"), False)
mock_update_spec.assert_called_once()
self.assertEqual(mock_update_spec.call_args[1].get("preview"), False)
mock_update_debian_changelog.assert_called_once()
self.assertEqual(
mock_update_debian_changelog.call_args[1].get("preview"),
False,
)
# Git operations
mock_get_current_branch.assert_called_once()
self.assertEqual(mock_get_current_branch.return_value, "develop")
git_calls = [c.args[0] for c in mock_run_git_command.call_args_list]
self.assertIn('git commit -am "Release version 1.2.4"', git_calls)
self.assertIn('git tag -a v1.2.4 -m "Test release"', git_calls)
self.assertIn("git push origin develop", git_calls)
self.assertIn("git push origin --tags", git_calls)
@patch("pkgmgr.release._run_git_command")
@patch("pkgmgr.release.update_debian_changelog")
@patch("pkgmgr.release.update_spec_version")
@patch("pkgmgr.release.update_pkgbuild_version")
@patch("pkgmgr.release.update_flake_version")
@patch("pkgmgr.release.get_current_branch", return_value="develop")
@patch("pkgmgr.release.update_changelog")
@patch("pkgmgr.release.update_pyproject_version")
@patch("pkgmgr.release._bump_semver")
@patch("pkgmgr.release._determine_current_version")
def test_release_preview_mode_skips_git_and_uses_preview_flag(
self,
mock_determine_current_version,
mock_bump_semver,
mock_update_pyproject,
mock_update_changelog,
mock_get_current_branch,
mock_update_flake,
mock_update_pkgbuild,
mock_update_spec,
mock_update_debian_changelog,
mock_run_git_command,
) -> None:
mock_determine_current_version.return_value = SemVer(1, 2, 3)
mock_bump_semver.return_value = SemVer(1, 2, 4)
release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type="patch",
message="Preview release",
preview=True,
)
# All update helpers must be called with preview=True
mock_update_pyproject.assert_called_once()
self.assertTrue(mock_update_pyproject.call_args[1].get("preview"))
mock_update_changelog.assert_called_once()
self.assertTrue(mock_update_changelog.call_args[1].get("preview"))
mock_update_flake.assert_called_once()
self.assertTrue(mock_update_flake.call_args[1].get("preview"))
mock_update_pkgbuild.assert_called_once()
self.assertTrue(mock_update_pkgbuild.call_args[1].get("preview"))
mock_update_spec.assert_called_once()
self.assertTrue(mock_update_spec.call_args[1].get("preview"))
mock_update_debian_changelog.assert_called_once()
self.assertTrue(mock_update_debian_changelog.call_args[1].get("preview"))
# In preview mode no git commands must be executed
mock_run_git_command.assert_not_called()
if __name__ == "__main__":
unittest.main()