Compare commits

..
26 Commits
Author SHA1 Message Date
kevinveenbirkenbach c78f5ac75e Release version 2.0.3
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-10-18 23:33:52 +02:00
kevinveenbirkenbachandClaude Opus 5.5 32e896fd09 fix(release): lint the changelog entry inside the file it lands in
The entry was linted alone, so rules that compare it with the rest of
CHANGELOG.md never fired. infinito-nexus 16.0.0 passed this check with
"-" bullets on top of older "*" entries, and the repository's own
markdown lint then reported 400 MD004 findings and refused the push.

update_changelog now also lints the merged file and reports only the
findings the existing file does not already produce
(lint_changelog_context), so pre-existing issues are not blamed on the
entry. markdownlint-cli2 runs with --no-globs, so a target config with
its own globs no longer lints the whole repository and blocks a release
on unrelated files. Without markdownlint-cli2 the built-in check now
requires "-" list markers (MD004 dash), the strictest style.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 23:31:22 +02:00
kevinveenbirkenbach 1f6fe52855 Release version 2.0.2
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-10-01 17:47:12 +02:00
kevinveenbirkenbachandClaude Opus 5 156ac595e6 test: hold three tests against the code they are supposed to cover
All three failed on behaviour that is correct, which is why they had to move
rather than the code.

Two date from aa48981, which moved the default configs into the package. The
loader test still wrote them beside it, into site-packages, and read its own
stale comment as proof that the loader looks there. The cli-update test
demanded a fallback onto that same directory; on a wheel install it belongs
to other packages, so the test now pins the opposite and keeps the boundary.

The third read a config.yaml in the destination to show the source one was
not copied over it. The code never writes that file, so the read died before
it could show anything; the absence is the assertion.

The debian installer test stubbed which() for apt-get and dpkg-buildpackage
but not for sudo, so _privileged_prefix returned None and the installer
warned and left before reaching the apt-get calls the test was waiting for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:45:43 +02:00
kevinveenbirkenbachandClaude Opus 5 b778ad7dbe fix(install): treat install-* as what it is, not as an install target
The detection accepted any target starting with install-, while the run
below it has always called plain make install. A repository carrying
install-python and no install therefore pulled the installer in and died on
"No rule to make target 'install'" instead of being skipped, which is what
the surrounding branch is there for. meta-infinite-graph, universal-logout
and file-dedupe all failed this way, and with them two e2e tests.

775c301 added the parsing to run make install only where an install target
exists. The prefix arm undid that, so it goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:45:42 +02:00
kevinveenbirkenbachandClaude Opus 5 02384979bc fix(release): stop rejecting the changelog entry pkgmgr itself produced
A heading in the message became a bold line, and markdown-lint reads a bold
line on its own as a heading: MD036. Every structured entry therefore failed
the check it had just been normalised for, and since the loop reopens the
editor on failure there was no text that could end it.

Headings now move below the "## [version]" line the entry is filed under.
A "###" under a "##" is the hierarchy, not a competitor; only "#" and "##"
would have collided, and those are the ones raised.

The findings never reached the person either. They were printed and the
editor took the terminal over in the same breath, so the reason was gone
before it could be read. They are held on screen until Enter, and written
into the editor buffer as ';' lines above the rejected text, where the fix
is actually made; the buffer already drops those lines when it is read back.

transform_changelog_message and lint_changelog_entry were each tested alone,
which is how a transform that produces lint errors went unnoticed. A test now
runs the output of the first through the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:45:42 +02:00
kevinveenbirkenbach 59f470fb5e Merge pull request #5 from kevinveenbirkenbach/dependabot/github_actions/docker/login-action-4.6.0
build(deps): bump docker/login-action from 3.7.0 to 4.6.0
2026-10-01 16:28:28 +02:00
kevinveenbirkenbach 1e5cc3f1aa Merge pull request #4 from kevinveenbirkenbach/dependabot/github_actions/DeterminateSystems/nix-installer-action-23
build(deps): bump DeterminateSystems/nix-installer-action from 16 to 23
2026-10-01 16:28:14 +02:00
kevinveenbirkenbach fbe8018451 Merge pull request #3 from kevinveenbirkenbach/dependabot/github_actions/docker/setup-buildx-action-4.4.0
build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.4.0
2026-10-01 16:27:57 +02:00
kevinveenbirkenbach 161d5d220b Merge pull request #2 from kevinveenbirkenbach/dependabot/github_actions/actions/setup-python-7
build(deps): bump actions/setup-python from 5 to 7
2026-10-01 16:27:32 +02:00
kevinveenbirkenbach 0d4e33f4d3 Merge pull request #1 from kevinveenbirkenbach/dependabot/github_actions/DeterminateSystems/update-flake-lock-29
build(deps): bump DeterminateSystems/update-flake-lock from 24 to 29
2026-10-01 16:27:01 +02:00
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
dependabot[bot] 69dc430ca1 build(deps): bump docker/login-action from 3.7.0 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...dbcb813823bdd20940b903addbd779551569679f)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:26:19 +00:00
dependabot[bot] dd172c0c99 build(deps): bump DeterminateSystems/nix-installer-action from 16 to 23
Bumps [DeterminateSystems/nix-installer-action](https://github.com/determinatesystems/nix-installer-action) from 16 to 23.
- [Release notes](https://github.com/determinatesystems/nix-installer-action/releases)
- [Commits](https://github.com/determinatesystems/nix-installer-action/compare/v16...v23)

---
updated-dependencies:
- dependency-name: DeterminateSystems/nix-installer-action
  dependency-version: '23'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:25:10 +00:00
dependabot[bot] 42a7445865 build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.4.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.4.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...594f3bf4285d9ea8dc53c9a0c9c4092420091003)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:25:03 +00:00
dependabot[bot] 0ec74ad06f build(deps): bump actions/setup-python from 5 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:24:58 +00:00
dependabot[bot] fab5a5b6af build(deps): bump DeterminateSystems/update-flake-lock from 24 to 29
Bumps [DeterminateSystems/update-flake-lock](https://github.com/determinatesystems/update-flake-lock) from 24 to 29.
- [Release notes](https://github.com/determinatesystems/update-flake-lock/releases)
- [Commits](https://github.com/determinatesystems/update-flake-lock/compare/v24...v29)

---
updated-dependencies:
- dependency-name: DeterminateSystems/update-flake-lock
  dependency-version: '29'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:24:52 +00: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
37 changed files with 831 additions and 432 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"
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: "3.12"
+2 -2
View File
@@ -38,13 +38,13 @@ jobs:
- name: Set up Docker Buildx
if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
with:
use: true
- name: Login to GHCR
if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
+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@v23
- name: Update flake.lock and open a pull request
uses: DeterminateSystems/update-flake-lock@v29
with:
pr-title: "chore(deps): update flake.lock"
pr-labels: |
dependencies
nix
+94
View File
@@ -1,5 +1,99 @@
# Changelog
## [2.0.3] - 2026-10-18
Fixed:
- Release: the entry is linted inside the CHANGELOG.md it lands in
- Release: a list marker that breaks the file's consistency is caught (MD004)
- Release: findings the file already has are not blamed on the entry
- Release: *--no-globs* keeps the target repo's other files out of the lint
- Release: without markdownlint-cli2, list items must use dash bullets
Upgrade notes:
- Release: write list items with dash bullets when markdownlint-cli2 is absent
## [2.0.2] - 2026-10-01
Fixes a release flow that could reject every entry written for it.
### Fixed
- A heading in a changelog message became bold, which markdown-lint reads
back as a heading and refuses with MD036
- Since the editor reopens on failure, no structured entry could ever pass
- Headings now move below the release heading they are filed under
- The lint findings reached nobody: the editor took over the terminal in the
same breath they were printed
- They are held until Enter and repeated inside the editor, above the text
- The Makefile installer accepted install-* as proof of an install target but
always ran plain make install
- A repository with install-python and no install died on "No rule to make
target" instead of being skipped
### Tests
- transform and lint were covered separately, so a transform producing lint
errors went unseen; one test now runs the first through the second
- Three tests were checking behaviour the code had already left behind
### Dependencies
- docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
- actions/setup-python 5 to 7
- DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
### Upgrading
- Nothing to do; a changelog entry written with headings now keeps them
## [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.3";
# 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.3
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')
+104
View File
@@ -1,3 +1,107 @@
package-manager (2.0.3-1) unstable; urgency=medium
Fixed:
- Release: the entry is linted inside the CHANGELOG.md it lands in
- Release: a list marker that breaks the file's consistency is caught (MD004)
- Release: findings the file already has are not blamed on the entry
- Release: *--no-globs* keeps the target repo's other files out of the lint
- Release: without markdownlint-cli2, list items must use dash bullets
Upgrade notes:
- Release: write list items with dash bullets when markdownlint-cli2 is absent
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 18 Oct 2026 23:33:52 +0200
package-manager (2.0.2-1) unstable; urgency=medium
Fixes a release flow that could reject every entry written for it.
### Fixed
- A heading in a changelog message became bold, which markdown-lint reads
back as a heading and refuses with MD036
- Since the editor reopens on failure, no structured entry could ever pass
- Headings now move below the release heading they are filed under
- The lint findings reached nobody: the editor took over the terminal in the
same breath they were printed
- They are held until Enter and repeated inside the editor, above the text
- The Makefile installer accepted install-* as proof of an install target but
always ran plain make install
- A repository with install-python and no install died on "No rule to make
target" instead of being skipped
### Tests
- transform and lint were covered separately, so a transform producing lint
errors went unseen; one test now runs the first through the second
- Three tests were checking behaviour the code had already left behind
### Dependencies
- docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
- actions/setup-python 5 to 7
- DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
### Upgrading
- Nothing to do; a changelog entry written with headings now keeps them
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 01 Oct 2026 17:47:12 +0200
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.
+90 -1
View File
@@ -1,5 +1,5 @@
Name: package-manager
Version: 1.16.0
Version: 2.0.3
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -74,6 +74,95 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%changelog
* Sun Oct 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.3-1
- Fixed:
- - Release: the entry is linted inside the CHANGELOG.md it lands in
- - Release: a list marker that breaks the file's consistency is caught (MD004)
- - Release: findings the file already has are not blamed on the entry
- - Release: *--no-globs* keeps the target repo's other files out of the lint
- - Release: without markdownlint-cli2, list items must use dash bullets
- Upgrade notes:
- - Release: write list items with dash bullets when markdownlint-cli2 is absent
* Thu Oct 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.2-1
- Fixes a release flow that could reject every entry written for it.
- ### Fixed
- - A heading in a changelog message became bold, which markdown-lint reads
- back as a heading and refuses with MD036
- - Since the editor reopens on failure, no structured entry could ever pass
- - Headings now move below the release heading they are filed under
- - The lint findings reached nobody: the editor took over the terminal in the
- same breath they were printed
- - They are held until Enter and repeated inside the editor, above the text
- - The Makefile installer accepted install-* as proof of an install target but
- always ran plain make install
- - A repository with install-python and no install died on "No rule to make
- target" instead of being skipped
- ### Tests
- - transform and lint were covered separately, so a transform producing lint
- errors went unseen; one test now runs the first through the second
- - Three tests were checking behaviour the code had already left behind
- ### Dependencies
- - docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
- - actions/setup-python 5 to 7
- - DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
- ### Upgrading
- - Nothing to do; a changelog entry written with headings now keeps them
* 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.3"
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(
@@ -31,9 +31,7 @@ class MakefileInstaller(BaseInstaller):
except OSError:
return False
if re.search(r"^install\s*:", content, flags=re.MULTILINE):
return True
return bool(re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE))
return bool(re.search(r"^install\s*:", content, flags=re.MULTILINE))
def run(self, ctx: RepoContext) -> None:
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
+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
@@ -6,11 +6,15 @@ import re
import shutil
import subprocess
import tempfile
from collections import Counter
_HEADING = re.compile(r"^\s*#{1,6}\s+(.*?)\s*#*\s*$")
_HEADING = re.compile(r"^\s*(#{1,6})\s+(.*?)\s*#*\s*$")
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
_FINDING = re.compile(r"\bMD\d{3}\b")
_LOCATION = re.compile(r"^.*?:\d+(?::\d+)?\s+")
_NON_DASH_ITEM = re.compile(r"^\s*[*+]\s")
_MULTI_BLANK = re.compile(r"\n{3,}")
_ENTRY_LEVEL = 3
class ChangelogLintError(RuntimeError):
@@ -20,10 +24,10 @@ class ChangelogLintError(RuntimeError):
def transform_changelog_message(text: str) -> str:
"""Normalise a free-form release message into the changelog house style.
A leading ``#`` heading becomes a bold line of its own (markdown
headings inside an entry body would collide with the ``## [version]``
structure), and inline ``code`` spans become ``*italic*`` so the entry
stays free of backticks.
Headings are pushed below the ``## [version]`` line the entry is filed
under, so they nest instead of competing with it. Bolding them instead
would read as a heading to markdown-lint and fail MD036. Inline ``code``
spans become ``*italic*`` so the entry stays free of backticks.
"""
text = _INLINE_CODE.sub(r"*\1*", text)
@@ -31,9 +35,10 @@ def transform_changelog_message(text: str) -> str:
for line in text.split("\n"):
heading = _HEADING.match(line)
if heading:
level = max(len(heading.group(1)), _ENTRY_LEVEL)
if out and out[-1].strip():
out.append("")
out.append(f"**{heading.group(1).strip()}**")
out.append(f"{'#' * level} {heading.group(2).strip()}")
out.append("")
else:
out.append(line)
@@ -57,6 +62,35 @@ def lint_changelog_entry(reference_path: str, entry: str) -> list[str]:
return _builtin_lint(document)
def lint_changelog_context(reference_path: str, existing: str, merged: str) -> list[str]:
"""Return the findings *merged* has beyond those *existing* already has.
Args:
reference_path: path of the target CHANGELOG.md; its directory supplies
the markdownlint config.
existing: the target file before the entry is inserted.
merged: the target file with the entry inserted.
Returns:
Findings of *merged* that *existing* does not produce. Empty when
``markdownlint-cli2`` is unavailable.
"""
if not shutil.which("markdownlint-cli2"):
return []
known = Counter(
_LOCATION.sub("", finding)
for finding in (_markdownlint(reference_path, existing) if existing.strip() else [])
)
new: list[str] = []
for finding in _markdownlint(reference_path, merged):
key = _LOCATION.sub("", finding)
if known[key]:
known[key] -= 1
else:
new.append(finding)
return new
def _markdownlint(reference_path: str, document: str) -> list[str]:
directory = os.path.dirname(os.path.abspath(reference_path)) or "."
fd, tmp_path = tempfile.mkstemp(
@@ -67,7 +101,7 @@ def _markdownlint(reference_path: str, document: str) -> list[str]:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(document)
proc = subprocess.run(
["markdownlint-cli2", name],
["markdownlint-cli2", "--no-globs", name],
cwd=directory,
capture_output=True,
text=True,
@@ -94,6 +128,8 @@ def _builtin_lint(document: str) -> list[str]:
errors.append(f"line {number}: trailing whitespace (MD009)")
if "`" in line:
errors.append(f"line {number}: backtick is not allowed (use *italic*)")
if _NON_DASH_ITEM.match(line):
errors.append(f"line {number}: list items use '-' (MD004)")
if "\n\n\n" in document:
errors.append("multiple consecutive blank lines (MD012)")
return errors
@@ -7,6 +7,7 @@ from datetime import date
from .changelog_lint import (
ChangelogLintError,
lint_changelog_context,
lint_changelog_entry,
transform_changelog_message,
)
@@ -16,6 +17,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 +32,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 +49,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"
@@ -75,30 +81,10 @@ def update_changelog(
print(f" - {finding}")
print()
if message is not None:
body, entry = _entry_for(message)
findings = lint_changelog_entry(changelog_path, entry)
if findings:
_print_findings(findings)
raise ChangelogLintError(
"Provided changelog message is not markdown-lint clean."
)
elif preview or not sys.stdin.isatty():
body, entry = _entry_for(message or f"Release {new_version}")
else:
attempt: str | None = None
while True:
print(
"\n[INFO] Provide the changelog entry — a leading '#' becomes "
"bold, `code` becomes italic.\n"
)
raw = _open_editor_for_changelog(attempt)
body, entry = _entry_for(raw or f"Release {new_version}")
findings = lint_changelog_entry(changelog_path, entry)
if not findings:
break
_print_findings(findings)
attempt = body
def _wait_for_reader() -> None:
try:
input("[INFO] Press Enter to re-open the editor and fix the entry...")
except EOFError:
print("[INFO] Re-opening the editor so you can fix the entry...")
changelog = ""
@@ -109,6 +95,39 @@ def update_changelog(
except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
def _findings_for(entry: str) -> list[str]:
return lint_changelog_entry(changelog_path, entry) or lint_changelog_context(
changelog_path, changelog, _insert_after_h1(changelog, entry)
)
if message is not None:
body, entry = _entry_for(message)
findings = _findings_for(entry)
if findings:
_print_findings(findings)
raise ChangelogLintError(
"Provided changelog message is not markdown-lint clean."
)
elif preview or not sys.stdin.isatty():
body, entry = _entry_for(message or f"Release {new_version}")
else:
attempt: str | None = None
rejected: list[str] | None = None
while True:
print(
"\n[INFO] Provide the changelog entry - a leading '#' becomes "
"a sub-heading, `code` becomes italic.\n"
)
raw = _open_editor_for_changelog(attempt, rejected)
body, entry = _entry_for(raw or f"Release {new_version}")
findings = _findings_for(entry)
if not findings:
break
_print_findings(findings)
attempt = body
rejected = findings
_wait_for_reader()
new_changelog = _insert_after_h1(changelog, entry)
print("\n================ CHANGELOG ENTRY ================")
+16 -3
View File
@@ -6,7 +6,14 @@ import subprocess
import tempfile
def _open_editor_for_changelog(initial_message: str | None = None) -> str:
def _open_editor_for_changelog(
initial_message: str | None = None,
findings: list[str] | None = None,
) -> str:
"""Args:
initial_message: the previous attempt, pre-loaded for editing.
findings: why that attempt was rejected, shown above it.
"""
editor = os.environ.get("EDITOR", "nano")
with tempfile.NamedTemporaryFile(
@@ -18,9 +25,15 @@ def _open_editor_for_changelog(initial_message: str | None = None) -> str:
tmp.write(
"; Write the changelog entry for this release.\n"
"; Lines starting with ';' are ignored.\n"
"; A leading '#' becomes bold; `code` becomes italic.\n"
"; Empty result will fall back to a generic message.\n\n"
"; A leading '#' becomes a sub-heading; `code` becomes italic.\n"
"; Empty result will fall back to a generic message.\n"
)
if findings:
tmp.write(";\n; markdown-lint rejected the previous entry:\n")
for finding in findings:
for line in str(finding).splitlines() or [""]:
tmp.write(f"; {line}\n")
tmp.write("\n")
if initial_message:
tmp.write(initial_message.strip() + "\n")
tmp.flush()
-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:
@@ -72,6 +72,8 @@ class TestDebianControlInstaller(unittest.TestCase):
return "/usr/bin/dpkg-buildpackage"
if name == "apt-get":
return "/usr/bin/apt-get"
if name == "sudo":
return "/usr/bin/sudo"
return None
mock_which.side_effect = which_side_effect
@@ -81,6 +81,19 @@ class TestMakefileInstaller(unittest.TestCase):
)
mock_run_command.assert_not_called()
@patch("pkgmgr.actions.install.installers.makefile.run_command")
@patch(
"builtins.open",
new_callable=mock_open,
read_data="install-python:\n\tpip install -r requirements.txt\n",
)
@patch("os.path.exists", return_value=True)
def test_run_skips_a_makefile_that_only_has_a_prefixed_target(
self, mock_exists, mock_file, mock_run_command
):
self.installer.run(self.ctx)
mock_run_command.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -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()
@@ -1,23 +1,31 @@
from __future__ import annotations
import shutil
import unittest
from unittest.mock import patch
from pkgmgr.actions.release.files import changelog_lint
from pkgmgr.actions.release.files.changelog_lint import (
lint_changelog_context,
lint_changelog_entry,
transform_changelog_message,
)
from pkgmgr.actions.release.files.changelog_md import _insert_after_h1
_OLD_ASTERISK_FILE = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n* old bullet\n"
class TestTransformChangelogMessage(unittest.TestCase):
def test_heading_becomes_bold(self) -> None:
def test_heading_is_pushed_below_the_release_heading(self) -> None:
out = transform_changelog_message("# Title\n\nbody")
self.assertIn("**Title**", out)
self.assertNotIn("# Title", out)
self.assertIn("### Title", out)
self.assertNotIn("**Title**", out)
def test_multi_hash_heading_becomes_bold(self) -> None:
self.assertEqual(
transform_changelog_message("### Sub heading"), "**Sub heading**"
)
def test_a_heading_already_deep_enough_keeps_its_level(self) -> None:
self.assertEqual(transform_changelog_message("### Sub heading"), "### Sub heading")
def test_a_deeper_heading_is_left_alone(self) -> None:
self.assertEqual(transform_changelog_message("#### Deeper"), "#### Deeper")
def test_inline_code_becomes_italic(self) -> None:
out = transform_changelog_message("use `pkgmgr release` now")
@@ -27,18 +35,52 @@ class TestTransformChangelogMessage(unittest.TestCase):
def test_plain_message_is_unchanged(self) -> None:
self.assertEqual(transform_changelog_message("just text"), "just text")
def test_no_heading_or_backtick_survives(self) -> None:
def test_no_backtick_survives_and_no_heading_competes(self) -> None:
out = transform_changelog_message("# Heading\n\n* item with `code`")
self.assertNotIn("`", out)
for line in out.split("\n"):
self.assertFalse(line.lstrip().startswith("#"))
if line.lstrip().startswith("#"):
self.assertTrue(line.startswith("###"), line)
class TestLintChangelogEntry(unittest.TestCase):
def test_clean_entry_has_no_findings(self) -> None:
entry = "## [1.0.0] - 2026-01-01\n\n* a clean bullet\n\n"
entry = "## [1.0.0] - 2026-01-01\n\n- a clean bullet\n\n"
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
def test_a_structured_message_survives_its_own_transform(self) -> None:
body = transform_changelog_message(
"# Security\n\n- a bullet\n\n## Fixed\n\n- another bullet"
)
entry = f"## [1.0.0] - 2026-01-01\n\n{body}\n\n"
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
def test_the_builtin_check_refuses_a_non_dash_list_marker(self) -> None:
entry = "## [1.0.0] - 2026-01-01\n\n* a bullet\n\n"
with patch.object(changelog_lint.shutil, "which", return_value=None):
findings = lint_changelog_entry("CHANGELOG.md", entry)
self.assertTrue(any("MD004" in finding for finding in findings), findings)
@unittest.skipUnless(shutil.which("markdownlint-cli2"), "markdownlint-cli2 is not installed")
class TestLintChangelogContext(unittest.TestCase):
def test_a_marker_that_breaks_the_files_consistency_is_reported(self) -> None:
entry = "## [1.1.0] - 2026-02-01\n\n- new bullet\n\n"
merged = _insert_after_h1(_OLD_ASTERISK_FILE, entry)
findings = lint_changelog_context("CHANGELOG.md", _OLD_ASTERISK_FILE, merged)
self.assertTrue(any("MD004" in finding for finding in findings), findings)
def test_a_finding_the_file_already_has_is_not_blamed_on_the_entry(self) -> None:
existing = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n* old bullet \n"
entry = "## [1.1.0] - 2026-02-01\n\n* new bullet\n\n"
merged = _insert_after_h1(existing, entry)
self.assertEqual(lint_changelog_context("CHANGELOG.md", existing, merged), [])
def test_a_consistent_entry_has_no_findings(self) -> None:
entry = "## [1.1.0] - 2026-02-01\n\n* new bullet\n\n"
merged = _insert_after_h1(_OLD_ASTERISK_FILE, entry)
self.assertEqual(lint_changelog_context("CHANGELOG.md", _OLD_ASTERISK_FILE, merged), [])
if __name__ == "__main__": # pragma: no cover
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()
@@ -0,0 +1,60 @@
from __future__ import annotations
import unittest
from unittest import mock
from pkgmgr.actions.release.files.editor import _open_editor_for_changelog
class TestOpenEditorForChangelog(unittest.TestCase):
def _buffer(self, **kwargs: object) -> str:
"""Returns: what the editor was handed, with the editor itself a no-op."""
seen: dict[str, str] = {}
def record(argv: list[str]) -> int:
with open(argv[1], encoding="utf-8") as handle:
seen["text"] = handle.read()
return 0
with mock.patch(
"pkgmgr.actions.release.files.editor.subprocess.call", side_effect=record
):
_open_editor_for_changelog(**kwargs)
return seen["text"]
def test_the_rejection_reaches_the_editor_the_entry_is_fixed_in(self) -> None:
text = self._buffer(
initial_message="### Security\n\n- a bullet",
findings=["changelog entry:3 error MD036/no-emphasis-as-heading"],
)
self.assertIn("MD036/no-emphasis-as-heading", text)
self.assertIn("### Security", text)
def test_every_finding_line_is_commented_out(self) -> None:
text = self._buffer(findings=["first line\nsecond line"])
for line in text.splitlines():
if "line" in line and "ignored" not in line:
self.assertTrue(line.startswith(";"), line)
def test_a_finding_never_lands_in_the_entry(self) -> None:
seen: dict[str, str] = {}
def rewrite(argv: list[str]) -> int:
with open(argv[1], encoding="utf-8") as handle:
seen["text"] = handle.read()
return 0
with mock.patch(
"pkgmgr.actions.release.files.editor.subprocess.call", side_effect=rewrite
):
kept = _open_editor_for_changelog(
initial_message="- a bullet", findings=["MD036 somewhere"]
)
self.assertEqual(kept, "- a bullet")
def test_without_findings_the_header_stays_as_it_was(self) -> None:
self.assertNotIn("markdown-lint rejected", self._buffer())
if __name__ == "__main__": # pragma: no cover
unittest.main()
@@ -313,13 +313,13 @@ class TestUpdateChangelog(unittest.TestCase):
def test_update_changelog_does_not_inject_a_leading_bullet(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
update_changelog(path, "1.2.3", message="* Provided bullet", preview=False)
update_changelog(path, "1.2.3", message="- Provided bullet", preview=False)
with open(path, encoding="utf-8") as f:
content = f.read()
self.assertIn("\n\n* Provided bullet\n", content)
self.assertNotIn("* * Provided bullet", content)
self.assertIn("\n\n- Provided bullet\n", content)
self.assertNotIn("- - Provided bullet", content)
def test_update_changelog_transforms_heading_and_inline_code(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
@@ -327,16 +327,17 @@ class TestUpdateChangelog(unittest.TestCase):
update_changelog(
path,
"1.2.3",
message="# Summary\n\n* uses `foo` tool",
message="# Summary\n\n- uses `foo` tool",
preview=False,
)
with open(path, encoding="utf-8") as f:
content = f.read()
self.assertIn("**Summary**", content)
self.assertIn("### Summary", content)
self.assertIn("*foo*", content)
self.assertNotIn("# Summary", content)
self.assertNotIn("\n# Summary", content)
self.assertNotIn("\n## Summary", content)
self.assertNotIn("`foo`", content)
def test_update_changelog_preview_does_not_write(self) -> None:
@@ -29,22 +29,19 @@ class FindDefaultsSourceDirTests(unittest.TestCase):
self.assertEqual(Path(found).resolve(), (pkg_root / "config").resolve())
def test_falls_back_to_project_root_config(self):
def test_a_config_dir_beside_the_package_is_not_taken(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
pkg_root = root / "site-packages" / "pkgmgr"
pkg_root.mkdir(parents=True)
# only project_root config exists
(pkg_root.parent / "config").mkdir(parents=True)
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
found = config_cmd._find_defaults_source_dir()
self.assertEqual(
Path(found).resolve(), (pkg_root.parent / "config").resolve()
)
self.assertIsNone(found)
def test_returns_none_when_no_config_dirs_exist(self):
with tempfile.TemporaryDirectory() as td:
@@ -88,9 +85,8 @@ class UpdateDefaultConfigsTests(unittest.TestCase):
self.assertTrue((dest_cfg_dir / "a.yaml").is_file())
self.assertTrue((dest_cfg_dir / "b.yml").is_file())
self.assertFalse(
(dest_cfg_dir / "config.yaml")
.read_text(encoding="utf-8")
.startswith("should_not_copy")
(dest_cfg_dir / "config.yaml").exists(),
"the source config.yaml must not be copied over the user's own",
)
# Ensure config.yaml was not overwritten (it may exist, but should remain original if we create it)
+1 -3
View File
@@ -242,9 +242,7 @@ class LoadConfigIntegrationUnitTests(unittest.TestCase):
# Provide package defaults via fake pkgmgr + pkg_root/config
root = Path(td) / "site-packages"
pkg_root = root / "pkgmgr"
cfg_dir = (
root / "config"
) # NOTE: load.py checks multiple roots, including pkg_root.parent (=site-packages)
cfg_dir = pkg_root / "config"
pkg_root.mkdir(parents=True)
cfg_dir.mkdir(parents=True)