Compare commits

..
4 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach a4a5b661b9 Release version 1.14.0
Mark stable commit / mark-stable (push) Canceled after 0s
2026-05-27 20:53:14 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.7 43fbcfb227 feat(release): add retry mode to re-deploy existing release without re-tagging
Recovers from a release whose tag+commit landed cleanly but whose
post-tag steps (git push, latest-tag bump, twine upload) failed
mid-flight. pkgmgr release --retry skips the version bump, file
rewrites, commit, and tag-creation steps and re-runs only the
idempotent tail: re-push the existing HEAD tag, re-align the floating
latest tag, and (unless --no-publish) re-invoke publish.

The retry logic lives in its own module pkgmgr.actions.release.retry
so the workflow.py orchestrator stays focused on the forward path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:52:06 +02:00
Kevin Veen-Birkenbach a6c40451fe Release version 1.13.4
Mark stable commit / mark-stable (push) Canceled after 0s
2026-05-27 20:32:39 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.7 5fa2709a84 feat(release): resolve package name from packaging files, not folder
Previously the release workflow derived the distro-package name from
`os.path.basename(repo_root)`. Renaming the repo folder (e.g.
`infinito-nexus` → `infinito-nexus-core`) silently rewrote
`debian/changelog`'s top entry to the new folder name while
`debian/control` still pinned the legacy `Package:` value. dpkg-source
refuses to build a source package when the two disagree.

Add `resolve_package_name(paths)` that consults the existing packaging
files in priority order (debian/control `Package:` → PKGBUILD
`pkgname=` → RPM `.spec` `Name:`) and only falls back to the folder
basename when no packaging metadata is present. Extend `RepoPaths`
with a `debian_control` slot so `resolve_repo_paths` can discover the
file under both `packaging/debian/control` and the legacy `debian/`
location.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:27:01 +02:00
16 changed files with 691 additions and 16 deletions

No files matched your search

+74
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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')
+76
View File
@@ -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
+71 -1
View File
@@ -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
View File
@@ -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"
@@ -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"
+67
View File
@@ -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}.")
+8 -1
View File
@@ -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,
+1 -3
View File
@@ -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)
+11 -5
View File
@@ -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}")
+15 -2
View File
@@ -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):
+17 -1
View File
@@ -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."
),
)
+9
View File
@@ -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()