Release version 2.1.0
This commit is contained in:
4
CHANGELOG.md
Normal file
4
CHANGELOG.md
Normal 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)
|
||||||
|
|
||||||
2
PKGBUILD
2
PKGBUILD
@@ -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
6
debian/changelog
vendored
@@ -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.
|
||||||
|
|||||||
@@ -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 = ./.;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
today’s 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)
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
510
tests/unit/pkgmgr/test_release.py
Normal file
510
tests/unit/pkgmgr/test_release.py
Normal 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()
|
||||||
Reference in New Issue
Block a user