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 __future__ import annotations
from typing import Optional
import os import os
import sys import sys
from typing import Optional
from pkgmgr.actions.branch import close_branch from pkgmgr.actions.branch import close_branch
from pkgmgr.core.git import get_current_branch, GitError from pkgmgr.core.git import get_current_branch, GitError
from pkgmgr.core.repository.paths import resolve_repo_paths
from .files import ( from .files import (
update_changelog, update_changelog,
@@ -55,8 +58,12 @@ def _release_impl(
print(f"New version: {new_ver_str} ({release_type})") print(f"New version: {new_ver_str} ({release_type})")
repo_root = os.path.dirname(os.path.abspath(pyproject_path)) 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) update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
changelog_message = update_changelog( changelog_message = update_changelog(
changelog_path, changelog_path,
new_ver_str, new_ver_str,
@@ -64,38 +71,46 @@ def _release_impl(
preview=preview, preview=preview,
) )
flake_path = os.path.join(repo_root, "flake.nix") update_flake_version(paths.flake_nix, new_ver_str, preview=preview)
update_flake_version(flake_path, new_ver_str, preview=preview)
pkgbuild_path = os.path.join(repo_root, "PKGBUILD") if paths.arch_pkgbuild:
update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview) 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") if paths.rpm_spec:
update_spec_version(spec_path, new_ver_str, preview=preview) 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 effective_message: Optional[str] = message
if effective_message is None and isinstance(changelog_message, str): if effective_message is None and isinstance(changelog_message, str):
if changelog_message.strip(): if changelog_message.strip():
effective_message = 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" package_name = os.path.basename(repo_root) or "package-manager"
if paths.debian_changelog:
update_debian_changelog( update_debian_changelog(
debian_changelog_path, 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.")
if paths.rpm_spec:
update_spec_changelog(
spec_path=paths.rpm_spec,
package_name=package_name, package_name=package_name,
new_version=new_ver_str, new_version=new_ver_str,
message=effective_message, message=effective_message,
preview=preview, preview=preview,
) )
update_spec_changelog( # --- Git commit / tag / push ----------------------------------------------
spec_path=spec_path,
package_name=package_name,
new_version=new_ver_str,
message=effective_message,
preview=preview,
)
commit_msg = f"Release version {new_ver_str}" commit_msg = f"Release version {new_ver_str}"
tag_msg = effective_message or commit_msg tag_msg = effective_message or commit_msg
@@ -103,12 +118,12 @@ def _release_impl(
files_to_add = [ files_to_add = [
pyproject_path, pyproject_path,
changelog_path, changelog_path,
flake_path, paths.flake_nix,
pkgbuild_path, paths.arch_pkgbuild,
spec_path, paths.rpm_spec,
debian_changelog_path, 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: if preview:
for path in existing_files: 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 from __future__ import annotations
import os import os
@@ -6,6 +7,8 @@ from typing import Optional
import yaml import yaml
from pkgmgr.core.repository.paths import resolve_repo_paths
def read_pyproject_version(repo_dir: str) -> Optional[str]: 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. 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): if not os.path.isfile(path):
return None 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 This is required to correctly resolve installed Python package
versions via importlib.metadata. 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): if not os.path.isfile(path):
return None return None
@@ -65,7 +70,8 @@ def read_flake_version(repo_dir: str) -> Optional[str]:
Looks for: Looks for:
version = "X.Y.Z"; 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): if not os.path.isfile(path):
return None return None
@@ -84,15 +90,16 @@ def read_flake_version(repo_dir: str) -> Optional[str]:
def read_pkgbuild_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: Combines pkgver and pkgrel if both exist:
pkgver=1.2.3 pkgver=1.2.3
pkgrel=1 pkgrel=1
-> 1.2.3-1 -> 1.2.3-1
""" """
path = os.path.join(repo_dir, "PKGBUILD") paths = resolve_repo_paths(repo_dir)
if not os.path.isfile(path): path = paths.arch_pkgbuild
if not path or not os.path.isfile(path):
return None return None
try: try:
@@ -117,13 +124,19 @@ def read_pkgbuild_version(repo_dir: str) -> Optional[str]:
def read_debian_changelog_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: Expected format:
package (1.2.3-1) unstable; urgency=medium package (1.2.3-1) unstable; urgency=medium
""" """
path = os.path.join(repo_dir, "debian", "changelog") paths = resolve_repo_paths(repo_dir)
if not os.path.isfile(path): path = paths.debian_changelog
if not path or not os.path.isfile(path):
return None return None
try: try:
@@ -146,16 +159,21 @@ def read_spec_version(repo_dir: str) -> Optional[str]:
""" """
Read the version from an RPM spec file. Read the version from an RPM spec file.
Preferred paths:
packaging/fedora/package-manager.spec
packaging/fedora/*.spec
repo_root/*.spec
Combines: Combines:
Version: 1.2.3 Version: 1.2.3
Release: 1%{?dist} Release: 1%{?dist}
-> 1.2.3-1 -> 1.2.3-1
""" """
for fn in os.listdir(repo_dir): paths = resolve_repo_paths(repo_dir)
if not fn.endswith(".spec"): path = paths.rpm_spec
continue if not path or not os.path.isfile(path):
return None
path = os.path.join(repo_dir, fn)
try: try:
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
text = f.read() text = f.read()
@@ -176,8 +194,6 @@ def read_spec_version(repo_dir: str) -> Optional[str]:
return version or None return version or None
return None
def read_ansible_galaxy_version(repo_dir: str) -> Optional[str]: def read_ansible_galaxy_version(repo_dir: str) -> Optional[str]:
""" """

View File

@@ -0,0 +1,64 @@
# tests/integration/test_repository_paths_exist.py
from __future__ import annotations
import os
from pathlib import Path
import pytest
from pkgmgr.core.repository.paths import resolve_repo_paths
def _find_repo_root() -> Path:
"""
Locate the pkgmgr repository root from the test location.
This assumes the standard layout:
repo_root/
src/pkgmgr/...
tests/integration/...
"""
here = Path(__file__).resolve()
for parent in here.parents:
if (parent / "pyproject.toml").is_file() and (parent / "src" / "pkgmgr").is_dir():
return parent
raise RuntimeError("Could not determine repository root for pkgmgr integration test")
def test_pkgmgr_repository_paths_exist() -> None:
"""
Integration test: verify that the pkgmgr repository provides all
canonical files defined by RepoPaths.
pkgmgr acts as the TEMPLATE repository for all other packages.
Therefore, every path resolved here is expected to exist.
"""
repo_root = _find_repo_root()
paths = resolve_repo_paths(str(repo_root))
missing: list[str] = []
def _require(path: str | None, description: str) -> None:
if not path:
missing.append(f"{description}: <not resolved>")
return
if not os.path.isfile(path):
missing.append(f"{description}: {path} (missing)")
# Core metadata (must always exist)
_require(paths.pyproject_toml, "pyproject.toml")
_require(paths.flake_nix, "flake.nix")
# Human-facing changelog (pkgmgr must provide one)
_require(paths.changelog_md, "CHANGELOG.md")
# Packaging files (pkgmgr is the reference implementation)
_require(paths.arch_pkgbuild, "Arch PKGBUILD")
_require(paths.debian_changelog, "Debian changelog")
_require(paths.rpm_spec, "RPM spec file")
if missing:
pytest.fail(
"pkgmgr repository does not satisfy the canonical repository layout:\n"
+ "\n".join(f" - {item}" for item in missing)
)