Compare commits

..
10 Commits
Author SHA1 Message Date
kevinveenbirkenbach a4b29b4d49 Release version 2.0.1
CI / security-codeql (push) Has been cancelled
CI / test-unit (push) Has been cancelled
CI / test-integration (push) Has been cancelled
CI / test-env-virtual (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / test-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-python (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Update flake.lock / Update flake.lock (push) Has been cancelled
2026-09-18 17:15:35 +02:00
kevinveenbirkenbachandClaude Opus 5 4e83779459 fix(release): do not end a changelog on a blank line
An entry carries a trailing blank line so that a `## [version]` below it is
separated. Two of the four placements in _insert_after_h1 put the entry at the
end of the file instead, where that separator becomes a blank line after the
final newline and markdownlint reports MD012. Both now pass the entry through
_at_end_of_file.

Only the first entry a file ever receives is affected, which is why it appears
on a repository's opening release and never again: every later entry is
inserted above an existing H2, and there the separator is correct. The
base-images repository hit it on 1.0.0 and its lint job went red on the first
push it ever had.

The other two placements are deliberately untouched. In the legacy branch the
old content follows the entry, and before the first H2 the previous release
section does, so the blank line has to stay.

Verified: four tests, one per placement, assert both that no document holds
three consecutive newlines and that it ends on exactly one; two of them also
assert the separator survives where it belongs, which fails if the trim is
applied too widely. 74 tests pass under tests/unit/pkgmgr/actions/release.

Unrelated and pre-existing: test_update_changelog_transforms_heading_and_inline_code
fails wherever markdownlint-cli2 is installed, because transform_changelog_message
renders a leading `#` as bold and markdownlint calls that MD036.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:14:11 +02:00
kevinveenbirkenbach b22ff17b0e Release version 2.0.0
CI / security-codeql (push) Has been cancelled
CI / test-unit (push) Has been cancelled
CI / test-integration (push) Has been cancelled
CI / test-env-virtual (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / test-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-python (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2026-09-18 16:29:32 +02:00
kevinveenbirkenbachandClaude Opus 5 1e7642d358 chore: drop what the base-images refactor left behind
scripts/docker/slim.sh survived the removal of the stage that copied and ran
it, and nothing else referenced it.

The README still offered a `Virgin` image type with `pkgmgr-arch-virgin` as its
example, which is no longer published. Its second row was wrong independently
of that refactor: the image does not contain Nix, it runs `make install`, which
is scripts/installation/init.sh, and neither that nor entry.sh installs it. The
section now states the tags compute_publish_tags() actually pushes, including
the unsuffixed alias the default distribution gets, and points at base-images
for the clean environment the virgin image used to serve. A missing blank line
before a horizontal rule had also been rendering the paragraph above it as a
heading.

Three comment blocks explained what the repository used to do rather than what
it does, in the Dockerfile, in scripts/build/publish.sh and in the Makefile.
That belongs in the history, which is where it already is.

Verified: markdownlint-cli2 on the README goes from 21 findings to 10, all
remaining ones pre-existing and outside this section; `bash -n` on publish.sh
and `make -n build` pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:22:45 +02:00
kevinveenbirkenbachandClaude Opus 5 787d58ce31 chore(ci): keep the dependencies current without being asked
Dependabot covers pip and github-actions, weekly on Monday. There is no docker
ecosystem here because the Dockerfile builds `FROM ${BASE_IMAGE}`, a build arg
resolved by scripts/build/base.sh, so nothing in the file is a tag Dependabot
could pin; the base image is refreshed by the nightly rebuild in base-images
instead.

Dependabot has no Nix ecosystem either, and flake.lock pins the nixpkgs
revision that supplies the interpreter and the C headers every repository in
the catalogue is installed against. Left alone, that lock ages silently: the
shell keeps building, on packages nobody refreshed. update-flake-lock opens a
pull request for it on the same Monday schedule, and Dependabot's
github-actions axis keeps that workflow's own three action pins current.

Verified: both files parse, and the workflow resolves to one job whose steps
pin actions/checkout@v4, nix-installer-action@v16 and update-flake-lock@v24.
Neither runs before this lands on the default branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:07:12 +02:00
kevinveenbirkenbachandClaude Opus 5 7b0b9dc9c7 fix(e2e): let the install sweep fail when a repository fails
Both sweeps passed `--silent`, which does not quiet the output it sounds like
it quiets: the warning list is printed under `not quiet`, while `silent` guards
only `raise SystemExit(1)` in actions/install/__init__.py and in
actions/update/manager.py. The failures were therefore in the log, the process
exited 0, and the test asserted success on a run that had installed nothing for
two repositories.

The module docstring already promises the test passes only if both commands
complete successfully. Dropping the flag makes that sentence true again; it is
how portfolio and ldap-schema-manager went unnoticed, and both are fixed in the
two preceding commits.

The flag itself is untouched and still available on the CLI.

Verified: the sweep runs green without it in the arch image, and hid two
failures with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:07:12 +02:00
kevinveenbirkenbachandClaude Opus 5 01f421f252 fix(nix): give the dev shell the interpreter and headers the catalogue needs
The dev shell's interpreter is the one `pkgmgr update --all` installs every
repository in the catalogue with, so it has to satisfy their floors rather than
only this project's. portfolio declares requires-python >=3.12, which 3.11 does
not resolve; the package block moves along with the shell because the comment
there promises they are the same version.

ldap-schema-manager pulls python-ldap, which compiles against lber.h and ldap.h
from OpenLDAP plus the SASL headers and locates them through pkg-config. None
of the three were in buildInputs, so that repository failed in the compiler
rather than in the resolver.

Both are failures of the same sweep, and both are environment rather than code.

Verified: a full `pkgmgr update --all` sweep in the arch image against this
flake, with 0 installation failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:07:12 +02:00
kevinveenbirkenbachandClaude Opus 5 e6ac35e705 fix(install): install a repository once, by the first hook that supports it
The pipeline used to skip a second installer only when it advertised
capabilities the first had already provided, so a hook with a different
capability still ran on the very same project. file-dedupe was installed
through pip and then again through `make install`, whose first line upgrades
pip; PEP 668 refuses that, and the run went red on a repository that was
already installed correctly.

One hook now installs a repository and the pipeline stops there. The order of
INSTALLERS is the preference: `make install` leads, because it is the hook a
repository writes for itself, while the others infer an installation from a
manifest.

A hook that fails installed nothing, so the rule is one *successful*
installation rather than one attempt: _run_installer returns a bool instead of
re-raising, and the caller moves on to the next hook. To keep that fallback
from turning a broken repository into a silent pass, a repository whose hooks
all fail now exits with the list of what was tried.

The capability bookkeeping the old skip rule needed is gone from the pipeline;
discover_capabilities remains on BaseInstaller and is now unused there.

Verified: 14 targeted unit and integration tests, and a full `pkgmgr update
--all` sweep in the arch image with 0 installation failures. Delta measured at
the consumer: repositories left without a CLI entry point, 16 before and 16
after, diff identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:07:12 +02:00
kevinveenbirkenbachandClaude Opus 5 e45b6ed4a5 refactor(docker): consume the base images instead of building them
The `virgin` stage installed the distribution build dependencies, and that
layer now lives in its own repository:
https://github.com/kevinveenbirkenbach/base-images. What remains here is one
image per distribution that installs pkgmgr on top of it. `slim` goes with it:
it was published for every distribution and pulled by nothing.

scripts/build/base.sh stops pinning five upstream images and resolves
ghcr.io/<owner>/base-<distro>:<tag> instead. Its env names follow the images
repository rather than this one, so the code shows whose namespace that is.
image.sh loses the --target axis, the -virgin/-slim tag suffixes with it, and
passes --platform on push. Manjaro joins the set; os_resolver.sh already maps
it onto arch, so it needs no dependency script of its own.

The two virgin workflows pull the base image rather than building it. What
they prove is unchanged: pkgmgr installs into an untouched container, as root
and as an unprivileged user.

test_distro_dependency_scripts_install_gpg_tools goes: it grepped the
dependency scripts for gnupg, and those scripts are base-images' responsibility
now, where the contract test runs `gpg --version` instead of reading a package
list.

Verified against locally built base images: the arch image builds FROM
base-arch, and the full suite passes inside it - 405 unit, 90 integration and
49 e2e tests.

Requires base-images to be published first; until then every build here fails
at the pull.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:07:12 +02:00
kevinveenbirkenbach 4cfb95da1f Release version 1.16.1 2026-07-29 16:41:21 +02:00
25 changed files with 445 additions and 365 deletions

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "00:00"
rebase-strategy: "auto"
labels:
- "dependencies"
- "python"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "00:00"
labels:
- "dependencies"
- "ci"
+3 -5
View File
@@ -24,10 +24,8 @@ jobs:
- name: Show Docker version
run: docker version
- name: Build virgin container (${{ matrix.distro }})
run: |
set -euo pipefail
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
- name: Pull base image (${{ matrix.distro }})
run: docker pull "ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest"
- name: Virgin ${{ matrix.distro }} pkgmgr test (root)
run: |
@@ -39,7 +37,7 @@ jobs:
-v pkgmgr_pip_cache:/root/.cache/pip \
-e NIX_CONFIG="${NIX_CONFIG}" \
-w /opt/src/pkgmgr \
"pkgmgr-${{ matrix.distro }}-virgin" \
"ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest" \
bash -lc '
set -euo pipefail
+3 -5
View File
@@ -24,10 +24,8 @@ jobs:
- name: Show Docker version
run: docker version
- name: Build virgin container (${{ matrix.distro }})
run: |
set -euo pipefail
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
- name: Pull base image (${{ matrix.distro }})
run: docker pull "ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest"
- name: Virgin ${{ matrix.distro }} pkgmgr test (user)
run: |
@@ -37,7 +35,7 @@ jobs:
-v "$PWD":/opt/src/pkgmgr \
-e NIX_CONFIG="${NIX_CONFIG}" \
-w /opt/src/pkgmgr \
"pkgmgr-${{ matrix.distro }}-virgin" \
"ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest" \
bash -lc '
set -euo pipefail
+32
View File
@@ -0,0 +1,32 @@
name: Update flake.lock
on:
schedule:
- cron: "0 0 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
lock:
name: Update flake.lock
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v16
- name: Update flake.lock and open a pull request
uses: DeterminateSystems/update-flake-lock@v24
with:
pr-title: "chore(deps): update flake.lock"
pr-labels: |
dependencies
nix
+46
View File
@@ -1,5 +1,51 @@
# Changelog
## [2.0.1] - 2026-09-18
* A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
* The entry's separator only belongs there when another entry follows it.
* Affects the first entry a file receives, so it shows on an opening release.
* Four tests cover the placements in *_insert_after_h1*, one per branch.
## [2.0.0] - 2026-09-18
Breaking
* The *virgin* and *slim* image variants are gone; both tag suffixes with them.
* Building an image now requires the base images to exist in the registry.
* *make build-missing-virgin* is removed; *build* targets build the one image.
* The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
* *scripts/build/image.sh* loses its *--target* axis.
* A repository is installed by one hook, no longer by pip and *make* both.
Changed
* *INSTALLERS* is a preference order led by *make install*, first success wins.
* A failed hook falls back to the next; all failing exits with what was tried.
* The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
* Publishing pushes *--platform* manifest lists for amd64 and arm64.
* *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
* The virgin workflows pull the base image instead of building it.
* The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
* It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
* *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
* The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
* The README drops the *Virgin* type and the Nix claim the image never met.
* It now lists the tags *compute_publish_tags()* pushes, alias included.
* Dependabot covers pip and github-actions weekly on Monday.
* A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
* *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
* *scripts/docker/slim.sh* is deleted with the stage that ran it.
## [1.16.1] - 2026-07-29
* *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
* Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
* Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
* The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
* New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
* New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
## [1.16.0] - 2026-06-28
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
+5 -47
View File
@@ -1,67 +1,25 @@
# syntax=docker/dockerfile:1
# ------------------------------------------------------------
# Base image selector — overridden by build args / Makefile
# ------------------------------------------------------------
# hadolint ignore=DL3006,InvalidDefaultArgInFrom
# BASE_IMAGE carries no default on purpose: a default would build one
# distribution's image under another distribution's tag whenever the build arg
# is forgotten. scripts/build/base.sh is the only place that resolves it.
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
# ============================================================
# Target: virgin
# - installs distro deps (incl. make)
# - no pkgmgr build
# - no entrypoint
# ============================================================
FROM ${BASE_IMAGE} AS virgin
SHELL ["/bin/bash", "-lc"]
RUN echo "BASE_IMAGE=${BASE_IMAGE}" && cat /etc/os-release || true
WORKDIR /build
# Copy scripts first so dependency installation can be cached
COPY scripts/installation/ scripts/installation/
# Install distro-specific build dependencies (including make)
RUN bash scripts/installation/dependencies.sh
# Virgin default
CMD ["bash"]
# ============================================================
# Target: full
# - inherits from virgin
# - builds + installs pkgmgr
# - sets entrypoint + default cmd
# - NOTE: does NOT run slim.sh (that is done in slim stage)
# ============================================================
FROM virgin AS full
WORKDIR /build
# Copy full repository for build
COPY . .
# Build and install distro-native package-manager package
RUN set -eu; \
echo "Building and installing package-manager via make install..."; \
make install; \
rm -rf /build
# Entry point
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
WORKDIR /opt/src/pkgmgr
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
CMD ["pkgmgr", "--help"]
# ============================================================
# Target: slim
# - based on full
# - runs slim.sh
# ============================================================
FROM full AS slim
COPY scripts/docker/slim.sh /usr/local/bin/slim.sh
RUN chmod +x /usr/local/bin/slim.sh && /usr/local/bin/slim.sh
+3 -19
View File
@@ -17,20 +17,9 @@ export NIX_CONFIG
# ------------------------------------------------------------
# Base images
# (kept for documentation/reference; actual build logic is in scripts/build)
# ------------------------------------------------------------
BASE_IMAGE_ARCH := archlinux:latest
BASE_IMAGE_DEBIAN := debian:stable-slim
BASE_IMAGE_UBUNTU := ubuntu:latest
BASE_IMAGE_FEDORA := fedora:latest
BASE_IMAGE_CENTOS := quay.io/centos/centos:stream9
# Make them available in scripts
export BASE_IMAGE_ARCH
export BASE_IMAGE_DEBIAN
export BASE_IMAGE_UBUNTU
export BASE_IMAGE_FEDORA
export BASE_IMAGE_CENTOS
BASE_IMAGES_TAG ?= latest
export BASE_IMAGES_TAG
# PYthon Unittest Pattern
TEST_PATTERN := test_*.py
@@ -63,17 +52,12 @@ setup-nix:
# Docker build targets (delegated to scripts/build)
# ------------------------------------------------------------
build:
@bash scripts/build/image.sh --target virgin
@bash scripts/build/image.sh
build-missing-virgin:
@bash scripts/build/image.sh --target virgin --missing
build-missing: build-missing-virgin
build-missing:
@bash scripts/build/image.sh --missing
build-no-cache:
@bash scripts/build/image.sh --target virgin --no-cache
@bash scripts/build/image.sh --no-cache
build-no-cache-all:
+18 -13
View File
@@ -169,26 +169,31 @@ nix run github:kevinveenbirkenbach/package-manager#pkgmgr -- --help
PKGMGR can be executed **inside Docker containers** for CI, testing and isolated
workflows.
---
#### Container types
#### Container images
Two container types are available.
One image per distribution, each installing PKGMGR as a distro-native package
on top of the matching base image from
[base-images](https://github.com/kevinveenbirkenbach/base-images). Every image
is a manifest list covering `linux/amd64` and `linux/arm64`.
All images live under `ghcr.io/kevinveenbirkenbach/`:
| Image type | Contains | Typical use |
| ---------- | ----------------------------- | ----------------------- |
| **Virgin** | Base OS + system dependencies | Clean test environments |
| **Stable** | PKGMGR + Nix (flakes enabled) | Ready-to-use workflows |
| Tag | Contains |
| --------------------------- | ---------------------------------- |
| `pkgmgr-<distro>:latest` | newest build for that distribution |
| `pkgmgr-<distro>:<version>` | one release |
| `pkgmgr-<distro>:stable` | newest release marked stable |
Example images:
`<distro>` is one of `arch`, `debian`, `ubuntu`, `fedora`, `centos`. The Arch
image is published without the suffix as well, so `pkgmgr:stable` and
`pkgmgr-arch:stable` are the same image.
* Virgin: `pkgmgr-arch-virgin`
* Stable: `ghcr.io/kevinveenbirkenbach/pkgmgr:stable`
Use **virgin images** for isolated test runs,
use the **stable image** for fast, reproducible execution.
For a clean environment that carries the system dependencies but no PKGMGR,
pull `base-<distro>` from
[base-images](https://github.com/kevinveenbirkenbach/base-images) directly.
---
+8 -5
View File
@@ -26,13 +26,13 @@
packages = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
python = pkgs.python311;
pyPkgs = pkgs.python311Packages;
python = pkgs.python312;
pyPkgs = pkgs.python312Packages;
in
rec {
pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager";
version = "1.16.0";
version = "2.0.1";
# Use the git repo as source
src = ./.;
@@ -79,8 +79,8 @@
if pkgs ? ansible-core then pkgs.ansible-core
else pkgs.ansible;
# Use the same Python version as the package (3.11)
python = pkgs.python311;
# Use the same Python version as the package
python = pkgs.python312;
pythonWithDeps = python.withPackages (ps: [
ps.pip
@@ -95,6 +95,9 @@
pkgs.git
pkgs.gnupg
ansiblePkg
pkgs.pkg-config
pkgs.openldap
pkgs.cyrus_sasl
];
shellHook = ''
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager
pkgver=1.16.0
pkgver=2.0.1
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')
+52
View File
@@ -1,3 +1,55 @@
package-manager (2.0.1-1) unstable; urgency=medium
* A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
* The entry's separator only belongs there when another entry follows it.
* Affects the first entry a file receives, so it shows on an opening release.
* Four tests cover the placements in *_insert_after_h1*, one per branch.
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 18 Sep 2026 17:15:35 +0200
package-manager (2.0.0-1) unstable; urgency=medium
Breaking
* The *virgin* and *slim* image variants are gone; both tag suffixes with them.
* Building an image now requires the base images to exist in the registry.
* *make build-missing-virgin* is removed; *build* targets build the one image.
* The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
* *scripts/build/image.sh* loses its *--target* axis.
* A repository is installed by one hook, no longer by pip and *make* both.
Changed
* *INSTALLERS* is a preference order led by *make install*, first success wins.
* A failed hook falls back to the next; all failing exits with what was tried.
* The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
* Publishing pushes *--platform* manifest lists for amd64 and arm64.
* *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
* The virgin workflows pull the base image instead of building it.
* The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
* It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
* *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
* The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
* The README drops the *Virgin* type and the Nix claim the image never met.
* It now lists the tags *compute_publish_tags()* pushes, alias included.
* Dependabot covers pip and github-actions weekly on Monday.
* A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
* *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
* *scripts/docker/slim.sh* is deleted with the stage that ran it.
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 18 Sep 2026 16:29:32 +0200
package-manager (1.16.1-1) unstable; urgency=medium
* *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
* Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
* Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
* The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
* New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
* New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 29 Jul 2026 16:41:21 +0200
package-manager (1.16.0-1) unstable; urgency=medium
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
+44 -1
View File
@@ -1,5 +1,5 @@
Name: package-manager
Version: 1.16.0
Version: 2.0.1
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -74,6 +74,49 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%changelog
* Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.1-1
- * A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
- * The entry's separator only belongs there when another entry follows it.
- * Affects the first entry a file receives, so it shows on an opening release.
- * Four tests cover the placements in *_insert_after_h1*, one per branch.
* Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.0-1
- Breaking
- * The *virgin* and *slim* image variants are gone; both tag suffixes with them.
- * Building an image now requires the base images to exist in the registry.
- * *make build-missing-virgin* is removed; *build* targets build the one image.
- * The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
- * *scripts/build/image.sh* loses its *--target* axis.
- * A repository is installed by one hook, no longer by pip and *make* both.
- Changed
- * *INSTALLERS* is a preference order led by *make install*, first success wins.
- * A failed hook falls back to the next; all failing exits with what was tried.
- * The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
- * Publishing pushes *--platform* manifest lists for amd64 and arm64.
- * *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
- * The virgin workflows pull the base image instead of building it.
- * The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
- * It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
- * *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
- * The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
- * The README drops the *Virgin* type and the Nix claim the image never met.
- * It now lists the tags *compute_publish_tags()* pushes, alias included.
- * Dependabot covers pip and github-actions weekly on Monday.
- * A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
- * *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
- * *scripts/docker/slim.sh* is deleted with the stage that ran it.
* Wed Jul 29 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.1-1
- * *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
- * Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
- * Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
- * The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
- * New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
- * New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
* Sun Jun 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.0-1
- * New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
- * The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kpmx"
version = "1.16.0"
version = "2.0.1"
description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md"
requires-python = ">=3.9"
+22 -10
View File
@@ -1,20 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BASE_IMAGE_ARCH:=archlinux:latest}"
: "${BASE_IMAGE_DEBIAN:=debian:stable-slim}"
: "${BASE_IMAGE_UBUNTU:=ubuntu:latest}"
: "${BASE_IMAGE_FEDORA:=fedora:latest}"
: "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:latest}"
# The distribution bases this repository builds on. They are published by
# https://github.com/kevinveenbirkenbach/base-images, one multi-platform image
# per distribution, and carry the build dependencies the package build needs.
#
# Env overrides exist for testing an unpublished base; the registry namespace
# follows the images repository, not this one.
: "${BASE_IMAGES_REGISTRY:=ghcr.io}"
: "${BASE_IMAGES_OWNER:=kevinveenbirkenbach}"
: "${BASE_IMAGES_TAG:=latest}"
resolve_base_image() {
local PKGMGR_DISTRO="$1"
case "$PKGMGR_DISTRO" in
arch) echo "$BASE_IMAGE_ARCH" ;;
debian) echo "$BASE_IMAGE_DEBIAN" ;;
ubuntu) echo "$BASE_IMAGE_UBUNTU" ;;
fedora) echo "$BASE_IMAGE_FEDORA" ;;
centos) echo "$BASE_IMAGE_CENTOS" ;;
arch|manjaro|debian|ubuntu|fedora|centos)
echo "${BASE_IMAGES_REGISTRY}/${BASE_IMAGES_OWNER}/base-${PKGMGR_DISTRO}:${BASE_IMAGES_TAG}"
;;
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
esac
}
# Platforms each distribution's base offers; see base-images/scripts/build/distros.sh.
resolve_platforms() {
local PKGMGR_DISTRO="$1"
case "$PKGMGR_DISTRO" in
arch|manjaro|debian|ubuntu|fedora|centos) echo "linux/amd64,linux/arm64" ;;
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
esac
}
+3 -26
View File
@@ -10,7 +10,6 @@ source "${SCRIPT_DIR}/base.sh"
NO_CACHE=0
MISSING_ONLY=0
TARGET=""
IMAGE_TAG="" # local image name or base tag (without registry)
PUSH=0 # if 1 -> use buildx and push (requires docker buildx)
PUBLISH=0 # if 1 -> push with semantic tags (latest/version/stable + arch aliases)
@@ -23,9 +22,6 @@ DEFAULT_DISTRO="arch"
usage() {
local default_tag="pkgmgr-${PKGMGR_DISTRO}"
if [[ -n "${TARGET:-}" ]]; then
default_tag="${default_tag}-${TARGET}"
fi
cat <<EOF
Usage: PKGMGR_DISTRO=<distro> $0 [options]
@@ -33,7 +29,6 @@ Usage: PKGMGR_DISTRO=<distro> $0 [options]
Build options:
--missing Build only if the image does not already exist (local build only)
--no-cache Build with --no-cache
--target <name> Build a specific Dockerfile target (e.g. virgin, slim)
--tag <image> Override the output image tag (default: ${default_tag})
Publish options:
@@ -47,7 +42,7 @@ Publish options:
Notes:
- --publish implies --push and requires --registry, --owner, and --version.
- Local build (no --push) uses "docker build" and creates local images like "pkgmgr-arch" / "pkgmgr-arch-virgin" / "pkgmgr-arch-slim".
- Local build (no --push) uses "docker build" and creates a local image like "pkgmgr-arch".
EOF
}
@@ -55,11 +50,6 @@ while [[ $# -gt 0 ]]; do
case "$1" in
--no-cache) NO_CACHE=1; shift ;;
--missing) MISSING_ONLY=1; shift ;;
--target)
TARGET="${2:-}"
[[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin|slim)"; exit 2; }
shift 2
;;
--tag)
IMAGE_TAG="${2:-}"
[[ -n "${IMAGE_TAG}" ]] || { echo "ERROR: --tag requires a value"; exit 2; }
@@ -104,9 +94,6 @@ done
# Derive default local tag if not provided
if [[ -z "${IMAGE_TAG}" ]]; then
IMAGE_TAG="${REPO_PREFIX}-${PKGMGR_DISTRO}"
if [[ -n "${TARGET}" ]]; then
IMAGE_TAG="${IMAGE_TAG}-${TARGET}"
fi
fi
BASE_IMAGE="$(resolve_base_image "$PKGMGR_DISTRO")"
@@ -143,7 +130,7 @@ echo "------------------------------------------------------------"
echo "[build] Building image"
echo "distro = ${PKGMGR_DISTRO}"
echo "BASE_IMAGE = ${BASE_IMAGE}"
if [[ -n "${TARGET}" ]]; then echo "target = ${TARGET}"; fi
echo "platforms = $(resolve_platforms "$PKGMGR_DISTRO")"
if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi
if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi
if [[ "${PUBLISH}" == "1" ]]; then
@@ -162,23 +149,12 @@ if [[ "${NO_CACHE}" == "1" ]]; then
build_args+=(--no-cache)
fi
if [[ -n "${TARGET}" ]]; then
build_args+=(--target "${TARGET}")
fi
compute_publish_tags() {
local distro_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
local alias_tag_base=""
if [[ -n "${TARGET}" ]]; then
distro_tag_base="${distro_tag_base}-${TARGET}"
fi
if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then
alias_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}"
if [[ -n "${TARGET}" ]]; then
alias_tag_base="${alias_tag_base}-${TARGET}"
fi
fi
local tags=()
@@ -211,6 +187,7 @@ if [[ "${PUSH}" == "1" ]]; then
bx_args+=(-t "${IMAGE_TAG}")
fi
bx_args+=(--platform "$(resolve_platforms "$PKGMGR_DISTRO")")
bx_args+=("${build_args[@]}")
bx_args+=(.)
+1 -33
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# Publish all distro images (full + virgin + slim) to a registry via image.sh --publish
# Publish one image per distro, each covering every platform its base offers.
#
# Required env:
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
@@ -11,9 +11,6 @@ set -euo pipefail
# REGISTRY (default: ghcr.io)
# IS_STABLE (default: false)
# DISTROS (default: "arch debian ubuntu fedora centos")
#
# Notes:
# - This expects Dockerfile targets: virgin, full (default), slim
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -36,41 +33,12 @@ for d in ${DISTROS}; do
echo "[publish] PKGMGR_DISTRO=${d}"
echo "============================================================"
# ----------------------------------------------------------
# virgin
# -> ghcr.io/<owner>/pkgmgr-<distro>-virgin:{latest,<version>,stable?}
# ----------------------------------------------------------
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \
--registry "${REGISTRY}" \
--owner "${OWNER}" \
--version "${VERSION}" \
--stable "${IS_STABLE}" \
--target virgin
# ----------------------------------------------------------
# full (default target)
# -> ghcr.io/<owner>/pkgmgr-<distro>:{latest,<version>,stable?}
# ----------------------------------------------------------
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \
--registry "${REGISTRY}" \
--owner "${OWNER}" \
--version "${VERSION}" \
--stable "${IS_STABLE}"
# ----------------------------------------------------------
# slim
# -> ghcr.io/<owner>/pkgmgr-<distro>-slim:{latest,<version>,stable?}
# + alias for default distro: ghcr.io/<owner>/pkgmgr-slim:{...}
# ----------------------------------------------------------
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \
--registry "${REGISTRY}" \
--owner "${OWNER}" \
--version "${VERSION}" \
--stable "${IS_STABLE}" \
--target slim
done
echo
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
log() { echo "[cleanup] $*"; }
warn() { echo "[cleanup][WARN] $*" >&2; }
MODE="${MODE:-safe}" # safe | aggressive
# safe: caches/logs/tmp only
# aggressive: safe + docs/man/info (optional)
ID="unknown"
if [ -f /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
ID="${ID:-unknown}"
fi
log "Starting image cleanup"
log "Mode: ${MODE}"
log "Detected OS: ${ID}"
# ------------------------------------------------------------
# Package manager caches (SAFE)
# ------------------------------------------------------------
case "${ID}" in
alpine)
log "Cleaning apk cache"
if [ -d /var/cache/apk ]; then
du -sh /var/cache/apk || true
rm -rvf /var/cache/apk/* || true
else
log "apk cache directory not present (already clean)"
fi
;;
arch)
log "Cleaning pacman cache"
du -sh /var/cache/pacman/pkg 2>/dev/null || true
pacman -Scc --noconfirm || true
rm -rvf /var/cache/pacman/pkg/* || true
;;
debian|ubuntu)
log "Cleaning apt cache"
du -sh /var/lib/apt/lists 2>/dev/null || true
apt-get clean || true
rm -rvf /var/lib/apt/lists/* || true
;;
fedora)
log "Cleaning dnf cache"
du -sh /var/cache/dnf 2>/dev/null || true
dnf clean all || true
rm -rvf /var/cache/dnf/* || true
;;
centos|rhel)
log "Cleaning yum/dnf cache"
du -sh /var/cache/yum /var/cache/dnf 2>/dev/null || true
(command -v dnf >/dev/null 2>&1 && dnf clean all) || true
(command -v yum >/dev/null 2>&1 && yum clean all) || true
rm -rvf /var/cache/yum/* /var/cache/dnf/* || true
;;
*)
warn "Unknown distro '${ID}' — skipping package manager cleanup"
;;
esac
# ------------------------------------------------------------
# Python caches (SAFE)
# ------------------------------------------------------------
log "Cleaning pip cache"
du -sh /root/.cache/pip 2>/dev/null || true
rm -rvf /root/.cache/pip 2>/dev/null || true
rm -rvf /home/*/.cache/pip 2>/dev/null || true
log "Cleaning __pycache__ directories"
find /opt /usr /root /home -type d -name "__pycache__" -print -prune 2>/dev/null || true
find /opt /usr /root /home -type d -name "__pycache__" -prune -exec rm -rvf {} + 2>/dev/null || true
# ------------------------------------------------------------
# Logs (SAFE)
# ------------------------------------------------------------
log "Truncating log files (keeping paths intact)"
if [ -d /var/log ]; then
find /var/log -type f -name "*.log" -print 2>/dev/null || true
find /var/log -type f -name "*.log" -exec sh -lc ': > "$1" 2>/dev/null || true' _ {} \; 2>/dev/null || true
find /var/log -type f -name "*.out" -print 2>/dev/null || true
find /var/log -type f -name "*.out" -exec sh -lc ': > "$1" 2>/dev/null || true' _ {} \; 2>/dev/null || true
fi
if command -v journalctl >/dev/null 2>&1; then
log "Vacuuming journald logs"
journalctl --disk-usage || true
journalctl --vacuum-size=10M || true
journalctl --vacuum-time=1s || true
journalctl --disk-usage || true
else
log "journald not present (skipping)"
fi
# ------------------------------------------------------------
# Temporary files (SAFE)
# ------------------------------------------------------------
log "Cleaning temporary directories"
if [ -d /tmp ]; then
du -sh /tmp 2>/dev/null || true
rm -rvf /tmp/* || true
fi
if [ -d /var/tmp ]; then
du -sh /var/tmp 2>/dev/null || true
rm -rvf /var/tmp/* || true
fi
# ------------------------------------------------------------
# Generic caches (SAFE)
# ------------------------------------------------------------
log "Cleaning generic caches"
du -sh /root/.cache 2>/dev/null || true
rm -rvf /root/.cache/* 2>/dev/null || true
rm -rvf /home/*/.cache/* 2>/dev/null || true
# ------------------------------------------------------------
# Optional aggressive extras (still safe for runtime)
# ------------------------------------------------------------
if [[ "${MODE}" == "aggressive" ]]; then
log "Aggressive mode enabled: removing docs/man/info"
du -sh /usr/share/doc /usr/share/man /usr/share/info 2>/dev/null || true
rm -rvf /usr/share/doc/* /usr/share/man/* /usr/share/info/* 2>/dev/null || true
fi
log "Cleanup finished successfully"
+2 -1
View File
@@ -38,13 +38,14 @@ from pkgmgr.core.repository.verify import verify_repository
Repository = dict[str, Any]
INSTALLERS = [
MakefileInstaller(),
ArchPkgbuildInstaller(),
DebianControlInstaller(),
RpmSpecInstaller(),
NixFlakeInstaller(),
PythonInstaller(),
MakefileInstaller(),
]
"""Preference order: the pipeline runs the first supported hook and stops."""
def _ensure_repo_dir(
+24 -18
View File
@@ -81,13 +81,15 @@ class InstallationPipeline:
else:
repo.pop("command", None)
provided_capabilities: set[str] = set()
attempted: list[str] = []
for installer in self._installers:
layer_name = getattr(installer, "layer", None)
if layer_name is None:
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
if self._run_installer(installer, ctx, identifier, repo_dir, quiet):
return
attempted.append(installer.__class__.__name__)
continue
try:
@@ -120,15 +122,6 @@ class InstallationPipeline:
if not installer.supports(ctx):
continue
caps = installer.discover_capabilities(ctx)
if caps and caps.issubset(provided_capabilities):
if not quiet:
print(
f"Skipping installer {installer.__class__.__name__} "
f"for {identifier} – capabilities {caps} already provided."
)
continue
if not quiet:
if (
ctx.force_update
@@ -142,13 +135,17 @@ class InstallationPipeline:
else:
print(
f"[pkgmgr] Running installer {installer.__class__.__name__} "
f"for {identifier} in '{repo_dir}' "
f"(new capabilities: {caps or set()})..."
f"for {identifier} in '{repo_dir}'..."
)
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
provided_capabilities.update(caps)
if not self._run_installer(installer, ctx, identifier, repo_dir, quiet):
attempted.append(installer.__class__.__name__)
if not quiet:
print(
f"[pkgmgr] {installer.__class__.__name__} installed nothing "
f"for {identifier}; falling back to the next hook."
)
continue
new_state = resolver.resolve()
if new_state.command:
@@ -165,6 +162,13 @@ class InstallationPipeline:
repo.pop("command", None)
state = new_state
return
if attempted:
raise SystemExit(
f"every installation hook failed for {identifier}: "
f"{', '.join(attempted)}"
)
@staticmethod
def _run_installer(
@@ -173,9 +177,11 @@ class InstallationPipeline:
identifier: str,
repo_dir: str,
quiet: bool,
) -> None:
) -> bool:
"""Run one hook. Returns whether it installed anything."""
try:
installer.run(ctx)
return True
except SystemExit as exc:
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
print(
@@ -193,4 +199,4 @@ class InstallationPipeline:
f" pkgmgr install {identifier} "
"--clone-mode shallow --no-verification"
)
raise
return False
@@ -16,6 +16,11 @@ H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
H2_RE = re.compile(r"^##\s+\S", re.MULTILINE)
def _at_end_of_file(entry: str) -> str:
"""Return *entry* without the blank line that separates it from a next one."""
return entry.rstrip("\n") + "\n"
def _insert_after_h1(existing: str, entry: str) -> str:
"""Place *entry* after the H1 (and any intro prose), above the first H2.
@@ -26,7 +31,7 @@ def _insert_after_h1(existing: str, entry: str) -> str:
``## ``) is preserved: *entry* is prepended unchanged.
"""
if not existing.strip():
return f"# Changelog\n\n{entry}"
return f"# Changelog\n\n{_at_end_of_file(entry)}"
if not H1_RE.search(existing):
# Legacy layout: file starts with `## [version]` and has no H1.
@@ -43,7 +48,7 @@ def _insert_after_h1(existing: str, entry: str) -> str:
if existing.endswith("\n\n")
else ("\n" if existing.endswith("\n") else "\n\n")
)
return f"{existing}{suffix}{entry}"
return f"{existing}{suffix}{_at_end_of_file(entry)}"
# Insert new entry just before the first H2.
head = existing[: h2_match.start()].rstrip("\n") + "\n\n"
-2
View File
@@ -93,7 +93,6 @@ class TestIntegrationUpdateAllshallowNoSystem(unittest.TestCase):
"--clone-mode",
"shallow",
"--no-verification",
"--silent",
]
self._run_cmd(["pkgmgr", *args], label="pkgmgr", env=env)
pkgmgr_help_debug()
@@ -108,7 +107,6 @@ class TestIntegrationUpdateAllshallowNoSystem(unittest.TestCase):
"--clone-mode",
"shallow",
"--no-verification",
"--silent",
]
self._run_cmd(
["nix", "run", ".#pkgmgr", "--", *args],
@@ -1,6 +1,5 @@
from __future__ import annotations
import re
import unittest
from pathlib import Path
@@ -25,33 +24,6 @@ class TestGitVerificationRuntimeDependencies(unittest.TestCase):
self.assertIn("pkgs.git", flake_text)
self.assertIn("pkgs.gnupg", flake_text)
def test_distro_dependency_scripts_install_gpg_tools(self) -> None:
repo_root = _find_repo_root()
expected_packages = {
"arch": "gnupg",
"debian": "gnupg",
"ubuntu": "gnupg",
"fedora": "gnupg2",
"centos": "gnupg2",
}
missing: list[str] = []
for distro, package_name in expected_packages.items():
script_path = (
repo_root / "scripts" / "installation" / distro / "dependencies.sh"
)
content = script_path.read_text(encoding="utf-8")
if not re.search(rf"\b{re.escape(package_name)}\b", content):
missing.append(
f"{distro}: expected package {package_name} in {script_path}"
)
if missing:
self.fail(
"Git signature verification runtime dependencies are incomplete:\n"
+ "\n".join(f" - {item}" for item in missing)
)
if __name__ == "__main__":
unittest.main()
@@ -1,9 +1,5 @@
"""
Integration tests for recursive capability resolution and installer shadowing.
These tests verify that, given different repository layouts (Makefile, pyproject,
flake.nix, PKGBUILD), only the expected installers are executed based on the
capabilities provided by higher layers.
Integration tests for installer selection across repository layouts.
Layer order (strongest → weakest):
@@ -129,10 +125,8 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
"With only a Makefile, the MakefileInstaller should run exactly once.",
)
def test_python_and_makefile_both_run_when_caps_disjoint(self) -> None:
"""
If Python and Makefile have disjoint capabilities, both installers run.
"""
def test_only_the_first_supported_hook_runs(self) -> None:
"""A repository with two usable hooks is installed by the first one."""
repo_dir = self._new_repo()
# pyproject.toml without any explicit "make install" hint
@@ -153,9 +147,8 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
self.assertEqual(
called,
["python", "makefile"],
"PythonInstaller and MakefileInstaller should both run when their "
"capabilities are disjoint.",
["python"],
"Only the first supported hook may install the repository.",
)
def test_python_shadows_makefile_when_pyproject_mentions_make_install(self) -> None:
@@ -21,11 +21,13 @@ class DummyInstaller(BaseInstaller):
layer: str | None = None,
supports_result: bool = True,
capabilities: set[str] | None = None,
fails: bool = False,
) -> None:
self._name = name
self.layer = layer # type: ignore[assignment]
self._supports_result = supports_result
self._capabilities = capabilities or set()
self._fails = fails
self.ran = False
def supports(self, ctx: RepoContext) -> bool: # type: ignore[override]
@@ -33,6 +35,8 @@ class DummyInstaller(BaseInstaller):
def run(self, ctx: RepoContext) -> None: # type: ignore[override]
self.ran = True
if self._fails:
raise SystemExit(2)
def discover_capabilities(self, ctx: RepoContext) -> set[str]: # type: ignore[override]
return set(self._capabilities)
@@ -117,15 +121,12 @@ class TestInstallationPipeline(unittest.TestCase):
@patch("pkgmgr.actions.install.pipeline.create_ink")
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
def test_capabilities_prevent_duplicate_installers(
def test_only_one_installation_hook_runs(
self,
mock_resolve_command_for_repo: MagicMock,
mock_create_ink: MagicMock,
) -> None:
"""
If one installer has already provided a set of capabilities,
a second installer advertising the same capabilities should be skipped.
"""
"""A repository is installed once, by the first hook that supports it."""
mock_resolve_command_for_repo.return_value = None # no CLI initially
ctx = _minimal_context()
@@ -148,9 +149,103 @@ class TestInstallationPipeline(unittest.TestCase):
self.assertTrue(first.ran, "First installer should run.")
self.assertFalse(
second.ran,
"Second installer with identical capabilities must be skipped.",
"A second installation hook must not run after the first one did.",
)
@patch("pkgmgr.actions.install.pipeline.create_ink")
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
def test_a_differing_second_hook_is_not_run_either(
self,
mock_resolve_command_for_repo: MagicMock,
mock_create_ink: MagicMock,
) -> None:
"""The rule is one hook, not one hook per capability."""
mock_resolve_command_for_repo.return_value = None
ctx = _minimal_context()
first = DummyInstaller(
"python-installer",
layer=CliLayer.PYTHON.value,
supports_result=True,
capabilities={"python-runtime"},
)
second = DummyInstaller(
"makefile-installer",
layer=CliLayer.MAKEFILE.value,
supports_result=True,
capabilities={"make-install"},
)
pipeline = InstallationPipeline([first, second])
pipeline.run(ctx)
self.assertTrue(first.ran, "The first supported hook should run.")
self.assertFalse(
second.ran,
"A hook advertising a different capability must not run either: "
"the project is already installed.",
)
@patch("pkgmgr.actions.install.pipeline.create_ink")
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
def test_a_failed_hook_falls_back_to_the_next(
self,
mock_resolve_command_for_repo: MagicMock,
mock_create_ink: MagicMock,
) -> None:
"""A hook that fails installed nothing, so the next one gets its turn."""
mock_resolve_command_for_repo.return_value = None
ctx = _minimal_context()
broken = DummyInstaller(
"makefile-installer",
layer=CliLayer.MAKEFILE.value,
supports_result=True,
fails=True,
)
working = DummyInstaller(
"python-installer",
layer=CliLayer.PYTHON.value,
supports_result=True,
)
pipeline = InstallationPipeline([broken, working])
pipeline.run(ctx)
self.assertTrue(broken.ran, "The preferred hook should be tried first.")
self.assertTrue(
working.ran,
"The next hook must run when the preferred one installed nothing.",
)
@patch("pkgmgr.actions.install.pipeline.create_ink")
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
def test_a_repository_whose_hooks_all_fail_is_reported(
self,
mock_resolve_command_for_repo: MagicMock,
mock_create_ink: MagicMock,
) -> None:
"""Falling back must not turn a broken repository into a silent pass."""
mock_resolve_command_for_repo.return_value = None
ctx = _minimal_context()
first = DummyInstaller(
"makefile-installer",
layer=CliLayer.MAKEFILE.value,
supports_result=True,
fails=True,
)
second = DummyInstaller(
"python-installer",
layer=CliLayer.PYTHON.value,
supports_result=True,
fails=True,
)
pipeline = InstallationPipeline([first, second])
with self.assertRaises(SystemExit):
pipeline.run(ctx)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,41 @@
from __future__ import annotations
import unittest
from pkgmgr.actions.release.files.changelog_md import _insert_after_h1
ENTRY = "## [1.0.0] - 2026-09-18\n\nOfficial Release\n\n"
class TestInsertAfterH1(unittest.TestCase):
def _assert_lint_clean(self, document: str) -> None:
self.assertNotIn(
"\n\n\n",
document,
f"MD012: multiple consecutive blank lines in\n{document!r}",
)
self.assertTrue(
document.endswith("\n") and not document.endswith("\n\n"),
f"MD012: blank line at end of file in\n{document!r}",
)
def test_an_empty_changelog_gets_one_trailing_newline(self) -> None:
self._assert_lint_clean(_insert_after_h1("", ENTRY))
def test_a_first_entry_under_a_bare_h1_gets_one_trailing_newline(self) -> None:
self._assert_lint_clean(_insert_after_h1("# Changelog\n", ENTRY))
def test_a_second_entry_stays_separated_from_the_first(self) -> None:
existing = "# Changelog\n\n## [0.9.0] - 2026-09-01\n\nOlder\n"
document = _insert_after_h1(existing, ENTRY)
self._assert_lint_clean(document)
self.assertIn("Official Release\n\n## [0.9.0]", document)
def test_a_legacy_headerless_changelog_gains_an_h1(self) -> None:
document = _insert_after_h1("## [0.9.0] - 2026-09-01\n\nOlder\n", ENTRY)
self._assert_lint_clean(document)
self.assertTrue(document.startswith("# Changelog\n\n## [1.0.0]"))
if __name__ == "__main__":
unittest.main()