diff --git a/src/pkgmgr/actions/release/package_name.py b/src/pkgmgr/actions/release/package_name.py new file mode 100644 index 0000000..c77d4ca --- /dev/null +++ b/src/pkgmgr/actions/release/package_name.py @@ -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" diff --git a/src/pkgmgr/actions/release/workflow.py b/src/pkgmgr/actions/release/workflow.py index 3af910d..c8daf5b 100644 --- a/src/pkgmgr/actions/release/workflow.py +++ b/src/pkgmgr/actions/release/workflow.py @@ -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( diff --git a/src/pkgmgr/actions/repository/_parallel.py b/src/pkgmgr/actions/repository/_parallel.py index 6d0e847..d931f3d 100644 --- a/src/pkgmgr/actions/repository/_parallel.py +++ b/src/pkgmgr/actions/repository/_parallel.py @@ -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) diff --git a/src/pkgmgr/actions/repository/pull.py b/src/pkgmgr/actions/repository/pull.py index c61249f..4f13237 100644 --- a/src/pkgmgr/actions/repository/pull.py +++ b/src/pkgmgr/actions/repository/pull.py @@ -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}") diff --git a/src/pkgmgr/core/repository/paths.py b/src/pkgmgr/core/repository/paths.py index 8c6e68b..c231fe3 100644 --- a/src/pkgmgr/core/repository/paths.py +++ b/src/pkgmgr/core/repository/paths.py @@ -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, ) diff --git a/tests/unit/pkgmgr/actions/release/test_package_name.py b/tests/unit/pkgmgr/actions/release/test_package_name.py new file mode 100644 index 0000000..be761e0 --- /dev/null +++ b/tests/unit/pkgmgr/actions/release/test_package_name.py @@ -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()