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>
This commit is contained in:
2026-05-27 20:27:01 +02:00
parent 386d8aa2f2
commit 5fa2709a84
6 changed files with 208 additions and 9 deletions

View File

@@ -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"

View File

@@ -24,6 +24,7 @@ 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 .versioning import bump_semver, determine_current_version
@@ -90,7 +91,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(

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)

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}")

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,
)

View File

@@ -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()