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
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:
@@ -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:
|
||||
|
||||
124
src/pkgmgr/core/repository/paths.py
Normal file
124
src/pkgmgr/core/repository/paths.py
Normal 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,
|
||||
)
|
||||
@@ -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]:
|
||||
|
||||
64
tests/integration/test_repository_paths_exist.py
Normal file
64
tests/integration/test_repository_paths_exist.py
Normal 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)
|
||||
)
|
||||
Reference in New Issue
Block a user