Compare commits

..
56 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach d1a951ee94 Release version 2.2.0
Mark stable commit / mark-stable (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-11-01 01:55:31 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 f40cc5cfde feat(push): add --no-sign to push unsigned commits
pkgmgr push --no-sign skips the re-sign rebase and the unsigned-range
refusal, so a checkout without a usable signing key can still sync the
fetch URL and every mirror pushurl. Leases and the git cherry guard
still apply. push_branch takes sign as a required keyword; pkgmgr
release and release --retry pass sign=True, so releases stay signed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 01:55:31 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 944b4cb663 ci(publish): build container images on native arch runners
Each distro now builds amd64 on ubuntu-latest and arm64 on
ubuntu-24.04-arm instead of emulating arm64 under QEMU. image.sh gains
--platform, which pushes one <version>-<arch> tag, and --merge, which
joins those tags under the release, stable and alias tags with
buildx imagetools. The workflow splits into prepare, build and merge
jobs; scripts/build/publish.sh follows the same sequence locally.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 00:53:16 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 919e6a6434 feat(push): re-sign and lease every mirror pushurl
pkgmgr push now signs unpushed commits like git-sign-push and pushes
each pushurl with its own --force-with-lease: the fetch URL against the
tracking ref, every mirror against its own fetched tip, each only after
git cherry shows no commit exists only there. --rewrite allows a
deliberate history rewrite. Without a repository selection it pushes
the current checkout, including worktrees outside the base dir.
pkgmgr release and release --retry sign before tagging and push the
branch and tag through the same path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 00:53:16 +01:00
Kevin Veen-Birkenbach 4a4bf137ac Release version 2.1.1
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
Mark stable commit / mark-stable (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-10-31 18:47:35 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 ea82d91b75 fix(release): skip pyproject.toml without a static project version
A pyproject.toml that holds only tool config (no [project] table) or lists "version" in [project].dynamic now prints an INFO line and is skipped, like a missing file. A [project] table with neither a version nor a dynamic entry still raises.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 18:42:41 +01:00
Kevin Veen-Birkenbach 1c0a4abcbb Release version 2.1.0
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
Mark stable commit / mark-stable (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-10-31 14:38:54 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 810d27badc feat(release): start from the highest version across all sources
CodeQL Advanced / Check security (none, actions) (push) Canceled after 0s
Test Units / test-unit (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
Test Code Integration / test-integration (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
Test OS Containers / test-env-virtual (ubuntu) (push) Canceled after 0s
Test OS Containers / test-env-virtual (debian) (push) Canceled after 0s
Test OS Containers / test-env-virtual (centos) (push) Canceled after 0s
Test OS Containers / test-env-virtual (arch) (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
Test End-To-End / test-e2e (ubuntu) (push) Canceled after 0s
Test End-To-End / test-e2e (fedora) (push) Canceled after 0s
Test End-To-End / test-e2e (debian) (push) Canceled after 0s
Test End-To-End / test-e2e (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (arch) (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
Test Virgin User / test-virgin-user (ubuntu) (push) Canceled after 0s
Test Virgin User / test-virgin-user (fedora) (push) Canceled after 0s
Test Virgin User / test-virgin-user (centos) (push) Canceled after 0s
Test Virgin User / test-virgin-user (arch) (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
Test Virgin Root / test-virgin-root (ubuntu) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (arch) (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
ShellCheck / lint-shell (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
Docker Linter / Lint Dockerfile (push) Canceled after 0s
CodeQL Advanced / Check security (none, python) (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
Test OS Containers / test-env-virtual (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (fedora) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (centos) (push) Canceled after 0s
Ruff (Python code sniffer) / lint-python (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
The release base used to be the latest v* tag only. It is now the highest version found in that tag and in every file the release writes: pyproject.toml, CHANGELOG.md, flake.nix, PKGBUILD, the RPM spec, debian/changelog and Cargo.toml. A lagging file can no longer lower the base and a file ahead of the tag raises it; when the sources disagree the release says so and names the version it starts from. Repos whose sources agree release exactly as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 14:18:07 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 d823a42edc feat(release): bump the root crate in Cargo.toml and Cargo.lock
A repo whose Cargo.toml has a [package] section now gets its version set to the release version, and the matching local [[package]] entry in Cargo.lock (same name, no source line) follows in the same release commit, so cargo build --locked keeps passing. Workspaces without [package] and repos without Cargo.toml release as before; a Cargo.lock without the local entry aborts the release before any file is written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 14:18:07 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 168d59e1b2 feat(branch): add gc and purge for merged and abandoned branches
pkgmgr branch gc lists every branch and worktree of the repo (or of all managed repos with --all) together with the compose stacks found via com.docker.compose.project.working_dir. Branches whose tips are merged (ancestor of the default branch off its first-parent line, or head SHA of a merged GitHub PR) and whose worktrees are clean are removed with --apply: compose down -v plus the project's images, the worktree (foreign-owned files through a throwaway alpine container, metadata only after the checkout is gone), the local branch and the branch on every remote via push --force-with-lease. Default, protected, open-PR, busy and locked items are skipped; every item is re-checked right before it is removed. pkgmgr branch purge handles unmerged or dirty items with a confirmation per item.

Both refuse to act outside the host PID namespace, where processes and worktree locks cannot be verified. The fetch skips auto-maintenance, whose worktree-prune task deletes the metadata of sandboxed worktrees.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 14:18:06 +01:00
Kevin Veen-Birkenbach 579ded3438 Release version 2.0.5
CodeQL Advanced / Check security (none, actions) (push) Canceled after 0s
Test Units / test-unit (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
Test Code Integration / test-integration (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
Test OS Containers / test-env-virtual (ubuntu) (push) Canceled after 0s
Test OS Containers / test-env-virtual (debian) (push) Canceled after 0s
Test OS Containers / test-env-virtual (centos) (push) Canceled after 0s
Test OS Containers / test-env-virtual (arch) (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
Test End-To-End / test-e2e (ubuntu) (push) Canceled after 0s
Test End-To-End / test-e2e (fedora) (push) Canceled after 0s
Test End-To-End / test-e2e (debian) (push) Canceled after 0s
Test End-To-End / test-e2e (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (arch) (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
Test Virgin User / test-virgin-user (ubuntu) (push) Canceled after 0s
Test Virgin User / test-virgin-user (fedora) (push) Canceled after 0s
Test Virgin User / test-virgin-user (centos) (push) Canceled after 0s
Test Virgin User / test-virgin-user (arch) (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
Test Virgin Root / test-virgin-root (ubuntu) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (arch) (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
ShellCheck / lint-shell (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
Docker Linter / Lint Dockerfile (push) Canceled after 0s
Mark stable commit / mark-stable (push) Canceled after 0s
CodeQL Advanced / Check security (none, python) (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
Test OS Containers / test-env-virtual (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (fedora) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (centos) (push) Canceled after 0s
Ruff (Python code sniffer) / lint-python (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-10-29 13:57:31 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 eb17cddd21 fix(config): open nano on the terminal in config edit
pkgmgr config edit ran nano through run_command, which pipes stdout and stderr to capture them, so nano aborted with 'Standard output is not a terminal'. The editor now runs with subprocess.call and the inherited terminal, and a failing editor exits with its code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:47:45 +01:00
Kevin Veen-Birkenbach c7b070a62b Release version 2.0.4
Mark stable commit / mark-stable (push) Canceled after 0s
CodeQL Advanced / Check security (none, actions) (push) Canceled after 0s
Test Units / test-unit (push) Canceled after 0s
Test Code Integration / test-integration (push) Canceled after 0s
Test OS Containers / test-env-virtual (ubuntu) (push) Canceled after 0s
Test OS Containers / test-env-virtual (debian) (push) Canceled after 0s
Test OS Containers / test-env-virtual (centos) (push) Canceled after 0s
Test OS Containers / test-env-virtual (arch) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (ubuntu) (push) Canceled after 0s
Test End-To-End / test-e2e (fedora) (push) Canceled after 0s
Test End-To-End / test-e2e (debian) (push) Canceled after 0s
Test End-To-End / test-e2e (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (ubuntu) (push) Canceled after 0s
Test Virgin User / test-virgin-user (fedora) (push) Canceled after 0s
Test Virgin User / test-virgin-user (centos) (push) Canceled after 0s
Test Virgin User / test-virgin-user (arch) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (ubuntu) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (arch) (push) Canceled after 0s
ShellCheck / lint-shell (push) Canceled after 0s
Docker Linter / Lint Dockerfile (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
CodeQL Advanced / Check security (none, python) (push) Canceled after 0s
Test OS Containers / test-env-virtual (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (fedora) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (centos) (push) Canceled after 0s
Ruff (Python code sniffer) / lint-python (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-10-19 10:06:02 +02:00
Kevin Veen-BirkenbachandClaude Opus 5.5 20cd174a1f fix(release): date changelog entries without a ruff-version-dependent noqa
ruff 0.16 flags date.today() (DTZ011) while 0.17 drops that rule from its defaults and flags the noqa as unused (RUF100), so CI's unpinned ruff failed lint-python. datetime.now().astimezone().date() yields the same local date and passes both, matching debian.py and rpm_changelog.py.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 09:58:31 +02:00
Kevin Veen-Birkenbach ae4bfb5965 Merge pull request #7 from kevinveenbirkenbach/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 4 to 7
2026-10-19 09:36:25 +02:00
Kevin Veen-Birkenbach 4f71680bb1 Merge pull request #6 from kevinveenbirkenbach/dependabot/github_actions/hadolint/hadolint-action-3.5.0
build(deps): bump hadolint/hadolint-action from 3.3.0 to 3.5.0
2026-10-19 09:35:34 +02:00
Kevin Veen-Birkenbach c78f5ac75e Release version 2.0.3
Mark stable commit / mark-stable (push) Canceled after 0s
CodeQL Advanced / Check security (none, actions) (push) Canceled after 0s
Test Units / test-unit (push) Canceled after 0s
Test Code Integration / test-integration (push) Canceled after 0s
Test OS Containers / test-env-virtual (ubuntu) (push) Canceled after 0s
Test OS Containers / test-env-virtual (debian) (push) Canceled after 0s
Test OS Containers / test-env-virtual (centos) (push) Canceled after 0s
Test OS Containers / test-env-virtual (arch) (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (ubuntu) (push) Canceled after 0s
Test End-To-End / test-e2e (fedora) (push) Canceled after 0s
Test End-To-End / test-e2e (debian) (push) Canceled after 0s
Test End-To-End / test-e2e (centos) (push) Canceled after 0s
Test End-To-End / test-e2e (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (ubuntu) (push) Canceled after 0s
Test Virgin User / test-virgin-user (fedora) (push) Canceled after 0s
Test Virgin User / test-virgin-user (centos) (push) Canceled after 0s
Test Virgin User / test-virgin-user (arch) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (ubuntu) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (arch) (push) Canceled after 0s
ShellCheck / lint-shell (push) Canceled after 0s
Docker Linter / Lint Dockerfile (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
CodeQL Advanced / Check security (none, python) (push) Canceled after 0s
Test OS Containers / test-env-virtual (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Canceled after 0s
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Canceled after 0s
Test Virgin User / test-virgin-user (debian) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (fedora) (push) Canceled after 0s
Test Virgin Root / test-virgin-root (centos) (push) Canceled after 0s
Ruff (Python code sniffer) / lint-python (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-10-18 23:33:52 +02:00
Kevin Veen-BirkenbachandClaude 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
dependabot[bot] d24eb0155c build(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 00:15:09 +00:00
dependabot[bot] c6e866b2c9 build(deps): bump hadolint/hadolint-action from 3.3.0 to 3.5.0
Bumps [hadolint/hadolint-action](https://github.com/hadolint/hadolint-action) from 3.3.0 to 3.5.0.
- [Release notes](https://github.com/hadolint/hadolint-action/releases)
- [Commits](https://github.com/hadolint/hadolint-action/compare/2332a7b74a6de0dda2e2221d575162eba76ba5e5...06be81baf89a55ffd0e24b8f04a4185738dd3387)

---
updated-dependencies:
- dependency-name: hadolint/hadolint-action
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 00:14:01 +00:00
Kevin Veen-Birkenbach 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-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Update flake.lock / Update flake.lock (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
2026-10-01 17:47:12 +02:00
Kevin Veen-BirkenbachandClaude 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
Kevin Veen-BirkenbachandClaude 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
Kevin Veen-BirkenbachandClaude 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
Kevin Veen-Birkenbach 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
Kevin Veen-Birkenbach 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
Kevin Veen-Birkenbach 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
Kevin Veen-Birkenbach 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
Kevin Veen-Birkenbach 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
Kevin Veen-Birkenbach 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-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Update flake.lock / Update flake.lock (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
2026-09-18 17:15:35 +02:00
Kevin Veen-BirkenbachandClaude 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
Kevin Veen-Birkenbach 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-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-docker (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
Kevin Veen-BirkenbachandClaude 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
Kevin Veen-BirkenbachandClaude 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
Kevin Veen-BirkenbachandClaude 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
Kevin Veen-BirkenbachandClaude 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
Kevin Veen-BirkenbachandClaude 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
Kevin Veen-BirkenbachandClaude 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
Kevin Veen-Birkenbach 4cfb95da1f Release version 1.16.1
Mark stable commit / mark-stable (push) Canceled after 0s
2026-07-29 16:41:21 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 ec7b959893 chore(claude): drop Bash(*) and require confirmation for commit and push
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-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-python (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
The allow list in this file never took effect. Claude Code discards every
`permissions.allow` entry from project settings while the workspace has
not been trusted (hasTrustDialogAccepted is false for this repo), and it
does so silently in interactive sessions. `Bash(*)` was therefore inert.

Removing it rather than granting trust: the rule only ever bites on the
unsandboxed path, where it would have meant "any command, outside the
sandbox, without a prompt" for everyone who clones this repo. Sandboxed
Bash is auto-allowed anyway, so the entry bought nothing safe.

Add ask rules for git commit and git push instead. Unlike allow rules,
ask and deny are not filtered by the trust gate, and the sandbox
auto-allow path checks them before granting, so they apply to sandboxed
and unsandboxed invocations alike. Both a bare and a wildcard form are
listed because `git commit *` compiles to /^git commit .*$/ and would
not match the bare command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 00:11:29 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 6363709987 fix(code-scanning): stamp report timestamps in UTC
datetime.now() returned a naive timestamp, so `generated_at` in the code
scanning report carried no offset and a reader could not tell which zone
it was written in. Use datetime.now(timezone.utc) for both the report
field and the fallback output directory name.

The changelog keeps date.today(): a release entry carries the author's
local calendar day, so UTC would be wrong there. The rule is suppressed
with that reason instead.

This is the last lint finding; `ruff check src tests` now passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:39:34 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 7764a2946c fix: catch concrete exceptions instead of bare Exception
Replace 46 blind `except Exception` handlers with the exception types the
guarded code can actually raise, so an unforeseen failure surfaces instead
of being silently swallowed. The types were derived per site from the try
block: file reads get (OSError, UnicodeDecodeError), TOML and YAML parsing
add their decode errors, int() guards get ValueError, imports get
ImportError, subprocess calls get (OSError, subprocess.SubprocessError),
git queries get GitRunError, and importlib.metadata lookups get
PackageNotFoundError.

Three handlers stay blind, now with a stated reason: the per-repository
loops in install and update are batch boundaries where one failing
repository must never abort the run.

Three imports come along because the narrowing needs them: GitRunError in
the changelog and version commands, and http.client in token validation --
urllib raises HTTPException, which is not an OSError subclass and would
otherwise have escaped.

Add tests/integration/test_error_path_degradation.py, which drives 44 of
the 46 handlers into their except branch and asserts the documented
degradation. Faults are injected for real rather than mocked wherever
possible: undecodable bytes, malformed YAML, digit strings past
sys.get_int_max_str_digits(), a real HTTPError with an unreadable body.
That approach paid for itself immediately -- tomllib.load() decodes
internally and raises UnicodeDecodeError, which was missing from the
pyproject handlers and would have turned a malformed pyproject.toml into
a traceback.

The two handlers left uncovered are the branch-close path in _release_impl,
reachable only through a full release, and the jinja2 import guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:37:14 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 85d1abf61c fix(credentials): raise a concrete error when the keyring backend fails
The resolver caught bare `Exception` around keyring reads and writes.
That could not be narrowed at the call site: the errors originate in
python-keyring, an optional dependency, so `keyring.errors.KeyringError`
is not referenceable without importing the package unconditionally.

Move the open-ended catch to the boundary where keyring is already
imported. KeyringTokenProvider.get/set now wrap the backend calls and
re-raise as KeyringOperationError, preserving the original exception as
__cause__. The resolver catches that concrete type instead.

KeyringOperationError is deliberately distinct from KeyringUnavailableError:
"no usable backend" and "backend present but the call failed" are different
states, and the resolver treats them differently — the former warns once and
visibly, the latter degrades silently.

Both degradation paths are unchanged: a failed write still lets the token
be used for the current run, a failed read still falls through to the prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:50:10 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 b4e0594901 style: modernise typing and clean up lint findings
Repository-wide mechanical cleanup so `ruff check src tests` has a chance
of passing; no behavioural changes.

- Add `from __future__ import annotations` where PEP 604 unions are used.
  This has to come first: pyproject declares requires-python >= 3.9, where
  `X | None` is not evaluable at runtime unless annotations are stringified.
- Replace typing.List/Dict/Tuple/Set with the builtin generics and
  Optional[X] with X | None, then drop the imports that became unused.
  The four actions/*/__init__.py files needed this by hand because ruff
  leaves unused imports in __init__.py alone (possible re-exports).
- Strip shebangs from 72 importable modules. None of them are executable
  or invoked directly; the entry points are console_scripts and runpy.
- Flatten nested `with` blocks, collapse needless-bool returns, and apply
  the remaining mechanical ruff fixes (PIE810, FLY002, PERF102, FURB192,
  RUF059, I001).
- Pass check=False explicitly to the four subprocess.run() calls that
  inspect returncode themselves. That is the existing default.

Two rewrites are visible to mocks, so their tests move with them:
subprocess.run(stdout=PIPE, stderr=PIPE) became capture_output=True, and
open(path, "r", ...) lost the redundant mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:47:13 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 14b95d5639 build(make): add lint target and run it as part of make test
`make lint` runs ruff on src and tests directly on the host, using the
same invocation as the lint-python workflow so local and CI agree. The
script fails with a clear install hint when ruff is not on PATH.

`make test` now depends on `lint`, so linting runs before the container
images are built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:25:19 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 8294bce436 style: autoformat src and tests
Repository-wide formatting pass over src/ and tests/: sorted imports and
__all__ entries, dropped redundant `# -*- coding: utf-8 -*-` headers,
unquoted forward-reference annotations (safe under
`from __future__ import annotations`), merged nested `with` statements,
moved `Iterable` and friends from `typing` to `collections.abc`, and
removed `# noqa: F401` markers that no longer suppress anything.

No behavioural changes. Unit and integration suites show the same four
pre-existing failures before and after the pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:17:35 +02:00
Kevin Veen-BirkenbachandClaude Opus 5 f1517963a5 fix(repos create): default new repos to main, not master
`git init` without `-b` inherits the ambient `init.defaultBranch` config,
which is `master` unless the host has configured otherwise, so `pkgmgr
repos create` produced master-based repositories. On top of that, the
push fallback renamed an already-correct `main` branch back to `master`
whenever the initial push failed (missing remote, auth error).

`init()` now requires an explicit `initial_branch` and runs
`git init -b <branch>`; the bootstrapper passes `main` and no longer
falls back to `master` on push failure.

`resolve_base_branch()` keeps its main -> master fallback, since it reads
existing repositories that may legitimately use master.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:06:09 +02:00
Kevin Veen-Birkenbach d0e99b6483 Release version 1.16.0
Mark stable commit / mark-stable (push) Canceled after 0s
2026-06-28 16:54:16 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.8 13fc8fe885 feat(cli): add code-scanning command to download GitHub scan results
pkgmgr code-scanning fetches a repository's GitHub code scanning alerts (and analysis metadata) via the gh CLI and writes alerts.json, a readable summary.md digest, and analyses.json into a timestamped directory (default /tmp/<repo>/code-scanner/<YYYYMMDDHHMMSS>) for offline analysis. The repository is resolved from gh or --repo; authentication is delegated to gh; --state filters by alert state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 02:37:20 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.8 e8aed49a4c feat(release): lint and normalise the changelog message interactively
The release flow now sanitises the release message into the changelog house style and validates it before writing: a leading '#' heading becomes a bold line, inline `code` becomes *italic*, and the resulting entry is checked with markdownlint-cli2 (using the target repo's own config, i.e. the same rules it enforces) or a built-in fallback. In an interactive run the editor re-opens with the findings until the entry is clean; with --message it transforms then aborts on any finding. The editor's ignore-marker moved from '#' to ';' so heading lines survive as content.

debian/changelog and the RPM %changelog mirror the transformed body, keeping all three changelogs consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 02:37:19 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.8 87ee6c66c8 fix(release): preserve multi-line changelog bodies in package mirrors
update_debian_changelog and update_spec_changelog wrapped the whole release message in a single prefixed line, so only the first line of a multi-line (already-bulleted) message was indented/dashed while the rest stayed at column 0. That made dpkg-parsechangelog emit 'badly formatted heading line' and rpmspec report 'bad date', failing the package build. Each non-empty body line is now indented (Debian) or dash-prefixed (RPM) individually.

update_changelog no longer auto-prepends a '* ' bullet to the CHANGELOG.md entry: the message is inserted verbatim with blank-line separation, so a caller-provided markdown list stays markdown-lint-clean instead of gaining a stray leading bullet.

Adds regression tests for the multi-line debian/rpm mirroring and the no-injected-bullet behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 02:37:18 +02:00
320 changed files with 6021 additions and 1916 deletions

No files matched your search

+5 -4
View File
@@ -1,11 +1,12 @@
{
"permissions": {
"allow": [
"Bash(*)"
],
"ask": [
"Skill(update-config)",
"Skill(update-config:*)"
"Skill(update-config:*)",
"Bash(git commit)",
"Bash(git commit *)",
"Bash(git push)",
"Bash(git push *)"
]
},
"sandbox": {
+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"
+2 -2
View File
@@ -15,12 +15,12 @@ jobs:
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Run hadolint (produce SARIF)
id: hadolint
continue-on-error: true
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387
with:
dockerfile: ./Dockerfile
format: sarif
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: "3.12"
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
lint-shell:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install ShellCheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Run ShellCheck
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true # We need tags and main history for version comparison
+78 -11
View File
@@ -6,17 +6,21 @@ on:
types: [completed]
jobs:
publish:
prepare:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
should_publish: ${{ steps.info.outputs.should_publish }}
version: ${{ steps.info.outputs.version }}
is_stable: ${{ steps.info.outputs.is_stable }}
steps:
- name: Checkout repository (with tags)
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -36,24 +40,87 @@ jobs:
if: steps.branch-check.outputs.is_on_main == 'true'
run: bash scripts/github/publish-containers/compute-publish-container-info.sh
build:
needs: prepare
if: ${{ needs.prepare.outputs.should_publish == 'true' }}
runs-on: ${{ matrix.target.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
distro: &distros [arch, debian, ubuntu, fedora, centos]
target:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout workflow_run commit
uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
- 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 }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish all images
if: ${{ steps.info.outputs.should_publish == 'true' }}
- name: Build and push ${{ matrix.distro }} for ${{ matrix.target.platform }}
env:
OWNER: ${{ github.repository_owner }}
VERSION: ${{ steps.info.outputs.version }}
IS_STABLE: ${{ steps.info.outputs.is_stable }}
VERSION: ${{ needs.prepare.outputs.version }}
PKGMGR_DISTRO: ${{ matrix.distro }}
PLATFORM: ${{ matrix.target.platform }}
run: bash scripts/github/publish-containers/publish-container-images.sh
merge:
needs: [prepare, build]
if: ${{ !cancelled() && needs.prepare.outputs.should_publish == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
distro: *distros
steps:
- name: Checkout workflow_run commit
uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
with:
use: true
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Join both platforms of ${{ matrix.distro }} under the release tags
env:
OWNER: ${{ github.repository_owner }}
VERSION: ${{ needs.prepare.outputs.version }}
IS_STABLE: ${{ needs.prepare.outputs.is_stable }}
PKGMGR_DISTRO: ${{ matrix.distro }}
run: bash scripts/github/publish-containers/merge-container-images.sh
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Show Docker version
run: docker version
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Show Docker version
run: docker version
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Show commit SHA
run: git rev-parse HEAD
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Show Docker version
run: docker version
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Show Docker version
run: docker version
+4 -6
View File
@@ -19,15 +19,13 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- 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
+4 -6
View File
@@ -19,15 +19,13 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- 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@v7
- 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
+175
View File
@@ -1,5 +1,180 @@
# Changelog
## [2.2.0] - 2026-11-01
Added:
- Push: *--no-sign* pushes unsigned commits as they are; leases still apply
- Push: *--rewrite* replaces commits a remote already has (amend, squash)
Changed:
- Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
- Push: each pushurl gets its own lease; mirrors no longer fail as stale
- Push: a commit that exists only on a mirror stops the push, nothing lost
- Push: without a selection it pushes the current checkout or worktree
- Release: signs before tagging and pushes branch and tag like *pkgmgr push*
Fixed:
- Container: images build on native amd64 and arm64 runners, not QEMU
Upgrade notes:
- Push: needs a working signing key; pass *--no-sign* to push unsigned
- Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
## [2.1.1] - 2026-10-31
Fixed:
- Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
Upgrade notes:
- Container: no 2.1.0 image reached GHCR; the two notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
## [2.1.0] - 2026-10-31
Added:
- Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
- Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
- Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
- Release: Cargo.toml version and its Cargo.lock entry follow the release
Changed:
- Release: starts from the highest version in the tag and all version files
Upgrade notes:
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
## [2.0.5] - 2026-10-29
Fixed:
- Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
- Config: *pkgmgr config edit* exits with the editor's code when it fails
## [2.0.4] - 2026-10-19
Fixed:
- CI: Python lint passes again on ruff 0.17 and still on 0.16
- CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
Upgrade notes:
- 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
- Release: write list items with dash bullets when markdownlint-cli2 is absent
## [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.
* 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.
* The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
## [1.15.2] - 2026-05-28
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
+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
+13 -21
View File
@@ -2,6 +2,7 @@
build build-no-cache build-no-cache-all build-missing \
delete-volumes purge \
test test-unit test-e2e test-integration test-env-virtual test-env-nix \
lint \
setup setup-venv setup-nix
# Distro
@@ -16,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
@@ -62,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:
@@ -82,6 +67,13 @@ build-no-cache-all:
PKGMGR_DISTRO="$$d" $(MAKE) build-no-cache; \
done
# ------------------------------------------------------------
# Lint targets (run on the host, not in a container)
# ------------------------------------------------------------
lint:
@bash scripts/lint/python.sh
# ------------------------------------------------------------
# Test targets (delegated to scripts/test)
# ------------------------------------------------------------
@@ -101,8 +93,8 @@ test-env-virtual: build-missing
test-env-nix: build-missing
@bash scripts/test/test-env-nix.sh
# Combined test target for local + CI (unit + integration + e2e)
test: test-env-virtual test-unit test-integration test-e2e
# Combined test target for local + CI (lint + unit + integration + e2e)
test: lint test-env-virtual test-unit test-integration test-e2e
delete-volumes:
@docker volume rm "pkgmgr_nix_store_${PKGMGR_DISTRO}" "pkgmgr_nix_cache_${PKGMGR_DISTRO}" || echo "No volumes to delete."
+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.15.2";
version = "2.2.0";
# 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.15.2
pkgver=2.2.0
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')
+197
View File
@@ -1,3 +1,200 @@
package-manager (2.2.0-1) unstable; urgency=medium
Added:
- Push: *--no-sign* pushes unsigned commits as they are; leases still apply
- Push: *--rewrite* replaces commits a remote already has (amend, squash)
Changed:
- Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
- Push: each pushurl gets its own lease; mirrors no longer fail as stale
- Push: a commit that exists only on a mirror stops the push, nothing lost
- Push: without a selection it pushes the current checkout or worktree
- Release: signs before tagging and pushes branch and tag like *pkgmgr push*
Fixed:
- Container: images build on native amd64 and arm64 runners, not QEMU
Upgrade notes:
- Push: needs a working signing key; pass *--no-sign* to push unsigned
- Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 01 Nov 2026 01:55:29 +0100
package-manager (2.1.1-1) unstable; urgency=medium
Fixed:
- Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
Upgrade notes:
- Container: no 2.1.0 image reached GHCR; the two notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 31 Oct 2026 18:47:35 +0100
package-manager (2.1.0-1) unstable; urgency=medium
Added:
- Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
- Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
- Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
- Release: Cargo.toml version and its Cargo.lock entry follow the release
Changed:
- Release: starts from the highest version in the tag and all version files
Upgrade notes:
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 31 Oct 2026 14:38:54 +0100
package-manager (2.0.5-1) unstable; urgency=medium
Fixed:
- Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
- Config: *pkgmgr config edit* exits with the editor's code when it fails
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 29 Oct 2026 13:57:31 +0100
package-manager (2.0.4-1) unstable; urgency=medium
Fixed:
- CI: Python lint passes again on ruff 0.17 and still on 0.16
- CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
Upgrade notes:
- 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
- Release: write list items with dash bullets when markdownlint-cli2 is absent
-- Kevin Veen-Birkenbach <kevin@veen.world> Mon, 19 Oct 2026 10:06:02 +0200
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.
* 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.
* The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 28 Jun 2026 16:54:16 +0200
package-manager (1.15.2-1) unstable; urgency=medium
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
+165 -1
View File
@@ -1,5 +1,5 @@
Name: package-manager
Version: 1.15.2
Version: 2.2.0
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -74,6 +74,170 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%changelog
* Sun Nov 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.2.0-1
- Added:
- - Push: *--no-sign* pushes unsigned commits as they are; leases still apply
- - Push: *--rewrite* replaces commits a remote already has (amend, squash)
- Changed:
- - Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
- - Push: each pushurl gets its own lease; mirrors no longer fail as stale
- - Push: a commit that exists only on a mirror stops the push, nothing lost
- - Push: without a selection it pushes the current checkout or worktree
- - Release: signs before tagging and pushes branch and tag like *pkgmgr push*
- Fixed:
- - Container: images build on native amd64 and arm64 runners, not QEMU
- Upgrade notes:
- - Push: needs a working signing key; pass *--no-sign* to push unsigned
- - Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
- - Release: a version file ahead of the newest tag now raises the next version
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.1-1
- Fixed:
- - Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
- Upgrade notes:
- - Container: no 2.1.0 image reached GHCR; the two notes below carry over
- - Release: a version file ahead of the newest tag now raises the next version
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.0-1
- Added:
- - Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
- - Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
- - Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
- - Release: Cargo.toml version and its Cargo.lock entry follow the release
- Changed:
- - Release: starts from the highest version in the tag and all version files
- Upgrade notes:
- - Release: a version file ahead of the newest tag now raises the next version
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
* Thu Oct 29 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.5-1
- Fixed:
- - Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
- - Config: *pkgmgr config edit* exits with the editor's code when it fails
* Mon Oct 19 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.4-1
- Fixed:
- - CI: Python lint passes again on ruff 0.17 and still on 0.16
- - CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
- Upgrade notes:
- - 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
- - Release: write list items with dash bullets when markdownlint-cli2 is absent
* 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.
- * The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.2-1
- Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kpmx"
version = "1.15.2"
version = "2.2.0"
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
}
+46 -29
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)
@@ -19,13 +18,12 @@ OWNER="" # e.g. github org/user
REPO_PREFIX="pkgmgr" # image base name (pkgmgr)
VERSION="" # X.Y.Z (required for --publish)
IS_STABLE="false" # "true" -> publish stable tags
PLATFORM="" # e.g. linux/arm64 -> build and push only this platform
MERGE=0 # if 1 -> join the per-platform tags of --version under the publish tags
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 +31,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:
@@ -44,10 +41,13 @@ Publish options:
--repo-prefix <name> Image base name (default: pkgmgr)
--version <X.Y.Z> Version for --publish
--stable <true|false> Whether to publish :stable tags (default: false)
--platform <os/arch> Build and push only this platform; with --publish as <version>-<arch>
--merge Join the <version>-<arch> tags of all platforms under the publish tags
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".
- --platform requires --push; --merge requires --publish and builds nothing.
- Local build (no --push) uses "docker build" and creates a local image like "pkgmgr-arch".
EOF
}
@@ -55,11 +55,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; }
@@ -92,6 +87,12 @@ while [[ $# -gt 0 ]]; do
[[ -n "${IS_STABLE}" ]] || { echo "ERROR: --stable requires a value (true|false)"; exit 2; }
shift 2
;;
--platform)
PLATFORM="${2:-}"
[[ -n "${PLATFORM}" ]] || { echo "ERROR: --platform requires a value"; exit 2; }
shift 2
;;
--merge) MERGE=1; shift ;;
-h|--help) usage; exit 0 ;;
*)
echo "ERROR: Unknown argument: $1" >&2
@@ -104,9 +105,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")"
@@ -130,6 +128,19 @@ if [[ "${PUBLISH}" == "1" ]]; then
[[ -n "${VERSION}" ]] || { echo "ERROR: --publish requires --version"; exit 2; }
fi
if [[ -n "${PLATFORM}" ]]; then
[[ "${PUSH}" == "1" ]] || { echo "ERROR: --platform requires --push or --publish"; exit 2; }
[[ ",$(resolve_platforms "$PKGMGR_DISTRO")," == *",${PLATFORM},"* ]] \
|| { echo "ERROR: ${PKGMGR_DISTRO} offers no platform ${PLATFORM}"; exit 2; }
fi
if [[ "${MERGE}" == "1" ]]; then
[[ "${PUBLISH}" == "1" ]] || { echo "ERROR: --merge requires --publish"; exit 2; }
[[ -z "${PLATFORM}" ]] || { echo "ERROR: --merge and --platform exclude each other"; exit 2; }
fi
DISTRO_TAG_BASE="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
# Guard: --push without --publish requires fully-qualified --tag
if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then
if [[ "${IMAGE_TAG}" != */* ]]; then
@@ -143,7 +154,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 = ${PLATFORM:-$(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 +173,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 distro_tag_base="${DISTRO_TAG_BASE}"
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=()
@@ -200,10 +200,26 @@ compute_publish_tags() {
printf '%s\n' "${tags[@]}"
}
if [[ "${PUSH}" == "1" ]]; then
if [[ "${MERGE}" == "1" ]]; then
merge_args=(docker buildx imagetools create)
while IFS= read -r t; do
merge_args+=(-t "$t")
done < <(compute_publish_tags)
IFS=',' read -ra platforms <<< "$(resolve_platforms "$PKGMGR_DISTRO")"
for p in "${platforms[@]}"; do
merge_args+=("${DISTRO_TAG_BASE}:${VERSION}-${p#*/}")
done
echo "[build] Running: ${merge_args[*]}"
"${merge_args[@]}"
docker buildx imagetools inspect "${DISTRO_TAG_BASE}:${VERSION}"
elif [[ "${PUSH}" == "1" ]]; then
bx_args=(docker buildx build --push)
if [[ "${PUBLISH}" == "1" ]]; then
if [[ "${PUBLISH}" == "1" && -n "${PLATFORM}" ]]; then
bx_args+=(-t "${DISTRO_TAG_BASE}:${VERSION}-${PLATFORM#*/}")
elif [[ "${PUBLISH}" == "1" ]]; then
while IFS= read -r t; do
bx_args+=(-t "$t")
done < <(compute_publish_tags)
@@ -211,6 +227,7 @@ if [[ "${PUSH}" == "1" ]]; then
bx_args+=(-t "${IMAGE_TAG}")
fi
bx_args+=(--platform "${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}")
bx_args+=("${build_args[@]}")
bx_args+=(.)
+15 -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,12 +11,12 @@ 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)"
# shellcheck source=./scripts/build/base.sh
source "${SCRIPT_DIR}/base.sh"
REGISTRY="${REGISTRY:-ghcr.io}"
IS_STABLE="${IS_STABLE:-false}"
DISTROS="${DISTROS:-arch debian ubuntu fedora centos}"
@@ -36,41 +36,23 @@ for d in ${DISTROS}; do
echo "[publish] PKGMGR_DISTRO=${d}"
echo "============================================================"
# ----------------------------------------------------------
# virgin
# -> ghcr.io/<owner>/pkgmgr-<distro>-virgin:{latest,<version>,stable?}
# ----------------------------------------------------------
IFS=',' read -ra platforms <<< "$(resolve_platforms "${d}")"
for p in "${platforms[@]}"; do
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \
--registry "${REGISTRY}" \
--owner "${OWNER}" \
--version "${VERSION}" \
--platform "${p}"
done
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
--merge
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"
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
: "${OWNER:?OWNER must be set}"
: "${VERSION:?VERSION must be set}"
: "${IS_STABLE:?IS_STABLE must be set}"
: "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
bash scripts/build/image.sh \
--publish \
--registry ghcr.io \
--owner "${OWNER}" \
--version "${VERSION}" \
--stable "${IS_STABLE}" \
--merge
@@ -3,6 +3,12 @@ set -euo pipefail
: "${OWNER:?OWNER must be set}"
: "${VERSION:?VERSION must be set}"
: "${IS_STABLE:?IS_STABLE must be set}"
: "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
: "${PLATFORM:?PLATFORM must be set}"
bash scripts/build/publish.sh
bash scripts/build/image.sh \
--publish \
--registry ghcr.io \
--owner "${OWNER}" \
--version "${VERSION}" \
--platform "${PLATFORM}"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
echo "============================================================"
echo ">>> Running RUFF lint on src and tests (local, no container)"
echo "============================================================"
if ! command -v ruff >/dev/null 2>&1; then
echo "ruff is not installed or not on PATH." >&2
echo "Install it with: pip install ruff" >&2
exit 127
fi
ruff --version
ruff check src tests
-3
View File
@@ -1,6 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Top-level pkgmgr package.
+1 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
# expose subpackages for patch() / resolve_name() friendliness
from . import release as release # noqa: F401
from . import release as release
__all__ = ["release"]
+1 -1
View File
@@ -3,8 +3,8 @@
from __future__ import annotations
import re
from collections.abc import Iterable
from pathlib import Path
from typing import Iterable
DEFAULT_FILENAME_PATTERN = re.compile(r"^\d{3}-[^/]+\.md$")
TEMPLATE_FILENAME = "000-template.md"
+2 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
from dataclasses import dataclass
from pathlib import Path
@@ -101,10 +102,8 @@ def run_archive(
if not dry_run:
for path, _title in archived:
try:
with contextlib.suppress(FileNotFoundError):
path.unlink()
except FileNotFoundError:
pass
return ArchivePlan(
archived=archived,
+5 -3
View File
@@ -1,14 +1,16 @@
# -*- coding: utf-8 -*-
"""
Public API for branch actions.
"""
from .open_branch import open_branch
from .close_branch import close_branch
from .drop_branch import drop_branch
from .gc import branch_gc, branch_purge
from .open_branch import open_branch
__all__ = [
"open_branch",
"branch_gc",
"branch_purge",
"close_branch",
"drop_branch",
"open_branch",
]
+3 -7
View File
@@ -1,9 +1,5 @@
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.git.commands import (
GitDeleteRemoteBranchError,
checkout,
@@ -14,12 +10,12 @@ from pkgmgr.core.git.commands import (
pull,
push,
)
from pkgmgr.core.git.queries import resolve_base_branch
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import get_current_branch, resolve_base_branch
def close_branch(
name: Optional[str],
name: str | None,
base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".",
+3 -7
View File
@@ -1,20 +1,16 @@
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.git.commands import (
GitDeleteRemoteBranchError,
delete_local_branch,
delete_remote_branch,
)
from pkgmgr.core.git.queries import resolve_base_branch
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import get_current_branch, resolve_base_branch
def drop_branch(
name: Optional[str],
name: str | None,
base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".",
+707
View File
@@ -0,0 +1,707 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from pkgmgr.core.git.commands import delete_local_branch
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import list_remotes, resolve_base_branch
from pkgmgr.core.git.run import run
from .gc_forge import ForgeFacts, fetch_forge_facts
# PROC_PID_INIT_INO: only the initial PID namespace sees every process of the host.
INIT_PID_NAMESPACE = "pid:[4026531836]"
CLEANUP_IMAGE = "alpine:3.20"
COMPOSE_PROJECT = "com.docker.compose.project"
COMPOSE_WORKING_DIR = "com.docker.compose.project.working_dir"
LOCK_PID = re.compile(r"\bpid (\d+)")
ALWAYS_PROTECTED = ("main", "master")
LABELS = {
"merged-clean": "DELETE",
"unmerged": "KEEP",
"dirty": "KEEP",
"protected": "PROTECT",
"stale": "STALE",
}
REFUSAL = (
"Refusing: not in the host PID namespace (e.g. a sandbox), so running "
"processes and worktree locks cannot be verified. Run it on the host."
)
@dataclass
class Worktree:
path: str
head: str | None
branch: str | None
locked: str | None
prunable: bool
main: bool
@dataclass
class Container:
project: str
working_dir: str
image: str
@dataclass
class Item:
branch: str | None
worktree: str | None = None
local_sha: str | None = None
remotes: dict[str, str] = field(default_factory=dict)
compose_projects: list[str] = field(default_factory=list)
images: list[str] = field(default_factory=list)
state: str = ""
merged_by: str | None = None
skip: str | None = None
actions: list[str] = field(default_factory=list)
reason: str = ""
result: str | None = None
@property
def eligible(self) -> bool:
return self.state == "merged-clean" and self.skip is None and bool(self.actions)
def matches(self, name: str) -> bool:
return name == self.branch or (
self.worktree is not None and os.path.abspath(name) == self.worktree
)
@dataclass
class Repo:
repo: str
default: str
remotes: list[str]
protected: set[str]
first_parent: set[str]
forge: ForgeFacts
containers: list[Container] | None
host_ns: bool
warnings: list[str] = field(default_factory=list)
items: list[Item] = field(default_factory=list)
def host_pid_namespace() -> bool:
try:
return os.readlink("/proc/self/ns/pid") == INIT_PID_NAMESPACE
except OSError:
return False
def main_repo_root(cwd: str) -> str:
return os.path.dirname(_common_dir(cwd))
def _common_dir(cwd: str) -> str:
return run(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=cwd)
def _docker(args: list[str]) -> str:
return subprocess.run(
["docker", *args], check=True, capture_output=True, text=True
).stdout
def _describe(exc: Exception) -> str:
if isinstance(exc, subprocess.CalledProcessError):
return f"{' '.join(exc.cmd)}: {(exc.stderr or '').strip()}"
return str(exc).strip().splitlines()[0]
def list_compose_containers() -> list[Container] | None:
fmt = (
f'{{{{.Label "{COMPOSE_PROJECT}"}}}}\t'
f'{{{{.Label "{COMPOSE_WORKING_DIR}"}}}}\t{{{{.Image}}}}'
)
try:
out = _docker(
["ps", "-a", "--filter", f"label={COMPOSE_WORKING_DIR}", "--format", fmt]
)
except FileNotFoundError:
return []
except subprocess.CalledProcessError:
return None
return [
Container(*line.split("\t"))
for line in out.splitlines()
if line.count("\t") == 2
]
def _inside(path: str, root: str) -> bool:
return path == root or path.startswith(root.rstrip("/") + "/")
def _worktrees(repo_root: str) -> list[Worktree]:
trees: list[Worktree] = []
for block in run(["worktree", "list", "--porcelain"], cwd=repo_root).split("\n\n"):
fields: dict[str, str] = {}
for line in block.splitlines():
key, _, value = line.partition(" ")
fields[key] = value
if "worktree" not in fields:
continue
branch = fields.get("branch")
trees.append(
Worktree(
path=fields["worktree"],
head=fields.get("HEAD"),
branch=branch.removeprefix("refs/heads/") if branch else None,
locked=fields.get("locked"),
prunable="prunable" in fields,
main=not trees,
)
)
return trees
def _refs(
repo_root: str, remotes: list[str]
) -> tuple[dict[str, str], dict[str, dict[str, str]]]:
out = run(
[
"for-each-ref",
"--format=%(refname) %(objectname)",
"refs/heads",
"refs/remotes",
],
cwd=repo_root,
)
by_length = sorted(remotes, key=len, reverse=True)
local: dict[str, str] = {}
remote: dict[str, dict[str, str]] = {}
for line in out.splitlines():
ref, sha = line.split(" ")
if ref.startswith("refs/heads/"):
local[ref.removeprefix("refs/heads/")] = sha
continue
rest = ref.removeprefix("refs/remotes/")
owner = next((r for r in by_length if rest.startswith(r + "/")), None)
if owner and rest[len(owner) + 1 :] != "HEAD":
remote.setdefault(rest[len(owner) + 1 :], {})[owner] = sha
return local, remote
def _process_cwds() -> dict[int, str]:
cwds: dict[int, str] = {}
for entry in os.listdir("/proc"):
if entry.isdigit() and int(entry) != os.getpid():
try:
cwds[int(entry)] = os.readlink(f"/proc/{entry}/cwd")
except OSError:
continue
return cwds
def _comm(pid: int) -> str:
try:
with open(f"/proc/{pid}/comm", encoding="utf-8") as fh:
return fh.read().strip()
except OSError:
return "?"
def _lock_skip(reason: str | None, host_ns: bool) -> str | None:
if reason is None:
return None
match = LOCK_PID.search(reason)
if not match:
return f"lock: {reason or 'without reason'}"
if not host_ns:
return "lock: liveness unknown"
pid = int(match.group(1))
return f"lock: pid {pid} alive" if os.path.exists(f"/proc/{pid}") else None
def _is_ancestor(sha: str, ref: str, repo_root: str) -> bool:
try:
run(["merge-base", "--is-ancestor", sha, ref], cwd=repo_root)
except GitRunError as exc:
if exc.returncode == 1:
return False
raise
return True
def _merged_by(repo: Repo, sha: str) -> str | None:
if sha not in repo.first_parent and _is_ancestor(sha, repo.default, repo.repo):
return "ancestor"
number = repo.forge.merged_heads.get(sha)
return f"pr#{number}" if number else None
def _skip(
repo: Repo,
item: Item,
tree: Worktree | None,
checkout: bool,
cwds: dict[int, str],
) -> str | None:
number = repo.forge.open_heads.get(item.branch) if item.branch else None
if number:
return f"open-pr #{number}"
if repo.forge.unavailable:
return "forge unavailable"
if tree is None:
return None
if tree.main:
return "checked out in main worktree"
if not checkout:
return "worktree checkout missing"
if repo.containers is None:
return "docker unavailable"
lock = _lock_skip(tree.locked, repo.host_ns)
if lock:
return lock
busy = sorted(pid for pid, cwd in cwds.items() if _inside(cwd, tree.path))
if busy:
return f"process: pid {busy[0]} ({_comm(busy[0])})"
return None
def _build(
repo: Repo,
branch: str | None,
tree: Worktree | None,
snapshot: tuple[dict[str, str], dict[str, dict[str, str]], dict[int, str]],
) -> Item:
local, remote, cwds = snapshot
item = Item(
branch=branch,
worktree=tree.path if tree else None,
local_sha=local.get(branch) if branch else None,
remotes=dict(remote.get(branch, {})) if branch else {},
)
if branch in repo.protected:
item.state, item.reason = "protected", "default or protected branch"
return item
if tree and repo.containers:
mine = [c for c in repo.containers if _inside(c.working_dir, tree.path)]
item.compose_projects = sorted({c.project for c in mine})
item.images = sorted({c.image for c in mine})
shas = [item.local_sha, *item.remotes.values(), tree.head if tree else None]
verdicts = {sha: _merged_by(repo, sha) for sha in dict.fromkeys(shas) if sha}
unmerged = [sha for sha, verdict in verdicts.items() if verdict is None]
checkout = tree is not None and not tree.prunable and os.path.isdir(tree.path)
try:
dirty = (
len(
run(
["status", "--porcelain", "--untracked-files=all"], cwd=tree.path
).splitlines()
)
if checkout and not tree.main
else 0
)
except GitRunError as exc:
item.state, item.reason = "dirty", f"status failed: {_describe(exc)}"
dirty = -1
if dirty > 0:
item.state, item.reason = "dirty", f"dirty: {dirty} paths"
elif dirty == 0 and unmerged:
ahead = int(
run(
["rev-list", "--count", f"{repo.default}..{unmerged[0]}"], cwd=repo.repo
)
)
item.state = "unmerged"
item.reason = (
f"unmerged: {ahead} commits not in {repo.default}"
if ahead
else "no own commits"
)
elif dirty == 0:
item.state = "merged-clean"
item.merged_by = next(
(verdict for verdict in verdicts.values() if verdict != "ancestor"),
"ancestor",
)
item.reason = f"merged by {item.merged_by}"
item.skip = _skip(repo, item, tree, checkout, cwds)
for project in item.compose_projects:
item.actions += [f"compose-down:{project}", f"images:{project}"]
if tree:
item.actions.append("worktree")
if item.local_sha:
item.actions.append("local-branch")
item.actions += [f"remote-branch:{name}" for name in item.remotes]
return item
def _snapshot(
repo: Repo,
) -> tuple[
list[Worktree], tuple[dict[str, str], dict[str, dict[str, str]], dict[int, str]]
]:
local, remote = _refs(repo.repo, repo.remotes)
cwds = _process_cwds() if repo.host_ns else {}
return _worktrees(repo.repo), (local, remote, cwds)
def _items(repo: Repo) -> list[Item]:
trees, snapshot = _snapshot(repo)
local, remote, _ = snapshot
by_branch = {t.branch: t for t in trees if t.branch}
names = sorted(set(local) | set(remote) | set(by_branch))
items = [_build(repo, name, by_branch.get(name), snapshot) for name in names]
items += [
_build(repo, None, tree, snapshot)
for tree in trees
if tree.branch is None and not tree.main
]
return items + _stale(repo.repo)
def _stale(repo_root: str) -> list[Item]:
base = os.path.join(_common_dir(repo_root), "worktrees")
items: list[Item] = []
for entry in sorted(os.listdir(base)) if os.path.isdir(base) else []:
meta = os.path.join(base, entry)
gitdir = os.path.join(meta, "gitdir")
if not os.path.isfile(gitdir) or os.path.getsize(gitdir) == 0:
reason = "metadata without gitdir, not pruned"
else:
with open(gitdir, encoding="utf-8") as fh:
target = fh.read().strip()
if os.path.exists(target):
continue
reason = f"checkout {os.path.dirname(target)} missing, not pruned"
items.append(Item(branch=None, worktree=meta, state="stale", reason=reason))
return items
def plan_repo(
repo_root: str, containers: list[Container] | None, host_ns: bool
) -> Repo:
warnings: list[str] = []
try:
# Auto-maintenance runs worktree-prune, which deletes the metadata of
# sandboxed worktrees whose gitdir stub is missing.
run(
["fetch", "--all", "--prune", "--quiet", "--no-auto-maintenance"],
cwd=repo_root,
)
except GitRunError as exc:
warnings.append(f"fetch failed: {_describe(exc)}")
if containers is None:
warnings.append("docker unavailable: worktree items are skipped")
remotes = sorted(list_remotes(cwd=repo_root), key=lambda r: (r != "origin", r))
heads: dict[str, str] = {}
for remote in remotes:
try:
heads[remote] = run(
["symbolic-ref", "--short", f"refs/remotes/{remote}/HEAD"],
cwd=repo_root,
)
except GitRunError:
continue
default = next(iter(heads.values()), None) or resolve_base_branch(
"main", "master", cwd=repo_root
)
head_names = {ref[len(remote) + 1 :] for remote, ref in heads.items()}
forge = fetch_forge_facts(
{r: run(["remote", "get-url", r], cwd=repo_root) for r in remotes}
)
if forge.unavailable:
warnings.append(f"forge unavailable: {forge.unavailable}")
repo = Repo(
repo=repo_root,
default=default,
remotes=remotes,
protected={default, *ALWAYS_PROTECTED, *head_names, *forge.protected},
first_parent=set(
run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines()
),
forge=forge,
containers=containers,
host_ns=host_ns,
warnings=warnings,
)
repo.items = _items(repo)
return repo
def _foreign_owned(path: str) -> bool:
uid = os.getuid()
if os.lstat(path).st_uid != uid:
return True
for root, dirs, files in os.walk(path):
for name in (*dirs, *files):
if os.lstat(os.path.join(root, name)).st_uid != uid:
return True
return False
def _remove_worktree(repo_root: str, path: str) -> None:
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
meta = os.path.normpath(
os.path.join(path, fh.read().strip().removeprefix("gitdir: "))
)
if os.path.dirname(meta) != os.path.join(_common_dir(repo_root), "worktrees"):
raise RuntimeError(f"unexpected worktree metadata path {meta}")
if _foreign_owned(path):
parent, name = os.path.split(path.rstrip("/"))
_docker(
[
"run",
"--rm",
"-v",
f"{parent}:/w",
CLEANUP_IMAGE,
"rm",
"-rf",
f"/w/{name}",
]
)
else:
shutil.rmtree(path)
if os.path.lexists(path):
raise RuntimeError(f"worktree {path} still exists; metadata {meta} kept")
shutil.rmtree(meta)
def _rebuild(repo: Repo, item: Item) -> Item:
trees, snapshot = _snapshot(repo)
tree = next(
(
t
for t in trees
if (t.branch == item.branch if item.branch else t.path == item.worktree)
),
None,
)
return _build(repo, item.branch, tree, snapshot)
def _fingerprint(item: Item) -> tuple:
return (
item.state,
item.skip,
item.local_sha,
item.remotes,
item.worktree,
item.compose_projects,
)
def execute(repo: Repo, item: Item) -> None:
fresh = _rebuild(repo, item)
if _fingerprint(fresh) != _fingerprint(item):
item.result = f"skipped: changed since plan ({fresh.skip or fresh.reason})"
return
notes: list[str] = []
try:
for project in item.compose_projects:
_docker(["compose", "-p", project, "down", "-v", "--remove-orphans"])
for image in item.images:
try:
_docker(["image", "rm", image])
except subprocess.CalledProcessError:
notes.append(f"image kept: {image}")
if item.worktree:
_remove_worktree(repo.repo, item.worktree)
if item.local_sha:
delete_local_branch(item.branch, cwd=repo.repo, force=True)
for remote, sha in item.remotes.items():
run(
[
"push",
f"--force-with-lease=refs/heads/{item.branch}:{sha}",
remote,
f":refs/heads/{item.branch}",
],
cwd=repo.repo,
)
except (subprocess.CalledProcessError, RuntimeError, OSError) as exc:
item.result = f"failed: {_describe(exc)}"
return
item.result = "; ".join(["done", *notes])
def _line(item: Item) -> str:
label = "SKIP" if item.state == "merged-clean" and item.skip else LABELS[item.state]
parts = [item.reason]
if item.skip and item.state != "protected":
parts.append(item.skip)
if item.worktree and item.state != "stale":
parts.append(f"worktree {item.worktree}")
if item.compose_projects:
parts.append("stack " + ", ".join(item.compose_projects))
if item.remotes:
parts.append("remotes " + ", ".join(item.remotes))
line = f"{label:<8} {item.branch or item.worktree} {'; '.join(parts)}"
return f"{line} -> {item.result}" if item.result else line
def _render(
repos: list[Repo], host_ns: bool, chosen: Callable[[Item], bool], as_json: bool
) -> None:
if as_json:
print(
json.dumps(
{
"host_pid_namespace": host_ns,
"repos": [
{
"repo": repo.repo,
"default": repo.default,
"warnings": repo.warnings,
"items": [asdict(i) for i in repo.items if chosen(i)],
}
for repo in repos
],
},
indent=2,
)
)
return
for repo in repos:
print(f"== {repo.repo} (default {repo.default})")
for warning in repo.warnings:
print(f"WARN {warning}")
for item in repo.items:
if chosen(item):
print(_line(item))
if not host_ns:
print(
"WARN process and lock checks unavailable: "
"not in the host PID namespace"
)
def _prepare(
repo_roots: list[str], include: list[str], exclude: list[str]
) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
host_ns = host_pid_namespace()
containers = list_compose_containers()
repos = [plan_repo(root, containers, host_ns) for root in repo_roots]
unknown = [
name
for name in include
if not any(item.matches(name) for repo in repos for item in repo.items)
]
if unknown:
print(f"Unknown --branch: {', '.join(unknown)}", file=sys.stderr)
return None
def chosen(item: Item) -> bool:
return (not include or any(item.matches(n) for n in include)) and not any(
item.matches(n) for n in exclude
)
return repos, host_ns, chosen
def _finish(repos: list[Repo]) -> int:
results = [item.result for repo in repos for item in repo.items if item.result]
if any(result.startswith("done") for result in results) and any(
repo.containers is not None for repo in repos
):
for args in (["image", "prune", "-f"], ["builder", "prune", "-f"]):
try:
_docker(args)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
print(f"WARN {_describe(exc)}", file=sys.stderr)
return 1 if any(result.startswith("failed") for result in results) else 0
def branch_gc(
repo_roots: list[str],
*,
apply: bool,
as_json: bool,
include: list[str],
exclude: list[str],
) -> int:
"""
Plan, and with apply=True execute, the cleanup of merged and clean branches.
Args:
repo_roots: main worktree roots of the repositories to scan.
apply: execute the plan instead of only printing it.
as_json: print the plan as JSON instead of text lines.
include: only items whose branch or worktree path matches one of these.
exclude: drop items whose branch or worktree path matches one of these.
Returns:
0 on success, 1 when an applied item failed, 2 when refused.
"""
if apply and not host_pid_namespace():
print(REFUSAL, file=sys.stderr)
return 2
prepared = _prepare(repo_roots, include, exclude)
if prepared is None:
return 2
repos, host_ns, chosen = prepared
if apply:
for repo in repos:
for item in repo.items:
if item.eligible and chosen(item):
execute(repo, item)
print(
f"[gc] {item.branch or item.worktree}: {item.result}",
file=sys.stderr,
)
_render(repos, host_ns, chosen, as_json)
return _finish(repos) if apply else 0
def branch_purge(
repo_roots: list[str], *, include: list[str], exclude: list[str]
) -> int:
"""
Delete unmerged or dirty items after a y/N confirmation per item.
Args:
repo_roots: main worktree roots of the repositories to scan.
include: only items whose branch or worktree path matches one of these.
exclude: drop items whose branch or worktree path matches one of these.
Returns:
0 on success, 1 when a confirmed item failed, 2 when refused.
"""
if not host_pid_namespace():
print(REFUSAL, file=sys.stderr)
return 2
prepared = _prepare(repo_roots, include, exclude)
if prepared is None:
return 2
repos, _, chosen = prepared
for repo in repos:
for item in repo.items:
if item.state not in ("unmerged", "dirty") or item.skip or not chosen(item):
continue
print(_line(item))
print(f" removes: {', '.join(item.actions)}")
try:
answer = input("Delete this item? This is destructive! (y/N): ")
except EOFError:
answer = ""
if answer.strip().lower() != "y":
continue
execute(repo, item)
print(f" -> {item.result}")
return _finish(repos)
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pkgmgr.actions.mirror.url_utils import parse_repo_from_git_url
from pkgmgr.core.credentials.resolver import ResolutionOptions, TokenResolver
from pkgmgr.core.credentials.types import CredentialError
from pkgmgr.core.remote_provisioning.http.client import HttpClient
from pkgmgr.core.remote_provisioning.http.errors import HttpError
GITHUB_HOST = "github.com"
GITHUB_API = "https://api.github.com"
PAGE_SIZE = 100
@dataclass
class ForgeFacts:
"""
Pull-request and protection facts of every GitHub remote of one repository.
Attributes:
merged_heads: head SHA of a merged PR -> PR number.
open_heads: head branch name of an open PR -> PR number.
protected: branch names protected on any of the remotes.
unavailable: why the facts could not be fetched, or None.
"""
merged_heads: dict[str, int] = field(default_factory=dict)
open_heads: dict[str, int] = field(default_factory=dict)
protected: set[str] = field(default_factory=set)
unavailable: str | None = None
def github_slugs(remote_urls: dict[str, str]) -> list[tuple[str, str]]:
slugs: list[tuple[str, str]] = []
for url in remote_urls.values():
host, owner, name = parse_repo_from_git_url(url)
if (
host.lower() == GITHUB_HOST
and owner
and name
and (owner, name) not in slugs
):
slugs.append((owner, name))
return slugs
def _pages(http: HttpClient, headers: dict[str, str], url: str) -> list[dict]:
items: list[dict] = []
page = 1
while True:
resp = http.request_json(
"GET", f"{url}&per_page={PAGE_SIZE}&page={page}", headers=headers
)
batch = json.loads(resp.text or "[]")
items.extend(batch)
if len(batch) < PAGE_SIZE:
return items
page += 1
def fetch_forge_facts(remote_urls: dict[str, str]) -> ForgeFacts:
facts = ForgeFacts()
slugs = github_slugs(remote_urls)
if not slugs:
return facts
try:
token = (
TokenResolver()
.get_token(
"github",
GITHUB_HOST,
options=ResolutionOptions(interactive=False, allow_prompt=False),
)
.token
)
except CredentialError as exc:
facts.unavailable = f"no GitHub token ({exc})"
return facts
http = HttpClient(timeout_s=30)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"User-Agent": "pkgmgr",
}
for owner, name in slugs:
base = f"{GITHUB_API}/repos/{owner}/{name}"
try:
for pr in _pages(http, headers, f"{base}/pulls?state=all"):
if pr["merged_at"]:
facts.merged_heads[pr["head"]["sha"]] = pr["number"]
elif pr["state"] == "open":
facts.open_heads[pr["head"]["ref"]] = pr["number"]
for branch in _pages(http, headers, f"{base}/branches?protected=true"):
facts.protected.add(branch["name"])
except (HttpError, ValueError, KeyError, TypeError) as exc:
facts.unavailable = f"GitHub API failed for {owner}/{name}: {exc}"
return facts
return facts
+1 -3
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git.commands import (
checkout,
create_branch,
@@ -13,7 +11,7 @@ from pkgmgr.core.git.queries import resolve_base_branch
def open_branch(
name: Optional[str],
name: str | None,
base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".",
+3 -8
View File
@@ -1,24 +1,19 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Helpers to generate changelog information from Git history.
"""
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git.queries import (
get_changelog,
GitChangelogQueryError,
get_changelog,
)
def generate_changelog(
cwd: str,
from_ref: Optional[str] = None,
to_ref: Optional[str] = None,
from_ref: str | None = None,
to_ref: str | None = None,
include_merges: bool = False,
) -> str:
"""
@@ -0,0 +1,161 @@
"""Download GitHub code scanning results via the ``gh`` CLI.
Fetches all code scanning alerts (and the analysis metadata) for a
repository and writes them, plus a readable digest, into a timestamped
directory so they can be read and analysed offline. Authentication is
delegated to ``gh`` (credentials from its keyring/login).
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
class CodeScanningError(RuntimeError):
"""Raised when code scanning results cannot be downloaded."""
@dataclass
class CodeScanningResult:
repo: str
output_dir: str
alert_count: int
files: list[str] = field(default_factory=list)
def _gh(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(["gh", *args], capture_output=True, text=True, check=False)
def _resolve_repo(repo: str | None) -> str:
if repo:
return repo
proc = _gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
name = proc.stdout.strip()
if proc.returncode != 0 or not name:
raise CodeScanningError(
"could not resolve the current repository; run inside a GitHub "
"repo or pass --repo OWNER/REPO "
f"({proc.stderr.strip()})"
)
return name
def _fetch_json(endpoint: str, params: list[str] | None = None) -> Any:
args = ["api", endpoint, "--paginate"]
for param in params or []:
args += ["-f", param]
proc = _gh(args)
if proc.returncode != 0:
raise CodeScanningError(
f"gh api {endpoint} failed: {proc.stderr.strip() or 'unknown error'}"
)
body = proc.stdout.strip()
return json.loads(body) if body else []
def _alert_row(alert: dict) -> str:
rule = alert.get("rule") or {}
instance = alert.get("most_recent_instance") or {}
location = instance.get("location") or {}
message = (instance.get("message") or {}).get("text", "").strip().replace("\n", " ")
severity = rule.get("security_severity_level") or rule.get("severity") or "unknown"
path = location.get("path", "?")
line = location.get("start_line", "?")
state = alert.get("state", "?")
rule_id = rule.get("id", "?")
return f"- [{severity}] {rule_id} — {path}:{line} ({state})\n {message}"
def _build_summary(repo: str, generated_at: str, alerts: list[dict]) -> str:
by_severity: Counter = Counter()
by_state: Counter = Counter()
by_rule: Counter = Counter()
for alert in alerts:
rule = alert.get("rule") or {}
by_severity[
rule.get("security_severity_level") or rule.get("severity") or "unknown"
] += 1
by_state[alert.get("state", "unknown")] += 1
by_rule[rule.get("id", "unknown")] += 1
lines = [
f"# Code scanning summary — {repo}",
"",
f"- Generated: {generated_at}",
f"- Total alerts: {len(alerts)}",
"",
"## By severity",
"",
]
lines += [f"- {sev}: {count}" for sev, count in by_severity.most_common()] or [
"- none"
]
lines += ["", "## By state", ""]
lines += [f"- {state}: {count}" for state, count in by_state.most_common()] or [
"- none"
]
lines += ["", "## By rule", ""]
lines += [f"- {rule}: {count}" for rule, count in by_rule.most_common()] or [
"- none"
]
lines += ["", "## Alerts", ""]
lines += [_alert_row(alert) for alert in alerts] or ["- none"]
return "\n".join(lines) + "\n"
def download_code_scanning(
repo: str | None = None,
output_dir: str | None = None,
state: str | None = None,
) -> CodeScanningResult:
if not shutil.which("gh"):
raise CodeScanningError("the GitHub CLI 'gh' is not installed or not on PATH")
repo = _resolve_repo(repo)
repo_name = repo.rstrip("/").split("/")[-1]
if output_dir is None:
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
output_dir = os.path.join("/tmp", repo_name, "code-scanner", timestamp)
output_dir = os.path.abspath(os.path.expanduser(output_dir))
os.makedirs(output_dir, exist_ok=True)
generated_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
alerts = _fetch_json(
f"repos/{repo}/code-scanning/alerts",
params=[f"state={state}"] if state else None,
)
files: list[str] = []
def _write(name: str, content: str) -> None:
path = os.path.join(output_dir, name)
with open(path, "w", encoding="utf-8") as handle:
handle.write(content)
files.append(path)
_write("alerts.json", json.dumps(alerts, indent=2, ensure_ascii=False) + "\n")
_write("summary.md", _build_summary(repo, generated_at, alerts))
try:
analyses = _fetch_json(f"repos/{repo}/code-scanning/analyses")
_write(
"analyses.json", json.dumps(analyses, indent=2, ensure_ascii=False) + "\n"
)
except CodeScanningError as exc:
_write("analyses.json", json.dumps({"error": str(exc)}, indent=2) + "\n")
return CodeScanningResult(
repo=repo,
output_dir=output_dir,
alert_count=len(alerts),
files=files,
)
+4 -2
View File
@@ -1,5 +1,7 @@
import yaml
import os
import yaml
from pkgmgr.core.config.save import save_user_config
@@ -28,7 +30,7 @@ def interactive_add(config, USER_CONFIG_PATH: str):
confirm = input("Add this entry to user config? (y/N): ").strip().lower()
if confirm == "y":
if os.path.exists(USER_CONFIG_PATH):
with open(USER_CONFIG_PATH, "r") as f:
with open(USER_CONFIG_PATH) as f:
user_config = yaml.safe_load(f) or {}
else:
user_config = {"repositories": []}
+6 -9
View File
@@ -1,6 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Initialize user configuration by scanning the repositories base directory.
@@ -23,7 +20,7 @@ For each discovered repository, the function:
from __future__ import annotations
import os
from typing import Any, Dict
from typing import Any
from pkgmgr.core.command.alias import generate_alias
from pkgmgr.core.config.save import save_user_config
@@ -31,8 +28,8 @@ from pkgmgr.core.git.queries import get_latest_commit
def config_init(
user_config: Dict[str, Any],
defaults_config: Dict[str, Any],
user_config: dict[str, Any],
defaults_config: dict[str, Any],
bin_dir: str,
user_config_path: str,
) -> None:
@@ -55,7 +52,7 @@ def config_init(
print("[INIT] Scanning repository base directory:")
print(f" {repositories_base_dir}")
print("")
print()
if not os.path.isdir(repositories_base_dir):
print(f"[ERROR] Base directory does not exist: {repositories_base_dir}")
@@ -129,7 +126,7 @@ def config_init(
"[WARN] Could not read commit (not a git repo or no commits)."
)
entry: Dict[str, Any] = {
entry: dict[str, Any] = {
"provider": provider,
"account": account,
"repository": repo_name,
@@ -153,7 +150,7 @@ def config_init(
new_entries.append(entry)
print("") # blank line between accounts
print() # blank line between accounts
# ------------------------------------------------------------
# Summary
+1
View File
@@ -1,4 +1,5 @@
import yaml
from pkgmgr.core.config.load import load_config
+21 -22
View File
@@ -1,6 +1,4 @@
# src/pkgmgr/actions/install/__init__.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
High-level entry point for repository installation.
@@ -16,48 +14,49 @@ Responsibilities:
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional, Tuple
from typing import Any
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.verify import verify_repository
from pkgmgr.actions.repository.clone import clone_repos
from pkgmgr.actions.install.context import RepoContext
from pkgmgr.actions.install.installers.makefile import (
MakefileInstaller,
)
from pkgmgr.actions.install.installers.nix import (
NixFlakeInstaller,
)
from pkgmgr.actions.install.installers.os_packages import (
ArchPkgbuildInstaller,
DebianControlInstaller,
RpmSpecInstaller,
)
from pkgmgr.actions.install.installers.nix import (
NixFlakeInstaller,
)
from pkgmgr.actions.install.installers.python import PythonInstaller
from pkgmgr.actions.install.installers.makefile import (
MakefileInstaller,
)
from pkgmgr.actions.install.pipeline import InstallationPipeline
from pkgmgr.actions.repository.clone import clone_repos
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.verify import verify_repository
Repository = Dict[str, Any]
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(
repo: Repository,
repositories_base_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
preview: bool,
no_verification: bool,
clone_mode: str,
identifier: str,
) -> Optional[str]:
) -> str | None:
"""
Compute and, if necessary, clone the repository directory.
@@ -127,7 +126,7 @@ def _create_context(
repo_dir: str,
repositories_base_dir: str,
bin_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
no_verification: bool,
preview: bool,
quiet: bool,
@@ -155,10 +154,10 @@ def _create_context(
def install_repos(
selected_repos: List[Repository],
selected_repos: list[Repository],
repositories_base_dir: str,
bin_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
no_verification: bool,
preview: bool,
quiet: bool,
@@ -179,7 +178,7 @@ def install_repos(
overall command never exits non-zero because of per-repository failures.
"""
pipeline = InstallationPipeline(INSTALLERS)
failures: List[Tuple[str, str]] = []
failures: list[tuple[str, str]] = []
for repo in selected_repos:
identifier = get_repo_identifier(repo, all_repos)
@@ -232,7 +231,7 @@ def install_repos(
f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..."
)
continue
except Exception as exc:
except Exception as exc: # noqa: BLE001 - batch boundary: one repository must never abort the run
failures.append((identifier, f"unexpected error: {exc}"))
if not quiet:
print(
+15 -20
View File
@@ -1,6 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Capability detection for pkgmgr.
@@ -35,7 +32,8 @@ from __future__ import annotations
import glob
import os
from abc import ABC, abstractmethod
from typing import Iterable, TYPE_CHECKING, Optional
from collections.abc import Iterable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pkgmgr.actions.install.context import RepoContext
@@ -46,12 +44,12 @@ if TYPE_CHECKING:
# ---------------------------------------------------------------------------
def _read_text_if_exists(path: str) -> Optional[str]:
def _read_text_if_exists(path: str) -> str | None:
"""Read a file as UTF-8 text, returning None if it does not exist or fails."""
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
with open(path, encoding="utf-8") as f:
return f.read()
except OSError:
return None
@@ -75,12 +73,12 @@ def _scan_files_for_patterns(files: Iterable[str], patterns: Iterable[str]) -> b
return False
def _first_spec_file(repo_dir: str) -> Optional[str]:
def _first_spec_file(repo_dir: str) -> str | None:
"""Return the first *.spec file in repo_dir, if any."""
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
if not matches:
return None
return sorted(matches)[0]
return min(matches)
# ---------------------------------------------------------------------------
@@ -100,7 +98,7 @@ class CapabilityMatcher(ABC):
raise NotImplementedError
@abstractmethod
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
"""
Return True if this capability is actually provided by the given layer
for this repository.
@@ -133,7 +131,7 @@ class PythonRuntimeCapability(CapabilityMatcher):
# OS packages may wrap Python builds, but must explicitly prove it
return layer in {"python", "nix", "os-packages"}
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
repo_dir = ctx.repo_dir
if layer == "python":
@@ -208,7 +206,7 @@ class MakeInstallCapability(CapabilityMatcher):
def applies_to_layer(self, layer: str) -> bool:
return layer in {"makefile", "python", "nix", "os-packages"}
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
repo_dir = ctx.repo_dir
if layer == "makefile":
@@ -216,7 +214,7 @@ class MakeInstallCapability(CapabilityMatcher):
if not os.path.exists(makefile):
return False
try:
with open(makefile, "r", encoding="utf-8") as f:
with open(makefile, encoding="utf-8") as f:
for line in f:
if line.strip().startswith("install:"):
return True
@@ -274,7 +272,7 @@ class NixFlakeCapability(CapabilityMatcher):
# Only Nix itself and OS packages that explicitly wrap Nix
return layer in {"nix", "os-packages"}
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
repo_dir = ctx.repo_dir
if layer == "nix":
@@ -328,7 +326,7 @@ LAYER_ORDER: list[str] = [
def detect_capabilities(
ctx: "RepoContext",
ctx: RepoContext,
layers: Iterable[str],
) -> dict[str, set[str]]:
"""
@@ -359,8 +357,8 @@ def detect_capabilities(
def resolve_effective_capabilities(
ctx: "RepoContext",
layers: Optional[Iterable[str]] = None,
ctx: RepoContext,
layers: Iterable[str] | None = None,
) -> dict[str, set[str]]:
"""
Resolve *effective* capabilities for each layer using a bottom-up strategy.
@@ -381,10 +379,7 @@ def resolve_effective_capabilities(
This means *any* higher layer can overshadow a lower layer, not just
a specific one like Nix. The resolver is completely generic.
"""
if layers is None:
layers_list = list(LAYER_ORDER)
else:
layers_list = list(layers)
layers_list = list(LAYER_ORDER) if layers is None else list(layers)
raw_caps = detect_capabilities(ctx, layers_list)
effective: dict[str, set[str]] = {layer: set() for layer in layers_list}
+3 -5
View File
@@ -1,6 +1,4 @@
# src/pkgmgr/actions/install/context.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Shared context object for repository installation steps.
@@ -10,19 +8,19 @@ they do not depend on global state or long parameter lists.
"""
from dataclasses import dataclass
from typing import Any, Dict, List
from typing import Any
@dataclass
class RepoContext:
"""Container for all repository-related data used during installation."""
repo: Dict[str, Any]
repo: dict[str, Any]
identifier: str
repo_dir: str
repositories_base_dir: str
bin_dir: str
all_repos: List[Dict[str, Any]]
all_repos: list[dict[str, Any]]
no_verification: bool
preview: bool
@@ -1,6 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Installer package for pkgmgr.
@@ -9,15 +6,17 @@ pkgmgr.actions.install.installers.
"""
from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401
# OS-specific installers
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
ArchPkgbuildInstaller as ArchPkgbuildInstaller,
) # noqa: F401
)
from pkgmgr.actions.install.installers.os_packages.debian_control import (
DebianControlInstaller as DebianControlInstaller,
) # noqa: F401
from pkgmgr.actions.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
)
from pkgmgr.actions.install.installers.os_packages.rpm_spec import (
RpmSpecInstaller, # noqa: F401
)
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
@@ -1,15 +1,12 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Base interface for all installer components in the pkgmgr installation pipeline.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Set, Optional
from pkgmgr.actions.install.context import RepoContext
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
from pkgmgr.actions.install.context import RepoContext
class BaseInstaller(ABC):
@@ -24,9 +21,9 @@ class BaseInstaller(ABC):
# Examples: "nix", "python", "makefile".
# This is used by capability matchers to decide which patterns to
# search for in the repository.
layer: Optional[str] = None
layer: str | None = None
def discover_capabilities(self, ctx: RepoContext) -> Set[str]:
def discover_capabilities(self, ctx: RepoContext) -> set[str]:
"""
Determine which logical capabilities this installer will provide
for this specific repository instance.
@@ -36,7 +33,7 @@ class BaseInstaller(ABC):
Makefile, etc.) and decide, via string matching, whether a given
capability is actually provided by this layer.
"""
caps: Set[str] = set()
caps: set[str] = set()
if not self.layer:
return caps
@@ -26,16 +26,12 @@ class MakefileInstaller(BaseInstaller):
def _has_install_target(self, makefile_path: str) -> bool:
try:
with open(makefile_path, "r", encoding="utf-8", errors="ignore") as f:
with open(makefile_path, encoding="utf-8", errors="ignore") as f:
content = f.read()
except OSError:
return False
if re.search(r"^install\s*:", content, flags=re.MULTILINE):
return True
if re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE):
return True
return False
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)
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING, List
from typing import TYPE_CHECKING
from .profile import NixProfileInspector
from .retry import GitHubRateLimitRetry
@@ -33,7 +33,7 @@ class NixConflictResolver:
def resolve(
self,
ctx: "RepoContext",
ctx: RepoContext,
install_cmd: str,
stdout: str,
stderr: str,
@@ -49,7 +49,7 @@ class NixConflictResolver:
store_prefixes = self._parser.existing_store_prefixes(combined)
# 2) Resolve them to concrete remove tokens
tokens: List[str] = self._profile.find_remove_tokens_for_store_prefixes(
tokens: list[str] = self._profile.find_remove_tokens_for_store_prefixes(
ctx,
self._runner,
store_prefixes,
@@ -2,7 +2,7 @@ from __future__ import annotations
import os
import shutil
from typing import TYPE_CHECKING, List, Tuple
from typing import TYPE_CHECKING
from pkgmgr.actions.install.installers.base import BaseInstaller
@@ -28,7 +28,7 @@ class NixFlakeInstaller(BaseInstaller):
# Newer nix rejects numeric indices; we learn this at runtime and cache the decision.
self._indices_supported: bool | None = None
def supports(self, ctx: "RepoContext") -> bool:
def supports(self, ctx: RepoContext) -> bool:
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
if not ctx.quiet:
print(
@@ -42,13 +42,13 @@ class NixFlakeInstaller(BaseInstaller):
return os.path.exists(os.path.join(ctx.repo_dir, self.FLAKE_FILE))
def _profile_outputs(self, ctx: "RepoContext") -> List[Tuple[str, bool]]:
def _profile_outputs(self, ctx: RepoContext) -> list[tuple[str, bool]]:
# (output_name, allow_failure)
if ctx.identifier in {"pkgmgr", "package-manager"}:
return [("pkgmgr", False), ("default", True)]
return [("default", False)]
def run(self, ctx: "RepoContext") -> None:
def run(self, ctx: RepoContext) -> None:
if not self.supports(ctx):
return
@@ -68,7 +68,7 @@ class NixFlakeInstaller(BaseInstaller):
else:
self._install_only(ctx, output, allow_failure)
def _installable(self, ctx: "RepoContext", output: str) -> str:
def _installable(self, ctx: RepoContext, output: str) -> str:
return f"{ctx.repo_dir}#{output}"
# ---------------------------------------------------------------------
@@ -76,7 +76,7 @@ class NixFlakeInstaller(BaseInstaller):
# ---------------------------------------------------------------------
def _install_only(
self, ctx: "RepoContext", output: str, allow_failure: bool
self, ctx: RepoContext, output: str, allow_failure: bool
) -> None:
install_cmd = f"nix profile install {self._installable(ctx, output)}"
@@ -162,7 +162,7 @@ class NixFlakeInstaller(BaseInstaller):
# ---------------------------------------------------------------------
def _force_upgrade_output(
self, ctx: "RepoContext", output: str, allow_failure: bool
self, ctx: RepoContext, output: str, allow_failure: bool
) -> None:
# Prefer token path if indices unsupported (new nix)
if self._indices_supported is False:
@@ -215,7 +215,7 @@ class NixFlakeInstaller(BaseInstaller):
s = (stderr or "").lower()
return "no longer supports indices" in s or "does not support indices" in s
def _upgrade_index(self, ctx: "RepoContext", idx: int) -> bool:
def _upgrade_index(self, ctx: RepoContext, idx: int) -> bool:
cmd = f"nix profile upgrade --refresh {idx}"
res = self._runner.run(ctx, cmd, allow_failure=True)
@@ -228,7 +228,7 @@ class NixFlakeInstaller(BaseInstaller):
return res.returncode == 0
def _remove_index(self, ctx: "RepoContext", idx: int) -> None:
def _remove_index(self, ctx: RepoContext, idx: int) -> None:
res = self._runner.run(ctx, f"nix profile remove {idx}", allow_failure=True)
if self._stderr_says_indices_unsupported(getattr(res, "stderr", "")):
@@ -237,7 +237,7 @@ class NixFlakeInstaller(BaseInstaller):
if self._indices_supported is None:
self._indices_supported = True
def _remove_tokens_for_output(self, ctx: "RepoContext", output: str) -> None:
def _remove_tokens_for_output(self, ctx: RepoContext, output: str) -> None:
tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output)
if not tokens:
return
@@ -1,4 +1,4 @@
from .inspector import NixProfileInspector
from .models import NixProfileEntry
__all__ = ["NixProfileInspector", "NixProfileEntry"]
__all__ = ["NixProfileEntry", "NixProfileInspector"]
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from .matcher import (
entry_matches_output,
@@ -29,7 +29,7 @@ class NixProfileInspector:
- find_remove_tokens_for_store_prefixes()
"""
def list_json(self, ctx: "RepoContext", runner: "CommandRunner") -> dict[str, Any]:
def list_json(self, ctx: RepoContext, runner: CommandRunner) -> dict[str, Any]:
res = runner.run(ctx, "nix profile list --json", allow_failure=False)
raw = extract_stdout_text(res)
return parse_profile_list_json(raw)
@@ -40,14 +40,14 @@ class NixProfileInspector:
def find_installed_indices_for_output(
self,
ctx: "RepoContext",
runner: "CommandRunner",
ctx: RepoContext,
runner: CommandRunner,
output: str,
) -> List[int]:
) -> list[int]:
data = self.list_json(ctx, runner)
entries = normalize_elements(data)
hits: List[int] = []
hits: list[int] = []
for e in entries:
if e.index is None:
continue
@@ -58,10 +58,10 @@ class NixProfileInspector:
def find_indices_by_store_path(
self,
ctx: "RepoContext",
runner: "CommandRunner",
ctx: RepoContext,
runner: CommandRunner,
store_path: str,
) -> List[int]:
) -> list[int]:
needle = (store_path or "").strip()
if not needle:
return []
@@ -69,7 +69,7 @@ class NixProfileInspector:
data = self.list_json(ctx, runner)
entries = normalize_elements(data)
hits: List[int] = []
hits: list[int] = []
for e in entries:
if e.index is None:
continue
@@ -84,10 +84,10 @@ class NixProfileInspector:
def find_remove_tokens_for_output(
self,
ctx: "RepoContext",
runner: "CommandRunner",
ctx: RepoContext,
runner: CommandRunner,
output: str,
) -> List[str]:
) -> list[str]:
"""
Returns profile remove tokens to remove entries matching a given output.
@@ -101,7 +101,7 @@ class NixProfileInspector:
data = self.list_json(ctx, runner)
entries = normalize_elements(data)
tokens: List[str] = [
tokens: list[str] = [
out
] # critical: matches nix's own suggestion for conflicts
@@ -119,7 +119,7 @@ class NixProfileInspector:
# stable unique preserving order
seen: set[str] = set()
uniq: List[str] = []
uniq: list[str] = []
for t in tokens:
if t and t not in seen:
uniq.append(t)
@@ -128,10 +128,10 @@ class NixProfileInspector:
def find_remove_tokens_for_store_prefixes(
self,
ctx: "RepoContext",
runner: "CommandRunner",
prefixes: List[str],
) -> List[str]:
ctx: RepoContext,
runner: CommandRunner,
prefixes: list[str],
) -> list[str]:
"""
Returns remove tokens for entries whose store path matches any prefix.
"""
@@ -143,7 +143,7 @@ class NixProfileInspector:
data = self.list_json(ctx, runner)
entries = normalize_elements(data)
tokens: List[str] = []
tokens: list[str] = []
for e in entries:
if not e.store_paths:
continue
@@ -156,7 +156,7 @@ class NixProfileInspector:
tokens.append(n)
seen: set[str] = set()
uniq: List[str] = []
uniq: list[str] = []
for t in tokens:
if t and t not in seen:
uniq.append(t)
@@ -1,7 +1,5 @@
from __future__ import annotations
from typing import List
from .models import NixProfileEntry
@@ -51,9 +49,9 @@ def entry_matches_store_path(entry: NixProfileEntry, store_path: str) -> bool:
return any((p or "") == needle for p in entry.store_paths)
def stable_unique_ints(values: List[int]) -> List[int]:
def stable_unique_ints(values: list[int]) -> list[int]:
seen: set[int] = set()
uniq: List[int] = []
uniq: list[int] = []
for v in values:
if v in seen:
continue
@@ -1,7 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
@dataclass(frozen=True)
@@ -11,7 +10,7 @@ class NixProfileEntry:
"""
key: str
index: Optional[int]
index: int | None
name: str
attr_path: str
store_paths: List[str]
store_paths: list[str]
@@ -1,12 +1,13 @@
from __future__ import annotations
import re
from typing import Any, Dict, Iterable, List, Optional
from collections.abc import Iterable
from typing import Any
from .models import NixProfileEntry
def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
def coerce_index(key: str, entry: dict[str, Any]) -> int | None:
"""
Nix JSON schema varies:
- elements keys might be "0", "1", ...
@@ -20,7 +21,7 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
if k.isdigit():
try:
return int(k)
except Exception:
except ValueError:
return None
# 2) Explicit index fields (schema-dependent)
@@ -31,7 +32,7 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
if isinstance(v, str) and v.strip().isdigit():
try:
return int(v.strip())
except Exception:
except ValueError:
pass
# 3) Last resort: extract trailing number from key if it looks like "<name>-<n>"
@@ -39,13 +40,13 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
if m:
try:
return int(m.group(1))
except Exception:
except ValueError:
return None
return None
def iter_store_paths(entry: Dict[str, Any]) -> Iterable[str]:
def iter_store_paths(entry: dict[str, Any]) -> Iterable[str]:
"""
Yield all possible store paths from a nix profile JSON entry.
@@ -72,7 +73,7 @@ def iter_store_paths(entry: Dict[str, Any]) -> Iterable[str]:
outs = entry.get("outputs")
if isinstance(outs, dict):
for _, ov in outs.items():
for ov in outs.values():
if isinstance(ov, dict):
p = ov.get("storePath")
if isinstance(p, str):
@@ -87,7 +88,7 @@ def normalize_store_path(store_path: str) -> str:
return (store_path or "").strip()
def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
def normalize_elements(data: dict[str, Any]) -> list[NixProfileEntry]:
"""
Converts nix profile list JSON into a list of normalized entries.
@@ -99,7 +100,7 @@ def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
if not isinstance(elements, dict):
return []
normalized: List[NixProfileEntry] = []
normalized: list[NixProfileEntry] = []
for k, entry in elements.items():
if not isinstance(entry, dict):
@@ -109,7 +110,7 @@ def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
name = str(entry.get("name", "") or "")
attr = str(entry.get("attrPath", "") or "")
store_paths: List[str] = []
store_paths: list[str] = []
for p in iter_store_paths(entry):
sp = normalize_store_path(p)
if sp:
@@ -1,10 +1,10 @@
from __future__ import annotations
import json
from typing import Any, Dict
from typing import Any
def parse_profile_list_json(raw: str) -> Dict[str, Any]:
def parse_profile_list_json(raw: str) -> dict[str, Any]:
"""
Parse JSON output from `nix profile list --json`.
@@ -1,7 +1,7 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, List, Tuple
from typing import TYPE_CHECKING
from .runner import CommandRunner
@@ -19,12 +19,12 @@ class NixProfileListReader:
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
return m.group(1) if m else raw
def entries(self, ctx: "RepoContext") -> List[Tuple[int, str]]:
def entries(self, ctx: RepoContext) -> list[tuple[int, str]]:
res = self._runner.run(ctx, "nix profile list", allow_failure=True)
if res.returncode != 0:
return []
entries: List[Tuple[int, str]] = []
entries: list[tuple[int, str]] = []
pat = re.compile(
r"^\s*(\d+)\s+.*?(/nix/store/[0-9a-z]{32}-[^/ \t]+)",
re.MULTILINE,
@@ -35,12 +35,12 @@ class NixProfileListReader:
sp = m.group(2)
try:
idx = int(idx_s)
except Exception:
except ValueError:
continue
entries.append((idx, self._store_prefix(sp)))
seen: set[int] = set()
uniq: List[Tuple[int, str]] = []
uniq: list[tuple[int, str]] = []
for idx, sp in entries:
if idx not in seen:
seen.add(idx)
@@ -49,20 +49,20 @@ class NixProfileListReader:
return uniq
def indices_matching_store_prefixes(
self, ctx: "RepoContext", prefixes: List[str]
) -> List[int]:
self, ctx: RepoContext, prefixes: list[str]
) -> list[int]:
prefixes = [self._store_prefix(p) for p in prefixes if p]
prefixes = [p for p in prefixes if p]
if not prefixes:
return []
hits: List[int] = []
hits: list[int] = []
for idx, sp in self.entries(ctx):
if any(sp == p for p in prefixes):
hits.append(idx)
seen: set[int] = set()
uniq: List[int] = []
uniq: list[int] = []
for i in hits:
if i not in seen:
seen.add(i)
@@ -2,13 +2,15 @@ from __future__ import annotations
import random
import time
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Iterable, TYPE_CHECKING
from typing import TYPE_CHECKING
from .types import RunResult
if TYPE_CHECKING:
from pkgmgr.actions.install.context import RepoContext
from .runner import CommandRunner
@@ -31,8 +33,8 @@ class GitHubRateLimitRetry:
def run_with_retry(
self,
ctx: "RepoContext",
runner: "CommandRunner",
ctx: RepoContext,
runner: CommandRunner,
install_cmd: str,
) -> RunResult:
quiet = bool(getattr(ctx, "quiet", False))
@@ -1,7 +1,6 @@
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
from .types import RunResult
@@ -16,7 +15,7 @@ class CommandRunner:
Supports preview mode and compact failure output logging.
"""
def run(self, ctx: "RepoContext", cmd: str, allow_failure: bool) -> RunResult:
def run(self, ctx: RepoContext, cmd: str, allow_failure: bool) -> RunResult:
repo_dir = getattr(ctx, "repo_dir", None) or getattr(ctx, "repo_path", None)
preview = bool(getattr(ctx, "preview", False))
quiet = bool(getattr(ctx, "quiet", False))
@@ -32,8 +31,7 @@ class CommandRunner:
shell=True,
cwd=repo_dir,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
capture_output=True,
text=True,
)
except Exception as e:
@@ -1,7 +1,6 @@
from __future__ import annotations
import re
from typing import List
class NixConflictTextParser:
@@ -11,13 +10,13 @@ class NixConflictTextParser:
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
return m.group(1) if m else raw
def remove_tokens(self, text: str) -> List[str]:
def remove_tokens(self, text: str) -> list[str]:
pat = re.compile(
r"^\s*nix profile remove\s+([^\s'\"`]+|'[^']+'|\"[^\"]+\")\s*$",
re.MULTILINE,
)
tokens: List[str] = []
tokens: list[str] = []
for m in pat.finditer(text or ""):
t = (m.group(1) or "").strip()
if (t.startswith("'") and t.endswith("'")) or (
@@ -28,7 +27,7 @@ class NixConflictTextParser:
tokens.append(t)
seen: set[str] = set()
uniq: List[str] = []
uniq: list[str] = []
for t in tokens:
if t not in seen:
seen.add(t)
@@ -36,9 +35,9 @@ class NixConflictTextParser:
return uniq
def existing_store_prefixes(self, text: str) -> List[str]:
def existing_store_prefixes(self, text: str) -> list[str]:
lines = (text or "").splitlines()
prefixes: List[str] = []
prefixes: list[str] = []
in_existing = False
in_new = False
@@ -69,7 +68,7 @@ class NixConflictTextParser:
norm = [self._store_prefix(p) for p in prefixes if p]
seen: set[str] = set()
uniq: List[str] = []
uniq: list[str] = []
for p in norm:
if p and p not in seen:
seen.add(p)
@@ -36,7 +36,7 @@ class ArchPkgbuildInstaller(BaseInstaller):
try:
if hasattr(os, "geteuid") and os.geteuid() == 0:
return False
except Exception:
except (AttributeError, OSError):
# On non-POSIX platforms just ignore this check.
pass
@@ -1,6 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Installer for Debian/Ubuntu packages defined via debian/control.
@@ -13,11 +10,11 @@ This installer:
It is intended for Debian-based systems where dpkg-buildpackage and
apt/dpkg tooling are available.
"""
from __future__ import annotations
import glob
import os
import shutil
from typing import List, Optional
from pkgmgr.actions.install.context import RepoContext
from pkgmgr.actions.install.installers.base import BaseInstaller
@@ -56,7 +53,7 @@ class DebianControlInstaller(BaseInstaller):
return os.path.exists(self._control_path(ctx))
def _find_built_debs(self, repo_dir: str) -> List[str]:
def _find_built_debs(self, repo_dir: str) -> list[str]:
"""
Find .deb files built by dpkg-buildpackage.
@@ -67,7 +64,7 @@ class DebianControlInstaller(BaseInstaller):
pattern = os.path.join(parent, "*.deb")
return sorted(glob.glob(pattern))
def _privileged_prefix(self) -> Optional[str]:
def _privileged_prefix(self) -> str | None:
"""
Determine how to run privileged commands:
@@ -1,6 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Installer for RPM-based packages defined in *.spec files.
@@ -14,12 +11,12 @@ This installer:
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
"""
from __future__ import annotations
import glob
import os
import shutil
import tarfile
from typing import List, Optional, Tuple
from pkgmgr.actions.install.context import RepoContext
from pkgmgr.actions.install.installers.base import BaseInstaller
@@ -53,7 +50,7 @@ class RpmSpecInstaller(BaseInstaller):
return has_dnf or has_yum or has_yum_builddep
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
def _spec_path(self, ctx: RepoContext) -> str | None:
"""Return the first *.spec file in the repository root, if any."""
pattern = os.path.join(ctx.repo_dir, "*.spec")
matches = sorted(glob.glob(pattern))
@@ -92,7 +89,7 @@ class RpmSpecInstaller(BaseInstaller):
for sub in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
os.makedirs(os.path.join(topdir, sub), exist_ok=True)
def _parse_name_version(self, spec_path: str) -> Optional[Tuple[str, str]]:
def _parse_name_version(self, spec_path: str) -> tuple[str, str] | None:
"""
Parse Name and Version from the given .spec file.
@@ -101,7 +98,7 @@ class RpmSpecInstaller(BaseInstaller):
name = None
version = None
with open(spec_path, "r", encoding="utf-8") as f:
with open(spec_path, encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
# Ignore comments
@@ -183,7 +180,7 @@ class RpmSpecInstaller(BaseInstaller):
return self._spec_path(ctx) is not None
def _find_built_rpms(self) -> List[str]:
def _find_built_rpms(self) -> list[str]:
"""
Find RPMs built by rpmbuild.
@@ -202,9 +199,7 @@ class RpmSpecInstaller(BaseInstaller):
if shutil.which("dnf") is not None:
cmd = f"sudo dnf builddep -y {spec_basename}"
elif shutil.which("yum-builddep") is not None:
cmd = f"sudo yum-builddep -y {spec_basename}"
elif shutil.which("yum") is not None:
elif shutil.which("yum-builddep") is not None or shutil.which("yum") is not None:
cmd = f"sudo yum-builddep -y {spec_basename}"
else:
print(
@@ -215,7 +210,7 @@ class RpmSpecInstaller(BaseInstaller):
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def _install_built_rpms(self, ctx: RepoContext, rpms: List[str]) -> None:
def _install_built_rpms(self, ctx: RepoContext, rpms: list[str]) -> None:
"""
Install or upgrade the built RPMs.
@@ -4,8 +4,8 @@ from __future__ import annotations
import os
import sys
from pkgmgr.actions.install.installers.base import BaseInstaller
from pkgmgr.actions.install.context import RepoContext
from pkgmgr.actions.install.installers.base import BaseInstaller
from pkgmgr.core.command.run import run_command
+3 -9
View File
@@ -1,6 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CLI layer model for the pkgmgr installation pipeline.
@@ -19,7 +16,6 @@ from __future__ import annotations
import os
from enum import Enum
from typing import Optional
class CliLayer(str, Enum):
@@ -38,7 +34,7 @@ CLI_LAYERS: list[CliLayer] = [
]
def layer_priority(layer: Optional[CliLayer]) -> int:
def layer_priority(layer: CliLayer | None) -> int:
"""
Return a numeric priority index for a given layer.
@@ -70,13 +66,11 @@ def classify_command_layer(command: str, repo_dir: str) -> CliLayer:
home = os.path.expanduser("~")
# OS package managers
if command_abs.startswith("/usr/") or command_abs.startswith("/bin/"):
if command_abs.startswith(("/usr/", "/bin/")):
return CliLayer.OS_PACKAGES
# Nix store / profile
if command_abs.startswith("/nix/store/") or command_abs.startswith(
os.path.join(home, ".nix-profile")
):
if command_abs.startswith(("/nix/store/", os.path.join(home, ".nix-profile"))):
return CliLayer.NIX
# User-local bin
+27 -23
View File
@@ -1,6 +1,4 @@
# src/pkgmgr/actions/install/pipeline.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Installation pipeline orchestration for repositories.
@@ -8,8 +6,8 @@ Installation pipeline orchestration for repositories.
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Optional, Sequence, Set
from pkgmgr.actions.install.context import RepoContext
from pkgmgr.actions.install.installers.base import BaseInstaller
@@ -24,8 +22,8 @@ from pkgmgr.core.command.resolve import resolve_command_for_repo
@dataclass
class CommandState:
command: Optional[str]
layer: Optional[CliLayer]
command: str | None
layer: CliLayer | None
class CommandResolver:
@@ -83,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:
@@ -122,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
@@ -144,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:
@@ -167,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(
@@ -175,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(
@@ -195,4 +199,4 @@ class InstallationPipeline:
f" pkgmgr install {identifier} "
"--clone-mode shallow --no-verification"
)
raise
return False
+6 -5
View File
@@ -9,19 +9,20 @@ Public API:
"""
from __future__ import annotations
from .types import Repository, MirrorMap
from .list_cmd import list_mirrors
from .diff_cmd import diff_mirrors
from .list_cmd import list_mirrors
from .merge_cmd import merge_mirrors
from .setup_cmd import setup_mirrors
from .types import MirrorMap, Repository
from .visibility_cmd import set_mirror_visibility
__all__ = [
"Repository",
"MirrorMap",
"list_mirrors",
"Repository",
"diff_mirrors",
"list_mirrors",
"merge_mirrors",
"setup_mirrors",
"set_mirror_visibility",
"setup_mirrors",
]
+1 -3
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from typing import List
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
@@ -12,7 +10,7 @@ from .types import MirrorMap, RepoMirrorContext, Repository
def build_context(
repo: Repository,
repositories_base_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
) -> RepoMirrorContext:
"""
Build a RepoMirrorContext for a single repository.
+2 -4
View File
@@ -1,16 +1,14 @@
from __future__ import annotations
from typing import List
from .context import build_context
from .printing import print_header
from .types import Repository
def diff_mirrors(
selected_repos: List[Repository],
selected_repos: list[Repository],
repositories_base_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
) -> None:
"""
Show differences between config mirrors and MIRRORS file.
+6 -10
View File
@@ -1,9 +1,7 @@
from __future__ import annotations
import os
from typing import Optional, Set
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.commands import (
GitAddRemoteError,
GitAddRemotePushUrlError,
@@ -12,6 +10,7 @@ from pkgmgr.core.git.commands import (
add_remote_push_url,
set_remote_url,
)
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import get_remote_push_urls, list_remotes
from .types import MirrorMap, RepoMirrorContext, Repository
@@ -38,13 +37,10 @@ def _is_git_remote_url(url: str) -> bool:
if u.startswith("ssh://"):
return True
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
return True
return False
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
def build_default_ssh_url(repo: Repository) -> Optional[str]:
def build_default_ssh_url(repo: Repository) -> str | None:
provider = repo.get("provider")
account = repo.get("account")
name = repo.get("repository")
@@ -66,7 +62,7 @@ def _git_mirrors_only(m: MirrorMap) -> MirrorMap:
def determine_primary_remote_url(
repo: Repository,
ctx: RepoMirrorContext,
) -> Optional[str]:
) -> str | None:
"""
Priority order (GIT URLS ONLY):
1. origin from resolved mirrors (if it is a git URL)
@@ -80,7 +76,7 @@ def determine_primary_remote_url(
return origin
for mirrors in (ctx.file_mirrors, ctx.config_mirrors):
for _, url in mirrors.items():
for url in mirrors.values():
if url and _is_git_remote_url(url):
return url
@@ -116,7 +112,7 @@ def _ensure_additional_push_urls(
Non-git URLs (like PyPI) are ignored and will never land in git config.
"""
git_only = _git_mirrors_only(mirrors)
desired: Set[str] = {u for u in git_only.values() if u and u != primary}
desired: set[str] = {u for u in git_only.values() if u and u != primary}
if not desired:
return
+1 -1
View File
@@ -42,7 +42,7 @@ def read_mirrors_file(repo_dir: str, filename: str = "MIRRORS") -> MirrorMap:
return mirrors
try:
with open(path, "r", encoding="utf-8") as fh:
with open(path, encoding="utf-8") as fh:
for line in fh:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
+2 -4
View File
@@ -1,16 +1,14 @@
from __future__ import annotations
from typing import List
from .context import build_context
from .printing import print_header, print_named_mirrors
from .types import Repository
def list_mirrors(
selected_repos: List[Repository],
selected_repos: list[Repository],
repositories_base_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
source: str = "all",
) -> None:
"""
+10 -12
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import os
from typing import Dict, List, Tuple, Optional
import yaml
@@ -11,13 +10,12 @@ from .context import build_context
from .io import write_mirrors_file
from .types import MirrorMap, Repository
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
def _repo_key(repo: Repository) -> Tuple[str, str, str]:
def _repo_key(repo: Repository) -> tuple[str, str, str]:
"""
Normalised key for identifying a repository in config files.
"""
@@ -28,7 +26,7 @@ def _repo_key(repo: Repository) -> Tuple[str, str, str]:
)
def _load_user_config(path: str) -> Dict[str, object]:
def _load_user_config(path: str) -> dict[str, object]:
"""
Load a user config YAML file as dict.
Non-dicts yield {}.
@@ -37,10 +35,10 @@ def _load_user_config(path: str) -> Dict[str, object]:
return {}
try:
with open(path, "r", encoding="utf-8") as f:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data if isinstance(data, dict) else {}
except Exception:
except (OSError, UnicodeDecodeError, yaml.YAMLError):
return {}
@@ -50,13 +48,13 @@ def _load_user_config(path: str) -> Dict[str, object]:
def merge_mirrors(
selected_repos: List[Repository],
selected_repos: list[Repository],
repositories_base_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
source: str,
target: str,
preview: bool = False,
user_config_path: Optional[str] = None,
user_config_path: str | None = None,
) -> None:
"""
Merge mirrors between config and MIRRORS file.
@@ -74,8 +72,8 @@ def merge_mirrors(
"""
# Load user config once if we intend to write to it.
user_cfg: Optional[Dict[str, object]] = None
user_cfg_path_expanded: Optional[str] = None
user_cfg: dict[str, object] | None = None
user_cfg_path_expanded: str | None = None
if target == "config" and user_config_path and not preview:
user_cfg_path_expanded = os.path.expanduser(user_config_path)
@@ -130,7 +128,7 @@ def merge_mirrors(
repos = user_cfg.get("repositories")
target_key = _repo_key(repo)
existing_repo: Optional[Repository] = None
existing_repo: Repository | None = None
# Find existing repo entry
for entry in repos:
@@ -1,7 +1,5 @@
from __future__ import annotations
from typing import List
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, ensure_remote_repo
from pkgmgr.core.remote_provisioning.ensure import EnsureOptions
@@ -64,7 +62,7 @@ def ensure_remote_repository_for_url(
def ensure_remote_repository(
repo: Repository,
repositories_base_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
preview: bool,
) -> None:
"""
+5 -9
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from typing import List
from pkgmgr.core.git.queries import probe_remote_reachable_detail
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
@@ -23,9 +21,7 @@ def _is_git_remote_url(url: str) -> bool:
return True
if u.startswith("ssh://"):
return True
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
return True
return False
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
def _provider_hint_from_host(host: str) -> str | None:
@@ -89,7 +85,7 @@ def _print_probe_result(name: str | None, url: str, *, cwd: str) -> None:
def _setup_local_mirrors_for_repo(
repo: Repository,
repositories_base_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
preview: bool,
) -> None:
ctx = build_context(repo, repositories_base_dir, all_repos)
@@ -106,7 +102,7 @@ def _setup_local_mirrors_for_repo(
def _setup_remote_mirrors_for_repo(
repo: Repository,
repositories_base_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
preview: bool,
ensure_remote: bool,
ensure_visibility: str | None,
@@ -195,9 +191,9 @@ def _setup_remote_mirrors_for_repo(
def setup_mirrors(
selected_repos: List[Repository],
selected_repos: list[Repository],
repositories_base_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
preview: bool = False,
local: bool = True,
remote: bool = True,
+3 -3
View File
@@ -1,10 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict
from typing import Any
Repository = Dict[str, Any]
MirrorMap = Dict[str, str]
Repository = dict[str, Any]
MirrorMap = dict[str, str]
@dataclass(frozen=True)
+2 -3
View File
@@ -2,10 +2,9 @@
from __future__ import annotations
from urllib.parse import urlparse
from typing import Optional, Tuple
def hostport_from_git_url(url: str) -> Tuple[str, Optional[str]]:
def hostport_from_git_url(url: str) -> tuple[str, str | None]:
url = (url or "").strip()
if not url:
return "", None
@@ -58,7 +57,7 @@ def _strip_dot_git(name: str) -> str:
return n
def parse_repo_from_git_url(url: str) -> Tuple[str, Optional[str], Optional[str]]:
def parse_repo_from_git_url(url: str) -> tuple[str, str | None, str | None]:
"""
Parse (host, owner, repo_name) from common Git remote URLs.
+3 -7
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from typing import List
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
@@ -20,9 +18,7 @@ def _is_git_remote_url(url: str) -> bool:
return True
if u.startswith("ssh://"):
return True
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
return True
return False
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
def _provider_hint_from_host(host: str) -> str | None:
@@ -66,9 +62,9 @@ def _apply_visibility_for_url(
def set_mirror_visibility(
selected_repos: List[Repository],
selected_repos: list[Repository],
repositories_base_dir: str,
all_repos: List[Repository],
all_repos: list[Repository],
*,
visibility: str,
preview: bool = False,
+4 -3
View File
@@ -1,9 +1,10 @@
import os
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.command.run import run_command
import sys
from pkgmgr.core.command.run import run_command
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
def exec_proxy_command(
proxy_prefix: str,
+12 -13
View File
@@ -1,6 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Backwards-compatible facade for the release file update helpers.
@@ -13,23 +10,25 @@ Keep this package stable so existing imports continue to work, e.g.:
from __future__ import annotations
from .editor import _open_editor_for_changelog
from .pyproject import update_pyproject_version
from .flake import update_flake_version
from .pkgbuild import update_pkgbuild_version
from .rpm_spec import update_spec_version
from .cargo import update_cargo_version
from .changelog_md import update_changelog
from .debian import _get_debian_author, update_debian_changelog
from .editor import _open_editor_for_changelog
from .flake import update_flake_version
from .pkgbuild import update_pkgbuild_version
from .pyproject import update_pyproject_version
from .rpm_changelog import update_spec_changelog
from .rpm_spec import update_spec_version
__all__ = [
"_get_debian_author",
"_open_editor_for_changelog",
"update_pyproject_version",
"update_cargo_version",
"update_changelog",
"update_debian_changelog",
"update_flake_version",
"update_pkgbuild_version",
"update_spec_version",
"update_changelog",
"_get_debian_author",
"update_debian_changelog",
"update_pyproject_version",
"update_spec_changelog",
"update_spec_version",
]
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import os
import re
def _read(path: str) -> str:
with open(path, encoding="utf-8") as f:
return f.read()
def _write(path: str, content: str) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def update_cargo_version(
repo_root: str, new_version: str, preview: bool = False
) -> list[str]:
"""
Set the root crate's version in Cargo.toml and its entry in Cargo.lock.
Args:
repo_root: repository root that may hold Cargo.toml and Cargo.lock.
new_version: release version without the leading "v".
preview: only print what would change.
Returns:
The paths that were (or in preview would be) changed.
"""
toml_path = os.path.join(repo_root, "Cargo.toml")
if not os.path.exists(toml_path):
print("[INFO] No Cargo.toml found. Skipping Cargo version update.")
return []
toml = _read(toml_path)
block = re.search(r"(?ms)^\[package\]\s*$.*?(?=^\s*\[|\Z)", toml)
if not block:
print("[INFO] Cargo.toml has no [package] section. Skipping.")
return []
name = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"', block.group(0))
if not name:
raise RuntimeError("Missing name key in [package] section of Cargo.toml")
new_block, count = re.subn(
r'(?m)^(\s*version\s*=\s*")[^"]+(")',
lambda m: f"{m.group(1)}{new_version}{m.group(2)}",
block.group(0),
count=1,
)
if count == 0:
raise RuntimeError("Missing version key in [package] section of Cargo.toml")
changes = {toml_path: toml[: block.start()] + new_block + toml[block.end() :]}
lock_path = os.path.join(repo_root, "Cargo.lock")
if os.path.exists(lock_path):
lock, count = re.subn(
rf'(?m)^(\[\[package\]\]\nname = "{re.escape(name.group(1))}"\n'
r'version = ")[^"]+("\n)(?!source = )',
lambda m: f"{m.group(1)}{new_version}{m.group(2)}",
_read(lock_path),
)
if count != 1:
raise RuntimeError(
f"Expected one local {name.group(1)!r} package in Cargo.lock, "
f"found {count}"
)
changes[lock_path] = lock
for path, content in changes.items():
if preview:
print(f"[PREVIEW] Would update {path} version to {new_version}")
else:
_write(path, content)
print(f"Updated {path} version to {new_version}")
return list(changes)
@@ -0,0 +1,135 @@
from __future__ import annotations
import contextlib
import os
import re
import shutil
import subprocess
import tempfile
from collections import Counter
_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):
"""Raised when a changelog entry cannot be made markdown-lint clean."""
def transform_changelog_message(text: str) -> str:
"""Normalise a free-form release message into the changelog house style.
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)
out: list[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"{'#' * level} {heading.group(2).strip()}")
out.append("")
else:
out.append(line)
joined = _MULTI_BLANK.sub("\n\n", "\n".join(out))
return joined.strip()
def lint_changelog_entry(reference_path: str, entry: str) -> list[str]:
"""Return markdown-lint findings for *entry* (empty list when clean).
The entry is checked inside a minimal ``# Changelog`` document so the
result reflects only the entry, not pre-existing issues in the target
file. ``markdownlint-cli2`` is used when available (picking up the
target repository's own config, i.e. the same rules the repo enforces);
otherwise a small built-in check runs.
"""
document = f"# Changelog\n\n{entry.strip()}\n"
if shutil.which("markdownlint-cli2"):
return _markdownlint(reference_path, document)
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(
suffix=".md", prefix=".pkgmgr-changelog-", dir=directory
)
name = os.path.basename(tmp_path)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(document)
proc = subprocess.run(
["markdownlint-cli2", "--no-globs", name],
cwd=directory,
capture_output=True,
text=True,
check=False,
)
if proc.returncode == 0:
return []
output = f"{proc.stdout or ''}{proc.stderr or ''}"
findings = [
line.strip().replace(name, "changelog entry")
for line in output.splitlines()
if _FINDING.search(line)
]
return findings or [output.strip() or "markdown-lint reported an error"]
finally:
with contextlib.suppress(OSError):
os.remove(tmp_path)
def _builtin_lint(document: str) -> list[str]:
errors: list[str] = []
for number, line in enumerate(document.split("\n"), start=1):
if line != line.rstrip():
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
@@ -2,15 +2,26 @@ from __future__ import annotations
import os
import re
from datetime import date
from typing import Optional
import sys
from datetime import datetime
from .changelog_lint import (
ChangelogLintError,
lint_changelog_context,
lint_changelog_entry,
transform_changelog_message,
)
from .editor import _open_editor_for_changelog
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.
@@ -21,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.
@@ -38,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"
@@ -49,7 +60,7 @@ def _insert_after_h1(existing: str, entry: str) -> str:
def update_changelog(
changelog_path: str,
new_version: str,
message: Optional[str] = None,
message: str | None = None,
preview: bool = False,
) -> str:
"""Insert a new release entry into CHANGELOG.md.
@@ -58,32 +69,64 @@ def update_changelog(
missing) and above any existing release entries, so the result stays
markdown-lint-clean (MD041 first-line-h1, MD012 no-multiple-blanks).
"""
today = date.today().isoformat()
today = datetime.now().astimezone().date().isoformat()
if message is None:
if preview:
message = "Automated release."
else:
print(
"\n[INFO] No release message provided, opening editor for changelog entry...\n"
)
editor_message = _open_editor_for_changelog()
if not editor_message:
message = "Automated release."
else:
message = editor_message
def _entry_for(raw: str) -> tuple[str, str]:
body = transform_changelog_message(raw).strip() or f"Release {new_version}"
return body, f"## [{new_version}] - {today}\n\n{body}\n\n"
entry = f"## [{new_version}] - {today}\n\n* {message}\n\n"
def _print_findings(findings: list[str]) -> None:
print("\n[ERROR] Changelog entry is not markdown-lint clean:")
for finding in findings:
print(f" - {finding}")
print()
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 = ""
if os.path.exists(changelog_path):
try:
with open(changelog_path, "r", encoding="utf-8") as f:
with open(changelog_path, encoding="utf-8") as f:
changelog = f.read()
except Exception as exc:
except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
changelog = ""
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:
changelog = ""
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)
@@ -93,10 +136,10 @@ def update_changelog(
if preview:
print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md")
return message
return body
with open(changelog_path, "w", encoding="utf-8") as f:
f.write(new_changelog)
print(f"Updated CHANGELOG.md with version {new_version}")
return message
return body
+13 -7
View File
@@ -2,12 +2,11 @@ from __future__ import annotations
import os
from datetime import datetime
from typing import Optional, Tuple
from pkgmgr.core.git.queries import get_config_value
def _get_debian_author() -> Tuple[str, str]:
def _get_debian_author() -> tuple[str, str]:
name = os.environ.get("DEBFULLNAME")
email = os.environ.get("DEBEMAIL")
@@ -33,7 +32,7 @@ def update_debian_changelog(
debian_changelog_path: str,
package_name: str,
new_version: str,
message: Optional[str] = None,
message: str | None = None,
preview: bool = False,
) -> None:
if not os.path.exists(debian_changelog_path):
@@ -47,10 +46,17 @@ def update_debian_changelog(
author_name, author_email = _get_debian_author()
first_line = f"{package_name} ({debian_version}) unstable; urgency=medium"
body_line = message.strip() if message else f"Automated release {new_version}."
body = (
message.strip()
if message and message.strip()
else f"Automated release {new_version}."
)
indented_body = "\n".join(
f" {line}" if line.strip() else line for line in body.split("\n")
)
stanza = (
f"{first_line}\n\n"
f" * {body_line}\n\n"
f"{indented_body}\n\n"
f" -- {author_name} <{author_email}> {date_str}\n\n"
)
@@ -62,9 +68,9 @@ def update_debian_changelog(
return
try:
with open(debian_changelog_path, "r", encoding="utf-8") as f:
with open(debian_changelog_path, encoding="utf-8") as f:
existing = f.read()
except Exception as exc:
except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read debian/changelog: {exc}")
existing = ""
+22 -10
View File
@@ -1,12 +1,19 @@
from __future__ import annotations
import contextlib
import os
import subprocess
import tempfile
from typing import Optional
def _open_editor_for_changelog(initial_message: Optional[str] = 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(
@@ -16,10 +23,17 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
) as tmp:
tmp_path = tmp.name
tmp.write(
"# Write the changelog entry for this release.\n"
"# Lines starting with '#' will be ignored.\n"
"# Empty result will fall back to a generic message.\n\n"
"; Write the changelog entry for this release.\n"
"; Lines starting with ';' are ignored.\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()
@@ -33,13 +47,11 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
)
try:
with open(tmp_path, "r", encoding="utf-8") as f:
with open(tmp_path, encoding="utf-8") as f:
content = f.read()
finally:
try:
with contextlib.suppress(OSError):
os.remove(tmp_path)
except OSError:
pass
lines = [line for line in content.splitlines() if not line.strip().startswith("#")]
lines = [line for line in content.splitlines() if not line.strip().startswith(";")]
return "\n".join(lines).strip()
+2 -2
View File
@@ -12,9 +12,9 @@ def update_flake_version(
return
try:
with open(flake_path, "r", encoding="utf-8") as f:
with open(flake_path, encoding="utf-8") as f:
content = f.read()
except Exception as exc:
except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read flake.nix: {exc}")
return
+2 -2
View File
@@ -12,9 +12,9 @@ def update_pkgbuild_version(
return
try:
with open(pkgbuild_path, "r", encoding="utf-8") as f:
with open(pkgbuild_path, encoding="utf-8") as f:
content = f.read()
except Exception as exc:
except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read PKGBUILD: {exc}")
return
+11 -1
View File
@@ -3,6 +3,11 @@ from __future__ import annotations
import os
import re
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore
def update_pyproject_version(
pyproject_path: str, new_version: str, preview: bool = False
@@ -12,12 +17,17 @@ def update_pyproject_version(
return
try:
with open(pyproject_path, "r", encoding="utf-8") as f:
with open(pyproject_path, encoding="utf-8") as f:
content = f.read()
except OSError as exc:
print(f"[WARN] Could not read pyproject.toml: {exc}")
return
project = tomllib.loads(content).get("project")
if project is None or "version" in project.get("dynamic", []):
print("[INFO] pyproject.toml declares no static version, skipping.")
return
m = re.search(r"(?ms)^\s*\[project\]\s*$.*?(?=^\s*\[|\Z)", content)
if not m:
raise RuntimeError("Missing [project] section in pyproject.toml")
@@ -2,7 +2,6 @@ from __future__ import annotations
import os
from datetime import datetime
from typing import Optional
from .debian import _get_debian_author
@@ -11,7 +10,7 @@ def update_spec_changelog(
spec_path: str,
package_name: str,
new_version: str,
message: Optional[str] = None,
message: str | None = None,
preview: bool = False,
) -> None:
if not os.path.exists(spec_path):
@@ -19,9 +18,9 @@ def update_spec_changelog(
return
try:
with open(spec_path, "r", encoding="utf-8") as f:
with open(spec_path, encoding="utf-8") as f:
content = f.read()
except Exception as exc:
except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read spec file for changelog update: {exc}")
return
@@ -30,11 +29,18 @@ def update_spec_changelog(
date_str = now.strftime("%a %b %d %Y")
author_name, author_email = _get_debian_author()
body_line = message.strip() if message else f"Automated release {new_version}."
body = (
message.strip()
if message and message.strip()
else f"Automated release {new_version}."
)
dashed_body = "\n".join(
f"- {line}" if line.strip() else line for line in body.split("\n")
)
stanza = (
f"* {date_str} {author_name} <{author_email}> - {debian_version}\n"
f"- {body_line}\n\n"
f"{dashed_body}\n\n"
)
marker = "%changelog"
@@ -57,7 +63,7 @@ def update_spec_changelog(
try:
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_content)
except Exception as exc:
except (OSError, UnicodeEncodeError) as exc:
print(f"[WARN] Failed to write updated spec changelog section: {exc}")
return
+2 -2
View File
@@ -15,9 +15,9 @@ def update_spec_version(
return
try:
with open(spec_path, "r", encoding="utf-8") as f:
with open(spec_path, encoding="utf-8") as f:
content = f.read()
except Exception as exc:
except (OSError, UnicodeDecodeError) as exc:
print(f"[WARN] Could not read spec file: {exc}")
return
+3 -5
View File
@@ -24,27 +24,25 @@ from __future__ import annotations
import os
import re
from typing import Optional
from pkgmgr.core.repository.paths import RepoPaths
_DEBIAN_PACKAGE_RE = re.compile(r"^Package:\s*(\S+)\s*$", re.MULTILINE)
_PKGBUILD_NAME_RE = re.compile(r"^pkgname=([^\s#]+)\s*$", re.MULTILINE)
_RPM_NAME_RE = re.compile(r"^Name:\s*(\S+)\s*$", re.MULTILINE)
def _read(path: Optional[str]) -> str:
def _read(path: str | None) -> str:
if not path or not os.path.isfile(path):
return ""
try:
with open(path, "r", encoding="utf-8") as f:
with open(path, encoding="utf-8") as f:
return f.read()
except OSError:
return ""
def _extract(pattern: re.Pattern[str], text: str) -> Optional[str]:
def _extract(pattern: re.Pattern[str], text: str) -> str | None:
if not text:
return None
match = pattern.search(text)
+4 -3
View File
@@ -10,7 +10,7 @@ the side effects downstream are incomplete.
`retry_release` re-runs the idempotent tail of that flow so a botched
release can be re-pushed without touching code or recreating tags:
* `git push origin <branch> <tag>` for the existing HEAD tag
* push the branch and the existing HEAD tag through `pkgmgr push`'s leases
* re-align the floating `latest` tag if HEAD tag is the highest
Publishing (PyPI etc.) stays the caller's responsibility — the publish
@@ -23,7 +23,8 @@ from __future__ import annotations
import os
from pkgmgr.actions.publish.git_tags import head_semver_tags
from pkgmgr.core.git import GitRunError, run
from pkgmgr.actions.repository.push import push_branch
from pkgmgr.core.git import GitRunError
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.version.semver import SemVer
@@ -50,7 +51,7 @@ def retry_release(
tag = max(tags, key=SemVer.parse)
print(f"Re-pushing existing tag: {tag}")
run(["push", "origin", branch, tag], preview=preview)
push_branch(".", [], sign=True, refs=(tag,), preview=preview)
try:
if is_highest_version_tag(tag):
+51 -18
View File
@@ -1,40 +1,73 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Version discovery and bumping helpers for the release workflow.
"""
from __future__ import annotations
import re
from pkgmgr.core.git.queries import get_tags
from pkgmgr.core.version.semver import (
SemVer,
find_latest_version,
bump_major,
bump_minor,
bump_patch,
find_latest_version,
)
from pkgmgr.core.version.source import (
read_cargo_version,
read_changelog_version,
read_debian_changelog_version,
read_flake_version,
read_pkgbuild_version,
read_pyproject_version,
read_spec_version,
)
FILE_SOURCES = {
"pyproject.toml": read_pyproject_version,
"CHANGELOG.md": read_changelog_version,
"flake.nix": read_flake_version,
"PKGBUILD": read_pkgbuild_version,
"RPM spec": read_spec_version,
"debian/changelog": read_debian_changelog_version,
"Cargo.toml": read_cargo_version,
}
_CORE_VERSION = re.compile(r"^v?(\d+\.\d+\.\d+)")
def determine_current_version() -> SemVer:
"""
Determine the current semantic version from Git tags.
Behaviour:
- If there are no tags or no SemVer-compatible tags, return 0.0.0.
- Otherwise, use the latest SemVer tag as current version.
def _file_versions(repo_dir: str) -> dict[str, SemVer]:
versions: dict[str, SemVer] = {}
for source, read in FILE_SOURCES.items():
match = _CORE_VERSION.match(read(repo_dir) or "")
if match:
versions[source] = SemVer.parse(match.group(1))
return versions
def determine_current_version(repo_dir: str) -> SemVer:
"""
tags = get_tags()
if not tags:
Determine the highest version across the latest SemVer tag and every
version file the release writes.
Args:
repo_dir: repository root holding the version files.
Returns:
The highest version found, or 0.0.0 when no source has one.
"""
versions = _file_versions(repo_dir)
latest = find_latest_version(get_tags() or [])
if latest is not None:
versions["tag"] = latest[1]
if not versions:
return SemVer(0, 0, 0)
latest = find_latest_version(tags)
if latest is None:
return SemVer(0, 0, 0)
_tag, ver = latest
return ver
highest = max(versions.values())
if len(set(versions.values())) > 1:
found = ", ".join(f"{source} {ver}" for source, ver in versions.items())
print(f"[INFO] Version sources disagree ({found}); starting from {highest}.")
return highest
def bump_semver(current: SemVer, release_type: str) -> SemVer:
+18 -14
View File
@@ -2,15 +2,16 @@ from __future__ import annotations
import os
import sys
from typing import Optional
from pkgmgr.actions.branch import close_branch
from pkgmgr.core.git import GitRunError, run
from pkgmgr.actions.repository.push import push_branch, sign_unpushed
from pkgmgr.core.git import GitRunError
from pkgmgr.core.git.commands import add, commit, tag_annotated
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.repository.paths import resolve_repo_paths
from .files import (
update_cargo_version,
update_changelog,
update_debian_changelog,
update_flake_version,
@@ -34,7 +35,7 @@ def _release_impl(
pyproject_path: str = "pyproject.toml",
changelog_path: str = "CHANGELOG.md",
release_type: str = "patch",
message: Optional[str] = None,
message: str | None = None,
preview: bool = False,
close: bool = False,
force: bool = False,
@@ -49,7 +50,8 @@ def _release_impl(
# Pull BEFORE making any modifications
ensure_clean_and_synced(preview=preview)
current_ver = determine_current_version()
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
current_ver = determine_current_version(repo_root)
new_ver = bump_semver(current_ver, release_type)
new_ver_str = str(new_ver)
new_tag = new_ver.to_tag(with_prefix=True)
@@ -59,7 +61,6 @@ def _release_impl(
print(f"Current version: {current_ver}")
print(f"New version: {new_ver_str} ({release_type})")
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
paths = resolve_repo_paths(repo_root)
# --- Update versioned files ------------------------------------------------
@@ -75,6 +76,8 @@ def _release_impl(
update_flake_version(paths.flake_nix, new_ver_str, preview=preview)
cargo_files = update_cargo_version(repo_root, new_ver_str, preview=preview)
if paths.arch_pkgbuild:
update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview)
else:
@@ -87,10 +90,9 @@ def _release_impl(
else:
print("[INFO] No RPM spec file found. Skipping spec version update.")
effective_message: Optional[str] = message
if effective_message is None and isinstance(changelog_message, str):
if changelog_message.strip():
effective_message = changelog_message.strip()
effective_message: str | None = message
if isinstance(changelog_message, str) and changelog_message.strip():
effective_message = changelog_message.strip()
package_name = resolve_package_name(paths)
@@ -126,6 +128,7 @@ def _release_impl(
paths.arch_pkgbuild,
paths.rpm_spec,
paths.debian_changelog,
*cargo_files,
]
existing_files = [
p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)
@@ -134,8 +137,9 @@ def _release_impl(
if preview:
add(existing_files, preview=True)
commit(commit_msg, all=True, preview=True)
sign_unpushed(".", preview=True)
tag_annotated(new_tag, tag_msg, preview=True)
run(["push", "origin", branch, new_tag], preview=True)
push_branch(".", [], sign=True, refs=(new_tag,), preview=True)
if is_highest_version_tag(new_tag):
update_latest_tag(new_tag, preview=True)
@@ -155,10 +159,10 @@ def _release_impl(
add(existing_files, preview=False)
commit(commit_msg, all=True, preview=False)
sign_unpushed(".", preview=False)
tag_annotated(new_tag, tag_msg, preview=False)
# Push branch and ONLY the newly created version tag in one command (no --tags)
run(["push", "origin", branch, new_tag], preview=False)
push_branch(".", [], sign=True, refs=(new_tag,), preview=False)
# Update 'latest' only if this is the highest version tag
try:
@@ -188,7 +192,7 @@ def _release_impl(
print(f"[INFO] Deleting branch {branch} after successful release...")
try:
close_branch(name=branch, base_branch="main", cwd=".")
except Exception as exc:
except (RuntimeError, GitRunError) as exc:
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
@@ -196,7 +200,7 @@ def release(
pyproject_path: str = "pyproject.toml",
changelog_path: str = "CHANGELOG.md",
release_type: str = "patch",
message: Optional[str] = None,
message: str | None = None,
preview: bool = False,
force: bool = False,
close: bool = False,
+10 -10
View File
@@ -3,29 +3,29 @@ from __future__ import annotations
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Callable, Dict, List, Tuple
from typing import Any, Callable
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
Repository = Dict[str, Any]
RepoRef = Tuple[str, str]
OpResult = Tuple[bool, str]
Repository = dict[str, Any]
RepoRef = tuple[str, str]
OpResult = tuple[bool, str]
RepoOp = Callable[[str], OpResult]
def resolve_repos(
selected_repos: List[Repository],
selected_repos: list[Repository],
repositories_base_dir: str,
all_repos: List[Repository],
) -> List[RepoRef]:
all_repos: list[Repository],
) -> list[RepoRef]:
"""
Resolve ``(identifier, repo_dir)`` pairs for ``selected_repos``.
Repositories whose directory does not exist on disk are reported and
skipped, matching the prior behavior of pull/push handlers.
"""
resolved: List[RepoRef] = []
resolved: list[RepoRef] = []
for repo in selected_repos:
ident = get_repo_identifier(repo, all_repos)
rd = get_repo_dir(repositories_base_dir, repo)
@@ -37,7 +37,7 @@ def resolve_repos(
def run_on_repos(
repos: List[RepoRef],
repos: list[RepoRef],
op: RepoOp,
*,
jobs: int,
@@ -55,7 +55,7 @@ def run_on_repos(
return
effective_jobs = max(1, min(jobs, len(repos)))
failed: List[Tuple[str, str]] = []
failed: list[tuple[str, str]] = []
if effective_jobs == 1:
for ident, rd in repos:
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff. Show more