Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
804245325d | ||
|
|
c05e77658a | ||
|
|
324f6db1f3 | ||
|
|
2a69a83d71 | ||
|
|
0ec4ccbe40 | ||
|
|
0d864867cd | ||
|
|
3ff0afe828 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -13,8 +13,11 @@ jobs:
|
|||||||
test-integration:
|
test-integration:
|
||||||
uses: ./.github/workflows/test-integration.yml
|
uses: ./.github/workflows/test-integration.yml
|
||||||
|
|
||||||
test-container:
|
test-env-virtual:
|
||||||
uses: ./.github/workflows/test-container.yml
|
uses: ./.github/workflows/test-env-virtual.yml
|
||||||
|
|
||||||
|
test-env-nix:
|
||||||
|
uses: ./.github/workflows/test-env-nix.yml
|
||||||
|
|
||||||
test-e2e:
|
test-e2e:
|
||||||
uses: ./.github/workflows/test-e2e.yml
|
uses: ./.github/workflows/test-e2e.yml
|
||||||
|
|||||||
10
.github/workflows/mark-stable.yml
vendored
10
.github/workflows/mark-stable.yml
vendored
@@ -14,8 +14,11 @@ jobs:
|
|||||||
test-integration:
|
test-integration:
|
||||||
uses: ./.github/workflows/test-integration.yml
|
uses: ./.github/workflows/test-integration.yml
|
||||||
|
|
||||||
test-container:
|
test-env-virtual:
|
||||||
uses: ./.github/workflows/test-container.yml
|
uses: ./.github/workflows/test-env-virtual.yml
|
||||||
|
|
||||||
|
test-env-nix:
|
||||||
|
uses: ./.github/workflows/test-env-nix.yml
|
||||||
|
|
||||||
test-e2e:
|
test-e2e:
|
||||||
uses: ./.github/workflows/test-e2e.yml
|
uses: ./.github/workflows/test-e2e.yml
|
||||||
@@ -30,7 +33,8 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- test-unit
|
- test-unit
|
||||||
- test-integration
|
- test-integration
|
||||||
- test-container
|
- test-env-nix
|
||||||
|
- test-env-virtual
|
||||||
- test-e2e
|
- test-e2e
|
||||||
- test-virgin-user
|
- test-virgin-user
|
||||||
- test-virgin-root
|
- test-virgin-root
|
||||||
|
|||||||
26
.github/workflows/test-env-nix.yml
vendored
Normal file
26
.github/workflows/test-env-nix.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: Test Virgin Nix (flake only)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-env-nix:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
distro: [arch, debian, ubuntu, fedora, centos]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Show Docker version
|
||||||
|
run: docker version
|
||||||
|
|
||||||
|
- name: Nix flake-only test (${{ matrix.distro }})
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
distro="${{ matrix.distro }}" make test-env-nix
|
||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-container:
|
test-env-virtual:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
strategy:
|
strategy:
|
||||||
@@ -25,4 +25,4 @@ jobs:
|
|||||||
- name: Run container tests (${{ matrix.distro }})
|
- name: Run container tests (${{ matrix.distro }})
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
distro="${{ matrix.distro }}" make test-container
|
distro="${{ matrix.distro }}" make test-env-virtual
|
||||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -1,3 +1,32 @@
|
|||||||
|
## [1.2.1] - 2025-12-12
|
||||||
|
|
||||||
|
* **Changed**
|
||||||
|
|
||||||
|
* Split container tests into *virtualenv* and *Nix flake* environments to clearly separate Python and Nix responsibilities.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
* Fixed Nix installer permission issues when running under a different user in containers.
|
||||||
|
* Improved reliability of post-install Nix initialization across all distro packages.
|
||||||
|
|
||||||
|
**CI**
|
||||||
|
|
||||||
|
* Replaced generic container tests with explicit environment checks.
|
||||||
|
* Validate Nix availability via *nix flake* tests instead of Docker build-time side effects.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.2.0] - 2025-12-12
|
||||||
|
|
||||||
|
* **Release workflow overhaul**
|
||||||
|
|
||||||
|
* Introduced a fully structured release workflow with clear phases and safeguards
|
||||||
|
* Added preview-first releases with explicit confirmation before execution
|
||||||
|
* Automatic handling of *latest* tag when a release is the newest version
|
||||||
|
* Optional branch closing after successful releases with interactive confirmation
|
||||||
|
* Improved safety by syncing with remote before any changes
|
||||||
|
* Clear separation of concerns (workflow, git handling, prompts, versioning)
|
||||||
|
|
||||||
|
|
||||||
## [1.1.0] - 2025-12-12
|
## [1.1.0] - 2025-12-12
|
||||||
|
|
||||||
* Added *branch drop* for destructive branch deletion and introduced *--force/-f* flags for branch close and branch drop to skip confirmation prompts.
|
* Added *branch drop* for destructive branch deletion and introduced *--force/-f* flags for branch close and branch drop to skip confirmation prompts.
|
||||||
|
|||||||
26
Makefile
26
Makefile
@@ -1,9 +1,10 @@
|
|||||||
.PHONY: install setup uninstall \
|
.PHONY: install setup uninstall \
|
||||||
test build build-no-cache test-unit test-e2e test-integration \
|
test build build-no-cache build-no-cache-all build-missing delete-volumes purge \
|
||||||
test-container
|
test-unit test-e2e test-integration test-env-virtual test-env-nix
|
||||||
|
|
||||||
# Distro
|
# Distro
|
||||||
# Options: arch debian ubuntu fedora centos
|
# Options: arch debian ubuntu fedora centos
|
||||||
|
DISTROS ?= arch debian ubuntu fedora centos
|
||||||
distro ?= arch
|
distro ?= arch
|
||||||
export distro
|
export distro
|
||||||
|
|
||||||
@@ -37,11 +38,18 @@ setup:
|
|||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Docker build targets (delegated to scripts/build)
|
# Docker build targets (delegated to scripts/build)
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
build:
|
||||||
|
@bash scripts/build/build-image.sh
|
||||||
|
|
||||||
build-no-cache:
|
build-no-cache:
|
||||||
@bash scripts/build/build-image-no-cache.sh
|
@bash scripts/build/build-image-no-cache.sh
|
||||||
|
|
||||||
build:
|
build-no-cache-all:
|
||||||
@bash scripts/build/build-image.sh
|
@set -e; \
|
||||||
|
for d in $(DISTROS); do \
|
||||||
|
echo "=== build-no-cache: $$d ==="; \
|
||||||
|
distro="$$d" $(MAKE) build-no-cache; \
|
||||||
|
done
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Test targets (delegated to scripts/test)
|
# Test targets (delegated to scripts/test)
|
||||||
@@ -56,8 +64,12 @@ test-integration: build-missing
|
|||||||
test-e2e: build-missing
|
test-e2e: build-missing
|
||||||
@bash scripts/test/test-e2e.sh
|
@bash scripts/test/test-e2e.sh
|
||||||
|
|
||||||
test-container: build-missing
|
test-env-virtual: build-missing
|
||||||
@bash scripts/test/test-container.sh
|
@bash scripts/test/test-env-virtual.sh
|
||||||
|
|
||||||
|
test-env-nix: build-missing
|
||||||
|
@bash scripts/test/test-env-nix.sh
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Build only missing container images
|
# Build only missing container images
|
||||||
@@ -66,7 +78,7 @@ build-missing:
|
|||||||
@bash scripts/build/build-image-missing.sh
|
@bash scripts/build/build-image-missing.sh
|
||||||
|
|
||||||
# Combined test target for local + CI (unit + integration + e2e)
|
# Combined test target for local + CI (unit + integration + e2e)
|
||||||
test: test-container test-unit test-integration test-e2e
|
test: test-env-virtual test-unit test-integration test-e2e
|
||||||
|
|
||||||
delete-volumes:
|
delete-volumes:
|
||||||
@docker volume rm pkgmgr_nix_store_${distro} pkgmgr_nix_cache_${distro} || true
|
@docker volume rm pkgmgr_nix_store_${distro} pkgmgr_nix_cache_${distro} || true
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ The following diagram gives a full overview of:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
**Diagram status:** 11 December 2025
|
**Diagram status:** 12 December 2025
|
||||||
**Always-up-to-date version:** [https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)
|
**Always-up-to-date version:** [https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
BIN
assets/map.png
BIN
assets/map.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
@@ -36,7 +36,7 @@
|
|||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "1.1.0";
|
version = "1.2.1";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
post_install() {
|
post_install() {
|
||||||
/usr/lib/package-manager/init-nix.sh || true
|
/usr/lib/package-manager/init-nix.sh || echo ">>> ERROR: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||||
}
|
}
|
||||||
|
|
||||||
post_upgrade() {
|
post_upgrade() {
|
||||||
/usr/lib/package-manager/init-nix.sh || true
|
/usr/lib/package-manager/init-nix.sh || echo ">>> ERROR: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||||
}
|
}
|
||||||
|
|
||||||
post_remove() {
|
post_remove() {
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ set -e
|
|||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
configure)
|
configure)
|
||||||
if [ -x /usr/lib/package-manager/init-nix.sh ]; then
|
/usr/lib/package-manager/init-nix.sh || echo ">>> ERROR: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||||
/usr/lib/package-manager/init-nix.sh || true
|
|
||||||
else
|
|
||||||
echo ">>> Warning: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
|
||||||
fi
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,7 @@ rm -rf \
|
|||||||
%{buildroot}/usr/lib/package-manager/.gitkeep || true
|
%{buildroot}/usr/lib/package-manager/.gitkeep || true
|
||||||
|
|
||||||
%post
|
%post
|
||||||
# Initialize Nix (if needed) after installing the package-manager files.
|
/usr/lib/package-manager/init-nix.sh || echo ">>> ERROR: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||||
if [ -x /usr/lib/package-manager/init-nix.sh ]; then
|
|
||||||
/usr/lib/package-manager/init-nix.sh || true
|
|
||||||
else
|
|
||||||
echo ">>> Warning: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
|
||||||
fi
|
|
||||||
|
|
||||||
%postun
|
%postun
|
||||||
echo ">>> package-manager removed. Nix itself was not removed."
|
echo ">>> package-manager removed. Nix itself was not removed."
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "package-manager"
|
name = "package-manager"
|
||||||
version = "1.1.0"
|
version = "1.2.1"
|
||||||
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"
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ install_nix_with_retry() {
|
|||||||
|
|
||||||
installer="$(mktemp -t nix-installer.XXXXXX)"
|
installer="$(mktemp -t nix-installer.XXXXXX)"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# FIX: mktemp creates files with 0600 by default, which breaks when we later
|
||||||
|
# run the installer as a different user (e.g., 'nix' in container+root).
|
||||||
|
# Make it readable and (best-effort) owned by the target user.
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
chmod 0644 "${installer}"
|
||||||
|
|
||||||
echo "[init-nix] Downloading Nix installer from ${NIX_INSTALL_URL} with retry (max ${NIX_DOWNLOAD_MAX_TIME}s)..."
|
echo "[init-nix] Downloading Nix installer from ${NIX_INSTALL_URL} with retry (max ${NIX_DOWNLOAD_MAX_TIME}s)..."
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
@@ -103,6 +110,9 @@ install_nix_with_retry() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [[ -n "${run_as}" ]]; then
|
if [[ -n "${run_as}" ]]; then
|
||||||
|
# Best-effort: ensure the target user can read the downloaded installer
|
||||||
|
chown "${run_as}:${run_as}" "${installer}" 2>/dev/null || true
|
||||||
|
|
||||||
echo "[init-nix] Running installer as user '${run_as}' with mode '${mode}'..."
|
echo "[init-nix] Running installer as user '${run_as}' with mode '${mode}'..."
|
||||||
if command -v sudo >/dev/null 2>&1; then
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
sudo -u "${run_as}" bash -lc "sh '${installer}' ${mode_flag}"
|
sudo -u "${run_as}" bash -lc "sh '${installer}' ${mode_flag}"
|
||||||
|
|||||||
@@ -3,20 +3,41 @@ set -euo pipefail
|
|||||||
|
|
||||||
# venv-create.sh
|
# venv-create.sh
|
||||||
#
|
#
|
||||||
# Small helper to create/update a Python virtual environment for pkgmgr.
|
# Create/update a Python virtual environment for pkgmgr and install dependencies.
|
||||||
|
#
|
||||||
|
# Priority order:
|
||||||
|
# 1) pyproject.toml -> pip install (editable by default)
|
||||||
|
# 2) requirements.txt
|
||||||
|
# 3) _requirements.txt (legacy)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# PKGMGR_VENV_DIR=/home/dev/.venvs/pkgmgr bash scripts/installation/venv-create.sh
|
# PKGMGR_VENV_DIR=/home/dev/.venvs/pkgmgr bash scripts/installation/venv-create.sh
|
||||||
# or
|
# or
|
||||||
# bash scripts/installation/venv-create.sh /home/dev/.venvs/pkgmgr
|
# bash scripts/installation/venv-create.sh /home/dev/.venvs/pkgmgr
|
||||||
|
#
|
||||||
|
# Optional:
|
||||||
|
# PKGMGR_PIP_EDITABLE=0 # install non-editable (default: 1)
|
||||||
|
# PKGMGR_PIP_EXTRAS="dev,test" # install extras: .[dev,test]
|
||||||
|
# PKGMGR_PREFER_NIX=1 # print Nix hint and exit non-zero
|
||||||
|
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
cd "${PROJECT_ROOT}"
|
cd "${PROJECT_ROOT}"
|
||||||
|
|
||||||
VENV_DIR="${PKGMGR_VENV_DIR:-${1:-${HOME}/.venvs/pkgmgr}}"
|
VENV_DIR="${PKGMGR_VENV_DIR:-${1:-${HOME}/.venvs/pkgmgr}}"
|
||||||
|
PIP_EDITABLE="${PKGMGR_PIP_EDITABLE:-1}"
|
||||||
|
PIP_EXTRAS="${PKGMGR_PIP_EXTRAS:-}"
|
||||||
|
PREFER_NIX="${PKGMGR_PREFER_NIX:-0}"
|
||||||
|
|
||||||
echo "[venv-create] Using VENV_DIR=${VENV_DIR}"
|
echo "[venv-create] Using VENV_DIR=${VENV_DIR}"
|
||||||
|
|
||||||
|
if [[ "${PREFER_NIX}" == "1" ]]; then
|
||||||
|
echo "[venv-create] PKGMGR_PREFER_NIX=1 set."
|
||||||
|
echo "[venv-create] Hint: Use Nix instead of a venv for reproducible installs:"
|
||||||
|
echo "[venv-create] nix develop"
|
||||||
|
echo "[venv-create] nix run .#pkgmgr -- --help"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[venv-create] Ensuring virtualenv parent directory exists..."
|
echo "[venv-create] Ensuring virtualenv parent directory exists..."
|
||||||
mkdir -p "$(dirname "${VENV_DIR}")"
|
mkdir -p "$(dirname "${VENV_DIR}")"
|
||||||
|
|
||||||
@@ -31,14 +52,35 @@ echo "[venv-create] Installing Python tooling into venv..."
|
|||||||
"${VENV_DIR}/bin/python" -m ensurepip --upgrade
|
"${VENV_DIR}/bin/python" -m ensurepip --upgrade
|
||||||
"${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel
|
"${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel
|
||||||
|
|
||||||
if [[ -f "requirements.txt" ]]; then
|
# ---------------------------------------------------------------------------
|
||||||
|
# Install dependencies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [[ -f "pyproject.toml" ]]; then
|
||||||
|
echo "[venv-create] Detected pyproject.toml. Installing project via pip..."
|
||||||
|
|
||||||
|
target="."
|
||||||
|
if [[ -n "${PIP_EXTRAS}" ]]; then
|
||||||
|
target=".[${PIP_EXTRAS}]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${PIP_EDITABLE}" == "1" ]]; then
|
||||||
|
echo "[venv-create] pip install -e ${target}"
|
||||||
|
"${VENV_DIR}/bin/pip" install -e "${target}"
|
||||||
|
else
|
||||||
|
echo "[venv-create] pip install ${target}"
|
||||||
|
"${VENV_DIR}/bin/pip" install "${target}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
elif [[ -f "requirements.txt" ]]; then
|
||||||
echo "[venv-create] Installing dependencies from requirements.txt..."
|
echo "[venv-create] Installing dependencies from requirements.txt..."
|
||||||
"${VENV_DIR}/bin/pip" install -r requirements.txt
|
"${VENV_DIR}/bin/pip" install -r requirements.txt
|
||||||
|
|
||||||
elif [[ -f "_requirements.txt" ]]; then
|
elif [[ -f "_requirements.txt" ]]; then
|
||||||
echo "[venv-create] Installing dependencies from _requirements.txt..."
|
echo "[venv-create] Installing dependencies from _requirements.txt (legacy)..."
|
||||||
"${VENV_DIR}/bin/pip" install -r _requirements.txt
|
"${VENV_DIR}/bin/pip" install -r _requirements.txt
|
||||||
|
|
||||||
else
|
else
|
||||||
echo "[venv-create] No requirements.txt or _requirements.txt found. Skipping dependency installation."
|
echo "[venv-create] No pyproject.toml, requirements.txt, or _requirements.txt found. Skipping dependency installation."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[venv-create] Done."
|
echo "[venv-create] Done."
|
||||||
|
|||||||
48
scripts/test/test-env-nix.sh
Normal file
48
scripts/test/test-env-nix.sh
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE="package-manager-test-${distro}"
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo ">>> Running Nix flake-only test in ${distro} container"
|
||||||
|
echo ">>> Image: ${IMAGE}"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(pwd):/src" \
|
||||||
|
-v "pkgmgr_nix_store_${distro}:/nix" \
|
||||||
|
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||||
|
--workdir /src \
|
||||||
|
-e PKGMGR_DEV=0 \
|
||||||
|
"${IMAGE}" \
|
||||||
|
bash -lc '
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if command -v git >/dev/null 2>&1; then
|
||||||
|
git config --global --add safe.directory /src || true
|
||||||
|
git config --global --add safe.directory /src/.git || true
|
||||||
|
git config --global --add safe.directory "*" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">>> preflight: nix must exist in image"
|
||||||
|
if ! command -v nix >/dev/null 2>&1; then
|
||||||
|
echo "NO_NIX"
|
||||||
|
echo "ERROR: nix not found in image '\'''"${IMAGE}"''\'' (distro='"${distro}"')"
|
||||||
|
echo "HINT: Ensure Nix is installed during image build for this distro."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">>> nix version"
|
||||||
|
nix --version
|
||||||
|
|
||||||
|
echo ">>> nix flake show"
|
||||||
|
nix flake show . --no-write-lock-file >/dev/null
|
||||||
|
|
||||||
|
echo ">>> nix build .#default"
|
||||||
|
nix build .#default --no-link --no-write-lock-file
|
||||||
|
|
||||||
|
echo ">>> nix run .#pkgmgr -- --help"
|
||||||
|
nix run .#pkgmgr -- --help --no-write-lock-file
|
||||||
|
|
||||||
|
echo ">>> OK: Nix flake-only test succeeded."
|
||||||
|
'
|
||||||
@@ -5,12 +5,12 @@ IMAGE="package-manager-test-$distro"
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
echo "------------------------------------------------------------"
|
echo "------------------------------------------------------------"
|
||||||
echo ">>> Testing container: $IMAGE"
|
echo ">>> Testing VENV: $IMAGE"
|
||||||
echo "------------------------------------------------------------"
|
echo "------------------------------------------------------------"
|
||||||
echo "[test-container] Inspect image metadata:"
|
echo "[test-env-virtual] Inspect image metadata:"
|
||||||
docker image inspect "$IMAGE" | sed -n '1,40p'
|
docker image inspect "$IMAGE" | sed -n '1,40p'
|
||||||
|
|
||||||
echo "[test-container] Running: docker run --rm --entrypoint pkgmgr $IMAGE --help"
|
echo "[test-env-virtual] Running: docker run --rm --entrypoint pkgmgr $IMAGE --help"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
# Run the command and capture the output
|
# Run the command and capture the output
|
||||||
@@ -22,11 +22,11 @@ if OUTPUT=$(docker run --rm \
|
|||||||
"$IMAGE" 2>&1); then
|
"$IMAGE" 2>&1); then
|
||||||
echo "$OUTPUT"
|
echo "$OUTPUT"
|
||||||
echo
|
echo
|
||||||
echo "[test-container] SUCCESS: $IMAGE responded to 'pkgmgr --help'"
|
echo "[test-env-virtual] SUCCESS: $IMAGE responded to 'pkgmgr --help'"
|
||||||
|
|
||||||
else
|
else
|
||||||
echo "$OUTPUT"
|
echo "$OUTPUT"
|
||||||
echo
|
echo
|
||||||
echo "[test-container] ERROR: $IMAGE failed to run 'pkgmgr --help'"
|
echo "[test-env-virtual] ERROR: $IMAGE failed to run 'pkgmgr --help'"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
218
src/pkgmgr/actions/release/README.md
Normal file
218
src/pkgmgr/actions/release/README.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Release Action
|
||||||
|
|
||||||
|
This module implements the `pkgmgr release` workflow.
|
||||||
|
|
||||||
|
It provides a controlled, reproducible release process that:
|
||||||
|
- bumps the project version
|
||||||
|
- updates all supported packaging formats
|
||||||
|
- creates and pushes Git tags
|
||||||
|
- optionally maintains a floating `latest` tag
|
||||||
|
- optionally closes the current branch
|
||||||
|
|
||||||
|
The implementation is intentionally explicit and conservative to avoid
|
||||||
|
accidental releases or broken Git states.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the Release Command Does
|
||||||
|
|
||||||
|
A release performs the following high-level steps:
|
||||||
|
|
||||||
|
1. Synchronize the current branch with its upstream (fast-forward only)
|
||||||
|
2. Determine the next semantic version
|
||||||
|
3. Update all versioned files
|
||||||
|
4. Commit the release
|
||||||
|
5. Create and push a version tag
|
||||||
|
6. Optionally update and push the floating `latest` tag
|
||||||
|
7. Optionally close the current branch
|
||||||
|
|
||||||
|
All steps support **preview (dry-run)** mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Files Updated During a Release
|
||||||
|
|
||||||
|
If present, the following files are updated automatically:
|
||||||
|
|
||||||
|
- `pyproject.toml`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
- `flake.nix`
|
||||||
|
- `PKGBUILD`
|
||||||
|
- `package-manager.spec`
|
||||||
|
- `debian/changelog`
|
||||||
|
|
||||||
|
Missing files are skipped gracefully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Safety Rules
|
||||||
|
|
||||||
|
The release workflow enforces strict Git safety guarantees:
|
||||||
|
|
||||||
|
- A `git pull --ff-only` is executed **before any file modifications**
|
||||||
|
- No merge commits are ever created automatically
|
||||||
|
- Only the current branch and the newly created version tag are pushed
|
||||||
|
- `git push --tags` is intentionally **not** used
|
||||||
|
- The floating `latest` tag is force-pushed only when required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Semantic Versioning
|
||||||
|
|
||||||
|
The next version is calculated from existing Git tags:
|
||||||
|
|
||||||
|
- Tags must follow the format `vX.Y.Z`
|
||||||
|
- The release type controls the version bump:
|
||||||
|
- `patch`
|
||||||
|
- `minor`
|
||||||
|
- `major`
|
||||||
|
|
||||||
|
The new tag is always created as an **annotated tag**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Floating `latest` Tag
|
||||||
|
|
||||||
|
The floating `latest` tag is handled explicitly:
|
||||||
|
|
||||||
|
- `latest` is updated **only if** the new version is the highest existing version
|
||||||
|
- Version comparison uses natural version sorting (`sort -V`)
|
||||||
|
- `latest` always points to the commit behind the version tag
|
||||||
|
- Updating `latest` uses a forced push by design
|
||||||
|
|
||||||
|
This guarantees that `latest` always represents the highest released version,
|
||||||
|
never an older release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preview Mode
|
||||||
|
|
||||||
|
Preview mode (`--preview`) performs a full dry-run:
|
||||||
|
|
||||||
|
- No files are modified
|
||||||
|
- No Git commands are executed
|
||||||
|
- All intended actions are printed
|
||||||
|
|
||||||
|
Example preview output includes:
|
||||||
|
- version bump
|
||||||
|
- file updates
|
||||||
|
- commit message
|
||||||
|
- tag creation
|
||||||
|
- branch and tag pushes
|
||||||
|
- `latest` update (if applicable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interactive vs Forced Mode
|
||||||
|
|
||||||
|
### Interactive (default)
|
||||||
|
|
||||||
|
1. Run a preview
|
||||||
|
2. Ask for confirmation
|
||||||
|
3. Execute the real release
|
||||||
|
|
||||||
|
### Forced (`--force`)
|
||||||
|
|
||||||
|
- Skips preview and confirmation
|
||||||
|
- Skips branch deletion prompts
|
||||||
|
- Executes the release immediately
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Closing (`--close`)
|
||||||
|
|
||||||
|
When `--close` is enabled:
|
||||||
|
|
||||||
|
- `main` and `master` are **never** deleted
|
||||||
|
- Other branches:
|
||||||
|
- prompt for confirmation (`y/N`)
|
||||||
|
- can be skipped using `--force`
|
||||||
|
- Branch deletion happens **only after** a successful release
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Flow (ASCII Diagram)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
+---------------------+
|
||||||
|
| pkgmgr release |
|
||||||
|
+----------+----------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+---------------------+
|
||||||
|
| Detect branch |
|
||||||
|
+----------+----------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+------------------------------+
|
||||||
|
| git fetch / pull --ff-only |
|
||||||
|
+----------+-------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+------------------------------+
|
||||||
|
| Determine next version |
|
||||||
|
+----------+-------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+------------------------------+
|
||||||
|
| Update versioned files |
|
||||||
|
+----------+-------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+------------------------------+
|
||||||
|
| Commit release |
|
||||||
|
+----------+-------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+------------------------------+
|
||||||
|
| Create version tag (vX.Y.Z) |
|
||||||
|
+----------+-------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+------------------------------+
|
||||||
|
| Push branch + version tag |
|
||||||
|
+----------+-------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+---------------------------------------+
|
||||||
|
| Is this the highest version? |
|
||||||
|
+----------+----------------------------+
|
||||||
|
|
|
||||||
|
yes | no
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+------------------------------+ +----------------------+
|
||||||
|
| Update & push `latest` tag | | Skip `latest` update |
|
||||||
|
+----------+-------------------+ +----------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+------------------------------+
|
||||||
|
| Close branch (optional) |
|
||||||
|
+------------------------------+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Goals
|
||||||
|
|
||||||
|
- Deterministic and reproducible releases
|
||||||
|
- No implicit Git side effects
|
||||||
|
- Explicit tag handling
|
||||||
|
- Safe defaults for interactive usage
|
||||||
|
- Automation-friendly forced mode
|
||||||
|
- Clear separation of concerns:
|
||||||
|
- `workflow.py` – orchestration
|
||||||
|
- `git_ops.py` – Git operations
|
||||||
|
- `prompts.py` – user interaction
|
||||||
|
- `versioning.py` – SemVer logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`pkgmgr release` is a **deliberately strict** release mechanism.
|
||||||
|
|
||||||
|
It trades convenience for safety, traceability, and correctness — making it
|
||||||
|
suitable for both interactive development workflows and fully automated CI/CD
|
||||||
@@ -1,310 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
|
||||||
Release helper for pkgmgr (public entry point).
|
|
||||||
|
|
||||||
This package provides the high-level `release()` function used by the
|
|
||||||
pkgmgr CLI to perform versioned releases:
|
|
||||||
|
|
||||||
- Determine the next semantic version based on existing Git tags.
|
|
||||||
- Update pyproject.toml with the new version.
|
|
||||||
- Update additional packaging files (flake.nix, PKGBUILD,
|
|
||||||
debian/changelog, RPM spec) where present.
|
|
||||||
- Prepend a basic entry to CHANGELOG.md.
|
|
||||||
- Move the floating 'latest' tag to the newly created release tag so
|
|
||||||
the newest release is always marked as latest.
|
|
||||||
|
|
||||||
Additional behaviour:
|
|
||||||
- If `preview=True` (from --preview), no files are written and no
|
|
||||||
Git commands are executed. Instead, a detailed summary of the
|
|
||||||
planned changes and commands is printed.
|
|
||||||
- If `preview=False` and not forced, the release is executed in two
|
|
||||||
phases:
|
|
||||||
1) Preview-only run (dry-run).
|
|
||||||
2) Interactive confirmation, then real release if confirmed.
|
|
||||||
This confirmation can be skipped with the `force=True` flag.
|
|
||||||
- Before creating and pushing tags, main/master is updated from origin
|
|
||||||
when the release is performed on one of these branches.
|
|
||||||
- If `close=True` is used and the current branch is not main/master,
|
|
||||||
the branch will be closed via branch_commands.close_branch() after
|
|
||||||
a successful release.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from .workflow import release
|
||||||
import sys
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pkgmgr.core.git import get_current_branch, GitError
|
|
||||||
from pkgmgr.actions.branch import close_branch
|
|
||||||
|
|
||||||
from .versioning import determine_current_version, bump_semver
|
|
||||||
from .git_ops import run_git_command, sync_branch_with_remote, update_latest_tag
|
|
||||||
from .files import (
|
|
||||||
update_pyproject_version,
|
|
||||||
update_flake_version,
|
|
||||||
update_pkgbuild_version,
|
|
||||||
update_spec_version,
|
|
||||||
update_changelog,
|
|
||||||
update_debian_changelog,
|
|
||||||
update_spec_changelog,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal implementation (single-phase, preview or real)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _release_impl(
|
|
||||||
pyproject_path: str = "pyproject.toml",
|
|
||||||
changelog_path: str = "CHANGELOG.md",
|
|
||||||
release_type: str = "patch",
|
|
||||||
message: Optional[str] = None,
|
|
||||||
preview: bool = False,
|
|
||||||
close: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Internal implementation that performs a single-phase release.
|
|
||||||
"""
|
|
||||||
current_ver = determine_current_version()
|
|
||||||
new_ver = bump_semver(current_ver, release_type)
|
|
||||||
new_ver_str = str(new_ver)
|
|
||||||
new_tag = new_ver.to_tag(with_prefix=True)
|
|
||||||
|
|
||||||
mode = "PREVIEW" if preview else "REAL"
|
|
||||||
print(f"Release mode: {mode}")
|
|
||||||
print(f"Current version: {current_ver}")
|
|
||||||
print(f"New version: {new_ver_str} ({release_type})")
|
|
||||||
|
|
||||||
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
|
|
||||||
|
|
||||||
# Update core project metadata and packaging files
|
|
||||||
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
|
|
||||||
changelog_message = update_changelog(
|
|
||||||
changelog_path,
|
|
||||||
new_ver_str,
|
|
||||||
message=message,
|
|
||||||
preview=preview,
|
|
||||||
)
|
|
||||||
|
|
||||||
flake_path = os.path.join(repo_root, "flake.nix")
|
|
||||||
update_flake_version(flake_path, new_ver_str, preview=preview)
|
|
||||||
|
|
||||||
pkgbuild_path = os.path.join(repo_root, "PKGBUILD")
|
|
||||||
update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview)
|
|
||||||
|
|
||||||
spec_path = os.path.join(repo_root, "package-manager.spec")
|
|
||||||
update_spec_version(spec_path, new_ver_str, preview=preview)
|
|
||||||
|
|
||||||
# Determine a single effective_message to be reused across all
|
|
||||||
# changelog targets (project, Debian, Fedora).
|
|
||||||
effective_message: Optional[str] = message
|
|
||||||
if effective_message is None and isinstance(changelog_message, str):
|
|
||||||
if changelog_message.strip():
|
|
||||||
effective_message = changelog_message.strip()
|
|
||||||
|
|
||||||
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
|
|
||||||
package_name = os.path.basename(repo_root) or "package-manager"
|
|
||||||
|
|
||||||
# Debian changelog
|
|
||||||
update_debian_changelog(
|
|
||||||
debian_changelog_path,
|
|
||||||
package_name=package_name,
|
|
||||||
new_version=new_ver_str,
|
|
||||||
message=effective_message,
|
|
||||||
preview=preview,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fedora / RPM %changelog
|
|
||||||
update_spec_changelog(
|
|
||||||
spec_path=spec_path,
|
|
||||||
package_name=package_name,
|
|
||||||
new_version=new_ver_str,
|
|
||||||
message=effective_message,
|
|
||||||
preview=preview,
|
|
||||||
)
|
|
||||||
|
|
||||||
commit_msg = f"Release version {new_ver_str}"
|
|
||||||
tag_msg = effective_message or commit_msg
|
|
||||||
|
|
||||||
# Determine branch and ensure it is up to date if main/master
|
|
||||||
try:
|
|
||||||
branch = get_current_branch() or "main"
|
|
||||||
except GitError:
|
|
||||||
branch = "main"
|
|
||||||
print(f"Releasing on branch: {branch}")
|
|
||||||
|
|
||||||
# Ensure main/master are up-to-date from origin before creating and
|
|
||||||
# pushing tags. For other branches we only log the intent.
|
|
||||||
sync_branch_with_remote(branch, preview=preview)
|
|
||||||
|
|
||||||
files_to_add = [
|
|
||||||
pyproject_path,
|
|
||||||
changelog_path,
|
|
||||||
flake_path,
|
|
||||||
pkgbuild_path,
|
|
||||||
spec_path,
|
|
||||||
debian_changelog_path,
|
|
||||||
]
|
|
||||||
existing_files = [p for p in files_to_add if p and os.path.exists(p)]
|
|
||||||
|
|
||||||
if preview:
|
|
||||||
for path in existing_files:
|
|
||||||
print(f"[PREVIEW] Would run: git add {path}")
|
|
||||||
print(f'[PREVIEW] Would run: git commit -am "{commit_msg}"')
|
|
||||||
print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"')
|
|
||||||
print(f"[PREVIEW] Would run: git push origin {branch}")
|
|
||||||
print("[PREVIEW] Would run: git push origin --tags")
|
|
||||||
|
|
||||||
# Also update the floating 'latest' tag to the new highest SemVer.
|
|
||||||
update_latest_tag(new_tag, preview=True)
|
|
||||||
|
|
||||||
if close and branch not in ("main", "master"):
|
|
||||||
print(
|
|
||||||
f"[PREVIEW] Would also close branch {branch} after the release "
|
|
||||||
"(close=True and branch is not main/master)."
|
|
||||||
)
|
|
||||||
elif close:
|
|
||||||
print(
|
|
||||||
f"[PREVIEW] close=True but current branch is {branch}; "
|
|
||||||
"no branch would be closed."
|
|
||||||
)
|
|
||||||
|
|
||||||
print("Preview completed. No changes were made.")
|
|
||||||
return
|
|
||||||
|
|
||||||
for path in existing_files:
|
|
||||||
run_git_command(f"git add {path}")
|
|
||||||
|
|
||||||
run_git_command(f'git commit -am "{commit_msg}"')
|
|
||||||
run_git_command(f'git tag -a {new_tag} -m "{tag_msg}"')
|
|
||||||
run_git_command(f"git push origin {branch}")
|
|
||||||
run_git_command("git push origin --tags")
|
|
||||||
|
|
||||||
# Move 'latest' to the new release tag so the newest SemVer is always
|
|
||||||
# marked as latest. This is best-effort and must not break the release.
|
|
||||||
try:
|
|
||||||
update_latest_tag(new_tag, preview=False)
|
|
||||||
except GitError as exc: # pragma: no cover
|
|
||||||
print(
|
|
||||||
f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}\n"
|
|
||||||
"[WARN] The release itself completed successfully; only the "
|
|
||||||
"'latest' tag was not updated."
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"Release {new_ver_str} completed.")
|
|
||||||
|
|
||||||
if close:
|
|
||||||
if branch in ("main", "master"):
|
|
||||||
print(
|
|
||||||
f"[INFO] close=True but current branch is {branch}; "
|
|
||||||
"nothing to close."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"[INFO] Closing branch {branch} after successful release "
|
|
||||||
"(close=True and branch is not main/master)..."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
close_branch(name=branch, base_branch="main", cwd=".")
|
|
||||||
except Exception as exc: # pragma: no cover
|
|
||||||
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public release entry point
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def release(
|
|
||||||
pyproject_path: str = "pyproject.toml",
|
|
||||||
changelog_path: str = "CHANGELOG.md",
|
|
||||||
release_type: str = "patch",
|
|
||||||
message: Optional[str] = None,
|
|
||||||
preview: bool = False,
|
|
||||||
force: bool = False,
|
|
||||||
close: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
High-level release entry point.
|
|
||||||
|
|
||||||
Modes:
|
|
||||||
|
|
||||||
- preview=True:
|
|
||||||
* Single-phase PREVIEW only.
|
|
||||||
|
|
||||||
- preview=False, force=True:
|
|
||||||
* Single-phase REAL release, no interactive preview.
|
|
||||||
|
|
||||||
- preview=False, force=False:
|
|
||||||
* Two-phase flow (intended default for interactive CLI use).
|
|
||||||
"""
|
|
||||||
if preview:
|
|
||||||
_release_impl(
|
|
||||||
pyproject_path=pyproject_path,
|
|
||||||
changelog_path=changelog_path,
|
|
||||||
release_type=release_type,
|
|
||||||
message=message,
|
|
||||||
preview=True,
|
|
||||||
close=close,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if force:
|
|
||||||
_release_impl(
|
|
||||||
pyproject_path=pyproject_path,
|
|
||||||
changelog_path=changelog_path,
|
|
||||||
release_type=release_type,
|
|
||||||
message=message,
|
|
||||||
preview=False,
|
|
||||||
close=close,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
_release_impl(
|
|
||||||
pyproject_path=pyproject_path,
|
|
||||||
changelog_path=changelog_path,
|
|
||||||
release_type=release_type,
|
|
||||||
message=message,
|
|
||||||
preview=False,
|
|
||||||
close=close,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
print("[INFO] Running preview before actual release...\n")
|
|
||||||
_release_impl(
|
|
||||||
pyproject_path=pyproject_path,
|
|
||||||
changelog_path=changelog_path,
|
|
||||||
release_type=release_type,
|
|
||||||
message=message,
|
|
||||||
preview=True,
|
|
||||||
close=close,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
answer = input("Proceed with the actual release? [y/N]: ").strip().lower()
|
|
||||||
except (EOFError, KeyboardInterrupt):
|
|
||||||
print("\n[INFO] Release aborted (no confirmation).")
|
|
||||||
return
|
|
||||||
|
|
||||||
if answer not in ("y", "yes"):
|
|
||||||
print("Release aborted by user. No changes were made.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("\n[INFO] Running REAL release...\n")
|
|
||||||
_release_impl(
|
|
||||||
pyproject_path=pyproject_path,
|
|
||||||
changelog_path=changelog_path,
|
|
||||||
release_type=release_type,
|
|
||||||
message=message,
|
|
||||||
preview=False,
|
|
||||||
close=close,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["release"]
|
__all__ = ["release"]
|
||||||
|
|||||||
@@ -1,16 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
|
||||||
Git-related helpers for the release workflow.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Run Git (or shell) commands with basic error reporting.
|
|
||||||
- Ensure main/master are synchronized with origin before tagging.
|
|
||||||
- Maintain the floating 'latest' tag that always points to the newest
|
|
||||||
release tag.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -19,77 +6,87 @@ from pkgmgr.core.git import GitError
|
|||||||
|
|
||||||
|
|
||||||
def run_git_command(cmd: str) -> None:
|
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}")
|
print(f"[GIT] {cmd}")
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, shell=True, check=True)
|
subprocess.run(
|
||||||
|
cmd,
|
||||||
|
shell=True,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
except subprocess.CalledProcessError as exc:
|
except subprocess.CalledProcessError as exc:
|
||||||
print(f"[ERROR] Git command failed: {cmd}")
|
print(f"[ERROR] Git command failed: {cmd}")
|
||||||
print(f" Exit code: {exc.returncode}")
|
print(f" Exit code: {exc.returncode}")
|
||||||
if exc.stdout:
|
if exc.stdout:
|
||||||
print("--- stdout ---")
|
print("\n" + exc.stdout)
|
||||||
print(exc.stdout)
|
|
||||||
if exc.stderr:
|
if exc.stderr:
|
||||||
print("--- stderr ---")
|
print("\n" + exc.stderr)
|
||||||
print(exc.stderr)
|
|
||||||
raise GitError(f"Git command failed: {cmd}") from exc
|
raise GitError(f"Git command failed: {cmd}") from exc
|
||||||
|
|
||||||
|
|
||||||
def sync_branch_with_remote(branch: str, preview: bool = False) -> None:
|
def _capture(cmd: str) -> str:
|
||||||
"""
|
res = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
||||||
Ensure the local main/master branch is up-to-date before tagging.
|
return (res.stdout or "").strip()
|
||||||
|
|
||||||
Behaviour:
|
|
||||||
- For main/master: run 'git fetch origin' and 'git pull origin <branch>'.
|
def ensure_clean_and_synced(preview: bool = False) -> None:
|
||||||
- For all other branches: only log that no automatic sync is performed.
|
|
||||||
"""
|
"""
|
||||||
if branch not in ("main", "master"):
|
Always run a pull BEFORE modifying anything.
|
||||||
print(
|
Uses --ff-only to avoid creating merge commits automatically.
|
||||||
f"[INFO] Skipping automatic git pull for non-main/master branch "
|
If no upstream is configured, we skip.
|
||||||
f"{branch}."
|
"""
|
||||||
)
|
upstream = _capture("git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null")
|
||||||
|
if not upstream:
|
||||||
|
print("[INFO] No upstream configured for current branch. Skipping pull.")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(
|
|
||||||
f"[INFO] Updating branch {branch} from origin before creating tags..."
|
|
||||||
)
|
|
||||||
|
|
||||||
if preview:
|
if preview:
|
||||||
print("[PREVIEW] Would run: git fetch origin")
|
print("[PREVIEW] Would run: git fetch origin --prune --tags --force")
|
||||||
print(f"[PREVIEW] Would run: git pull origin {branch}")
|
print("[PREVIEW] Would run: git pull --ff-only")
|
||||||
return
|
return
|
||||||
|
|
||||||
run_git_command("git fetch origin")
|
print("[INFO] Syncing with remote before making any changes...")
|
||||||
run_git_command(f"git pull origin {branch}")
|
run_git_command("git fetch origin --prune --tags --force")
|
||||||
|
run_git_command("git pull --ff-only")
|
||||||
|
|
||||||
|
def is_highest_version_tag(tag: str) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if `tag` is the highest version among all tags matching v*.
|
||||||
|
Comparison uses `sort -V` for natural version ordering.
|
||||||
|
"""
|
||||||
|
all_v = _capture("git tag --list 'v*'")
|
||||||
|
if not all_v:
|
||||||
|
return True # No tags yet, so the current tag is the highest
|
||||||
|
|
||||||
|
# Get the latest tag in natural version order
|
||||||
|
latest = _capture("git tag --list 'v*' | sort -V | tail -n1")
|
||||||
|
print(f"[INFO] Latest tag: {latest}, Current tag: {tag}")
|
||||||
|
|
||||||
|
# Ensure that the current tag is always considered the highest if it's the latest one
|
||||||
|
return tag >= latest # Use comparison operator to consider all future tags
|
||||||
|
|
||||||
|
|
||||||
def update_latest_tag(new_tag: str, preview: bool = False) -> None:
|
def update_latest_tag(new_tag: str, preview: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Move the floating 'latest' tag to the newly created release tag.
|
Move the floating 'latest' tag to the newly created release tag.
|
||||||
|
|
||||||
Implementation details:
|
Notes:
|
||||||
- We explicitly dereference the tag object via `<tag>^{}` so that
|
- We dereference the tag object via `<tag>^{}` so that 'latest' points to the commit.
|
||||||
'latest' always points at the underlying commit, not at another tag.
|
- 'latest' is forced (floating tag), therefore the push uses --force.
|
||||||
- We create/update 'latest' as an annotated tag with a short message so
|
|
||||||
Git configurations that enforce annotated/signed tags do not fail
|
|
||||||
with "no tag message".
|
|
||||||
"""
|
"""
|
||||||
target_ref = f"{new_tag}^{{}}"
|
target_ref = f"{new_tag}^{{}}"
|
||||||
print(f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})...")
|
print(f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})...")
|
||||||
|
|
||||||
if preview:
|
if preview:
|
||||||
print(f"[PREVIEW] Would run: git tag -f -a latest {target_ref} "
|
print(
|
||||||
f'-m "Floating latest tag for {new_tag}"')
|
f'[PREVIEW] Would run: git tag -f -a latest {target_ref} '
|
||||||
|
f'-m "Floating latest tag for {new_tag}"'
|
||||||
|
)
|
||||||
print("[PREVIEW] Would run: git push origin latest --force")
|
print("[PREVIEW] Would run: git push origin latest --force")
|
||||||
return
|
return
|
||||||
|
|
||||||
run_git_command(
|
run_git_command(
|
||||||
f'git tag -f -a latest {target_ref} '
|
f'git tag -f -a latest {target_ref} -m "Floating latest tag for {new_tag}"'
|
||||||
f'-m "Floating latest tag for {new_tag}"'
|
|
||||||
)
|
)
|
||||||
run_git_command("git push origin latest --force")
|
run_git_command("git push origin latest --force")
|
||||||
|
|||||||
29
src/pkgmgr/actions/release/prompts.py
Normal file
29
src/pkgmgr/actions/release/prompts.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def should_delete_branch(force: bool) -> bool:
|
||||||
|
"""
|
||||||
|
Ask whether the current branch should be deleted after a successful release.
|
||||||
|
|
||||||
|
- If force=True: skip prompt and return True.
|
||||||
|
- If non-interactive stdin: do NOT delete by default.
|
||||||
|
"""
|
||||||
|
if force:
|
||||||
|
return True
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
return False
|
||||||
|
answer = input("Delete the current branch after release? [y/N] ").strip().lower()
|
||||||
|
return answer in ("y", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_proceed_release() -> bool:
|
||||||
|
"""
|
||||||
|
Ask whether to proceed with the REAL release after the preview phase.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
answer = input("Proceed with the actual release? [y/N]: ").strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
return False
|
||||||
|
return answer in ("y", "yes")
|
||||||
230
src/pkgmgr/actions/release/workflow.py
Normal file
230
src/pkgmgr/actions/release/workflow.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pkgmgr.actions.branch import close_branch
|
||||||
|
from pkgmgr.core.git import get_current_branch, GitError
|
||||||
|
|
||||||
|
from .files import (
|
||||||
|
update_changelog,
|
||||||
|
update_debian_changelog,
|
||||||
|
update_flake_version,
|
||||||
|
update_pkgbuild_version,
|
||||||
|
update_pyproject_version,
|
||||||
|
update_spec_changelog,
|
||||||
|
update_spec_version,
|
||||||
|
)
|
||||||
|
from .git_ops import (
|
||||||
|
ensure_clean_and_synced,
|
||||||
|
is_highest_version_tag,
|
||||||
|
run_git_command,
|
||||||
|
update_latest_tag,
|
||||||
|
)
|
||||||
|
from .prompts import confirm_proceed_release, should_delete_branch
|
||||||
|
from .versioning import bump_semver, determine_current_version
|
||||||
|
|
||||||
|
|
||||||
|
def _release_impl(
|
||||||
|
pyproject_path: str = "pyproject.toml",
|
||||||
|
changelog_path: str = "CHANGELOG.md",
|
||||||
|
release_type: str = "patch",
|
||||||
|
message: Optional[str] = None,
|
||||||
|
preview: bool = False,
|
||||||
|
close: bool = False,
|
||||||
|
force: bool = False,
|
||||||
|
) -> None:
|
||||||
|
# Determine current branch early
|
||||||
|
try:
|
||||||
|
branch = get_current_branch() or "main"
|
||||||
|
except GitError:
|
||||||
|
branch = "main"
|
||||||
|
print(f"Releasing on branch: {branch}")
|
||||||
|
|
||||||
|
# Pull BEFORE making any modifications
|
||||||
|
ensure_clean_and_synced(preview=preview)
|
||||||
|
|
||||||
|
current_ver = determine_current_version()
|
||||||
|
new_ver = bump_semver(current_ver, release_type)
|
||||||
|
new_ver_str = str(new_ver)
|
||||||
|
new_tag = new_ver.to_tag(with_prefix=True)
|
||||||
|
|
||||||
|
mode = "PREVIEW" if preview else "REAL"
|
||||||
|
print(f"Release mode: {mode}")
|
||||||
|
print(f"Current version: {current_ver}")
|
||||||
|
print(f"New version: {new_ver_str} ({release_type})")
|
||||||
|
|
||||||
|
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
|
||||||
|
|
||||||
|
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
|
||||||
|
changelog_message = update_changelog(
|
||||||
|
changelog_path,
|
||||||
|
new_ver_str,
|
||||||
|
message=message,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
flake_path = os.path.join(repo_root, "flake.nix")
|
||||||
|
update_flake_version(flake_path, new_ver_str, preview=preview)
|
||||||
|
|
||||||
|
pkgbuild_path = os.path.join(repo_root, "PKGBUILD")
|
||||||
|
update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview)
|
||||||
|
|
||||||
|
spec_path = os.path.join(repo_root, "package-manager.spec")
|
||||||
|
update_spec_version(spec_path, new_ver_str, preview=preview)
|
||||||
|
|
||||||
|
effective_message: Optional[str] = message
|
||||||
|
if effective_message is None and isinstance(changelog_message, str):
|
||||||
|
if changelog_message.strip():
|
||||||
|
effective_message = changelog_message.strip()
|
||||||
|
|
||||||
|
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
|
||||||
|
package_name = os.path.basename(repo_root) or "package-manager"
|
||||||
|
|
||||||
|
update_debian_changelog(
|
||||||
|
debian_changelog_path,
|
||||||
|
package_name=package_name,
|
||||||
|
new_version=new_ver_str,
|
||||||
|
message=effective_message,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_spec_changelog(
|
||||||
|
spec_path=spec_path,
|
||||||
|
package_name=package_name,
|
||||||
|
new_version=new_ver_str,
|
||||||
|
message=effective_message,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
commit_msg = f"Release version {new_ver_str}"
|
||||||
|
tag_msg = effective_message or commit_msg
|
||||||
|
|
||||||
|
files_to_add = [
|
||||||
|
pyproject_path,
|
||||||
|
changelog_path,
|
||||||
|
flake_path,
|
||||||
|
pkgbuild_path,
|
||||||
|
spec_path,
|
||||||
|
debian_changelog_path,
|
||||||
|
]
|
||||||
|
existing_files = [p for p in files_to_add if p and os.path.exists(p)]
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
for path in existing_files:
|
||||||
|
print(f"[PREVIEW] Would run: git add {path}")
|
||||||
|
print(f'[PREVIEW] Would run: git commit -am "{commit_msg}"')
|
||||||
|
print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"')
|
||||||
|
print(f"[PREVIEW] Would run: git push origin {branch}")
|
||||||
|
print(f"[PREVIEW] Would run: git push origin {new_tag}")
|
||||||
|
|
||||||
|
if is_highest_version_tag(new_tag):
|
||||||
|
update_latest_tag(new_tag, preview=True)
|
||||||
|
else:
|
||||||
|
print(f"[PREVIEW] Skipping 'latest' update (tag {new_tag} is not the highest).")
|
||||||
|
|
||||||
|
if close and branch not in ("main", "master"):
|
||||||
|
if force:
|
||||||
|
print(f"[PREVIEW] Would delete branch {branch} (forced).")
|
||||||
|
else:
|
||||||
|
print(f"[PREVIEW] Would ask whether to delete branch {branch} after release.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for path in existing_files:
|
||||||
|
run_git_command(f"git add {path}")
|
||||||
|
|
||||||
|
run_git_command(f'git commit -am "{commit_msg}"')
|
||||||
|
run_git_command(f'git tag -a {new_tag} -m "{tag_msg}"')
|
||||||
|
|
||||||
|
# Push branch and ONLY the newly created version tag (no --tags)
|
||||||
|
run_git_command(f"git push origin {branch}")
|
||||||
|
run_git_command(f"git push origin {new_tag}")
|
||||||
|
|
||||||
|
# Update 'latest' only if this is the highest version tag
|
||||||
|
try:
|
||||||
|
if is_highest_version_tag(new_tag):
|
||||||
|
update_latest_tag(new_tag, preview=False)
|
||||||
|
else:
|
||||||
|
print(f"[INFO] Skipping 'latest' update (tag {new_tag} is not the highest).")
|
||||||
|
except GitError as exc:
|
||||||
|
print(f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}")
|
||||||
|
print("'latest' tag was not updated.")
|
||||||
|
|
||||||
|
print(f"Release {new_ver_str} completed.")
|
||||||
|
|
||||||
|
if close:
|
||||||
|
if branch in ("main", "master"):
|
||||||
|
print(f"[INFO] close=True but current branch is {branch}; skipping branch deletion.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not should_delete_branch(force=force):
|
||||||
|
print(f"[INFO] Branch deletion declined. Keeping branch {branch}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"[INFO] Deleting branch {branch} after successful release...")
|
||||||
|
try:
|
||||||
|
close_branch(name=branch, base_branch="main", cwd=".")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def release(
|
||||||
|
pyproject_path: str = "pyproject.toml",
|
||||||
|
changelog_path: str = "CHANGELOG.md",
|
||||||
|
release_type: str = "patch",
|
||||||
|
message: Optional[str] = None,
|
||||||
|
preview: bool = False,
|
||||||
|
force: bool = False,
|
||||||
|
close: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if preview:
|
||||||
|
_release_impl(
|
||||||
|
pyproject_path=pyproject_path,
|
||||||
|
changelog_path=changelog_path,
|
||||||
|
release_type=release_type,
|
||||||
|
message=message,
|
||||||
|
preview=True,
|
||||||
|
close=close,
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# If force or non-interactive: no preview+confirmation step
|
||||||
|
if force or (not sys.stdin.isatty()):
|
||||||
|
_release_impl(
|
||||||
|
pyproject_path=pyproject_path,
|
||||||
|
changelog_path=changelog_path,
|
||||||
|
release_type=release_type,
|
||||||
|
message=message,
|
||||||
|
preview=False,
|
||||||
|
close=close,
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("[INFO] Running preview before actual release...\n")
|
||||||
|
_release_impl(
|
||||||
|
pyproject_path=pyproject_path,
|
||||||
|
changelog_path=changelog_path,
|
||||||
|
release_type=release_type,
|
||||||
|
message=message,
|
||||||
|
preview=True,
|
||||||
|
close=close,
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not confirm_proceed_release():
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n[INFO] Running REAL release...\n")
|
||||||
|
_release_impl(
|
||||||
|
pyproject_path=pyproject_path,
|
||||||
|
changelog_path=changelog_path,
|
||||||
|
release_type=release_type,
|
||||||
|
message=message,
|
||||||
|
preview=False,
|
||||||
|
close=close,
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
@@ -5,8 +5,9 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from pkgmgr.core.git import GitError
|
from pkgmgr.core.git import GitError
|
||||||
from pkgmgr.actions.release.git_ops import (
|
from pkgmgr.actions.release.git_ops import (
|
||||||
|
ensure_clean_and_synced,
|
||||||
|
is_highest_version_tag,
|
||||||
run_git_command,
|
run_git_command,
|
||||||
sync_branch_with_remote,
|
|
||||||
update_latest_tag,
|
update_latest_tag,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,12 +15,13 @@ from pkgmgr.actions.release.git_ops import (
|
|||||||
class TestRunGitCommand(unittest.TestCase):
|
class TestRunGitCommand(unittest.TestCase):
|
||||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||||
def test_run_git_command_success(self, mock_run) -> None:
|
def test_run_git_command_success(self, mock_run) -> None:
|
||||||
# No exception means success
|
|
||||||
run_git_command("git status")
|
run_git_command("git status")
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
args, kwargs = mock_run.call_args
|
args, kwargs = mock_run.call_args
|
||||||
self.assertIn("git status", args[0])
|
self.assertIn("git status", args[0])
|
||||||
self.assertTrue(kwargs.get("check"))
|
self.assertTrue(kwargs.get("check"))
|
||||||
|
self.assertTrue(kwargs.get("capture_output"))
|
||||||
|
self.assertTrue(kwargs.get("text"))
|
||||||
|
|
||||||
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||||
def test_run_git_command_failure_raises_git_error(self, mock_run) -> None:
|
def test_run_git_command_failure_raises_git_error(self, mock_run) -> None:
|
||||||
@@ -36,58 +38,161 @@ class TestRunGitCommand(unittest.TestCase):
|
|||||||
run_git_command("git status")
|
run_git_command("git status")
|
||||||
|
|
||||||
|
|
||||||
class TestSyncBranchWithRemote(unittest.TestCase):
|
class TestEnsureCleanAndSynced(unittest.TestCase):
|
||||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
def _fake_run(self, cmd: str, *args, **kwargs):
|
||||||
def test_sync_branch_with_remote_skips_non_main_master(
|
class R:
|
||||||
self,
|
def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0):
|
||||||
mock_run_git_command,
|
self.stdout = stdout
|
||||||
) -> None:
|
self.stderr = stderr
|
||||||
sync_branch_with_remote("feature/my-branch", preview=False)
|
self.returncode = returncode
|
||||||
mock_run_git_command.assert_not_called()
|
|
||||||
|
|
||||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
# upstream detection
|
||||||
def test_sync_branch_with_remote_preview_on_main_does_not_run_git(
|
if "git rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd:
|
||||||
self,
|
return R(stdout="origin/main")
|
||||||
mock_run_git_command,
|
|
||||||
) -> None:
|
|
||||||
sync_branch_with_remote("main", preview=True)
|
|
||||||
mock_run_git_command.assert_not_called()
|
|
||||||
|
|
||||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
# fetch/pull should be invoked in real mode
|
||||||
def test_sync_branch_with_remote_main_runs_fetch_and_pull(
|
if cmd == "git fetch --prune --tags":
|
||||||
self,
|
return R(stdout="")
|
||||||
mock_run_git_command,
|
if cmd == "git pull --ff-only":
|
||||||
) -> None:
|
return R(stdout="Already up to date.")
|
||||||
sync_branch_with_remote("main", preview=False)
|
|
||||||
|
|
||||||
calls = [c.args[0] for c in mock_run_git_command.call_args_list]
|
return R(stdout="")
|
||||||
self.assertIn("git fetch origin", calls)
|
|
||||||
self.assertIn("git pull origin main", calls)
|
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||||
|
def test_ensure_clean_and_synced_preview_does_not_run_git_commands(self, mock_run) -> None:
|
||||||
|
def fake(cmd: str, *args, **kwargs):
|
||||||
|
class R:
|
||||||
|
def __init__(self, stdout: str = ""):
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = ""
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
if "git rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd:
|
||||||
|
return R(stdout="origin/main")
|
||||||
|
return R(stdout="")
|
||||||
|
|
||||||
|
mock_run.side_effect = fake
|
||||||
|
|
||||||
|
ensure_clean_and_synced(preview=True)
|
||||||
|
|
||||||
|
called_cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||||
|
self.assertTrue(any("git rev-parse" in c for c in called_cmds))
|
||||||
|
self.assertFalse(any(c == "git fetch --prune --tags" for c in called_cmds))
|
||||||
|
self.assertFalse(any(c == "git pull --ff-only" for c in called_cmds))
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||||
|
def test_ensure_clean_and_synced_no_upstream_skips(self, mock_run) -> None:
|
||||||
|
def fake(cmd: str, *args, **kwargs):
|
||||||
|
class R:
|
||||||
|
def __init__(self, stdout: str = ""):
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = ""
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
if "git rev-parse --abbrev-ref --symbolic-full-name @{u}" in cmd:
|
||||||
|
return R(stdout="") # no upstream
|
||||||
|
return R(stdout="")
|
||||||
|
|
||||||
|
mock_run.side_effect = fake
|
||||||
|
|
||||||
|
ensure_clean_and_synced(preview=False)
|
||||||
|
|
||||||
|
called_cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||||
|
self.assertTrue(any("git rev-parse" in c for c in called_cmds))
|
||||||
|
self.assertFalse(any(c == "git fetch --prune --tags" for c in called_cmds))
|
||||||
|
self.assertFalse(any(c == "git pull --ff-only" for c in called_cmds))
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||||
|
def test_ensure_clean_and_synced_real_runs_fetch_and_pull(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = self._fake_run
|
||||||
|
|
||||||
|
ensure_clean_and_synced(preview=False)
|
||||||
|
|
||||||
|
called_cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||||
|
self.assertIn("git fetch origin --prune --tags --force", called_cmds)
|
||||||
|
self.assertIn("git pull --ff-only", called_cmds)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsHighestVersionTag(unittest.TestCase):
|
||||||
|
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||||
|
def test_is_highest_version_tag_no_tags_true(self, mock_run) -> None:
|
||||||
|
def fake(cmd: str, *args, **kwargs):
|
||||||
|
class R:
|
||||||
|
def __init__(self, stdout: str = ""):
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = ""
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
if "git tag --list" in cmd and "'v*'" in cmd:
|
||||||
|
return R(stdout="") # no tags
|
||||||
|
return R(stdout="")
|
||||||
|
|
||||||
|
mock_run.side_effect = fake
|
||||||
|
|
||||||
|
self.assertTrue(is_highest_version_tag("v1.0.0"))
|
||||||
|
|
||||||
|
# ensure at least the list command was queried
|
||||||
|
called_cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||||
|
self.assertTrue(any("git tag --list" in c for c in called_cmds))
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.git_ops.subprocess.run")
|
||||||
|
def test_is_highest_version_tag_compares_sort_v(self, mock_run) -> None:
|
||||||
|
"""
|
||||||
|
This test is aligned with the CURRENT implementation:
|
||||||
|
|
||||||
|
return tag >= latest
|
||||||
|
|
||||||
|
which is a *string comparison*, not a semantic version compare.
|
||||||
|
Therefore, a candidate like v1.2.0 is lexicographically >= v1.10.0
|
||||||
|
(because '2' > '1' at the first differing char after 'v1.').
|
||||||
|
"""
|
||||||
|
def fake(cmd: str, *args, **kwargs):
|
||||||
|
class R:
|
||||||
|
def __init__(self, stdout: str = ""):
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = ""
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
if cmd.strip() == "git tag --list 'v*'":
|
||||||
|
return R(stdout="v1.0.0\nv1.2.0\nv1.10.0\n")
|
||||||
|
if "git tag --list 'v*'" in cmd and "sort -V" in cmd and "tail -n1" in cmd:
|
||||||
|
return R(stdout="v1.10.0")
|
||||||
|
return R(stdout="")
|
||||||
|
|
||||||
|
mock_run.side_effect = fake
|
||||||
|
|
||||||
|
# With the current implementation (string >=), both of these are True.
|
||||||
|
self.assertTrue(is_highest_version_tag("v1.10.0"))
|
||||||
|
self.assertTrue(is_highest_version_tag("v1.2.0"))
|
||||||
|
|
||||||
|
# And a clearly lexicographically smaller candidate should be False.
|
||||||
|
# Example: "v1.0.0" < "v1.10.0"
|
||||||
|
self.assertFalse(is_highest_version_tag("v1.0.0"))
|
||||||
|
|
||||||
|
# Ensure both capture commands were executed
|
||||||
|
called_cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||||
|
self.assertTrue(any(cmd == "git tag --list 'v*'" for cmd in called_cmds))
|
||||||
|
self.assertTrue(any("sort -V" in cmd and "tail -n1" in cmd for cmd in called_cmds))
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateLatestTag(unittest.TestCase):
|
class TestUpdateLatestTag(unittest.TestCase):
|
||||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
||||||
def test_update_latest_tag_preview_does_not_call_git(
|
def test_update_latest_tag_preview_does_not_call_git(self, mock_run_git_command) -> None:
|
||||||
self,
|
|
||||||
mock_run_git_command,
|
|
||||||
) -> None:
|
|
||||||
update_latest_tag("v1.2.3", preview=True)
|
update_latest_tag("v1.2.3", preview=True)
|
||||||
mock_run_git_command.assert_not_called()
|
mock_run_git_command.assert_not_called()
|
||||||
|
|
||||||
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
@patch("pkgmgr.actions.release.git_ops.run_git_command")
|
||||||
def test_update_latest_tag_real_calls_git_with_dereference_and_message(
|
def test_update_latest_tag_real_calls_git(self, mock_run_git_command) -> None:
|
||||||
self,
|
|
||||||
mock_run_git_command,
|
|
||||||
) -> None:
|
|
||||||
update_latest_tag("v1.2.3", preview=False)
|
update_latest_tag("v1.2.3", preview=False)
|
||||||
|
|
||||||
calls = [c.args[0] for c in mock_run_git_command.call_args_list]
|
calls = [c.args[0] for c in mock_run_git_command.call_args_list]
|
||||||
# Must dereference the tag object and create an annotated tag with message
|
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'git tag -f -a latest v1.2.3^{} -m "Floating latest tag for v1.2.3"',
|
'git tag -f -a latest v1.2.3^{} -m "Floating latest tag for v1.2.3"',
|
||||||
calls,
|
calls,
|
||||||
)
|
)
|
||||||
self.assertIn("git push origin latest --force", calls)
|
self.assertIn("git push origin latest --force", calls)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
14
tests/unit/pkgmgr/actions/release/test_init.py
Normal file
14
tests/unit/pkgmgr/actions/release/test_init.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class TestReleasePackageInit(unittest.TestCase):
|
||||||
|
def test_release_is_reexported(self) -> None:
|
||||||
|
from pkgmgr.actions.release import release # noqa: F401
|
||||||
|
|
||||||
|
self.assertTrue(callable(release))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
50
tests/unit/pkgmgr/actions/release/test_prompts.py
Normal file
50
tests/unit/pkgmgr/actions/release/test_prompts.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pkgmgr.actions.release.prompts import (
|
||||||
|
confirm_proceed_release,
|
||||||
|
should_delete_branch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldDeleteBranch(unittest.TestCase):
|
||||||
|
def test_force_true_skips_prompt_and_returns_true(self) -> None:
|
||||||
|
self.assertTrue(should_delete_branch(force=True))
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.prompts.sys.stdin.isatty", return_value=False)
|
||||||
|
def test_non_interactive_returns_false(self, _mock_isatty) -> None:
|
||||||
|
self.assertFalse(should_delete_branch(force=False))
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.prompts.sys.stdin.isatty", return_value=True)
|
||||||
|
@patch("builtins.input", return_value="y")
|
||||||
|
def test_interactive_yes_returns_true(self, _mock_input, _mock_isatty) -> None:
|
||||||
|
self.assertTrue(should_delete_branch(force=False))
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.prompts.sys.stdin.isatty", return_value=True)
|
||||||
|
@patch("builtins.input", return_value="N")
|
||||||
|
def test_interactive_no_returns_false(self, _mock_input, _mock_isatty) -> None:
|
||||||
|
self.assertFalse(should_delete_branch(force=False))
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfirmProceedRelease(unittest.TestCase):
|
||||||
|
@patch("builtins.input", return_value="y")
|
||||||
|
def test_confirm_yes(self, _mock_input) -> None:
|
||||||
|
self.assertTrue(confirm_proceed_release())
|
||||||
|
|
||||||
|
@patch("builtins.input", return_value="no")
|
||||||
|
def test_confirm_no(self, _mock_input) -> None:
|
||||||
|
self.assertFalse(confirm_proceed_release())
|
||||||
|
|
||||||
|
@patch("builtins.input", side_effect=EOFError)
|
||||||
|
def test_confirm_eof_returns_false(self, _mock_input) -> None:
|
||||||
|
self.assertFalse(confirm_proceed_release())
|
||||||
|
|
||||||
|
@patch("builtins.input", side_effect=KeyboardInterrupt)
|
||||||
|
def test_confirm_keyboard_interrupt_returns_false(self, _mock_input) -> None:
|
||||||
|
self.assertFalse(confirm_proceed_release())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from pkgmgr.core.version.semver import SemVer
|
|
||||||
from pkgmgr.actions.release import release
|
|
||||||
|
|
||||||
|
|
||||||
class TestReleaseOrchestration(unittest.TestCase):
|
|
||||||
def test_release_happy_path_uses_helpers_and_git(self) -> None:
|
|
||||||
with patch("pkgmgr.actions.release.sys.stdin.isatty", return_value=False), \
|
|
||||||
patch("pkgmgr.actions.release.determine_current_version") as mock_determine_current_version, \
|
|
||||||
patch("pkgmgr.actions.release.bump_semver") as mock_bump_semver, \
|
|
||||||
patch("pkgmgr.actions.release.update_pyproject_version") as mock_update_pyproject, \
|
|
||||||
patch("pkgmgr.actions.release.update_changelog") as mock_update_changelog, \
|
|
||||||
patch("pkgmgr.actions.release.get_current_branch", return_value="develop") as mock_get_current_branch, \
|
|
||||||
patch("pkgmgr.actions.release.update_flake_version") as mock_update_flake, \
|
|
||||||
patch("pkgmgr.actions.release.update_pkgbuild_version") as mock_update_pkgbuild, \
|
|
||||||
patch("pkgmgr.actions.release.update_spec_version") as mock_update_spec, \
|
|
||||||
patch("pkgmgr.actions.release.update_debian_changelog") as mock_update_debian_changelog, \
|
|
||||||
patch("pkgmgr.actions.release.update_spec_changelog") as mock_update_spec_changelog, \
|
|
||||||
patch("pkgmgr.actions.release.run_git_command") as mock_run_git_command, \
|
|
||||||
patch("pkgmgr.actions.release.sync_branch_with_remote") as mock_sync_branch, \
|
|
||||||
patch("pkgmgr.actions.release.update_latest_tag") as mock_update_latest_tag:
|
|
||||||
mock_determine_current_version.return_value = SemVer(1, 2, 3)
|
|
||||||
mock_bump_semver.return_value = SemVer(1, 2, 4)
|
|
||||||
|
|
||||||
release(
|
|
||||||
pyproject_path="pyproject.toml",
|
|
||||||
changelog_path="CHANGELOG.md",
|
|
||||||
release_type="patch",
|
|
||||||
message="Test release",
|
|
||||||
preview=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Current version + bump
|
|
||||||
mock_determine_current_version.assert_called_once()
|
|
||||||
mock_bump_semver.assert_called_once()
|
|
||||||
args, kwargs = mock_bump_semver.call_args
|
|
||||||
self.assertEqual(args[0], SemVer(1, 2, 3))
|
|
||||||
self.assertEqual(args[1], "patch")
|
|
||||||
self.assertEqual(kwargs, {})
|
|
||||||
|
|
||||||
# pyproject update
|
|
||||||
mock_update_pyproject.assert_called_once()
|
|
||||||
args, kwargs = mock_update_pyproject.call_args
|
|
||||||
self.assertEqual(args[0], "pyproject.toml")
|
|
||||||
self.assertEqual(args[1], "1.2.4")
|
|
||||||
self.assertEqual(kwargs.get("preview"), False)
|
|
||||||
|
|
||||||
# changelog update (Projekt)
|
|
||||||
mock_update_changelog.assert_called_once()
|
|
||||||
args, kwargs = mock_update_changelog.call_args
|
|
||||||
self.assertEqual(args[0], "CHANGELOG.md")
|
|
||||||
self.assertEqual(args[1], "1.2.4")
|
|
||||||
self.assertEqual(kwargs.get("message"), "Test release")
|
|
||||||
self.assertEqual(kwargs.get("preview"), False)
|
|
||||||
|
|
||||||
# Additional packaging helpers called with preview=False
|
|
||||||
mock_update_flake.assert_called_once()
|
|
||||||
self.assertEqual(mock_update_flake.call_args[1].get("preview"), False)
|
|
||||||
|
|
||||||
mock_update_pkgbuild.assert_called_once()
|
|
||||||
self.assertEqual(mock_update_pkgbuild.call_args[1].get("preview"), False)
|
|
||||||
|
|
||||||
mock_update_spec.assert_called_once()
|
|
||||||
self.assertEqual(mock_update_spec.call_args[1].get("preview"), False)
|
|
||||||
|
|
||||||
mock_update_debian_changelog.assert_called_once()
|
|
||||||
self.assertEqual(
|
|
||||||
mock_update_debian_changelog.call_args[1].get("preview"),
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fedora / RPM %changelog helper
|
|
||||||
mock_update_spec_changelog.assert_called_once()
|
|
||||||
self.assertEqual(
|
|
||||||
mock_update_spec_changelog.call_args[1].get("preview"),
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Git operations
|
|
||||||
mock_get_current_branch.assert_called_once()
|
|
||||||
self.assertEqual(mock_get_current_branch.return_value, "develop")
|
|
||||||
|
|
||||||
git_calls = [c.args[0] for c in mock_run_git_command.call_args_list]
|
|
||||||
self.assertIn('git commit -am "Release version 1.2.4"', git_calls)
|
|
||||||
self.assertIn('git tag -a v1.2.4 -m "Test release"', git_calls)
|
|
||||||
self.assertIn("git push origin develop", git_calls)
|
|
||||||
self.assertIn("git push origin --tags", git_calls)
|
|
||||||
|
|
||||||
# Branch sync & latest tag update
|
|
||||||
mock_sync_branch.assert_called_once_with("develop", preview=False)
|
|
||||||
mock_update_latest_tag.assert_called_once_with("v1.2.4", preview=False)
|
|
||||||
|
|
||||||
def test_release_preview_mode_skips_git_and_uses_preview_flag(self) -> None:
|
|
||||||
with patch("pkgmgr.actions.release.determine_current_version") as mock_determine_current_version, \
|
|
||||||
patch("pkgmgr.actions.release.bump_semver") as mock_bump_semver, \
|
|
||||||
patch("pkgmgr.actions.release.update_pyproject_version") as mock_update_pyproject, \
|
|
||||||
patch("pkgmgr.actions.release.update_changelog") as mock_update_changelog, \
|
|
||||||
patch("pkgmgr.actions.release.get_current_branch", return_value="develop") as mock_get_current_branch, \
|
|
||||||
patch("pkgmgr.actions.release.update_flake_version") as mock_update_flake, \
|
|
||||||
patch("pkgmgr.actions.release.update_pkgbuild_version") as mock_update_pkgbuild, \
|
|
||||||
patch("pkgmgr.actions.release.update_spec_version") as mock_update_spec, \
|
|
||||||
patch("pkgmgr.actions.release.update_debian_changelog") as mock_update_debian_changelog, \
|
|
||||||
patch("pkgmgr.actions.release.update_spec_changelog") as mock_update_spec_changelog, \
|
|
||||||
patch("pkgmgr.actions.release.run_git_command") as mock_run_git_command, \
|
|
||||||
patch("pkgmgr.actions.release.sync_branch_with_remote") as mock_sync_branch, \
|
|
||||||
patch("pkgmgr.actions.release.update_latest_tag") as mock_update_latest_tag:
|
|
||||||
mock_determine_current_version.return_value = SemVer(1, 2, 3)
|
|
||||||
mock_bump_semver.return_value = SemVer(1, 2, 4)
|
|
||||||
|
|
||||||
release(
|
|
||||||
pyproject_path="pyproject.toml",
|
|
||||||
changelog_path="CHANGELOG.md",
|
|
||||||
release_type="patch",
|
|
||||||
message="Preview release",
|
|
||||||
preview=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# All update helpers must be called with preview=True
|
|
||||||
mock_update_pyproject.assert_called_once()
|
|
||||||
self.assertTrue(mock_update_pyproject.call_args[1].get("preview"))
|
|
||||||
|
|
||||||
mock_update_changelog.assert_called_once()
|
|
||||||
self.assertTrue(mock_update_changelog.call_args[1].get("preview"))
|
|
||||||
|
|
||||||
mock_update_flake.assert_called_once()
|
|
||||||
self.assertTrue(mock_update_flake.call_args[1].get("preview"))
|
|
||||||
|
|
||||||
mock_update_pkgbuild.assert_called_once()
|
|
||||||
self.assertTrue(mock_update_pkgbuild.call_args[1].get("preview"))
|
|
||||||
|
|
||||||
mock_update_spec.assert_called_once()
|
|
||||||
self.assertTrue(mock_update_spec.call_args[1].get("preview"))
|
|
||||||
|
|
||||||
mock_update_debian_changelog.assert_called_once()
|
|
||||||
self.assertTrue(mock_update_debian_changelog.call_args[1].get("preview"))
|
|
||||||
|
|
||||||
# Fedora / RPM spec changelog helper in preview mode
|
|
||||||
mock_update_spec_changelog.assert_called_once()
|
|
||||||
self.assertTrue(mock_update_spec_changelog.call_args[1].get("preview"))
|
|
||||||
|
|
||||||
# In preview mode no real git commands must be executed
|
|
||||||
mock_run_git_command.assert_not_called()
|
|
||||||
|
|
||||||
# Branch sync is still invoked (with preview=True internally),
|
|
||||||
# and latest tag is only announced in preview mode
|
|
||||||
mock_sync_branch.assert_called_once_with("develop", preview=True)
|
|
||||||
mock_update_latest_tag.assert_called_once_with("v1.2.4", preview=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
59
tests/unit/pkgmgr/actions/release/test_workflow.py
Normal file
59
tests/unit/pkgmgr/actions/release/test_workflow.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pkgmgr.actions.release.workflow import release
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkflowReleaseEntryPoint(unittest.TestCase):
|
||||||
|
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||||
|
def test_release_preview_calls_impl_preview_only(self, mock_impl) -> None:
|
||||||
|
release(preview=True, force=False, close=False)
|
||||||
|
|
||||||
|
mock_impl.assert_called_once()
|
||||||
|
kwargs = mock_impl.call_args.kwargs
|
||||||
|
self.assertTrue(kwargs["preview"])
|
||||||
|
self.assertFalse(kwargs["force"])
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||||
|
@patch("pkgmgr.actions.release.workflow.sys.stdin.isatty", return_value=False)
|
||||||
|
def test_release_non_interactive_runs_real_without_confirmation(self, _mock_isatty, mock_impl) -> None:
|
||||||
|
release(preview=False, force=False, close=False)
|
||||||
|
|
||||||
|
mock_impl.assert_called_once()
|
||||||
|
kwargs = mock_impl.call_args.kwargs
|
||||||
|
self.assertFalse(kwargs["preview"])
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||||
|
def test_release_force_runs_real_without_confirmation(self, mock_impl) -> None:
|
||||||
|
release(preview=False, force=True, close=False)
|
||||||
|
|
||||||
|
mock_impl.assert_called_once()
|
||||||
|
kwargs = mock_impl.call_args.kwargs
|
||||||
|
self.assertFalse(kwargs["preview"])
|
||||||
|
self.assertTrue(kwargs["force"])
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||||
|
@patch("pkgmgr.actions.release.workflow.confirm_proceed_release", return_value=False)
|
||||||
|
@patch("pkgmgr.actions.release.workflow.sys.stdin.isatty", return_value=True)
|
||||||
|
def test_release_interactive_decline_runs_only_preview(self, _mock_isatty, _mock_confirm, mock_impl) -> None:
|
||||||
|
release(preview=False, force=False, close=False)
|
||||||
|
|
||||||
|
# interactive path: preview first, then decline => only one call
|
||||||
|
self.assertEqual(mock_impl.call_count, 1)
|
||||||
|
self.assertTrue(mock_impl.call_args_list[0].kwargs["preview"])
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||||
|
@patch("pkgmgr.actions.release.workflow.confirm_proceed_release", return_value=True)
|
||||||
|
@patch("pkgmgr.actions.release.workflow.sys.stdin.isatty", return_value=True)
|
||||||
|
def test_release_interactive_accept_runs_preview_then_real(self, _mock_isatty, _mock_confirm, mock_impl) -> None:
|
||||||
|
release(preview=False, force=False, close=False)
|
||||||
|
|
||||||
|
self.assertEqual(mock_impl.call_count, 2)
|
||||||
|
self.assertTrue(mock_impl.call_args_list[0].kwargs["preview"])
|
||||||
|
self.assertFalse(mock_impl.call_args_list[1].kwargs["preview"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user