refactor(release,version): centralize repository path resolution and validate template layout
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / codesniffer-shellcheck (push) Has been cancelled
Mark stable commit / codesniffer-ruff (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled

- Introduce RepoPaths resolver as single source of truth for repository file locations
- Update release workflow to use resolved packaging and changelog paths
- Update version readers to rely on the shared path resolver
- Add integration test asserting pkgmgr repository satisfies canonical template layout

https://chatgpt.com/share/693eaa75-98f0-800f-adca-439555f84154
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-14 13:15:41 +01:00
parent 69d28a461d
commit db9aaf920e
4 changed files with 277 additions and 58 deletions

View File

@@ -1,10 +1,13 @@
# src/pkgmgr/actions/release/workflow.py
from __future__ import annotations
from typing import Optional
import os
import sys
from typing import Optional
from pkgmgr.actions.branch import close_branch
from pkgmgr.core.git import get_current_branch, GitError
from pkgmgr.core.repository.paths import resolve_repo_paths
from .files import (
update_changelog,
@@ -55,8 +58,12 @@ def _release_impl(
print(f"New version: {new_ver_str} ({release_type})")
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
paths = resolve_repo_paths(repo_root)
# --- Update versioned files ------------------------------------------------
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
changelog_message = update_changelog(
changelog_path,
new_ver_str,
@@ -64,38 +71,46 @@ def _release_impl(
preview=preview,
)
flake_path = os.path.join(repo_root, "flake.nix")
update_flake_version(flake_path, new_ver_str, preview=preview)
update_flake_version(paths.flake_nix, new_ver_str, preview=preview)
pkgbuild_path = os.path.join(repo_root, "PKGBUILD")
update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview)
if paths.arch_pkgbuild:
update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview)
else:
print("[INFO] No PKGBUILD found (packaging/arch/PKGBUILD or PKGBUILD). Skipping.")
spec_path = os.path.join(repo_root, "package-manager.spec")
update_spec_version(spec_path, new_ver_str, preview=preview)
if paths.rpm_spec:
update_spec_version(paths.rpm_spec, new_ver_str, preview=preview)
else:
print("[INFO] No RPM spec file found. Skipping spec version update.")
effective_message: Optional[str] = message
if effective_message is None and isinstance(changelog_message, str):
if changelog_message.strip():
effective_message = changelog_message.strip()
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
package_name = os.path.basename(repo_root) or "package-manager"
update_debian_changelog(
debian_changelog_path,
package_name=package_name,
new_version=new_ver_str,
message=effective_message,
preview=preview,
)
if paths.debian_changelog:
update_debian_changelog(
paths.debian_changelog,
package_name=package_name,
new_version=new_ver_str,
message=effective_message,
preview=preview,
)
else:
print("[INFO] No debian changelog found. Skipping debian/changelog update.")
update_spec_changelog(
spec_path=spec_path,
package_name=package_name,
new_version=new_ver_str,
message=effective_message,
preview=preview,
)
if paths.rpm_spec:
update_spec_changelog(
spec_path=paths.rpm_spec,
package_name=package_name,
new_version=new_ver_str,
message=effective_message,
preview=preview,
)
# --- Git commit / tag / push ----------------------------------------------
commit_msg = f"Release version {new_ver_str}"
tag_msg = effective_message or commit_msg
@@ -103,12 +118,12 @@ def _release_impl(
files_to_add = [
pyproject_path,
changelog_path,
flake_path,
pkgbuild_path,
spec_path,
debian_changelog_path,
paths.flake_nix,
paths.arch_pkgbuild,
paths.rpm_spec,
paths.debian_changelog,
]
existing_files = [p for p in files_to_add if p and os.path.exists(p)]
existing_files = [p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)]
if preview:
for path in existing_files:

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Central repository path resolver.
Goal:
- Provide ONE place to define where packaging / changelog / metadata files live.
- Prefer modern layout (packaging/*) but stay backwards-compatible with legacy
root-level paths.
Both:
- readers (pkgmgr.core.version.source)
- writers (pkgmgr.actions.release.workflow)
should use this module instead of hardcoding paths.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Iterable, Optional
@dataclass(frozen=True)
class RepoPaths:
repo_dir: str
pyproject_toml: str
flake_nix: str
# Human changelog (typically Markdown)
changelog_md: Optional[str]
# Packaging-related files
arch_pkgbuild: Optional[str]
debian_changelog: Optional[str]
rpm_spec: Optional[str]
def _first_existing(candidates: Iterable[str]) -> Optional[str]:
for p in candidates:
if p and os.path.isfile(p):
return p
return None
def _find_first_spec_in_dir(dir_path: str) -> Optional[str]:
if not os.path.isdir(dir_path):
return None
try:
for fn in sorted(os.listdir(dir_path)):
if fn.endswith(".spec"):
p = os.path.join(dir_path, fn)
if os.path.isfile(p):
return p
except OSError:
return None
return None
def resolve_repo_paths(repo_dir: str) -> RepoPaths:
"""
Resolve canonical file locations for a repository.
Preferences (new layout first, legacy fallback second):
- PKGBUILD: packaging/arch/PKGBUILD -> PKGBUILD
- Debian changelog: packaging/debian/changelog -> debian/changelog
- RPM spec: packaging/fedora/package-manager.spec
-> first *.spec in packaging/fedora
-> first *.spec in repo root
- CHANGELOG.md: CHANGELOG.md -> packaging/CHANGELOG.md (optional fallback)
Notes:
- This resolver only returns paths; it does not read/parse files.
- Callers should treat Optional paths as "may not exist".
"""
repo_dir = os.path.abspath(repo_dir)
pyproject_toml = os.path.join(repo_dir, "pyproject.toml")
flake_nix = os.path.join(repo_dir, "flake.nix")
changelog_md = _first_existing(
[
os.path.join(repo_dir, "CHANGELOG.md"),
os.path.join(repo_dir, "packaging", "CHANGELOG.md"),
]
)
arch_pkgbuild = _first_existing(
[
os.path.join(repo_dir, "packaging", "arch", "PKGBUILD"),
os.path.join(repo_dir, "PKGBUILD"),
]
)
debian_changelog = _first_existing(
[
os.path.join(repo_dir, "packaging", "debian", "changelog"),
os.path.join(repo_dir, "debian", "changelog"),
]
)
# RPM spec: prefer the canonical file, else first spec in packaging/fedora, else first spec in repo root.
rpm_spec = _first_existing(
[
os.path.join(repo_dir, "packaging", "fedora", "package-manager.spec"),
]
)
if rpm_spec is None:
rpm_spec = _find_first_spec_in_dir(os.path.join(repo_dir, "packaging", "fedora"))
if rpm_spec is None:
rpm_spec = _find_first_spec_in_dir(repo_dir)
return RepoPaths(
repo_dir=repo_dir,
pyproject_toml=pyproject_toml,
flake_nix=flake_nix,
changelog_md=changelog_md,
arch_pkgbuild=arch_pkgbuild,
debian_changelog=debian_changelog,
rpm_spec=rpm_spec,
)

View File

@@ -1,3 +1,4 @@
# src/pkgmgr/core/version/source.py
from __future__ import annotations
import os
@@ -6,6 +7,8 @@ from typing import Optional
import yaml
from pkgmgr.core.repository.paths import resolve_repo_paths
def read_pyproject_version(repo_dir: str) -> Optional[str]:
"""
@@ -13,7 +16,8 @@ def read_pyproject_version(repo_dir: str) -> Optional[str]:
Expects a PEP 621-style [project] table with a 'version' field.
"""
path = os.path.join(repo_dir, "pyproject.toml")
paths = resolve_repo_paths(repo_dir)
path = paths.pyproject_toml
if not os.path.isfile(path):
return None
@@ -39,7 +43,8 @@ def read_pyproject_project_name(repo_dir: str) -> Optional[str]:
This is required to correctly resolve installed Python package
versions via importlib.metadata.
"""
path = os.path.join(repo_dir, "pyproject.toml")
paths = resolve_repo_paths(repo_dir)
path = paths.pyproject_toml
if not os.path.isfile(path):
return None
@@ -65,7 +70,8 @@ def read_flake_version(repo_dir: str) -> Optional[str]:
Looks for:
version = "X.Y.Z";
"""
path = os.path.join(repo_dir, "flake.nix")
paths = resolve_repo_paths(repo_dir)
path = paths.flake_nix
if not os.path.isfile(path):
return None
@@ -84,15 +90,16 @@ def read_flake_version(repo_dir: str) -> Optional[str]:
def read_pkgbuild_version(repo_dir: str) -> Optional[str]:
"""
Read the version from PKGBUILD in repo_dir.
Read the version from PKGBUILD (preferring packaging/arch/PKGBUILD).
Combines pkgver and pkgrel if both exist:
pkgver=1.2.3
pkgrel=1
-> 1.2.3-1
"""
path = os.path.join(repo_dir, "PKGBUILD")
if not os.path.isfile(path):
paths = resolve_repo_paths(repo_dir)
path = paths.arch_pkgbuild
if not path or not os.path.isfile(path):
return None
try:
@@ -117,13 +124,19 @@ def read_pkgbuild_version(repo_dir: str) -> Optional[str]:
def read_debian_changelog_version(repo_dir: str) -> Optional[str]:
"""
Read the latest version from debian/changelog.
Read the latest version from debian changelog.
Preferred path:
packaging/debian/changelog
Fallback:
debian/changelog
Expected format:
package (1.2.3-1) unstable; urgency=medium
"""
path = os.path.join(repo_dir, "debian", "changelog")
if not os.path.isfile(path):
paths = resolve_repo_paths(repo_dir)
path = paths.debian_changelog
if not path or not os.path.isfile(path):
return None
try:
@@ -146,37 +159,40 @@ def read_spec_version(repo_dir: str) -> Optional[str]:
"""
Read the version from an RPM spec file.
Preferred paths:
packaging/fedora/package-manager.spec
packaging/fedora/*.spec
repo_root/*.spec
Combines:
Version: 1.2.3
Release: 1%{?dist}
-> 1.2.3-1
"""
for fn in os.listdir(repo_dir):
if not fn.endswith(".spec"):
continue
paths = resolve_repo_paths(repo_dir)
path = paths.rpm_spec
if not path or not os.path.isfile(path):
return None
path = os.path.join(repo_dir, fn)
try:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
except Exception:
return None
try:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
except Exception:
return None
ver_match = re.search(r"^Version:\s*(.+)$", text, re.MULTILINE)
if not ver_match:
return None
version = ver_match.group(1).strip()
ver_match = re.search(r"^Version:\s*(.+)$", text, re.MULTILINE)
if not ver_match:
return None
version = ver_match.group(1).strip()
rel_match = re.search(r"^Release:\s*(.+)$", text, re.MULTILINE)
if rel_match:
release_raw = rel_match.group(1).strip()
release = release_raw.split("%", 1)[0].split(" ", 1)[0].strip()
if release:
return f"{version}-{release}"
rel_match = re.search(r"^Release:\s*(.+)$", text, re.MULTILINE)
if rel_match:
release_raw = rel_match.group(1).strip()
release = release_raw.split("%", 1)[0].split(" ", 1)[0].strip()
if release:
return f"{version}-{release}"
return version or None
return None
return version or None
def read_ansible_galaxy_version(repo_dir: str) -> Optional[str]: