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:
75
src/pkgmgr/actions/release/package_name.py
Normal file
75
src/pkgmgr/actions/release/package_name.py
Normal 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"
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
110
tests/unit/pkgmgr/actions/release/test_package_name.py
Normal file
110
tests/unit/pkgmgr/actions/release/test_package_name.py
Normal 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()
|
||||
Reference in New Issue
Block a user