Compare commits
5
Commits
70b06d2b3a
...
v1.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4a5b661b9 | ||
|
|
43fbcfb227 | ||
|
|
a6c40451fe | ||
|
|
5fa2709a84 | ||
|
|
386d8aa2f2 |
No files matched your search
@@ -1,3 +1,77 @@
|
||||
## [1.14.0] - 2026-05-27
|
||||
|
||||
* Added
|
||||
|
||||
* New release --retry mode re-deploys the HEAD release without
|
||||
re-tagging or modifying any files. It re-pushes the existing version
|
||||
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||
re-runs publish. Use this to recover from a release whose post-tag
|
||||
push or PyPI upload failed mid-flight. The release_type argument
|
||||
becomes optional under --retry.
|
||||
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||
helper so the workflow orchestrator stays focused on the forward
|
||||
path.
|
||||
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||
debian_changelog under both packaging/debian and the legacy debian
|
||||
layout.
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the distro-name lookup chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||
and branch-detection fallback.
|
||||
|
||||
Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||
then folder basename as legacy fallback. Renaming a repository
|
||||
folder no longer silently flips the debian/changelog top entry and
|
||||
the RPM changelog stanza to a new identifier. Those keep matching
|
||||
the authoritative value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement
|
||||
on the next release.
|
||||
|
||||
|
||||
## [1.13.4] - 2026-05-27
|
||||
|
||||
* Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||
folder basename as legacy fallback. Renaming a repository folder (for
|
||||
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||
a new identifier. Those keep matching the authoritative Package,
|
||||
pkgname, or Name value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Added
|
||||
|
||||
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||
debian_changelog under both packaging/debian (new layout) and debian
|
||||
(legacy layout).
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the priority chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement.
|
||||
|
||||
|
||||
## [1.13.3] - 2026-03-26
|
||||
|
||||
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
rec {
|
||||
pkgmgr = pyPkgs.buildPythonApplication {
|
||||
pname = "package-manager";
|
||||
version = "1.13.3";
|
||||
version = "1.14.0";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
|
||||
pkgname=package-manager
|
||||
pkgver=1.13.3
|
||||
pkgver=1.14.0
|
||||
pkgrel=1
|
||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||
arch=('any')
|
||||
|
||||
@@ -1,3 +1,79 @@
|
||||
package-manager (1.14.0-1) unstable; urgency=medium
|
||||
|
||||
* Added
|
||||
|
||||
* New release --retry mode re-deploys the HEAD release without
|
||||
re-tagging or modifying any files. It re-pushes the existing version
|
||||
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||
re-runs publish. Use this to recover from a release whose post-tag
|
||||
push or PyPI upload failed mid-flight. The release_type argument
|
||||
becomes optional under --retry.
|
||||
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||
helper so the workflow orchestrator stays focused on the forward
|
||||
path.
|
||||
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||
debian_changelog under both packaging/debian and the legacy debian
|
||||
layout.
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the distro-name lookup chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||
and branch-detection fallback.
|
||||
|
||||
Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||
then folder basename as legacy fallback. Renaming a repository
|
||||
folder no longer silently flips the debian/changelog top entry and
|
||||
the RPM changelog stanza to a new identifier. Those keep matching
|
||||
the authoritative value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement
|
||||
on the next release.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:53:14 +0200
|
||||
|
||||
package-manager (1.13.4-1) unstable; urgency=medium
|
||||
|
||||
* Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||
folder basename as legacy fallback. Renaming a repository folder (for
|
||||
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||
a new identifier. Those keep matching the authoritative Package,
|
||||
pkgname, or Name value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Added
|
||||
|
||||
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||
debian_changelog under both packaging/debian (new layout) and debian
|
||||
(legacy layout).
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the priority chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:32:39 +0200
|
||||
|
||||
package-manager (1.13.3-1) unstable; urgency=medium
|
||||
|
||||
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: package-manager
|
||||
Version: 1.13.3
|
||||
Version: 1.14.0
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
@@ -74,6 +74,76 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
||||
/usr/lib/package-manager/
|
||||
|
||||
%changelog
|
||||
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.14.0-1
|
||||
- Added
|
||||
|
||||
* New release --retry mode re-deploys the HEAD release without
|
||||
re-tagging or modifying any files. It re-pushes the existing version
|
||||
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||
re-runs publish. Use this to recover from a release whose post-tag
|
||||
push or PyPI upload failed mid-flight. The release_type argument
|
||||
becomes optional under --retry.
|
||||
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||
helper so the workflow orchestrator stays focused on the forward
|
||||
path.
|
||||
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||
debian_changelog under both packaging/debian and the legacy debian
|
||||
layout.
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the distro-name lookup chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||
and branch-detection fallback.
|
||||
|
||||
Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||
then folder basename as legacy fallback. Renaming a repository
|
||||
folder no longer silently flips the debian/changelog top entry and
|
||||
the RPM changelog stanza to a new identifier. Those keep matching
|
||||
the authoritative value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement
|
||||
on the next release.
|
||||
|
||||
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.4-1
|
||||
- Changed
|
||||
|
||||
* pkgmgr release now derives the distro-package name from existing
|
||||
packaging metadata instead of the repository folder name. The lookup
|
||||
order is packaging/debian/control Package field, then
|
||||
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||
folder basename as legacy fallback. Renaming a repository folder (for
|
||||
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||
a new identifier. Those keep matching the authoritative Package,
|
||||
pkgname, or Name value in the packaging files, which is what apt,
|
||||
pacman, and dnf index against.
|
||||
|
||||
Added
|
||||
|
||||
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||
debian_changelog under both packaging/debian (new layout) and debian
|
||||
(legacy layout).
|
||||
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||
the priority chain and is unit-tested under
|
||||
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||
|
||||
Fixed
|
||||
|
||||
* dpkg-source --before-build no longer fails with the message about
|
||||
source package having two conflicting values after a repo-folder
|
||||
rename, because the changelog and control file stay in agreement.
|
||||
|
||||
* Thu Mar 26 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.3-1
|
||||
- CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kpmx"
|
||||
version = "1.13.3"
|
||||
version = "1.14.0"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -38,7 +38,7 @@ echo "[aur-builder-setup] Configuring sudoers for aur_builder..."
|
||||
${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder"
|
||||
${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder
|
||||
|
||||
RUN_AS_AUR=(su - aur_builder -s /bin/bash -c)
|
||||
RUN_AS_AUR=(runuser -u aur_builder -- bash -c)
|
||||
|
||||
echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..."
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ echo "[arch/package] Using 'aur_builder' user for makepkg..."
|
||||
chown -R aur_builder:aur_builder "${BUILD_ROOT}"
|
||||
|
||||
echo "[arch/package] Running makepkg in: ${PKG_BUILD_DIR}"
|
||||
su aur_builder -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
|
||||
runuser -u aur_builder -- bash -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
|
||||
|
||||
echo "[arch/package] Installing generated Arch package..."
|
||||
pkg_path="$(find "${PKG_BUILD_DIR}" -maxdepth 1 -type f -name 'package-manager-*.pkg.tar.*' | head -n1)"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "[installation/install] Warning: Installation is just possible via root."
|
||||
exit 0
|
||||
echo "[installation/install] ERROR: Installation requires root. Re-run with sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[installation] Running as root (EUID=0)."
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Resolve the distro-package name for a release.
|
||||
|
||||
The release flow writes the package identifier into `debian/changelog`,
|
||||
the RPM `%changelog` stanza, etc. Historically pkgmgr derived this
|
||||
identifier from the repository folder name (`os.path.basename(repo_root)`),
|
||||
which silently breaks when the repo is renamed but the existing packaging
|
||||
files still ship the legacy name. Renaming the folder must not change the
|
||||
distro-package identity — `apt`, `pacman`, `dnf`, and every downstream
|
||||
manifest pin the old name.
|
||||
|
||||
The resolver therefore walks the existing packaging files in priority
|
||||
order and only falls back to the folder name when none of them ship an
|
||||
explicit name.
|
||||
|
||||
Priority:
|
||||
1. `debian/control` `Package:` field (most authoritative — dpkg-source
|
||||
refuses to build if changelog and control disagree)
|
||||
2. `packaging/arch/PKGBUILD` `pkgname=` value
|
||||
3. RPM spec `Name:` field
|
||||
4. Repository folder basename (legacy fallback)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.core.repository.paths import RepoPaths
|
||||
|
||||
|
||||
_DEBIAN_PACKAGE_RE = re.compile(r"^Package:\s*(\S+)\s*$", re.MULTILINE)
|
||||
_PKGBUILD_NAME_RE = re.compile(r"^pkgname=([^\s#]+)\s*$", re.MULTILINE)
|
||||
_RPM_NAME_RE = re.compile(r"^Name:\s*(\S+)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _read(path: Optional[str]) -> str:
|
||||
if not path or not os.path.isfile(path):
|
||||
return ""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _extract(pattern: re.Pattern[str], text: str) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return None
|
||||
value = match.group(1).strip().strip('"').strip("'")
|
||||
return value or None
|
||||
|
||||
|
||||
def resolve_package_name(paths: RepoPaths) -> str:
|
||||
"""Return the distro-package name for the repo, with a folder fallback.
|
||||
|
||||
The fallback uses `os.path.basename(paths.repo_dir)` so behaviour is
|
||||
backwards-compatible for repos that ship no packaging metadata yet.
|
||||
"""
|
||||
debian_name = _extract(_DEBIAN_PACKAGE_RE, _read(paths.debian_control))
|
||||
if debian_name:
|
||||
return debian_name
|
||||
|
||||
pkgbuild_name = _extract(_PKGBUILD_NAME_RE, _read(paths.arch_pkgbuild))
|
||||
if pkgbuild_name:
|
||||
return pkgbuild_name
|
||||
|
||||
rpm_name = _extract(_RPM_NAME_RE, _read(paths.rpm_spec))
|
||||
if rpm_name:
|
||||
return rpm_name
|
||||
|
||||
return os.path.basename(paths.repo_dir) or "package"
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Re-deploy an existing release without modifying files or creating a new tag.
|
||||
|
||||
The release workflow normally bumps versions, rewrites packaging
|
||||
manifests, commits, tags, pushes, and uploads to PyPI in one shot.
|
||||
When a post-tag step fails mid-flight (typical examples: `git push`
|
||||
rejected, `twine upload` aborted by a broken venv, `update-latest`
|
||||
rejected by branch protection) the local tag still exists on HEAD but
|
||||
the side effects downstream are incomplete.
|
||||
|
||||
`retry_release` re-runs the idempotent tail of that flow so a botched
|
||||
release can be re-pushed without touching code or recreating tags:
|
||||
|
||||
* `git push origin <branch> <tag>` for the existing HEAD tag
|
||||
* re-align the floating `latest` tag if HEAD tag is the highest
|
||||
|
||||
Publishing (PyPI etc.) stays the caller's responsibility — the publish
|
||||
workflow is already idempotent (twine rejects duplicates per spec) and
|
||||
can be invoked independently via the `publish` subcommand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pkgmgr.actions.publish.git_tags import head_semver_tags
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.version.semver import SemVer
|
||||
|
||||
from .git_ops import is_highest_version_tag, update_latest_tag
|
||||
|
||||
|
||||
def retry_release(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""Re-push the HEAD release without re-tagging or modifying any files."""
|
||||
try:
|
||||
branch = get_current_branch() or "main"
|
||||
except GitRunError:
|
||||
branch = "main"
|
||||
print(f"Retrying release push on branch: {branch}")
|
||||
|
||||
tags = head_semver_tags(cwd=os.path.dirname(os.path.abspath(pyproject_path)))
|
||||
if not tags:
|
||||
raise RuntimeError(
|
||||
"No version tag on HEAD. Nothing to retry — "
|
||||
"run `pkgmgr release <type>` first to create a release."
|
||||
)
|
||||
tag = max(tags, key=SemVer.parse)
|
||||
print(f"Re-pushing existing tag: {tag}")
|
||||
|
||||
run(["push", "origin", branch, tag], preview=preview)
|
||||
|
||||
try:
|
||||
if is_highest_version_tag(tag):
|
||||
update_latest_tag(tag, preview=preview)
|
||||
else:
|
||||
print(f"[INFO] Skipping 'latest' update (tag {tag} is not the highest).")
|
||||
except GitRunError as exc:
|
||||
print(f"[WARN] Failed to update floating 'latest' tag for {tag}: {exc}")
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Retry push for {tag} would now complete.")
|
||||
return
|
||||
|
||||
print(f"Retry push completed for {tag}.")
|
||||
@@ -24,7 +24,9 @@ from .git_ops import (
|
||||
is_highest_version_tag,
|
||||
update_latest_tag,
|
||||
)
|
||||
from .package_name import resolve_package_name
|
||||
from .prompts import confirm_proceed_release, should_delete_branch
|
||||
from .retry import retry_release
|
||||
from .versioning import bump_semver, determine_current_version
|
||||
|
||||
|
||||
@@ -90,7 +92,7 @@ def _release_impl(
|
||||
if changelog_message.strip():
|
||||
effective_message = changelog_message.strip()
|
||||
|
||||
package_name = os.path.basename(repo_root) or "package-manager"
|
||||
package_name = resolve_package_name(paths)
|
||||
|
||||
if paths.debian_changelog:
|
||||
update_debian_changelog(
|
||||
@@ -198,7 +200,12 @@ def release(
|
||||
preview: bool = False,
|
||||
force: bool = False,
|
||||
close: bool = False,
|
||||
retry: bool = False,
|
||||
) -> None:
|
||||
if retry:
|
||||
retry_release(pyproject_path=pyproject_path, preview=preview)
|
||||
return
|
||||
|
||||
if preview:
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
|
||||
@@ -83,9 +83,7 @@ def run_on_repos(
|
||||
|
||||
if failed:
|
||||
if effective_jobs > 1:
|
||||
print(
|
||||
f"\n[SUMMARY] {len(failed)} of {len(repos)} {op_name}(s) failed:"
|
||||
)
|
||||
print(f"\n[SUMMARY] {len(failed)} of {len(repos)} {op_name}(s) failed:")
|
||||
for ident, _msg in failed:
|
||||
print(f" - {ident}")
|
||||
sys.exit(1)
|
||||
@@ -28,7 +28,10 @@ def _verify_one(
|
||||
) -> Tuple[bool, bool, List[str]]:
|
||||
"""Returns (has_verified_info, verified_ok, errors)."""
|
||||
verified_ok, errors, _commit, _key = verify_repository(
|
||||
repo, repo_dir, mode="pull", no_verification=no_verification,
|
||||
repo,
|
||||
repo_dir,
|
||||
mode="pull",
|
||||
no_verification=no_verification,
|
||||
)
|
||||
return (bool(repo.get("verified")), verified_ok, errors)
|
||||
|
||||
@@ -56,9 +59,7 @@ def _verify_all(
|
||||
for repo, _ident, rd in candidates
|
||||
]
|
||||
results = [f.result() for f in futures]
|
||||
return [
|
||||
(ident, rd, *res) for (_repo, ident, rd), res in zip(candidates, results)
|
||||
]
|
||||
return [(ident, rd, *res) for (_repo, ident, rd), res in zip(candidates, results)]
|
||||
|
||||
|
||||
def pull_with_verification(
|
||||
@@ -95,7 +96,12 @@ def pull_with_verification(
|
||||
|
||||
approved: List[RepoRef] = []
|
||||
for ident, rd, has_verified_info, verified_ok, errors in verify_results:
|
||||
if not preview and not no_verification and has_verified_info and not verified_ok:
|
||||
if (
|
||||
not preview
|
||||
and not no_verification
|
||||
and has_verified_info
|
||||
and not verified_ok
|
||||
):
|
||||
print(f"Warning: Verification failed for {ident}:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
|
||||
@@ -49,7 +49,19 @@ def handle_release(
|
||||
print(f"[WARN] Skipping repository {identifier}: directory missing.")
|
||||
continue
|
||||
|
||||
print(f"[pkgmgr] Running release for repository {identifier}...")
|
||||
retry = bool(getattr(args, "retry", False))
|
||||
if retry and args.release_type:
|
||||
print(
|
||||
f"[WARN] Ignoring release_type '{args.release_type}' for {identifier} — --retry skips version bumps."
|
||||
)
|
||||
if not retry and not args.release_type:
|
||||
print(
|
||||
f"[WARN] Skipping {identifier}: release_type is required unless --retry is set."
|
||||
)
|
||||
continue
|
||||
|
||||
action_label = "retry-release" if retry else "release"
|
||||
print(f"[pkgmgr] Running {action_label} for repository {identifier}...")
|
||||
|
||||
cwd_before = os.getcwd()
|
||||
try:
|
||||
@@ -58,11 +70,12 @@ def handle_release(
|
||||
run_release(
|
||||
pyproject_path="pyproject.toml",
|
||||
changelog_path="CHANGELOG.md",
|
||||
release_type=args.release_type,
|
||||
release_type=args.release_type or "patch",
|
||||
message=args.message or None,
|
||||
preview=getattr(args, "preview", False),
|
||||
force=getattr(args, "force", False),
|
||||
close=getattr(args, "close", False),
|
||||
retry=retry,
|
||||
)
|
||||
|
||||
if not getattr(args, "no_publish", False):
|
||||
|
||||
@@ -24,8 +24,12 @@ def add_release_subparser(
|
||||
|
||||
release_parser.add_argument(
|
||||
"release_type",
|
||||
nargs="?",
|
||||
choices=["major", "minor", "patch"],
|
||||
help="Type of version increment for the release (major, minor, patch).",
|
||||
help=(
|
||||
"Type of version increment for the release (major, minor, patch). "
|
||||
"Omit when `--retry` is set."
|
||||
),
|
||||
)
|
||||
|
||||
release_parser.add_argument(
|
||||
@@ -61,3 +65,15 @@ def add_release_subparser(
|
||||
action="store_true",
|
||||
help="Do not run publish automatically after a successful release.",
|
||||
)
|
||||
|
||||
release_parser.add_argument(
|
||||
"--retry",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Re-deploy the existing HEAD release without re-tagging or "
|
||||
"modifying any files: re-push the existing version tag, "
|
||||
"re-align the floating `latest` tag, and (unless --no-publish) "
|
||||
"re-run publish. Use this to recover from a release whose "
|
||||
"post-tag push or PyPI upload failed mid-flight."
|
||||
),
|
||||
)
|
||||
@@ -36,6 +36,7 @@ class RepoPaths:
|
||||
# Packaging-related files
|
||||
arch_pkgbuild: Optional[str]
|
||||
debian_changelog: Optional[str]
|
||||
debian_control: Optional[str]
|
||||
rpm_spec: Optional[str]
|
||||
|
||||
|
||||
@@ -102,6 +103,13 @@ def resolve_repo_paths(repo_dir: str) -> RepoPaths:
|
||||
]
|
||||
)
|
||||
|
||||
debian_control = _first_existing(
|
||||
[
|
||||
os.path.join(repo_dir, "packaging", "debian", "control"),
|
||||
os.path.join(repo_dir, "debian", "control"),
|
||||
]
|
||||
)
|
||||
|
||||
# RPM spec: prefer the canonical file, else first spec in packaging/fedora, else first spec in repo root.
|
||||
rpm_spec = _first_existing(
|
||||
[
|
||||
@@ -122,5 +130,6 @@ def resolve_repo_paths(repo_dir: str) -> RepoPaths:
|
||||
changelog_md=changelog_md,
|
||||
arch_pkgbuild=arch_pkgbuild,
|
||||
debian_changelog=debian_changelog,
|
||||
debian_control=debian_control,
|
||||
rpm_spec=rpm_spec,
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Unit tests for `pkgmgr.actions.release.package_name.resolve_package_name`.
|
||||
|
||||
The resolver must prefer the explicit name from existing packaging files
|
||||
over the repository folder name so that renaming the folder does not
|
||||
silently rename the distro package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.actions.release.package_name import resolve_package_name
|
||||
from pkgmgr.core.repository.paths import RepoPaths
|
||||
|
||||
|
||||
def _paths(
|
||||
repo_dir: str,
|
||||
*,
|
||||
debian_control: Optional[str] = None,
|
||||
arch_pkgbuild: Optional[str] = None,
|
||||
rpm_spec: Optional[str] = None,
|
||||
) -> RepoPaths:
|
||||
return RepoPaths(
|
||||
repo_dir=repo_dir,
|
||||
pyproject_toml=os.path.join(repo_dir, "pyproject.toml"),
|
||||
flake_nix=os.path.join(repo_dir, "flake.nix"),
|
||||
changelog_md=None,
|
||||
arch_pkgbuild=arch_pkgbuild,
|
||||
debian_changelog=None,
|
||||
debian_control=debian_control,
|
||||
rpm_spec=rpm_spec,
|
||||
)
|
||||
|
||||
|
||||
class TestResolvePackageName(unittest.TestCase):
|
||||
def test_debian_control_wins_over_folder(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
|
||||
control = Path(repo) / "control"
|
||||
control.write_text(
|
||||
"Source: infinito-nexus\nPackage: infinito-nexus\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_package_name(_paths(repo, debian_control=str(control))),
|
||||
"infinito-nexus",
|
||||
)
|
||||
|
||||
def test_pkgbuild_used_when_no_control(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
|
||||
pkgbuild = Path(repo) / "PKGBUILD"
|
||||
pkgbuild.write_text(
|
||||
"pkgname=infinito-nexus\npkgver=1.0\n", encoding="utf-8"
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_package_name(_paths(repo, arch_pkgbuild=str(pkgbuild))),
|
||||
"infinito-nexus",
|
||||
)
|
||||
|
||||
def test_rpm_spec_used_when_no_control_no_pkgbuild(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
|
||||
spec = Path(repo) / "pkg.spec"
|
||||
spec.write_text("Name: infinito-nexus\n", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
resolve_package_name(_paths(repo, rpm_spec=str(spec))),
|
||||
"infinito-nexus",
|
||||
)
|
||||
|
||||
def test_folder_fallback_when_no_packaging_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="solo-tool_") as repo:
|
||||
self.assertEqual(
|
||||
resolve_package_name(_paths(repo)),
|
||||
os.path.basename(repo),
|
||||
)
|
||||
|
||||
def test_priority_debian_over_pkgbuild_over_spec(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as repo:
|
||||
control = Path(repo) / "control"
|
||||
control.write_text("Package: deb-name\n", encoding="utf-8")
|
||||
pkgbuild = Path(repo) / "PKGBUILD"
|
||||
pkgbuild.write_text("pkgname=arch-name\n", encoding="utf-8")
|
||||
spec = Path(repo) / "x.spec"
|
||||
spec.write_text("Name: rpm-name\n", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
resolve_package_name(
|
||||
_paths(
|
||||
repo,
|
||||
debian_control=str(control),
|
||||
arch_pkgbuild=str(pkgbuild),
|
||||
rpm_spec=str(spec),
|
||||
)
|
||||
),
|
||||
"deb-name",
|
||||
)
|
||||
|
||||
def test_strips_quotes_in_pkgbuild(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as repo:
|
||||
pkgbuild = Path(repo) / "PKGBUILD"
|
||||
pkgbuild.write_text("pkgname='quoted-name'\n", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
resolve_package_name(_paths(repo, arch_pkgbuild=str(pkgbuild))),
|
||||
"quoted-name",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Unit tests for the `release(retry=True)` re-deploy entry point.
|
||||
|
||||
`TestReleaseRetry` exercises the routing decision in `release()` —
|
||||
that `--retry` skips the full `_release_impl` flow and dispatches to
|
||||
`retry_release` instead.
|
||||
|
||||
`TestRetryRelease` exercises the standalone module `pkgmgr.actions.release.retry`:
|
||||
HEAD-tag discovery, idempotent push, latest-tag re-alignment, and
|
||||
fallback behaviour when the current branch cannot be detected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.release.retry import retry_release
|
||||
from pkgmgr.actions.release.workflow import release
|
||||
from pkgmgr.core.git import GitRunError
|
||||
|
||||
|
||||
class TestReleaseRetry(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.release.workflow.retry_release")
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
def test_retry_routes_to_retry_release_only(
|
||||
self, mock_release_impl, mock_retry_release
|
||||
) -> None:
|
||||
release(retry=True, preview=False)
|
||||
|
||||
mock_retry_release.assert_called_once()
|
||||
mock_release_impl.assert_not_called()
|
||||
self.assertFalse(mock_retry_release.call_args.kwargs["preview"])
|
||||
|
||||
@patch("pkgmgr.actions.release.workflow.retry_release")
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
def test_retry_preview_passthrough(
|
||||
self, mock_release_impl, mock_retry_release
|
||||
) -> None:
|
||||
release(retry=True, preview=True)
|
||||
|
||||
mock_retry_release.assert_called_once()
|
||||
mock_release_impl.assert_not_called()
|
||||
self.assertTrue(mock_retry_release.call_args.kwargs["preview"])
|
||||
|
||||
@patch("pkgmgr.actions.release.workflow.retry_release")
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
def test_retry_ignores_release_args(
|
||||
self, mock_release_impl, mock_retry_release
|
||||
) -> None:
|
||||
# release_type/message/close/force must NOT trigger the full release flow
|
||||
# when retry=True.
|
||||
release(
|
||||
retry=True,
|
||||
release_type="major",
|
||||
message="ignored body",
|
||||
preview=False,
|
||||
force=True,
|
||||
close=True,
|
||||
)
|
||||
|
||||
mock_retry_release.assert_called_once()
|
||||
mock_release_impl.assert_not_called()
|
||||
|
||||
|
||||
class TestRetryRelease(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
|
||||
@patch("pkgmgr.actions.release.retry.run")
|
||||
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
return_value="release-fix",
|
||||
)
|
||||
def test_retry_pushes_existing_tag_and_updates_latest(
|
||||
self,
|
||||
_mock_branch,
|
||||
mock_head_tags,
|
||||
mock_run,
|
||||
_mock_highest,
|
||||
mock_update_latest,
|
||||
) -> None:
|
||||
mock_head_tags.return_value = ["v1.13.4"]
|
||||
|
||||
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["push", "origin", "release-fix", "v1.13.4"], preview=False
|
||||
)
|
||||
mock_update_latest.assert_called_once_with("v1.13.4", preview=False)
|
||||
|
||||
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=False)
|
||||
@patch("pkgmgr.actions.release.retry.run")
|
||||
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
return_value="release-fix",
|
||||
)
|
||||
def test_retry_skips_latest_for_non_highest_tag(
|
||||
self,
|
||||
_mock_branch,
|
||||
mock_head_tags,
|
||||
_mock_run,
|
||||
_mock_highest,
|
||||
mock_update_latest,
|
||||
) -> None:
|
||||
mock_head_tags.return_value = ["v1.0.0"]
|
||||
|
||||
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||
|
||||
mock_update_latest.assert_not_called()
|
||||
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.head_semver_tags",
|
||||
return_value=[],
|
||||
)
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
return_value="main",
|
||||
)
|
||||
def test_retry_raises_when_head_has_no_tag(
|
||||
self, _mock_branch, _mock_head_tags
|
||||
) -> None:
|
||||
with self.assertRaises(RuntimeError) as cm:
|
||||
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||
self.assertIn("No version tag on HEAD", str(cm.exception))
|
||||
|
||||
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
|
||||
@patch("pkgmgr.actions.release.retry.run")
|
||||
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
side_effect=GitRunError("detached"),
|
||||
)
|
||||
def test_retry_falls_back_to_main_branch_when_detection_fails(
|
||||
self,
|
||||
_mock_branch,
|
||||
mock_head_tags,
|
||||
mock_run,
|
||||
_mock_highest,
|
||||
_mock_update_latest,
|
||||
) -> None:
|
||||
mock_head_tags.return_value = ["v2.0.0"]
|
||||
|
||||
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["push", "origin", "main", "v2.0.0"], preview=False
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user