Compare commits

..

75 Commits

Author SHA1 Message Date
f6228988e1 Release version 1.15.2
Some checks failed
CI / security-codeql (push) Has been cancelled
CI / test-unit (push) Has been cancelled
CI / test-integration (push) Has been cancelled
CI / test-env-virtual (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / test-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-python (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
2026-05-28 11:06:43 +02:00
5c7171acd9 fix(config): add alias: infinito for infinito-nexus/core
`pkgmgr install infinito` (and `pkgmgr path infinito`, `pkgmgr version
infinito`, etc.) failed with:

    Identifier 'infinito' did not match any repository in config.

The infinito-nexus/core entry in defaults.yaml had no alias, so the
resolver could only match it via the full id `github.com/infinito-nexus/core`
or the bare repository name `core`. Downstream consumers (the
infinito-nexus-core Dockerfile, roles/sys-cli, roles/web-app-navigator,
and the test-install-pkgmgr CI job) all invoke the short identifier
`infinito` and broke once kpmx 1.15.x was rolled out via the floating
`pkgmgr-<distro>:stable` images.

Registering `alias: infinito` on the entry restores the short identifier
without renaming the repository or touching the consumer side.
2026-05-28 11:05:47 +02:00
06cc5b6725 Release version 1.15.1 2026-05-28 08:18:23 +02:00
ece575cc73 fix(release/changelog): insert new entry under H1 instead of above it
update_changelog used to blindly prepend the new ## [version] entry to
the entire file body. When CHANGELOG.md leads with a # Changelog H1
the result was:

  ## [new] - YYYY-MM-DD
  ...
  # Changelog
  ## [old] - ...

which trips markdownlint MD041 (first-line-h1) and MD012
(no-multiple-blanks), and reads as if the document were two stacked
changelogs.

The new _insert_after_h1 helper:

* Synthesises # Changelog when the file is empty.
* Inserts the entry between the existing H1 (plus any intro prose)
  and the first existing ## release entry.
* For legacy headerless files (file starts with ##) prepends the
  synthesised H1 + entry, so the resulting document is also
  MD041-clean.

Tests cover the three layouts (empty, H1+existing-entries,
legacy-headerless). All 61 release-unit tests stay green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 08:17:35 +02:00
a4099717be Release version 1.15.0 2026-05-28 07:56:07 +02:00
a37b9ed8a7 feat(archive): add pkgmgr archive subcommand for task-tracked spec dirs
Walks a directory of numbered NNN-topic.md files, promotes every file
with zero unchecked task-list items into the directorys README under
the ## Archive section, then deletes the source file. Keeps spec
directories (typically docs/requirements) short and focused on open
work.

The action ships as pkgmgr.actions.archive with four leaf modules
(discovery, inspect, readme, workflow) and is wired into the CLI as
pkgmgr archive [DIR] [--readme PATH] [--dry-run] [--include-template].

Extracted verbatim from cli/contributing/requirements/archive in
infinito-nexus-core so every kpmx-managed repository can rely on the
same archival convention without copy-pasting helpers. Twenty unit
tests cover discovery, inspection, README merge, and end-to-end
workflow paths.

Also: realign tests/unit/pkgmgr/cli/commands/test_release.py with the
new run_release(retry=False) signature shipped in v1.14.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 07:55:11 +02:00
a4a5b661b9 Release version 1.14.0 2026-05-27 20:53:14 +02:00
43fbcfb227 feat(release): add retry mode to re-deploy existing release without re-tagging
Recovers from a release whose tag+commit landed cleanly but whose
post-tag steps (git push, latest-tag bump, twine upload) failed
mid-flight. pkgmgr release --retry skips the version bump, file
rewrites, commit, and tag-creation steps and re-runs only the
idempotent tail: re-push the existing HEAD tag, re-align the floating
latest tag, and (unless --no-publish) re-invoke publish.

The retry logic lives in its own module pkgmgr.actions.release.retry
so the workflow.py orchestrator stays focused on the forward path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:52:06 +02:00
a6c40451fe Release version 1.13.4 2026-05-27 20:32:39 +02:00
5fa2709a84 feat(release): resolve package name from packaging files, not folder
Previously the release workflow derived the distro-package name from
`os.path.basename(repo_root)`. Renaming the repo folder (e.g.
`infinito-nexus` → `infinito-nexus-core`) silently rewrote
`debian/changelog`'s top entry to the new folder name while
`debian/control` still pinned the legacy `Package:` value. dpkg-source
refuses to build a source package when the two disagree.

Add `resolve_package_name(paths)` that consults the existing packaging
files in priority order (debian/control `Package:` → PKGBUILD
`pkgname=` → RPM `.spec` `Name:`) and only falls back to the folder
basename when no packaging metadata is present. Extend `RepoPaths`
with a `debian_control` slot so `resolve_repo_paths` can discover the
file under both `packaging/debian/control` and the legacy `debian/`
location.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:27:01 +02:00
386d8aa2f2 fix(install): use runuser and fail non-root with exit 1
Some checks failed
CI / security-codeql (push) Has been cancelled
CI / test-unit (push) Has been cancelled
CI / test-integration (push) Has been cancelled
CI / test-env-virtual (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / test-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-python (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
`su -` runs through pam_systemd on Manjaro/Arch, creating a new login
session that conflicts with the outer sudo session and detaches the
install from the controlling terminal — making `sudo make install`
appear to end while it keeps running in the background. Replace `su`
calls with `runuser`, which is designed for root-invoked scripts and
skips PAM session management.

Also flips init.sh's non-root branch from `exit 0` (silent success) to
`exit 1` with a clear stderr message, so `make install` correctly fails
when invoked without root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:32:49 +02:00
70b06d2b3a chore(config): refresh default repository list
Some checks failed
CI / security-codeql (push) Has been cancelled
CI / test-unit (push) Has been cancelled
CI / test-integration (push) Has been cancelled
CI / test-env-virtual (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / test-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-python (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
Drops the `analysis-ready-code` entry and renames the `infinito-nexus`
default to `infinito-nexus/core`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:19:03 +02:00
00c668b595 chore(claude): expand permissions and require sandbox
- Adds `Bash(*)` to the allow list so routine shell commands run without
  prompting.
- Sets `sandbox.failIfUnavailable=true` so Claude Code aborts rather
  than silently running unsandboxed when the sandbox cannot initialize.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:18:54 +02:00
12a38b7e6a fix(nix): clear stale wheels before pypaBuildPhase
`dist/` carried in via the source tree can contain a stale wheel from a
previous build (e.g. kpmx-1.12.1 alongside the freshly built 1.13.3).
Both wheels declare a `bin/pkgmgr` entry, so `pypaInstallPhase` hits
FileExistsError on the second install. Wipe `dist/` in `preBuild` so
only the fresh wheel is installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:18:43 +02:00
37fd2192a5 feat(pull,push): parallel execution via --jobs flag
Adds `pkgmgr pull -j N` and `pkgmgr push -j N` for concurrent operation
across repositories (default: min(cpu_count, 8), use 1 for sequential).
Verification in pull also parallelizes; interactive prompts and the
actual git command still run on the main thread. Shared parallel-runner
and repo-resolution helpers live in a new `_parallel.py` module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:18:31 +02:00
607102e7f8 chore(claude): add project settings with sandbox and ask rules
Some checks failed
CI / security-codeql (push) Has been cancelled
CI / test-unit (push) Has been cancelled
CI / test-integration (push) Has been cancelled
CI / test-env-virtual (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / test-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-python (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:54:34 +02:00
133cf63b9f Release version 1.13.3 2026-03-26 17:10:21 +01:00
6334936e8a fix(ci): resolve workflow and docker scan findings 2026-03-26 16:44:02 +01:00
946965f016 fix(ci): grant reusable workflows security permissions 2026-03-26 16:33:40 +01:00
541a7f679f feat(ci): add docker lint and codeql workflows 2026-03-26 16:30:36 +01:00
128f71745a refactor(ci): organize workflow scripts and gate publish on main 2026-03-26 15:58:18 +01:00
df2ce636c8 fix(ci): make mark-stable main-only and cancel stale runs 2026-03-26 14:57:04 +01:00
3b0dabf2a7 Release version 1.13.2 2026-03-26 12:26:55 +01:00
697370c906 Merge branch 'fix/nix-centos' 2026-03-26 12:26:26 +01:00
bc57172d92 fix(nix): fail fast when bootstrap is unavailable 2026-03-26 07:56:55 +01:00
0e7e23dce5 Release version 1.13.1 2026-03-20 02:57:25 +01:00
9d53f4c6f5 Fix GPG verification runtime handling 2026-03-20 02:51:51 +01:00
a46d85b541 Release version 1.13.0 2026-03-20 01:29:38 +01:00
acaea11eb6 Set CentOS image to latest 2026-03-20 01:28:49 +01:00
056d21a859 Release version 1.12.5 2026-02-24 09:35:39 +01:00
612ba5069d Increase stable gate wait time to 2 hours 2026-02-24 09:34:45 +01:00
551e245218 Release version 1.12.4 2026-02-24 09:32:01 +01:00
814523eac2 Gate stable tag updates on successful main CI 2026-02-24 09:30:24 +01:00
4f2c5013a7 Release version 1.12.3 2026-02-24 08:29:34 +01:00
e01bb8c39a nix: pin flake input to nixos-25.11 and track flake.lock 2026-02-24 08:23:33 +01:00
461a3c334d Release version 1.12.2 2026-02-24 07:40:55 +01:00
e3de46c6a4 Removed infinito-sphinx from package manager, because it's managed now via docker in infinito.nexus 2026-02-24 07:40:01 +01:00
b20882f492 Release version 1.12.1
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2026-02-14 23:26:17 +01:00
430f21735e fix(nix): prefer distro nix binaries over PATH lookup 2026-02-14 23:23:16 +01:00
acf1b69b70 Release version 1.12.0 2026-02-08 18:26:25 +01:00
7d574e67ec Add concurrency groups to CI and mark-stable workflows
Introduce explicit concurrency settings to the CI and mark-stable
workflows to serialize runs per repository and ref. This prevents
overlapping executions for the same branch or tag and makes pipeline
behavior more predictable during rapid pushes.

https://chatgpt.com/share/6988bef0-1a0c-800f-93df-7a6c1bdc0331
2026-02-08 18:25:31 +01:00
aad6814fc5 Release version 1.11.2 2026-02-08 18:21:50 +01:00
411cd2df66 Remove tag trigger from mark-stable workflow
Stop running the mark-stable workflow on v* tag pushes so it executes
only on branch updates. This prevents duplicate or unintended runs
after version tags are created as part of the release process.

https://chatgpt.com/share/6988bef0-1a0c-800f-93df-7a6c1bdc0331
2026-02-08 18:20:48 +01:00
849d29c044 Release version 1.11.1 2026-02-08 18:18:09 +01:00
0947dea01e Fix release push to send branch and version tag together
Push master and the newly created version tag in a single git push command
so the CI release workflow can detect the tag on HEAD. This aligns the
release script with the new master-based release pipeline and prevents
missed automated releases caused by separate branch and tag pushes.

https://chatgpt.com/share/6988bef0-1a0c-800f-93df-7a6c1bdc0331
2026-02-08 17:51:15 +01:00
5d7e1fdbb3 Release version 1.11.0
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2026-01-21 01:18:31 +01:00
ac6981ad4d feat(pkgmgr): add slim Docker image target and publish slim variants
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
- add dedicated `slim` Dockerfile stage based on `full`
- move image cleanup into slim stage via slim.sh
- extend build script to support `--target slim`
- publish pkgmgr-*-slim images for all distros

https://chatgpt.com/share/69701a4e-b000-800f-be7e-162dcb93b1d2
2026-01-21 01:13:59 +01:00
f3a7b69bac Added correct changelog entry 2026-01-20 10:49:39 +01:00
5bcad7f5f3 Release version 1.10.0 2026-01-20 10:44:58 +01:00
d39582d1da feat(docker): introduce slim.sh for safe image cleanup and run it during build
- add verbose distro-aware cleanup script (apk/apt/pacman/dnf/yum)
- remove package manager caches, logs, tmp and user caches
- keep runtime-critical files untouched
- execute cleanup during image build to reduce final size

https://chatgpt.com/share/696f4ab6-fae8-800f-9a46-e73eb8317791
2026-01-20 10:28:16 +01:00
043d389a76 Release version 1.9.5 2026-01-16 10:09:43 +01:00
cc1e543ebc git(core): include cwd and git output in pull_args error
Show the working directory and captured git output when `git pull`
fails via pull_args(). This makes debugging repository-specific
failures (missing upstream, auth issues, detached HEAD, etc.)
significantly easier, especially when pulling multiple repositories.

https://chatgpt.com/share/6969ff2c-ed2c-800f-b506-5834b6b81141
2026-01-16 10:04:40 +01:00
25a0579809 Release version 1.9.4
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2026-01-13 14:48:50 +01:00
d4e461bb63 fix(nix): run installer via su instead of sudo to avoid PAM failures in minimal containers
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
https://chatgpt.com/share/69662b41-2768-800f-a721-292889889547
2026-01-13 14:43:12 +01:00
1864d0700e Release version 1.9.3
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2026-01-07 13:44:40 +01:00
a9bd8d202f packaging(arch): make nix optional on non-x86_64 architectures
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Arch Linux ARM currently ships a broken/out-of-sync nix package with
unresolvable dependencies. Declare nix as a hard dependency only on
x86_64 and as optional on other architectures, allowing installation
while relying on the official Nix installer bootstrap.

https://chatgpt.com/share/695e483c-1f68-800f-9f94-87d5295b871d
2026-01-07 13:43:32 +01:00
28df54503e Release version 1.9.2
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-21 15:30:22 +01:00
aa489811e3 fix(config): package and load default configs correctly
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
- Ship default YAML configs inside the pkgmgr package
- Ensure defaults are loaded when no user config exists
- Keep user configs fully respected and non-overwritten
- Fix config update command to copy packaged defaults reliably

https://chatgpt.com/share/6947e74f-573c-800f-b93d-5ed341fcd1a3
2025-12-21 15:26:01 +01:00
f66af0157b Release version 1.9.1
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-21 13:38:58 +01:00
b0b3ccf5aa fix(packaging): stop including legacy pkgmgr.installers package
- Restrict setuptools package discovery to src/ (pkgmgr* only)
- Drop config/ as a Python package mapping (keep config as plain data dir)
- Remove config_defaults fallback paths and use config/ exclusively
- Add unit + integration tests for defaults.yaml loading and CLI update copying

https://chatgpt.com/share/6947e74f-573c-800f-b93d-5ed341fcd1a3
2025-12-21 13:25:38 +01:00
e178afde31 Release version 1.9.0
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-20 14:37:58 +01:00
9802293871 ***feat(mirror): add remote repository visibility support***
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
* Add mirror visibility subcommand and provision --public flag
* Implement core visibility API with provider support (GitHub, Gitea)
* Extend provider interface and EnsureStatus
* Add unit, integration and e2e tests for visibility handling

https://chatgpt.com/share/6946a44e-4f48-800f-8124-9c0b9b2b6b04
2025-12-20 14:26:55 +01:00
a2138c9985 refactor(mirror): probe remotes with detailed reasons and provision all git mirrors
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
- Add probe_remote_reachable_detail and improved GitRunError metadata
- Print short failure reasons for unreachable remotes
- Provision each git mirror URL via ensure_remote_repository_for_url

https://chatgpt.com/share/6946956e-f738-800f-a446-e2c8bf5595f4
2025-12-20 13:23:24 +01:00
10998e50ad ci(test-virgin-user): preserve NIX_CONFIG across sudo to avoid GitHub API rate limits
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
https://chatgpt.com/share/6945565e-f1b0-800f-86d5-8d0083fe3390
2025-12-19 14:42:36 +01:00
a20814cb37 Release version 1.8.7
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-19 14:15:47 +01:00
feb5ba267f refactor(release): move file helpers into files package
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
https://chatgpt.com/share/69454ef4-e038-800f-a14b-4e633e76f241
2025-12-19 14:11:04 +01:00
591be4ef35 test(release): update pyproject version tests for PEP 621 and RuntimeError handling
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
- Adjust tests to expect RuntimeError instead of SystemExit
- Add coverage for missing [project] section in pyproject.toml
- Keep spec macro %{?dist} intact in test fixtures
- Minor cleanup and reformatting of test cases

https://chatgpt.com/share/69454836-4698-800f-9d19-7e67e8e789d6
2025-12-19 14:06:33 +01:00
3e6ef0fd68 release: fix pyproject.toml version update for PEP 621 projects
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Update version handling to correctly modify [project].version in pyproject.toml.
The previous implementation only matched top-level version assignments and
failed for PEP 621 layouts.

- Restrict update to the [project] section
- Allow leading whitespace in version lines
- Replace sys.exit() with proper exceptions
- Remove unused sys import

https://chatgpt.com/share/69454836-4698-800f-9d19-7e67e8e789d6
2025-12-19 13:42:26 +01:00
3d5c770def Solved ruff F401
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-18 19:16:15 +01:00
f4339a746a executet 'ruff format --check .'
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-18 14:04:44 +01:00
763f02a9a4 Release version 1.8.6
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-17 23:50:31 +01:00
2eec873a17 Solved Debian Bug
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
https://chatgpt.com/share/69432655-a948-800f-8c0d-353921cdf644
2025-12-17 23:29:04 +01:00
17ee947930 ci: pass NIX_CONFIG with GitHub token into all test containers
- Add NIX_CONFIG with GitHub access token to all CI test workflows
- Export NIX_CONFIG in Makefile for propagation to test scripts
- Forward NIX_CONFIG explicitly into all docker run invocations
- Prevent GitHub API rate limit errors during Nix-based tests

https://chatgpt.com/share/69432655-a948-800f-8c0d-353921cdf644
2025-12-17 23:29:04 +01:00
b989bdd4eb Release version 1.8.5 2025-12-17 23:29:04 +01:00
c4da8368d8 --- Release Error --- 2025-12-17 23:28:45 +01:00
271 changed files with 7452 additions and 1698 deletions

16
.claude/settings.json Normal file
View File

@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(*)"
],
"ask": [
"Skill(update-config)",
"Skill(update-config:*)"
]
},
"sandbox": {
"enabled": true,
"failIfUnavailable": true,
"autoAllowBashIfSandboxed": true
}
}

View File

@@ -2,34 +2,72 @@ name: CI
on: on:
push: push:
branches-ignore: branches:
- main - '**'
pull_request: pull_request:
permissions:
contents: read
concurrency:
group: global-ci-${{ github.repository }}-${{ github.ref_name }}
cancel-in-progress: false
jobs: jobs:
security-codeql:
permissions:
contents: read
packages: read
security-events: write
uses: ./.github/workflows/security-codeql.yml
test-unit: test-unit:
permissions:
contents: read
uses: ./.github/workflows/test-unit.yml uses: ./.github/workflows/test-unit.yml
test-integration: test-integration:
permissions:
contents: read
uses: ./.github/workflows/test-integration.yml uses: ./.github/workflows/test-integration.yml
test-env-virtual: test-env-virtual:
permissions:
contents: read
uses: ./.github/workflows/test-env-virtual.yml uses: ./.github/workflows/test-env-virtual.yml
test-env-nix: test-env-nix:
permissions:
contents: read
uses: ./.github/workflows/test-env-nix.yml uses: ./.github/workflows/test-env-nix.yml
test-e2e: test-e2e:
permissions:
contents: read
uses: ./.github/workflows/test-e2e.yml uses: ./.github/workflows/test-e2e.yml
test-virgin-user: test-virgin-user:
permissions:
contents: read
uses: ./.github/workflows/test-virgin-user.yml uses: ./.github/workflows/test-virgin-user.yml
test-virgin-root: test-virgin-root:
permissions:
contents: read
uses: ./.github/workflows/test-virgin-root.yml uses: ./.github/workflows/test-virgin-root.yml
lint-shell: lint-shell:
permissions:
contents: read
uses: ./.github/workflows/lint-shell.yml uses: ./.github/workflows/lint-shell.yml
lint-python: lint-python:
permissions:
contents: read
uses: ./.github/workflows/lint-python.yml uses: ./.github/workflows/lint-python.yml
lint-docker:
permissions:
contents: read
security-events: write
uses: ./.github/workflows/lint-docker.yml

40
.github/workflows/lint-docker.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Docker Linter
on:
workflow_call:
permissions:
contents: read
jobs:
lint-docker:
name: Lint Dockerfile
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run hadolint (produce SARIF)
id: hadolint
continue-on-error: true
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
with:
dockerfile: ./Dockerfile
format: sarif
output-file: hadolint-results.sarif
failure-threshold: warning
- name: Upload analysis results to GitHub
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: hadolint-results.sarif
wait-for-processing: true
category: hadolint
- name: Fail if SARIF contains warnings or errors
if: always()
run: python3 src/pkgmgr/github/check_hadolint_sarif.py hadolint-results.sarif

View File

@@ -3,6 +3,9 @@ name: Ruff (Python code sniffer)
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
lint-python: lint-python:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -3,6 +3,9 @@ name: ShellCheck
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
lint-shell: lint-shell:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,110 +1,39 @@
name: Mark stable commit name: Mark stable commit
concurrency:
group: mark-stable-${{ github.repository }}-main
cancel-in-progress: true
on: on:
push: push:
branches:
- main # still run tests for main
tags: tags:
- 'v*' # run tests for version tags (e.g. v0.9.1) - 'v*'
jobs: jobs:
test-unit:
uses: ./.github/workflows/test-unit.yml
test-integration:
uses: ./.github/workflows/test-integration.yml
test-env-virtual:
uses: ./.github/workflows/test-env-virtual.yml
test-env-nix:
uses: ./.github/workflows/test-env-nix.yml
test-e2e:
uses: ./.github/workflows/test-e2e.yml
test-virgin-user:
uses: ./.github/workflows/test-virgin-user.yml
test-virgin-root:
uses: ./.github/workflows/test-virgin-root.yml
lint-shell:
uses: ./.github/workflows/lint-shell.yml
lint-python:
uses: ./.github/workflows/lint-python.yml
mark-stable: mark-stable:
needs:
- lint-shell
- lint-python
- test-unit
- test-integration
- test-env-nix
- test-env-virtual
- test-e2e
- test-virgin-user
- test-virgin-root
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 330
# Only run this job if the push is for a version tag (v*)
if: startsWith(github.ref, 'refs/tags/v')
permissions: permissions:
contents: write # Required to move/update the tag actions: read
contents: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true # We need all tags for version comparison fetch-tags: true # We need tags and main history for version comparison
- name: Check whether tagged commit is on main
id: branch-check
run: bash scripts/github/common/check-tagged-commit-on-main.sh
- name: Wait for CI success on main for this commit
if: steps.branch-check.outputs.is_on_main == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: bash scripts/github/mark-stable/wait-for-main-ci-success.sh
- name: Move 'stable' tag only if this version is the highest - name: Move 'stable' tag only if this version is the highest
run: | if: steps.branch-check.outputs.is_on_main == 'true'
set -euo pipefail run: bash scripts/github/mark-stable/mark-stable-if-highest-version.sh
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
echo "Ref: $GITHUB_REF"
echo "SHA: $GITHUB_SHA"
VERSION="${GITHUB_REF#refs/tags/}"
echo "Current version tag: ${VERSION}"
echo "Collecting all version tags..."
ALL_V_TAGS="$(git tag --list 'v*' || true)"
if [[ -z "${ALL_V_TAGS}" ]]; then
echo "No version tags found. Skipping stable update."
exit 0
fi
echo "All version tags:"
echo "${ALL_V_TAGS}"
# Determine highest version using natural version sorting
LATEST_TAG="$(printf '%s\n' ${ALL_V_TAGS} | sort -V | tail -n1)"
echo "Highest version tag: ${LATEST_TAG}"
if [[ "${VERSION}" != "${LATEST_TAG}" ]]; then
echo "Current version ${VERSION} is NOT the highest version."
echo "Stable tag will NOT be updated."
exit 0
fi
echo "Current version ${VERSION} IS the highest version."
echo "Updating 'stable' tag..."
# Delete existing stable tag (local + remote)
git tag -d stable 2>/dev/null || true
git push origin :refs/tags/stable || true
# Create new stable tag
git tag stable "$GITHUB_SHA"
git push origin stable
echo "✅ Stable tag updated to ${VERSION}."

View File

@@ -21,44 +21,30 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Checkout workflow_run commit and refresh tags - name: Checkout workflow_run commit and refresh tags
run: | env:
set -euo pipefail WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }}
git checkout -f "${{ github.event.workflow_run.head_sha }}" run: bash scripts/github/publish-containers/checkout-workflow-run-commit.sh
git fetch --tags --force
git tag --list 'stable' 'v*' --sort=version:refname | tail -n 20 - name: Check whether tagged commit is on main
id: branch-check
env:
TARGET_SHA: ${{ github.event.workflow_run.head_sha }}
run: bash scripts/github/common/check-tagged-commit-on-main.sh
- name: Compute version and stable flag - name: Compute version and stable flag
id: info id: info
run: | if: steps.branch-check.outputs.is_on_main == 'true'
set -euo pipefail run: bash scripts/github/publish-containers/compute-publish-container-info.sh
SHA="$(git rev-parse HEAD)"
V_TAG="$(git tag --points-at "${SHA}" --list 'v*' | sort -V | tail -n1)"
if [[ -z "${V_TAG}" ]]; then
echo "No version tag found for ${SHA}. Skipping publish."
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
VERSION="${V_TAG#v}"
STABLE_SHA="$(git rev-parse -q --verify refs/tags/stable^{commit} 2>/dev/null || true)"
IS_STABLE=false
[[ -n "${STABLE_SHA}" && "${STABLE_SHA}" == "${SHA}" ]] && IS_STABLE=true
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "is_stable=${IS_STABLE}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: ${{ steps.info.outputs.should_publish == 'true' }} if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
with: with:
use: true use: true
- name: Login to GHCR - name: Login to GHCR
if: ${{ steps.info.outputs.should_publish == 'true' }} if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/login-action@v3 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -66,9 +52,8 @@ jobs:
- name: Publish all images - name: Publish all images
if: ${{ steps.info.outputs.should_publish == 'true' }} if: ${{ steps.info.outputs.should_publish == 'true' }}
run: | env:
set -euo pipefail OWNER: ${{ github.repository_owner }}
OWNER="${{ github.repository_owner }}" \ VERSION: ${{ steps.info.outputs.version }}
VERSION="${{ steps.info.outputs.version }}" \ IS_STABLE: ${{ steps.info.outputs.is_stable }}
IS_STABLE="${{ steps.info.outputs.is_stable }}" \ run: bash scripts/github/publish-containers/publish-container-images.sh
bash scripts/build/publish.sh

47
.github/workflows/security-codeql.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: CodeQL Advanced
on:
workflow_call:
jobs:
analyze:
name: Check security
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: python
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended,security-and-quality
- name: Run manual build steps
if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code.'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"

View File

@@ -3,6 +3,9 @@ name: Test End-To-End
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-e2e: test-e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -11,7 +14,9 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [arch, debian, ubuntu, fedora, centos] distro: [arch, debian, ubuntu, fedora, centos]
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -3,6 +3,9 @@ name: Test Virgin Nix (flake only)
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-env-nix: test-env-nix:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -12,7 +15,9 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [arch, debian, ubuntu, fedora, centos] distro: [arch, debian, ubuntu, fedora, centos]
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -3,6 +3,9 @@ name: Test OS Containers
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-env-virtual: test-env-virtual:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -11,7 +14,9 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [arch, debian, ubuntu, fedora, centos] distro: [arch, debian, ubuntu, fedora, centos]
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -3,11 +3,16 @@ name: Test Code Integration
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-integration: test-integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -3,11 +3,16 @@ name: Test Units
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-unit: test-unit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -3,6 +3,9 @@ name: Test Virgin Root
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-virgin-root: test-virgin-root:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -11,7 +14,9 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [arch, debian, ubuntu, fedora, centos] distro: [arch, debian, ubuntu, fedora, centos]
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -19,13 +24,11 @@ jobs:
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
# 🔹 BUILD virgin image if missing
- name: Build virgin container (${{ matrix.distro }}) - name: Build virgin container (${{ matrix.distro }})
run: | run: |
set -euo pipefail set -euo pipefail
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
# 🔹 RUN test inside virgin image
- name: Virgin ${{ matrix.distro }} pkgmgr test (root) - name: Virgin ${{ matrix.distro }} pkgmgr test (root)
run: | run: |
set -euo pipefail set -euo pipefail
@@ -34,6 +37,7 @@ jobs:
-v "$PWD":/opt/src/pkgmgr \ -v "$PWD":/opt/src/pkgmgr \
-v pkgmgr_repos:/root/Repositories \ -v pkgmgr_repos:/root/Repositories \
-v pkgmgr_pip_cache:/root/.cache/pip \ -v pkgmgr_pip_cache:/root/.cache/pip \
-e NIX_CONFIG="${NIX_CONFIG}" \
-w /opt/src/pkgmgr \ -w /opt/src/pkgmgr \
"pkgmgr-${{ matrix.distro }}-virgin" \ "pkgmgr-${{ matrix.distro }}-virgin" \
bash -lc ' bash -lc '

View File

@@ -3,6 +3,9 @@ name: Test Virgin User
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-virgin-user: test-virgin-user:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -11,7 +14,9 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [arch, debian, ubuntu, fedora, centos] distro: [arch, debian, ubuntu, fedora, centos]
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -19,19 +24,18 @@ jobs:
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
# 🔹 BUILD virgin image if missing
- name: Build virgin container (${{ matrix.distro }}) - name: Build virgin container (${{ matrix.distro }})
run: | run: |
set -euo pipefail set -euo pipefail
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
# 🔹 RUN test inside virgin image as non-root
- name: Virgin ${{ matrix.distro }} pkgmgr test (user) - name: Virgin ${{ matrix.distro }} pkgmgr test (user)
run: | run: |
set -euo pipefail set -euo pipefail
docker run --rm \ docker run --rm \
-v "$PWD":/opt/src/pkgmgr \ -v "$PWD":/opt/src/pkgmgr \
-e NIX_CONFIG="${NIX_CONFIG}" \
-w /opt/src/pkgmgr \ -w /opt/src/pkgmgr \
"pkgmgr-${{ matrix.distro }}-virgin" \ "pkgmgr-${{ matrix.distro }}-virgin" \
bash -lc ' bash -lc '
@@ -48,17 +52,19 @@ jobs:
chown -R dev:dev /nix chown -R dev:dev /nix
chmod 0755 /nix chmod 0755 /nix
chmod 1777 /nix/store chmod 1777 /nix/store
sudo -H -u dev env \
HOME=/home/dev \
NIX_CONFIG="$NIX_CONFIG" \
PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 \
bash -lc "
set -euo pipefail
cd /opt/src/pkgmgr
make setup-venv
. \"\$HOME/.venvs/pkgmgr/bin/activate\"
sudo -H -u dev env HOME=/home/dev PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 bash -lc " pkgmgr version pkgmgr
set -euo pipefail
cd /opt/src/pkgmgr
make setup-venv export NIX_REMOTE=local
. \"\$HOME/.venvs/pkgmgr/bin/activate\" nix run /opt/src/pkgmgr#pkgmgr -- version pkgmgr
"
pkgmgr version pkgmgr
export NIX_REMOTE=local
nix run /opt/src/pkgmgr#pkgmgr -- version pkgmgr
"
' '

3
.gitignore vendored
View File

@@ -24,10 +24,9 @@ package-manager-*
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Nix Cache to speed up tests # Nix cache to speed up tests
.nix/ .nix/
.nix-dev-installed .nix-dev-installed
flake.lock
# Ignore logs # Ignore logs
*.log *.log

View File

@@ -1,3 +1,209 @@
# Changelog
## [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.
## [1.15.1] - 2026-05-28
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
## [1.15.0] - 2026-05-28
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
## [1.14.0] - 2026-05-27
* Added
* New release --retry mode re-deploys the HEAD release without
re-tagging or modifying any files. It re-pushes the existing version
tag, re-aligns the floating latest tag, and (unless --no-publish)
re-runs publish. Use this to recover from a release whose post-tag
push or PyPI upload failed mid-flight. The release_type argument
becomes optional under --retry.
* New module pkgmgr.actions.release.retry hosts the retry_release
helper so the workflow orchestrator stays focused on the forward
path.
* RepoPaths now exposes a debian_control slot, discovered alongside
debian_changelog under both packaging/debian and the legacy debian
layout.
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the distro-name lookup chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
idempotent push, latest-tag re-alignment, missing-tag error path,
and branch-detection fallback.
Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
then folder basename as legacy fallback. Renaming a repository
folder no longer silently flips the debian/changelog top entry and
the RPM changelog stanza to a new identifier. Those keep matching
the authoritative value in the packaging files, which is what apt,
pacman, and dnf index against.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement
on the next release.
## [1.13.4] - 2026-05-27
* Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
folder basename as legacy fallback. Renaming a repository folder (for
example infinito-nexus to infinito-nexus-core) no longer silently
flips the debian/changelog top entry and the RPM changelog stanza to
a new identifier. Those keep matching the authoritative Package,
pkgname, or Name value in the packaging files, which is what apt,
pacman, and dnf index against.
Added
* RepoPaths gains a debian_control slot that is discovered alongside
debian_changelog under both packaging/debian (new layout) and debian
(legacy layout).
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the priority chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement.
## [1.13.3] - 2026-03-26
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
## [1.13.2] - 2026-03-26
* Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
## [1.13.1] - 2026-03-20
* Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
## [1.13.0] - 2026-03-20
* Set CentOS docker image to latest
## [1.12.5] - 2026-02-24
* The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
## [1.12.4] - 2026-02-24
* The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
## [1.12.3] - 2026-02-24
* Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
## [1.12.2] - 2026-02-24
* Removed infinito-sphinx package
## [1.12.1] - 2026-02-14
* pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
## [1.12.0] - 2026-02-08
* Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
## [1.11.2] - 2026-02-08
* Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
## [1.11.1] - 2026-02-08
* Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
## [1.11.0] - 2026-01-21
* Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
## [1.10.0] - 2026-01-20
* Introduce safe verbose image cleanup to reduce Docker image size and build artifacts
## [1.9.5] - 2026-01-16
* Release patch: improve git pull error diagnostics
## [1.9.4] - 2026-01-13
* fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
## [1.9.3] - 2026-01-07
* Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
## [1.9.2] - 2025-12-21
* Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
## [1.9.1] - 2025-12-21
* Fixed installation issues and improved loading of default configuration files.
## [1.9.0] - 2025-12-20
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
* All configured git mirrors are now provisioned.
## [1.8.7] - 2025-12-19
* * **Release version updates now correctly modify ***pyproject.toml*** files that follow PEP 621**, ensuring the ***[project].version*** field is updated as expected.
* **Invalid or incomplete ***pyproject.toml*** files are now handled gracefully** with clear error messages instead of abrupt process termination.
* **RPM spec files remain compatible during releases**: existing macros such as ***%{?dist}*** are preserved and no longer accidentally modified.
## [1.8.6] - 2025-12-17
* Prevent Rate Limits during GitHub Nix Setups
## [1.8.5] - 2025-12-17 ## [1.8.5] - 2025-12-17
* * Clearer Git error handling, especially when a directory is not a Git repository. * * Clearer Git error handling, especially when a directory is not a Git repository.

View File

@@ -33,6 +33,7 @@ CMD ["bash"]
# - inherits from virgin # - inherits from virgin
# - builds + installs pkgmgr # - builds + installs pkgmgr
# - sets entrypoint + default cmd # - sets entrypoint + default cmd
# - NOTE: does NOT run slim.sh (that is done in slim stage)
# ============================================================ # ============================================================
FROM virgin AS full FROM virgin AS full
@@ -42,10 +43,10 @@ WORKDIR /build
COPY . . COPY . .
# Build and install distro-native package-manager package # Build and install distro-native package-manager package
RUN set -euo pipefail; \ RUN set -eu; \
echo "Building and installing package-manager via make install..."; \ echo "Building and installing package-manager via make install..."; \
make install; \ make install; \
cd /; rm -rf /build rm -rf /build
# Entry point # Entry point
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
@@ -53,3 +54,14 @@ COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
WORKDIR /opt/src/pkgmgr WORKDIR /opt/src/pkgmgr
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"] ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
CMD ["pkgmgr", "--help"] 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

View File

@@ -10,6 +10,10 @@ DISTROS ?= arch debian ubuntu fedora centos
PKGMGR_DISTRO ?= arch PKGMGR_DISTRO ?= arch
export PKGMGR_DISTRO export PKGMGR_DISTRO
# Nix Config Variable (To avoid rate limit)
NIX_CONFIG ?=
export NIX_CONFIG
# ------------------------------------------------------------ # ------------------------------------------------------------
# Base images # Base images
# (kept for documentation/reference; actual build logic is in scripts/build) # (kept for documentation/reference; actual build logic is in scripts/build)

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1771714954,
"narHash": "sha256-nhZJPnBavtu40/L2aqpljrfUNb2rxmWTmSjK2c9UKds=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "afbbf774e2087c3d734266c22f96fca2e78d3620",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -6,7 +6,7 @@
}; };
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
}; };
outputs = { self, nixpkgs }: outputs = { self, nixpkgs }:
@@ -32,7 +32,7 @@
rec { rec {
pkgmgr = pyPkgs.buildPythonApplication { pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager"; pname = "package-manager";
version = "1.8.5"; version = "1.15.2";
# Use the git repo as source # Use the git repo as source
src = ./.; src = ./.;
@@ -40,6 +40,10 @@
# Build using pyproject.toml # Build using pyproject.toml
format = "pyproject"; format = "pyproject";
# Clear any stale wheels carried in from the source tree so
# pypaInstallPhase doesn't collide on bin/pkgmgr.
preBuild = "rm -rf dist";
# Build backend requirements from [build-system] # Build backend requirements from [build-system]
nativeBuildInputs = [ nativeBuildInputs = [
pyPkgs.setuptools pyPkgs.setuptools
@@ -51,6 +55,8 @@
pyPkgs.pyyaml pyPkgs.pyyaml
pyPkgs.jinja2 pyPkgs.jinja2
pyPkgs.pip pyPkgs.pip
pkgs.git
pkgs.gnupg
]; ];
doCheck = false; doCheck = false;
@@ -87,6 +93,7 @@
buildInputs = [ buildInputs = [
pythonWithDeps pythonWithDeps
pkgs.git pkgs.git
pkgs.gnupg
ansiblePkg ansiblePkg
]; ];

View File

@@ -1,15 +1,25 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world> # Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager pkgname=package-manager
pkgver=1.8.5 pkgver=1.15.2
pkgrel=1 pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any') arch=('any')
url="https://github.com/kevinveenbirkenbach/package-manager" url="https://github.com/kevinveenbirkenbach/package-manager"
license=('MIT') license=('MIT')
# Nix is the only runtime dependency; Python is provided by the Nix closure. # Nix is required at runtime to run pkgmgr via the flake.
depends=('nix') # On Arch x86_64 we can depend on the distro package.
# On other arches (e.g. ARM) we only declare it as optional because the
# repo package may be broken/out-of-sync; installation can be done via the official installer.
depends=()
optdepends=('nix: required to run pkgmgr via flake')
if [[ "${CARCH}" == "x86_64" ]]; then
depends=('nix')
optdepends=()
fi
makedepends=('rsync') makedepends=('rsync')
install=${pkgname}.install install=${pkgname}.install

View File

@@ -1,9 +1,9 @@
post_install() { post_install() {
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable." /usr/lib/package-manager/nix/init.sh
} }
post_upgrade() { post_upgrade() {
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable." /usr/lib/package-manager/nix/init.sh
} }
post_remove() { post_remove() {

View File

@@ -1,3 +1,237 @@
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.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 11:06:43 +0200
package-manager (1.15.1-1) unstable; urgency=medium
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 08:18:23 +0200
package-manager (1.15.0-1) unstable; urgency=medium
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 07:56:07 +0200
package-manager (1.14.0-1) unstable; urgency=medium
* Added
* New release --retry mode re-deploys the HEAD release without
re-tagging or modifying any files. It re-pushes the existing version
tag, re-aligns the floating latest tag, and (unless --no-publish)
re-runs publish. Use this to recover from a release whose post-tag
push or PyPI upload failed mid-flight. The release_type argument
becomes optional under --retry.
* New module pkgmgr.actions.release.retry hosts the retry_release
helper so the workflow orchestrator stays focused on the forward
path.
* RepoPaths now exposes a debian_control slot, discovered alongside
debian_changelog under both packaging/debian and the legacy debian
layout.
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the distro-name lookup chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
idempotent push, latest-tag re-alignment, missing-tag error path,
and branch-detection fallback.
Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
then folder basename as legacy fallback. Renaming a repository
folder no longer silently flips the debian/changelog top entry and
the RPM changelog stanza to a new identifier. Those keep matching
the authoritative value in the packaging files, which is what apt,
pacman, and dnf index against.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement
on the next release.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:53:14 +0200
package-manager (1.13.4-1) unstable; urgency=medium
* Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
folder basename as legacy fallback. Renaming a repository folder (for
example infinito-nexus to infinito-nexus-core) no longer silently
flips the debian/changelog top entry and the RPM changelog stanza to
a new identifier. Those keep matching the authoritative Package,
pkgname, or Name value in the packaging files, which is what apt,
pacman, and dnf index against.
Added
* RepoPaths gains a debian_control slot that is discovered alongside
debian_changelog under both packaging/debian (new layout) and debian
(legacy layout).
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the priority chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:32:39 +0200
package-manager (1.13.3-1) unstable; urgency=medium
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 26 Mar 2026 17:10:21 +0100
package-manager (1.13.2-1) unstable; urgency=medium
* Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 26 Mar 2026 12:26:55 +0100
package-manager (1.13.1-1) unstable; urgency=medium
* Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 20 Mar 2026 02:57:25 +0100
package-manager (1.13.0-1) unstable; urgency=medium
* Set CentOS docker image to latest
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 20 Mar 2026 01:29:38 +0100
package-manager (1.12.5-1) unstable; urgency=medium
* The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 09:35:39 +0100
package-manager (1.12.4-1) unstable; urgency=medium
* The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 09:32:01 +0100
package-manager (1.12.3-1) unstable; urgency=medium
* Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 08:29:34 +0100
package-manager (1.12.2-1) unstable; urgency=medium
* Removed infinito-sphinx package
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 07:40:55 +0100
package-manager (1.12.1-1) unstable; urgency=medium
* pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 14 Feb 2026 23:26:17 +0100
package-manager (1.12.0-1) unstable; urgency=medium
* Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 08 Feb 2026 18:26:25 +0100
package-manager (1.11.2-1) unstable; urgency=medium
* Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 08 Feb 2026 18:21:50 +0100
package-manager (1.11.1-1) unstable; urgency=medium
* Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 08 Feb 2026 18:18:09 +0100
package-manager (1.11.0-1) unstable; urgency=medium
* Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 21 Jan 2026 01:18:31 +0100
package-manager (1.10.0-1) unstable; urgency=medium
* Automated release.
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 20 Jan 2026 10:44:58 +0100
package-manager (1.9.5-1) unstable; urgency=medium
* Release patch: improve git pull error diagnostics
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 16 Jan 2026 10:09:43 +0100
package-manager (1.9.4-1) unstable; urgency=medium
* fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 13 Jan 2026 14:48:50 +0100
package-manager (1.9.3-1) unstable; urgency=medium
* Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 07 Jan 2026 13:44:40 +0100
package-manager (1.9.2-1) unstable; urgency=medium
* Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 21 Dec 2025 15:30:22 +0100
package-manager (1.9.1-1) unstable; urgency=medium
* Fixed installation issues and improved loading of default configuration files.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 21 Dec 2025 13:38:58 +0100
package-manager (1.9.0-1) unstable; urgency=medium
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
* All configured git mirrors are now provisioned.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 20 Dec 2025 14:37:58 +0100
package-manager (1.8.7-1) unstable; urgency=medium
* * **Release version updates now correctly modify ***pyproject.toml*** files that follow PEP 621**, ensuring the ***[project].version*** field is updated as expected.
* **Invalid or incomplete ***pyproject.toml*** files are now handled gracefully** with clear error messages instead of abrupt process termination.
* **RPM spec files remain compatible during releases**: existing macros such as ***%{?dist}*** are preserved and no longer accidentally modified.
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 19 Dec 2025 14:15:47 +0100
package-manager (1.8.6-1) unstable; urgency=medium
* Prevent Rate Limits during GitHub Nix Setups
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 17 Dec 2025 23:50:31 +0100
package-manager (1.8.5-1) unstable; urgency=medium package-manager (1.8.5-1) unstable; urgency=medium
* * Clearer Git error handling, especially when a directory is not a Git repository. * * Clearer Git error handling, especially when a directory is not a Git repository.

View File

@@ -3,7 +3,7 @@ set -e
case "$1" in case "$1" in
configure) configure)
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable." /usr/lib/package-manager/nix/init.sh
;; ;;
esac esac

View File

@@ -1,5 +1,5 @@
Name: package-manager Name: package-manager
Version: 1.8.5 Version: 1.15.2
Release: 1%{?dist} Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -62,7 +62,7 @@ rm -rf \
%{buildroot}/usr/lib/package-manager/.gitkeep || true %{buildroot}/usr/lib/package-manager/.gitkeep || true
%post %post
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable." /usr/lib/package-manager/nix/init.sh
%postun %postun
echo ">>> package-manager removed. Nix itself was not removed." echo ">>> package-manager removed. Nix itself was not removed."
@@ -74,6 +74,159 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/ /usr/lib/package-manager/
%changelog %changelog
* 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.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.1-1
- Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.0-1
- Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.14.0-1
- Added
* New release --retry mode re-deploys the HEAD release without
re-tagging or modifying any files. It re-pushes the existing version
tag, re-aligns the floating latest tag, and (unless --no-publish)
re-runs publish. Use this to recover from a release whose post-tag
push or PyPI upload failed mid-flight. The release_type argument
becomes optional under --retry.
* New module pkgmgr.actions.release.retry hosts the retry_release
helper so the workflow orchestrator stays focused on the forward
path.
* RepoPaths now exposes a debian_control slot, discovered alongside
debian_changelog under both packaging/debian and the legacy debian
layout.
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the distro-name lookup chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
idempotent push, latest-tag re-alignment, missing-tag error path,
and branch-detection fallback.
Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
then folder basename as legacy fallback. Renaming a repository
folder no longer silently flips the debian/changelog top entry and
the RPM changelog stanza to a new identifier. Those keep matching
the authoritative value in the packaging files, which is what apt,
pacman, and dnf index against.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement
on the next release.
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.4-1
- Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
folder basename as legacy fallback. Renaming a repository folder (for
example infinito-nexus to infinito-nexus-core) no longer silently
flips the debian/changelog top entry and the RPM changelog stanza to
a new identifier. Those keep matching the authoritative Package,
pkgname, or Name value in the packaging files, which is what apt,
pacman, and dnf index against.
Added
* RepoPaths gains a debian_control slot that is discovered alongside
debian_changelog under both packaging/debian (new layout) and debian
(legacy layout).
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the priority chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement.
* Thu Mar 26 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.3-1
- CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
* Thu Mar 26 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.2-1
- Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
* Fri Mar 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.1-1
- Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
* Fri Mar 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.0-1
- Set CentOS docker image to latest
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.5-1
- The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.4-1
- The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.3-1
- Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.2-1
- Removed infinito-sphinx package
* Sat Feb 14 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.1-1
- pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.0-1
- Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.2-1
- Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.1-1
- Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
* Wed Jan 21 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.0-1
- Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
* Tue Jan 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.10.0-1
- Automated release.
* Fri Jan 16 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.5-1
- Release patch: improve git pull error diagnostics
* Tue Jan 13 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.4-1
- fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
* Wed Jan 07 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.3-1
- Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
* Sun Dec 21 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.2-1
- Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
* Sun Dec 21 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.1-1
- Fixed installation issues and improved loading of default configuration files.
* Sat Dec 20 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.0-1
- * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
* All configured git mirrors are now provisioned.
* Fri Dec 19 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.7-1
- * **Release version updates now correctly modify ***pyproject.toml*** files that follow PEP 621**, ensuring the ***[project].version*** field is updated as expected.
* **Invalid or incomplete ***pyproject.toml*** files are now handled gracefully** with clear error messages instead of abrupt process termination.
* **RPM spec files remain compatible during releases**: existing macros such as ***%{?dist}*** are preserved and no longer accidentally modified.
* Wed Dec 17 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.6-1
- Prevent Rate Limits during GitHub Nix Setups
* Wed Dec 17 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.5-1 * Wed Dec 17 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.5-1
- * Clearer Git error handling, especially when a directory is not a Git repository. - * Clearer Git error handling, especially when a directory is not a Git repository.
* More reliable repository verification with improved commit and GPG signature checks. * More reliable repository verification with improved commit and GPG signature checks.

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "kpmx" name = "kpmx"
version = "1.8.5" version = "1.15.2"
description = "Kevin's package-manager tool (pkgmgr)" description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
@@ -43,11 +43,12 @@ pkgmgr = "pkgmgr.cli:main"
# ----------------------------- # -----------------------------
# Source layout: all packages live under "src/" # Source layout: all packages live under "src/"
[tool.setuptools] [tool.setuptools]
package-dir = { "" = "src", "config" = "config" } package-dir = { "" = "src" }
include-package-data = true
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src", "."] where = ["src"]
include = ["pkgmgr*", "config*"] include = ["pkgmgr*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
"config" = ["defaults.yaml"] "pkgmgr.config" = ["*.yml", "*.yaml"]

View File

@@ -5,7 +5,7 @@ set -euo pipefail
: "${BASE_IMAGE_DEBIAN:=debian:stable-slim}" : "${BASE_IMAGE_DEBIAN:=debian:stable-slim}"
: "${BASE_IMAGE_UBUNTU:=ubuntu:latest}" : "${BASE_IMAGE_UBUNTU:=ubuntu:latest}"
: "${BASE_IMAGE_FEDORA:=fedora:latest}" : "${BASE_IMAGE_FEDORA:=fedora:latest}"
: "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:stream9}" : "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:latest}"
resolve_base_image() { resolve_base_image() {
local PKGMGR_DISTRO="$1" local PKGMGR_DISTRO="$1"

View File

@@ -33,7 +33,7 @@ Usage: PKGMGR_DISTRO=<distro> $0 [options]
Build options: Build options:
--missing Build only if the image does not already exist (local build only) --missing Build only if the image does not already exist (local build only)
--no-cache Build with --no-cache --no-cache Build with --no-cache
--target <name> Build a specific Dockerfile target (e.g. virgin) --target <name> Build a specific Dockerfile target (e.g. virgin, slim)
--tag <image> Override the output image tag (default: ${default_tag}) --tag <image> Override the output image tag (default: ${default_tag})
Publish options: Publish options:
@@ -47,7 +47,7 @@ Publish options:
Notes: Notes:
- --publish implies --push and requires --registry, --owner, and --version. - --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". - Local build (no --push) uses "docker build" and creates local images like "pkgmgr-arch" / "pkgmgr-arch-virgin" / "pkgmgr-arch-slim".
EOF EOF
} }
@@ -57,7 +57,7 @@ while [[ $# -gt 0 ]]; do
--missing) MISSING_ONLY=1; shift ;; --missing) MISSING_ONLY=1; shift ;;
--target) --target)
TARGET="${2:-}" TARGET="${2:-}"
[[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin)"; exit 2; } [[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin|slim)"; exit 2; }
shift 2 shift 2
;; ;;
--tag) --tag)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Publish all distro images (full + virgin) to a registry via image.sh --publish # Publish all distro images (full + virgin + slim) to a registry via image.sh --publish
# #
# Required env: # Required env:
# OWNER (e.g. GITHUB_REPOSITORY_OWNER) # OWNER (e.g. GITHUB_REPOSITORY_OWNER)
@@ -11,6 +11,9 @@ set -euo pipefail
# REGISTRY (default: ghcr.io) # REGISTRY (default: ghcr.io)
# IS_STABLE (default: false) # IS_STABLE (default: false)
# DISTROS (default: "arch debian ubuntu fedora centos") # DISTROS (default: "arch debian ubuntu fedora centos")
#
# Notes:
# - This expects Dockerfile targets: virgin, full (default), slim
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -33,7 +36,10 @@ for d in ${DISTROS}; do
echo "[publish] PKGMGR_DISTRO=${d}" echo "[publish] PKGMGR_DISTRO=${d}"
echo "============================================================" echo "============================================================"
# ----------------------------------------------------------
# virgin # virgin
# -> ghcr.io/<owner>/pkgmgr-<distro>-virgin:{latest,<version>,stable?}
# ----------------------------------------------------------
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \ PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \ --publish \
--registry "${REGISTRY}" \ --registry "${REGISTRY}" \
@@ -42,13 +48,29 @@ for d in ${DISTROS}; do
--stable "${IS_STABLE}" \ --stable "${IS_STABLE}" \
--target virgin --target virgin
# ----------------------------------------------------------
# full (default target) # full (default target)
# -> ghcr.io/<owner>/pkgmgr-<distro>:{latest,<version>,stable?}
# ----------------------------------------------------------
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \ PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \ --publish \
--registry "${REGISTRY}" \ --registry "${REGISTRY}" \
--owner "${OWNER}" \ --owner "${OWNER}" \
--version "${VERSION}" \ --version "${VERSION}" \
--stable "${IS_STABLE}" --stable "${IS_STABLE}"
# ----------------------------------------------------------
# slim
# -> ghcr.io/<owner>/pkgmgr-<distro>-slim:{latest,<version>,stable?}
# + alias for default distro: ghcr.io/<owner>/pkgmgr-slim:{...}
# ----------------------------------------------------------
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \
--registry "${REGISTRY}" \
--owner "${OWNER}" \
--version "${VERSION}" \
--stable "${IS_STABLE}" \
--target slim
done done
echo echo

130
scripts/docker/slim.sh Normal file
View File

@@ -0,0 +1,130 @@
#!/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"

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_SHA="${TARGET_SHA:-${GITHUB_SHA:?GITHUB_SHA must be set}}"
git fetch --no-tags origin main
if git merge-base --is-ancestor "${TARGET_SHA}" "origin/main"; then
echo "is_on_main=true" >> "$GITHUB_OUTPUT"
echo "Target commit ${TARGET_SHA} is contained in origin/main."
else
echo "is_on_main=false" >> "$GITHUB_OUTPUT"
echo "Target commit ${TARGET_SHA} is not contained in origin/main. Skipping main-only action."
fi

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
echo "Ref: $GITHUB_REF"
echo "SHA: $GITHUB_SHA"
VERSION="${GITHUB_REF#refs/tags/}"
echo "Current version tag: ${VERSION}"
echo "Collecting all version tags..."
ALL_V_TAGS="$(git tag --list 'v*' || true)"
if [[ -z "${ALL_V_TAGS}" ]]; then
echo "No version tags found. Skipping stable update."
exit 0
fi
echo "All version tags:"
echo "${ALL_V_TAGS}"
LATEST_TAG="$(printf '%s\n' "${ALL_V_TAGS}" | sort -V | tail -n1)"
echo "Highest version tag: ${LATEST_TAG}"
if [[ "${VERSION}" != "${LATEST_TAG}" ]]; then
echo "Current version ${VERSION} is NOT the highest version."
echo "Stable tag will NOT be updated."
exit 0
fi
echo "Current version ${VERSION} IS the highest version."
echo "Updating 'stable' tag..."
git tag -d stable 2>/dev/null || true
git push origin :refs/tags/stable || true
git tag stable "$GITHUB_SHA"
git push origin stable
echo "Stable tag updated to ${VERSION}."

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
SHA="${GITHUB_SHA}"
API_URL="https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${SHA}&event=push&per_page=20"
WAIT_INTERVAL_SECONDS=20
MAX_ATTEMPTS=990 # 5 hours 30 minutes max wait
STATUS=""
CONCLUSION=""
echo "Waiting for CI on main for ${SHA} (up to 5 hours 30 minutes)..."
for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do
RESPONSE="$(curl -fsSL \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${API_URL}")"
STATUS="$(printf '%s' "${RESPONSE}" | jq -r '.workflow_runs[] | select(.head_branch=="main") | .status' | head -n1)"
CONCLUSION="$(printf '%s' "${RESPONSE}" | jq -r '.workflow_runs[] | select(.head_branch=="main") | .conclusion' | head -n1)"
if [[ -n "${STATUS}" ]]; then
echo "CI status=${STATUS} conclusion=${CONCLUSION:-none} (attempt ${attempt}/${MAX_ATTEMPTS})"
else
echo "No CI run for main found yet (attempt ${attempt}/${MAX_ATTEMPTS})"
fi
if [[ "${STATUS}" == "completed" ]]; then
if [[ "${CONCLUSION}" == "success" ]]; then
echo "CI succeeded for ${SHA}."
break
fi
echo "CI failed for ${SHA} (conclusion=${CONCLUSION})."
exit 1
fi
sleep "${WAIT_INTERVAL_SECONDS}"
done
if [[ "${STATUS}" != "completed" || "${CONCLUSION}" != "success" ]]; then
echo "Timed out waiting for successful CI on main for ${SHA}."
exit 1
fi

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
WORKFLOW_RUN_SHA="${WORKFLOW_RUN_SHA:?WORKFLOW_RUN_SHA must be set}"
git checkout -f "${WORKFLOW_RUN_SHA}"
git fetch --tags --force
git tag --list 'stable' 'v*' --sort=version:refname | tail -n 20

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
SHA="$(git rev-parse HEAD)"
V_TAG="$(git tag --points-at "${SHA}" --list 'v*' | sort -V | tail -n1)"
if [[ -z "${V_TAG}" ]]; then
echo "No version tag found for ${SHA}. Skipping publish."
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
VERSION="${V_TAG#v}"
STABLE_SHA="$(git rev-parse -q --verify 'refs/tags/stable^{commit}' 2>/dev/null || true)"
IS_STABLE=false
[[ -n "${STABLE_SHA}" && "${STABLE_SHA}" == "${SHA}" ]] && IS_STABLE=true
{
echo "should_publish=true"
echo "version=${VERSION}"
echo "is_stable=${IS_STABLE}"
} >> "$GITHUB_OUTPUT"

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
: "${OWNER:?OWNER must be set}"
: "${VERSION:?VERSION must be set}"
: "${IS_STABLE:?IS_STABLE must be set}"
bash scripts/build/publish.sh

View File

@@ -38,11 +38,7 @@ echo "[aur-builder-setup] Configuring sudoers for aur_builder..."
${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder" ${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder"
${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder ${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder
if command -v sudo >/dev/null 2>&1; then RUN_AS_AUR=(runuser -u aur_builder -- bash -c)
RUN_AS_AUR=(sudo -u aur_builder bash -lc)
else
RUN_AS_AUR=(su - aur_builder -c)
fi
echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..." echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..."

View File

@@ -16,6 +16,7 @@ fi
pacman -S --noconfirm --needed \ pacman -S --noconfirm --needed \
base-devel \ base-devel \
git \ git \
gnupg \
rsync \ rsync \
curl \ curl \
ca-certificates \ ca-certificates \

View File

@@ -47,7 +47,7 @@ echo "[arch/package] Using 'aur_builder' user for makepkg..."
chown -R aur_builder:aur_builder "${BUILD_ROOT}" chown -R aur_builder:aur_builder "${BUILD_ROOT}"
echo "[arch/package] Running makepkg in: ${PKG_BUILD_DIR}" echo "[arch/package] Running makepkg in: ${PKG_BUILD_DIR}"
su aur_builder -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps" runuser -u aur_builder -- bash -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
echo "[arch/package] Installing generated Arch package..." echo "[arch/package] Installing generated Arch package..."
pkg_path="$(find "${PKG_BUILD_DIR}" -maxdepth 1 -type f -name 'package-manager-*.pkg.tar.*' | head -n1)" pkg_path="$(find "${PKG_BUILD_DIR}" -maxdepth 1 -type f -name 'package-manager-*.pkg.tar.*' | head -n1)"

View File

@@ -6,6 +6,7 @@ echo "[centos/dependencies] Installing CentOS build dependencies..."
dnf -y update dnf -y update
dnf -y install \ dnf -y install \
git \ git \
gnupg2 \
rsync \ rsync \
rpm-build \ rpm-build \
make \ make \

View File

@@ -9,6 +9,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
debhelper \ debhelper \
dpkg-dev \ dpkg-dev \
git \ git \
gnupg \
rsync \ rsync \
bash \ bash \
curl \ curl \

View File

@@ -6,6 +6,7 @@ echo "[fedora/dependencies] Installing Fedora build dependencies..."
dnf -y update dnf -y update
dnf -y install \ dnf -y install \
git \ git \
gnupg2 \
rsync \ rsync \
rpm-build \ rpm-build \
make \ make \

View File

@@ -2,8 +2,8 @@
set -euo pipefail set -euo pipefail
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "[installation/install] Warning: Installation is just possible via root." echo "[installation/install] ERROR: Installation requires root. Re-run with sudo." >&2
exit 0 exit 1
fi fi
echo "[installation] Running as root (EUID=0)." echo "[installation] Running as root (EUID=0)."

View File

@@ -9,6 +9,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
debhelper \ debhelper \
dpkg-dev \ dpkg-dev \
git \ git \
gnupg \
tzdata \ tzdata \
lsb-release \ lsb-release \
rsync \ rsync \

View File

@@ -37,10 +37,16 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if ! command -v nix >/dev/null 2>&1; then if ! command -v nix >/dev/null 2>&1; then
if [[ -x "${FLAKE_DIR}/nix/init.sh" ]]; then if [[ -x "${FLAKE_DIR}/nix/init.sh" ]]; then
"${FLAKE_DIR}/nix/init.sh" || true "${FLAKE_DIR}/nix/init.sh"
fi fi
fi fi
if ! command -v nix >/dev/null 2>&1; then
echo "[launcher] ERROR: 'nix' binary not found on PATH after init." >&2
echo "[launcher] Nix is required to run pkgmgr (no Python fallback)." >&2
exit 1
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Primary path: use Nix flake if available (with GitHub 403 retry) # Primary path: use Nix flake if available (with GitHub 403 retry)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -51,7 +57,3 @@ if declare -F run_with_github_403_retry >/dev/null; then
else else
exec nix run "${FLAKE_DIR}#pkgmgr" -- "$@" exec nix run "${FLAKE_DIR}#pkgmgr" -- "$@"
fi fi
echo "[launcher] ERROR: 'nix' binary not found on PATH after init."
echo "[launcher] Nix is required to run pkgmgr (no Python fallback)."
exit 1

View File

@@ -49,11 +49,7 @@ install_nix_with_retry() {
if [[ -n "$run_as" ]]; then if [[ -n "$run_as" ]]; then
chown "$run_as:$run_as" "$installer" 2>/dev/null || true chown "$run_as:$run_as" "$installer" 2>/dev/null || true
echo "[init-nix] Running installer as user '$run_as' ($mode_flag)..." echo "[init-nix] Running installer as user '$run_as' ($mode_flag)..."
if command -v sudo >/dev/null 2>&1; then su - "$run_as" -s /bin/bash -c "bash -lc \"sh '$installer' $mode_flag\""
sudo -u "$run_as" bash -lc "sh '$installer' $mode_flag"
else
su - "$run_as" -c "sh '$installer' $mode_flag"
fi
else else
echo "[init-nix] Running installer as current user ($mode_flag)..." echo "[init-nix] Running installer as current user ($mode_flag)..."
sh "$installer" "$mode_flag" sh "$installer" "$mode_flag"

View File

@@ -36,16 +36,17 @@ real_exe() {
# Resolve nix binary path robustly (works across distros + Arch /usr/sbin) # Resolve nix binary path robustly (works across distros + Arch /usr/sbin)
resolve_nix_bin() { resolve_nix_bin() {
local nix_cmd="" # IMPORTANT: prefer distro-managed locations first.
nix_cmd="$(command -v nix 2>/dev/null || true)" # This avoids pinning /usr/local/bin/nix to a stale user-profile nix binary.
[[ -n "$nix_cmd" ]] && real_exe "$nix_cmd" && return 0
# IMPORTANT: prefer system locations before /usr/local to avoid self-symlink traps
[[ -x /usr/sbin/nix ]] && { echo "/usr/sbin/nix"; return 0; } # Arch package can land here [[ -x /usr/sbin/nix ]] && { echo "/usr/sbin/nix"; return 0; } # Arch package can land here
[[ -x /usr/bin/nix ]] && { echo "/usr/bin/nix"; return 0; } [[ -x /usr/bin/nix ]] && { echo "/usr/bin/nix"; return 0; }
[[ -x /bin/nix ]] && { echo "/bin/nix"; return 0; } [[ -x /bin/nix ]] && { echo "/bin/nix"; return 0; }
# /usr/local last, and only if it resolves to a real executable local nix_cmd=""
nix_cmd="$(command -v nix 2>/dev/null || true)"
[[ -n "$nix_cmd" ]] && real_exe "$nix_cmd" && return 0
# /usr/local after system locations, and only if it resolves to a real executable
[[ -e /usr/local/bin/nix ]] && real_exe "/usr/local/bin/nix" && return 0 [[ -e /usr/local/bin/nix ]] && real_exe "/usr/local/bin/nix" && return 0
[[ -x /nix/var/nix/profiles/default/bin/nix ]] && { [[ -x /nix/var/nix/profiles/default/bin/nix ]] && {

View File

@@ -11,6 +11,7 @@ docker run --rm \
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \ -v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
-e REINSTALL_PKGMGR=1 \ -e REINSTALL_PKGMGR=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \ -e TEST_PATTERN="${TEST_PATTERN}" \
-e NIX_CONFIG="${NIX_CONFIG}" \
--workdir /opt/src/pkgmgr \ --workdir /opt/src/pkgmgr \
"pkgmgr-${PKGMGR_DISTRO}" \ "pkgmgr-${PKGMGR_DISTRO}" \
bash -lc ' bash -lc '

View File

@@ -14,6 +14,7 @@ docker run --rm \
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \ -v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
--workdir /opt/src/pkgmgr \ --workdir /opt/src/pkgmgr \
-e REINSTALL_PKGMGR=1 \ -e REINSTALL_PKGMGR=1 \
-e NIX_CONFIG="${NIX_CONFIG}" \
"${IMAGE}" \ "${IMAGE}" \
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail

View File

@@ -19,6 +19,7 @@ if OUTPUT=$(docker run --rm \
-e REINSTALL_PKGMGR=1 \ -e REINSTALL_PKGMGR=1 \
-v "$(pwd):/opt/src/pkgmgr" \ -v "$(pwd):/opt/src/pkgmgr" \
-w /opt/src/pkgmgr \ -w /opt/src/pkgmgr \
-e NIX_CONFIG="${NIX_CONFIG}" \
"${IMAGE}" \ "${IMAGE}" \
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail

View File

@@ -12,6 +12,7 @@ docker run --rm \
--workdir /opt/src/pkgmgr \ --workdir /opt/src/pkgmgr \
-e REINSTALL_PKGMGR=1 \ -e REINSTALL_PKGMGR=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \ -e TEST_PATTERN="${TEST_PATTERN}" \
-e NIX_CONFIG="${NIX_CONFIG}" \
"pkgmgr-${PKGMGR_DISTRO}" \ "pkgmgr-${PKGMGR_DISTRO}" \
bash -lc ' bash -lc '
set -e; set -e;

View File

@@ -12,6 +12,7 @@ docker run --rm \
--workdir /opt/src/pkgmgr \ --workdir /opt/src/pkgmgr \
-e REINSTALL_PKGMGR=1 \ -e REINSTALL_PKGMGR=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \ -e TEST_PATTERN="${TEST_PATTERN}" \
-e NIX_CONFIG="${NIX_CONFIG}" \
"pkgmgr-${PKGMGR_DISTRO}" \ "pkgmgr-${PKGMGR_DISTRO}" \
bash -lc ' bash -lc '
set -e; set -e;

View File

@@ -25,12 +25,12 @@ __all__ = ["cli"]
def __getattr__(name: str) -> Any: def __getattr__(name: str) -> Any:
""" """
Lazily expose ``pkgmgr.cli`` as attribute on the top-level package. Lazily expose ``pkgmgr.cli`` as attribute on the top-level package.
This keeps ``import pkgmgr`` lightweight while still allowing This keeps ``import pkgmgr`` lightweight while still allowing
``from pkgmgr import cli`` in tests and entry points. ``from pkgmgr import cli`` in tests and entry points.
""" """
if name == "cli": if name == "cli":
return import_module("pkgmgr.cli") return import_module("pkgmgr.cli")
raise AttributeError(f"module 'pkgmgr' has no attribute {name!r}") raise AttributeError(f"module 'pkgmgr' has no attribute {name!r}")

View File

@@ -3,4 +3,4 @@ from __future__ import annotations
# expose subpackages for patch() / resolve_name() friendliness # expose subpackages for patch() / resolve_name() friendliness
from . import release as release # noqa: F401 from . import release as release # noqa: F401
__all__ = ["release"] __all__ = ["release"]

View File

@@ -0,0 +1,31 @@
"""Archive fully-checked Markdown files into a README index, then delete them.
The archive action walks a directory for numbered ``NNN-topic.md`` files
(default pattern ``^\\d{3}-[^/]+\\.md$``), promotes every file with zero
unchecked ``- [ ]`` task-list markers into a ``## Archive`` index inside
the directory README, and deletes the per-file source. Useful for
keeping ``docs/requirements/`` (or any other task-tracked spec folder)
short and focused on open work.
The module was extracted from
``cli/contributing/requirements/archive`` in infinito-nexus-core so
every kpmx-managed repository can rely on the same archival convention
without copy-pasting the helpers.
"""
from __future__ import annotations
from .discovery import iter_archivable_files
from .inspect import count_unchecked_items, extract_h1
from .readme import existing_archive_entries, merge_archive_section
from .workflow import ArchivePlan, run_archive
__all__ = [
"ArchivePlan",
"count_unchecked_items",
"existing_archive_entries",
"extract_h1",
"iter_archivable_files",
"merge_archive_section",
"run_archive",
]

View File

@@ -0,0 +1,53 @@
"""Locate archivable Markdown files under a target directory."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Iterable
DEFAULT_FILENAME_PATTERN = re.compile(r"^\d{3}-[^/]+\.md$")
TEMPLATE_FILENAME = "000-template.md"
def iter_archivable_files(
directory: Path,
*,
include_template: bool = False,
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
template_filename: str = TEMPLATE_FILENAME,
) -> list[Path]:
"""Return all files in *directory* whose name matches *pattern*, sorted.
``000-template.md`` (or whatever *template_filename* matches) is
excluded unless *include_template* is true. The check is filename
based; nested directories are not traversed.
"""
if not directory.is_dir():
return []
files: list[Path] = []
for path in sorted(directory.iterdir()):
if not path.is_file() or not pattern.match(path.name):
continue
if not include_template and path.name == template_filename:
continue
files.append(path)
return files
def filter_archivable_files(
paths: Iterable[Path],
*,
include_template: bool = False,
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
template_filename: str = TEMPLATE_FILENAME,
) -> list[Path]:
"""Same predicate as :func:`iter_archivable_files`, applied to an iterable."""
result: list[Path] = []
for path in paths:
if not path.is_file() or not pattern.match(path.name):
continue
if not include_template and path.name == template_filename:
continue
result.append(path)
return result

View File

@@ -0,0 +1,35 @@
"""Parse a single Markdown file: H1 heading and task-list completeness."""
from __future__ import annotations
import re
from pathlib import Path
H1_RE = re.compile(r"^#\s+(?P<title>\S.*?)\s*$")
UNCHECKED_TASK_RE = re.compile(r"^\s*[-*+]\s+\[\s\]\s")
def extract_h1(path: Path) -> str | None:
"""Return the first H1 title in *path* or ``None`` if there is none."""
try:
with path.open(encoding="utf-8") as fh:
for line in fh:
match = H1_RE.match(line.rstrip("\n"))
if match:
return match.group("title")
except OSError:
return None
return None
def count_unchecked_items(path: Path) -> int:
"""Return the number of ``- [ ]`` task-list markers anywhere in *path*.
A non-zero count means the file is not yet fully complete and MUST
NOT be archived.
"""
try:
with path.open(encoding="utf-8") as fh:
return sum(1 for line in fh if UNCHECKED_TASK_RE.match(line))
except OSError:
return 0

View File

@@ -0,0 +1,76 @@
"""Read and update the ``## Archive`` section of a directory README."""
from __future__ import annotations
import re
ARCHIVE_HEADING = "## Archive"
LIST_ITEM_RE = re.compile(r"^\s*-\s+(?P<body>\S.*)$")
def existing_archive_entries(readme_text: str) -> set[str]:
"""Return the deduplicated set of list-item bodies under ``## Archive``."""
lines = readme_text.splitlines()
in_archive = False
entries: set[str] = set()
for line in lines:
stripped = line.rstrip()
if stripped == ARCHIVE_HEADING:
in_archive = True
continue
if in_archive and stripped.startswith("## "):
break
if not in_archive:
continue
match = LIST_ITEM_RE.match(stripped)
if match:
entries.add(match.group("body").strip())
return entries
def merge_archive_section(readme_text: str, new_entries: list[str]) -> str:
"""Return ``readme_text`` with *new_entries* appended under ``## Archive``.
Existing entries are preserved verbatim. If the section is missing it
is created at the end of the document.
"""
if not new_entries:
return readme_text
lines = readme_text.splitlines()
archive_index = next(
(i for i, line in enumerate(lines) if line.rstrip() == ARCHIVE_HEADING),
None,
)
if archive_index is None:
suffix = [""] if (lines and lines[-1] != "") else []
suffix.append(ARCHIVE_HEADING)
suffix.append("")
suffix.extend(f"- {entry}" for entry in new_entries)
merged = lines + suffix
return "\n".join(merged) + "\n"
section_end = next(
(i for i in range(archive_index + 1, len(lines)) if lines[i].startswith("## ")),
len(lines),
)
body_start = archive_index + 1
while body_start < section_end and not lines[body_start].strip():
body_start += 1
last_item = body_start - 1
for i in range(body_start, section_end):
if LIST_ITEM_RE.match(lines[i]):
last_item = i
insertion_point = (last_item + 1) if last_item >= body_start else body_start
if insertion_point == body_start and body_start == archive_index + 1:
new_block = ["", *[f"- {entry}" for entry in new_entries]]
else:
new_block = [f"- {entry}" for entry in new_entries]
merged = lines[:insertion_point] + new_block + lines[insertion_point:]
trailing = "\n" if readme_text.endswith("\n") else ""
return "\n".join(merged) + trailing

View File

@@ -0,0 +1,115 @@
"""Orchestrator for archiving fully-checked Markdown files."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from .discovery import iter_archivable_files
from .inspect import count_unchecked_items, extract_h1
from .readme import existing_archive_entries, merge_archive_section
@dataclass(frozen=True)
class ArchivePlan:
"""Outcome of an archive analysis run.
Attributes:
archived: ``(source_path, title)`` for every file that was (or
would be) archived. Order matches the original directory
listing.
skipped_incomplete: ``(source_path, unchecked_count)`` for files
that still hold ``- [ ]`` markers.
skipped_without_h1: files that had no H1 heading to use as title.
new_entries: titles that will be appended to the README index.
existing_entries: titles already present in the README index.
"""
archived: list[tuple[Path, str]]
skipped_incomplete: list[tuple[Path, int]]
skipped_without_h1: list[Path]
new_entries: list[str]
existing_entries: set[str]
def _bucket_files(
files: list[Path],
) -> tuple[
list[tuple[Path, str]],
list[tuple[Path, int]],
list[Path],
]:
plan: list[tuple[Path, str]] = []
skipped_incomplete: list[tuple[Path, int]] = []
skipped_without_h1: list[Path] = []
for path in files:
unchecked = count_unchecked_items(path)
if unchecked > 0:
skipped_incomplete.append((path, unchecked))
continue
title = extract_h1(path)
if title is None:
skipped_without_h1.append(path)
continue
plan.append((path, title))
return plan, skipped_incomplete, skipped_without_h1
def _dedupe_titles(
plan: list[tuple[Path, str]], already_archived: set[str]
) -> list[str]:
new_entries: list[str] = []
for _path, title in plan:
if title in already_archived or title in new_entries:
continue
new_entries.append(title)
return new_entries
def run_archive(
directory: Path,
readme_path: Path,
*,
dry_run: bool = False,
include_template: bool = False,
) -> ArchivePlan:
"""Walk *directory* and archive every fully-checked file into *readme_path*.
Returns an :class:`ArchivePlan` describing the outcome. When
``dry_run`` is true no files are deleted and the README is not
rewritten — the plan still reflects what *would* happen.
Raises ``FileNotFoundError`` if *directory* or *readme_path* does
not exist.
"""
if not directory.is_dir():
raise FileNotFoundError(f"Archive directory not found: {directory}")
if not readme_path.is_file():
raise FileNotFoundError(f"README not found: {readme_path}")
files = iter_archivable_files(directory, include_template=include_template)
readme_text = readme_path.read_text(encoding="utf-8")
already_archived = existing_archive_entries(readme_text)
archived, skipped_incomplete, skipped_without_h1 = _bucket_files(files)
new_entries = _dedupe_titles(archived, already_archived)
if not dry_run and new_entries:
merged_text = merge_archive_section(readme_text, new_entries)
if merged_text != readme_text:
readme_path.write_text(merged_text, encoding="utf-8")
if not dry_run:
for path, _title in archived:
try:
path.unlink()
except FileNotFoundError:
pass
return ArchivePlan(
archived=archived,
skipped_incomplete=skipped_incomplete,
skipped_without_h1=skipped_without_h1,
new_entries=new_entries,
existing_entries=already_archived,
)

View File

@@ -48,9 +48,13 @@ def close_branch(
# Confirmation # Confirmation
if not force: if not force:
answer = input( answer = (
f"Merge branch '{name}' into '{target_base}' and delete it afterwards? (y/N): " input(
).strip().lower() f"Merge branch '{name}' into '{target_base}' and delete it afterwards? (y/N): "
)
.strip()
.lower()
)
if answer != "y": if answer != "y":
print("Aborted closing branch.") print("Aborted closing branch.")
return return

View File

@@ -41,15 +41,19 @@ def drop_branch(
# Confirmation # Confirmation
if not force: if not force:
answer = input( answer = (
f"Delete branch '{name}' locally and on origin? This is destructive! (y/N): " input(
).strip().lower() f"Delete branch '{name}' locally and on origin? This is destructive! (y/N): "
)
.strip()
.lower()
)
if answer != "y": if answer != "y":
print("Aborted dropping branch.") print("Aborted dropping branch.")
return return
delete_local_branch(name, cwd=cwd, force=False) delete_local_branch(name, cwd=cwd, force=False)
# Remote delete (special-case message) # Remote delete (special-case message)
try: try:
delete_remote_branch("origin", name, cwd=cwd) delete_remote_branch("origin", name, cwd=cwd)

View File

@@ -1,15 +1,18 @@
import yaml import yaml
import os import os
from pkgmgr.core.config.save import save_user_config from pkgmgr.core.config.save import save_user_config
def interactive_add(config,USER_CONFIG_PATH:str):
def interactive_add(config, USER_CONFIG_PATH: str):
"""Interactively prompt the user to add a new repository entry to the user config.""" """Interactively prompt the user to add a new repository entry to the user config."""
print("Adding a new repository configuration entry.") print("Adding a new repository configuration entry.")
new_entry = {} new_entry = {}
new_entry["provider"] = input("Provider (e.g., github.com): ").strip() new_entry["provider"] = input("Provider (e.g., github.com): ").strip()
new_entry["account"] = input("Account (e.g., yourusername): ").strip() new_entry["account"] = input("Account (e.g., yourusername): ").strip()
new_entry["repository"] = input("Repository name (e.g., mytool): ").strip() new_entry["repository"] = input("Repository name (e.g., mytool): ").strip()
new_entry["command"] = input("Command (optional, leave blank to auto-detect): ").strip() new_entry["command"] = input(
"Command (optional, leave blank to auto-detect): "
).strip()
new_entry["description"] = input("Description (optional): ").strip() new_entry["description"] = input("Description (optional): ").strip()
new_entry["replacement"] = input("Replacement (optional): ").strip() new_entry["replacement"] = input("Replacement (optional): ").strip()
new_entry["alias"] = input("Alias (optional): ").strip() new_entry["alias"] = input("Alias (optional): ").strip()
@@ -25,12 +28,12 @@ def interactive_add(config,USER_CONFIG_PATH:str):
confirm = input("Add this entry to user config? (y/N): ").strip().lower() confirm = input("Add this entry to user config? (y/N): ").strip().lower()
if confirm == "y": if confirm == "y":
if os.path.exists(USER_CONFIG_PATH): if os.path.exists(USER_CONFIG_PATH):
with open(USER_CONFIG_PATH, 'r') as f: with open(USER_CONFIG_PATH, "r") as f:
user_config = yaml.safe_load(f) or {} user_config = yaml.safe_load(f) or {}
else: else:
user_config = {"repositories": []} user_config = {"repositories": []}
user_config.setdefault("repositories", []) user_config.setdefault("repositories", [])
user_config["repositories"].append(new_entry) user_config["repositories"].append(new_entry)
save_user_config(user_config,USER_CONFIG_PATH) save_user_config(user_config, USER_CONFIG_PATH)
else: else:
print("Entry not added.") print("Entry not added.")

View File

@@ -107,11 +107,15 @@ def config_init(
# Already known? # Already known?
if key in default_keys: if key in default_keys:
skipped += 1 skipped += 1
print(f"[SKIP] (defaults) {provider}/{account}/{repo_name}") print(
f"[SKIP] (defaults) {provider}/{account}/{repo_name}"
)
continue continue
if key in existing_keys: if key in existing_keys:
skipped += 1 skipped += 1
print(f"[SKIP] (user-config) {provider}/{account}/{repo_name}") print(
f"[SKIP] (user-config) {provider}/{account}/{repo_name}"
)
continue continue
print(f"[ADD] {provider}/{account}/{repo_name}") print(f"[ADD] {provider}/{account}/{repo_name}")
@@ -121,7 +125,9 @@ def config_init(
if verified_commit: if verified_commit:
print(f"[INFO] Latest commit: {verified_commit}") print(f"[INFO] Latest commit: {verified_commit}")
else: else:
print("[WARN] Could not read commit (not a git repo or no commits).") print(
"[WARN] Could not read commit (not a git repo or no commits)."
)
entry: Dict[str, Any] = { entry: Dict[str, Any] = {
"provider": provider, "provider": provider,

View File

@@ -1,6 +1,7 @@
import yaml import yaml
from pkgmgr.core.config.load import load_config from pkgmgr.core.config.load import load_config
def show_config(selected_repos, user_config_path, full_config=False): def show_config(selected_repos, user_config_path, full_config=False):
"""Display configuration for one or more repositories, or the entire merged config.""" """Display configuration for one or more repositories, or the entire merged config."""
if full_config: if full_config:
@@ -8,8 +9,10 @@ def show_config(selected_repos, user_config_path, full_config=False):
print(yaml.dump(merged, default_flow_style=False)) print(yaml.dump(merged, default_flow_style=False))
else: else:
for repo in selected_repos: for repo in selected_repos:
identifier = f'{repo.get("provider")}/{repo.get("account")}/{repo.get("repository")}' identifier = (
f"{repo.get('provider')}/{repo.get('account')}/{repo.get('repository')}"
)
print(f"Repository: {identifier}") print(f"Repository: {identifier}")
for key, value in repo.items(): for key, value in repo.items():
print(f" {key}: {value}") print(f" {key}: {value}")
print("-" * 40) print("-" * 40)

View File

@@ -66,10 +66,7 @@ def _ensure_repo_dir(
repo_dir = get_repo_dir(repositories_base_dir, repo) repo_dir = get_repo_dir(repositories_base_dir, repo)
if not os.path.exists(repo_dir): if not os.path.exists(repo_dir):
print( print(f"Repository directory '{repo_dir}' does not exist. Cloning it now...")
f"Repository directory '{repo_dir}' does not exist. "
"Cloning it now..."
)
clone_repos( clone_repos(
[repo], [repo],
repositories_base_dir, repositories_base_dir,
@@ -79,10 +76,7 @@ def _ensure_repo_dir(
clone_mode, clone_mode,
) )
if not os.path.exists(repo_dir): if not os.path.exists(repo_dir):
print( print(f"Cloning failed for repository {identifier}. Skipping installation.")
f"Cloning failed for repository {identifier}. "
"Skipping installation."
)
return None return None
return repo_dir return repo_dir
@@ -115,7 +109,9 @@ def _verify_repo(
if silent: if silent:
# Non-interactive mode: continue with a warning. # Non-interactive mode: continue with a warning.
print(f"[Warning] Continuing despite verification failure for {identifier} (--silent).") print(
f"[Warning] Continuing despite verification failure for {identifier} (--silent)."
)
else: else:
choice = input("Continue anyway? [y/N]: ").strip().lower() choice = input("Continue anyway? [y/N]: ").strip().lower()
if choice != "y": if choice != "y":
@@ -232,12 +228,16 @@ def install_repos(
code = exc.code if isinstance(exc.code, int) else str(exc.code) code = exc.code if isinstance(exc.code, int) else str(exc.code)
failures.append((identifier, f"installer failed (exit={code})")) failures.append((identifier, f"installer failed (exit={code})"))
if not quiet: if not quiet:
print(f"[Warning] install: repository {identifier} failed (exit={code}). Continuing...") print(
f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..."
)
continue continue
except Exception as exc: except Exception as exc:
failures.append((identifier, f"unexpected error: {exc}")) failures.append((identifier, f"unexpected error: {exc}"))
if not quiet: if not quiet:
print(f"[Warning] install: repository {identifier} hit an unexpected error: {exc}. Continuing...") print(
f"[Warning] install: repository {identifier} hit an unexpected error: {exc}. Continuing..."
)
continue continue
if failures and emit_summary and not quiet: if failures and emit_summary and not quiet:

View File

@@ -14,6 +14,10 @@ from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F4
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401 from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
# OS-specific installers # OS-specific installers
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401 from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
from pkgmgr.actions.install.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401 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

View File

@@ -41,7 +41,9 @@ class BaseInstaller(ABC):
return caps return caps
for matcher in CAPABILITY_MATCHERS: for matcher in CAPABILITY_MATCHERS:
if matcher.applies_to_layer(self.layer) and matcher.is_provided(ctx, self.layer): if matcher.applies_to_layer(self.layer) and matcher.is_provided(
ctx, self.layer
):
caps.add(matcher.name) caps.add(matcher.name)
return caps return caps

View File

@@ -16,7 +16,9 @@ class MakefileInstaller(BaseInstaller):
def supports(self, ctx: RepoContext) -> bool: def supports(self, ctx: RepoContext) -> bool:
if os.environ.get("PKGMGR_DISABLE_MAKEFILE_INSTALLER") == "1": if os.environ.get("PKGMGR_DISABLE_MAKEFILE_INSTALLER") == "1":
if not ctx.quiet: if not ctx.quiet:
print("[INFO] PKGMGR_DISABLE_MAKEFILE_INSTALLER=1 skipping MakefileInstaller.") print(
"[INFO] PKGMGR_DISABLE_MAKEFILE_INSTALLER=1 skipping MakefileInstaller."
)
return False return False
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME) makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
@@ -46,7 +48,9 @@ class MakefileInstaller(BaseInstaller):
return return
if not ctx.quiet: if not ctx.quiet:
print(f"[pkgmgr] Running make install for {ctx.identifier} (MakefileInstaller)") print(
f"[pkgmgr] Running make install for {ctx.identifier} (MakefileInstaller)"
)
run_command("make install", cwd=ctx.repo_dir, preview=ctx.preview) run_command("make install", cwd=ctx.repo_dir, preview=ctx.preview)

View File

@@ -57,7 +57,9 @@ class NixConflictResolver:
# 3) Fallback: output-name based lookup (also covers nix suggesting: `nix profile remove pkgmgr`) # 3) Fallback: output-name based lookup (also covers nix suggesting: `nix profile remove pkgmgr`)
if not tokens: if not tokens:
tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output) tokens = self._profile.find_remove_tokens_for_output(
ctx, self._runner, output
)
if tokens: if tokens:
if not quiet: if not quiet:
@@ -94,7 +96,9 @@ class NixConflictResolver:
continue continue
if not quiet: if not quiet:
print("[nix] conflict detected but could not resolve profile entries to remove.") print(
"[nix] conflict detected but could not resolve profile entries to remove."
)
return False return False
return False return False

View File

@@ -75,7 +75,9 @@ class NixFlakeInstaller(BaseInstaller):
# Core install path # Core install path
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def _install_only(self, ctx: "RepoContext", output: str, allow_failure: bool) -> None: def _install_only(
self, ctx: "RepoContext", output: str, allow_failure: bool
) -> None:
install_cmd = f"nix profile install {self._installable(ctx, output)}" install_cmd = f"nix profile install {self._installable(ctx, output)}"
if not ctx.quiet: if not ctx.quiet:
@@ -96,7 +98,9 @@ class NixFlakeInstaller(BaseInstaller):
output=output, output=output,
): ):
if not ctx.quiet: if not ctx.quiet:
print(f"[nix] output '{output}' successfully installed after conflict cleanup.") print(
f"[nix] output '{output}' successfully installed after conflict cleanup."
)
return return
if not ctx.quiet: if not ctx.quiet:
@@ -107,20 +111,26 @@ class NixFlakeInstaller(BaseInstaller):
# If indices are supported, try legacy index-upgrade path. # If indices are supported, try legacy index-upgrade path.
if self._indices_supported is not False: if self._indices_supported is not False:
indices = self._profile.find_installed_indices_for_output(ctx, self._runner, output) indices = self._profile.find_installed_indices_for_output(
ctx, self._runner, output
)
upgraded = False upgraded = False
for idx in indices: for idx in indices:
if self._upgrade_index(ctx, idx): if self._upgrade_index(ctx, idx):
upgraded = True upgraded = True
if not ctx.quiet: if not ctx.quiet:
print(f"[nix] output '{output}' successfully upgraded (index {idx}).") print(
f"[nix] output '{output}' successfully upgraded (index {idx})."
)
if upgraded: if upgraded:
return return
if indices and not ctx.quiet: if indices and not ctx.quiet:
print(f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'.") print(
f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'."
)
for idx in indices: for idx in indices:
self._remove_index(ctx, idx) self._remove_index(ctx, idx)
@@ -139,7 +149,9 @@ class NixFlakeInstaller(BaseInstaller):
print(f"[nix] output '{output}' successfully re-installed.") print(f"[nix] output '{output}' successfully re-installed.")
return return
print(f"[ERROR] Failed to install Nix flake output '{output}' (exit {final.returncode})") print(
f"[ERROR] Failed to install Nix flake output '{output}' (exit {final.returncode})"
)
if not allow_failure: if not allow_failure:
raise SystemExit(final.returncode) raise SystemExit(final.returncode)
@@ -149,7 +161,9 @@ class NixFlakeInstaller(BaseInstaller):
# force_update path # force_update path
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def _force_upgrade_output(self, ctx: "RepoContext", output: str, allow_failure: bool) -> None: def _force_upgrade_output(
self, ctx: "RepoContext", output: str, allow_failure: bool
) -> None:
# Prefer token path if indices unsupported (new nix) # Prefer token path if indices unsupported (new nix)
if self._indices_supported is False: if self._indices_supported is False:
self._remove_tokens_for_output(ctx, output) self._remove_tokens_for_output(ctx, output)
@@ -158,14 +172,18 @@ class NixFlakeInstaller(BaseInstaller):
print(f"[nix] output '{output}' successfully upgraded.") print(f"[nix] output '{output}' successfully upgraded.")
return return
indices = self._profile.find_installed_indices_for_output(ctx, self._runner, output) indices = self._profile.find_installed_indices_for_output(
ctx, self._runner, output
)
upgraded_any = False upgraded_any = False
for idx in indices: for idx in indices:
if self._upgrade_index(ctx, idx): if self._upgrade_index(ctx, idx):
upgraded_any = True upgraded_any = True
if not ctx.quiet: if not ctx.quiet:
print(f"[nix] output '{output}' successfully upgraded (index {idx}).") print(
f"[nix] output '{output}' successfully upgraded (index {idx})."
)
if upgraded_any: if upgraded_any:
if not ctx.quiet: if not ctx.quiet:
@@ -173,7 +191,9 @@ class NixFlakeInstaller(BaseInstaller):
return return
if indices and not ctx.quiet: if indices and not ctx.quiet:
print(f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'.") print(
f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'."
)
for idx in indices: for idx in indices:
self._remove_index(ctx, idx) self._remove_index(ctx, idx)
@@ -223,7 +243,9 @@ class NixFlakeInstaller(BaseInstaller):
return return
if not ctx.quiet: if not ctx.quiet:
print(f"[nix] indices unsupported; removing by token(s): {', '.join(tokens)}") print(
f"[nix] indices unsupported; removing by token(s): {', '.join(tokens)}"
)
for t in tokens: for t in tokens:
self._runner.run(ctx, f"nix profile remove {t}", allow_failure=True) self._runner.run(ctx, f"nix profile remove {t}", allow_failure=True)

View File

@@ -101,7 +101,9 @@ class NixProfileInspector:
data = self.list_json(ctx, runner) data = self.list_json(ctx, runner)
entries = normalize_elements(data) entries = normalize_elements(data)
tokens: List[str] = [out] # critical: matches nix's own suggestion for conflicts tokens: List[str] = [
out
] # critical: matches nix's own suggestion for conflicts
for e in entries: for e in entries:
if entry_matches_output(e, out): if entry_matches_output(e, out):

View File

@@ -48,7 +48,9 @@ class NixProfileListReader:
return uniq return uniq
def indices_matching_store_prefixes(self, ctx: "RepoContext", prefixes: List[str]) -> List[int]: def indices_matching_store_prefixes(
self, ctx: "RepoContext", prefixes: List[str]
) -> List[int]:
prefixes = [self._store_prefix(p) for p in prefixes if p] prefixes = [self._store_prefix(p) for p in prefixes if p]
prefixes = [p for p in prefixes if p] prefixes = [p for p in prefixes if p]
if not prefixes: if not prefixes:

View File

@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from pkgmgr.actions.install.context import RepoContext from pkgmgr.actions.install.context import RepoContext
from .runner import CommandRunner from .runner import CommandRunner
@dataclass(frozen=True) @dataclass(frozen=True)
class RetryPolicy: class RetryPolicy:
max_attempts: int = 7 max_attempts: int = 7
@@ -35,13 +36,19 @@ class GitHubRateLimitRetry:
install_cmd: str, install_cmd: str,
) -> RunResult: ) -> RunResult:
quiet = bool(getattr(ctx, "quiet", False)) quiet = bool(getattr(ctx, "quiet", False))
delays = list(self._fibonacci_backoff(self._policy.base_delay_seconds, self._policy.max_attempts)) delays = list(
self._fibonacci_backoff(
self._policy.base_delay_seconds, self._policy.max_attempts
)
)
last: RunResult | None = None last: RunResult | None = None
for attempt, base_delay in enumerate(delays, start=1): for attempt, base_delay in enumerate(delays, start=1):
if not quiet: if not quiet:
print(f"[nix] attempt {attempt}/{self._policy.max_attempts}: {install_cmd}") print(
f"[nix] attempt {attempt}/{self._policy.max_attempts}: {install_cmd}"
)
res = runner.run(ctx, install_cmd, allow_failure=True) res = runner.run(ctx, install_cmd, allow_failure=True)
last = res last = res
@@ -56,7 +63,9 @@ class GitHubRateLimitRetry:
if attempt >= self._policy.max_attempts: if attempt >= self._policy.max_attempts:
break break
jitter = random.randint(self._policy.jitter_seconds_min, self._policy.jitter_seconds_max) jitter = random.randint(
self._policy.jitter_seconds_min, self._policy.jitter_seconds_max
)
wait_time = base_delay + jitter wait_time = base_delay + jitter
if not quiet: if not quiet:
@@ -67,7 +76,11 @@ class GitHubRateLimitRetry:
time.sleep(wait_time) time.sleep(wait_time)
return last if last is not None else RunResult(returncode=1, stdout="", stderr="nix install retry failed") return (
last
if last is not None
else RunResult(returncode=1, stdout="", stderr="nix install retry failed")
)
@staticmethod @staticmethod
def _is_github_rate_limit_error(text: str) -> bool: def _is_github_rate_limit_error(text: str) -> bool:

View File

@@ -9,6 +9,7 @@ from .types import RunResult
if TYPE_CHECKING: if TYPE_CHECKING:
from pkgmgr.actions.install.context import RepoContext from pkgmgr.actions.install.context import RepoContext
class CommandRunner: class CommandRunner:
""" """
Executes commands (shell=True) inside a repository directory (if provided). Executes commands (shell=True) inside a repository directory (if provided).
@@ -40,7 +41,9 @@ class CommandRunner:
raise raise
return RunResult(returncode=1, stdout="", stderr=str(e)) return RunResult(returncode=1, stdout="", stderr=str(e))
res = RunResult(returncode=p.returncode, stdout=p.stdout or "", stderr=p.stderr or "") res = RunResult(
returncode=p.returncode, stdout=p.stdout or "", stderr=p.stderr or ""
)
if res.returncode != 0 and not quiet: if res.returncode != 0 and not quiet:
self._print_compact_failure(res) self._print_compact_failure(res)

View File

@@ -20,7 +20,9 @@ class NixConflictTextParser:
tokens: List[str] = [] tokens: List[str] = []
for m in pat.finditer(text or ""): for m in pat.finditer(text or ""):
t = (m.group(1) or "").strip() t = (m.group(1) or "").strip()
if (t.startswith("'") and t.endswith("'")) or (t.startswith('"') and t.endswith('"')): if (t.startswith("'") and t.endswith("'")) or (
t.startswith('"') and t.endswith('"')
):
t = t[1:-1] t = t[1:-1]
if t: if t:
tokens.append(t) tokens.append(t)

View File

@@ -14,7 +14,9 @@ class PythonInstaller(BaseInstaller):
def supports(self, ctx: RepoContext) -> bool: def supports(self, ctx: RepoContext) -> bool:
if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1": if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1":
print("[INFO] PythonInstaller disabled via PKGMGR_DISABLE_PYTHON_INSTALLER.") print(
"[INFO] PythonInstaller disabled via PKGMGR_DISABLE_PYTHON_INSTALLER."
)
return False return False
return os.path.exists(os.path.join(ctx.repo_dir, "pyproject.toml")) return os.path.exists(os.path.join(ctx.repo_dir, "pyproject.toml"))

View File

@@ -132,7 +132,11 @@ class InstallationPipeline:
continue continue
if not quiet: if not quiet:
if ctx.force_update and state.layer is not None and installer_layer == state.layer: if (
ctx.force_update
and state.layer is not None
and installer_layer == state.layer
):
print( print(
f"[pkgmgr] Running installer {installer.__class__.__name__} " f"[pkgmgr] Running installer {installer.__class__.__name__} "
f"for {identifier} in '{repo_dir}' (upgrade requested)..." f"for {identifier} in '{repo_dir}' (upgrade requested)..."

View File

@@ -14,6 +14,7 @@ from .list_cmd import list_mirrors
from .diff_cmd import diff_mirrors from .diff_cmd import diff_mirrors
from .merge_cmd import merge_mirrors from .merge_cmd import merge_mirrors
from .setup_cmd import setup_mirrors from .setup_cmd import setup_mirrors
from .visibility_cmd import set_mirror_visibility
__all__ = [ __all__ = [
"Repository", "Repository",
@@ -22,4 +23,5 @@ __all__ = [
"diff_mirrors", "diff_mirrors",
"merge_mirrors", "merge_mirrors",
"setup_mirrors", "setup_mirrors",
"set_mirror_visibility",
] ]

View File

@@ -16,6 +16,7 @@ from .types import MirrorMap, Repository
# Helpers # 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. Normalised key for identifying a repository in config files.
@@ -47,6 +48,7 @@ def _load_user_config(path: str) -> Dict[str, object]:
# Main merge command # Main merge command
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def merge_mirrors( def merge_mirrors(
selected_repos: List[Repository], selected_repos: List[Repository],
repositories_base_dir: str, repositories_base_dir: str,

View File

@@ -11,35 +11,37 @@ from .types import Repository
from .url_utils import normalize_provider_host, parse_repo_from_git_url from .url_utils import normalize_provider_host, parse_repo_from_git_url
def ensure_remote_repository( def _provider_hint_from_host(host: str) -> str | None:
repo: Repository, h = (host or "").lower()
repositories_base_dir: str, if h == "github.com":
all_repos: List[Repository], return "github"
# Best-effort default for self-hosted git domains
return "gitea" if h else None
def ensure_remote_repository_for_url(
*,
url: str,
private_default: bool,
description: str,
preview: bool, preview: bool,
) -> None: ) -> None:
ctx = build_context(repo, repositories_base_dir, all_repos) host_raw, owner, name = parse_repo_from_git_url(url)
primary_url = determine_primary_remote_url(repo, ctx)
if not primary_url:
print("[INFO] No primary URL found; skipping remote provisioning.")
return
host_raw, owner, name = parse_repo_from_git_url(primary_url)
host = normalize_provider_host(host_raw) host = normalize_provider_host(host_raw)
if not host or not owner or not name: if not host or not owner or not name:
print("[WARN] Could not parse remote URL:", primary_url) print(f"[WARN] Could not parse repo from URL: {url}")
return return
spec = RepoSpec( spec = RepoSpec(
host=host, host=host,
owner=owner, owner=owner,
name=name, name=name,
private=bool(repo.get("private", True)), private=private_default,
description=str(repo.get("description", "")), description=description,
) )
provider_kind = str(repo.get("provider", "")).lower() or None provider_kind = _provider_hint_from_host(host)
try: try:
result = ensure_remote_repo( result = ensure_remote_repo(
@@ -56,4 +58,29 @@ def ensure_remote_repository(
if result.url: if result.url:
print(f"[REMOTE ENSURE] URL: {result.url}") print(f"[REMOTE ENSURE] URL: {result.url}")
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
print(f"[ERROR] Remote provisioning failed: {exc}") print(f"[ERROR] Remote provisioning failed for {url!r}: {exc}")
def ensure_remote_repository(
repo: Repository,
repositories_base_dir: str,
all_repos: List[Repository],
preview: bool,
) -> None:
"""
Backwards-compatible wrapper: ensure the *primary* remote repository
derived from the primary URL.
"""
ctx = build_context(repo, repositories_base_dir, all_repos)
primary_url = determine_primary_remote_url(repo, ctx)
if not primary_url:
print("[INFO] No primary URL found; skipping remote provisioning.")
return
ensure_remote_repository_for_url(
url=primary_url,
private_default=bool(repo.get("private", True)),
description=str(repo.get("description", "")),
preview=preview,
)

View File

@@ -2,12 +2,15 @@ from __future__ import annotations
from typing import List from typing import List
from pkgmgr.core.git.queries import probe_remote_reachable 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
from .context import build_context from .context import build_context
from .git_remote import ensure_origin_remote, determine_primary_remote_url from .git_remote import determine_primary_remote_url, ensure_origin_remote
from .remote_provision import ensure_remote_repository from .remote_provision import ensure_remote_repository_for_url
from .types import Repository from .types import Repository
from .url_utils import normalize_provider_host, parse_repo_from_git_url
def _is_git_remote_url(url: str) -> bool: def _is_git_remote_url(url: str) -> bool:
@@ -25,6 +28,64 @@ def _is_git_remote_url(url: str) -> bool:
return False return False
def _provider_hint_from_host(host: str) -> str | None:
h = (host or "").lower()
if h == "github.com":
return "github"
return "gitea" if h else None
def _apply_visibility_for_url(
*,
url: str,
private: bool,
description: str,
preview: bool,
) -> None:
host_raw, owner, name = parse_repo_from_git_url(url)
host = normalize_provider_host(host_raw)
if not host or not owner or not name:
print(f"[WARN] Could not parse repo from URL: {url}")
return
spec = RepoSpec(
host=host,
owner=owner,
name=name,
private=private,
description=description,
)
provider_kind = _provider_hint_from_host(host)
res = set_repo_visibility(
spec,
private=private,
provider_hint=ProviderHint(kind=provider_kind),
options=VisibilityOptions(preview=preview),
)
print(f"[REMOTE VISIBILITY] {res.status.upper()}: {res.message}")
def _print_probe_result(name: str | None, url: str, *, cwd: str) -> None:
"""
Print probe result for a git remote URL, including a short failure reason.
"""
ok, reason = probe_remote_reachable_detail(url, cwd=cwd)
prefix = f"{name}: " if name else ""
if ok:
print(f"[OK] {prefix}{url}")
return
print(f"[WARN] {prefix}{url}")
if reason:
reason = reason.strip()
if len(reason) > 240:
reason = reason[:240].rstrip() + ""
print(f" reason: {reason}")
def _setup_local_mirrors_for_repo( def _setup_local_mirrors_for_repo(
repo: Repository, repo: Repository,
repositories_base_dir: str, repositories_base_dir: str,
@@ -48,6 +109,7 @@ def _setup_remote_mirrors_for_repo(
all_repos: List[Repository], all_repos: List[Repository],
preview: bool, preview: bool,
ensure_remote: bool, ensure_remote: bool,
ensure_visibility: str | None,
) -> None: ) -> None:
ctx = build_context(repo, repositories_base_dir, all_repos) ctx = build_context(repo, repositories_base_dir, all_repos)
@@ -56,33 +118,78 @@ def _setup_remote_mirrors_for_repo(
print(f"[MIRROR SETUP:REMOTE] dir: {ctx.repo_dir}") print(f"[MIRROR SETUP:REMOTE] dir: {ctx.repo_dir}")
print("------------------------------------------------------------") print("------------------------------------------------------------")
if ensure_remote: git_mirrors = {
ensure_remote_repository( k: v for k, v in ctx.resolved_mirrors.items() if _is_git_remote_url(v)
repo, }
repositories_base_dir,
all_repos,
preview,
)
# Probe only git URLs (do not try ls-remote against PyPI etc.) def _desired_private_default() -> bool:
# If there are no mirrors at all, probe the primary git URL. # default behavior: repo['private'] (or True)
git_mirrors = {k: v for k, v in ctx.resolved_mirrors.items() if _is_git_remote_url(v)} if ensure_visibility == "public":
return False
if ensure_visibility == "private":
return True
return bool(repo.get("private", True))
def _should_enforce_visibility() -> bool:
return ensure_visibility in ("public", "private")
def _visibility_private_value() -> bool:
return ensure_visibility == "private"
description = str(repo.get("description", ""))
# If there are no git mirrors, fall back to primary (git) URL.
if not git_mirrors: if not git_mirrors:
primary = determine_primary_remote_url(repo, ctx) primary = determine_primary_remote_url(repo, ctx)
if not primary or not _is_git_remote_url(primary): if not primary or not _is_git_remote_url(primary):
print("[INFO] No git mirrors to probe.") print("[INFO] No git mirrors to probe or provision.")
print() print()
return return
ok = probe_remote_reachable(primary, cwd=ctx.repo_dir) if ensure_remote:
print("[OK]" if ok else "[WARN]", primary) print(f"[REMOTE ENSURE] ensuring primary: {primary}")
ensure_remote_repository_for_url(
url=primary,
private_default=_desired_private_default(),
description=description,
preview=preview,
)
# IMPORTANT: enforce visibility only if requested
if _should_enforce_visibility():
_apply_visibility_for_url(
url=primary,
private=_visibility_private_value(),
description=description,
preview=preview,
)
print()
_print_probe_result(None, primary, cwd=ctx.repo_dir)
print() print()
return return
# Provision ALL git mirrors (if requested)
if ensure_remote:
for name, url in git_mirrors.items():
print(f"[REMOTE ENSURE] ensuring mirror {name!r}: {url}")
ensure_remote_repository_for_url(
url=url,
private_default=_desired_private_default(),
description=description,
preview=preview,
)
if _should_enforce_visibility():
_apply_visibility_for_url(
url=url,
private=_visibility_private_value(),
description=description,
preview=preview,
)
print()
# Probe ALL git mirrors
for name, url in git_mirrors.items(): for name, url in git_mirrors.items():
ok = probe_remote_reachable(url, cwd=ctx.repo_dir) _print_probe_result(name, url, cwd=ctx.repo_dir)
print(f"[OK] {name}: {url}" if ok else f"[WARN] {name}: {url}")
print() print()
@@ -95,6 +202,7 @@ def setup_mirrors(
local: bool = True, local: bool = True,
remote: bool = True, remote: bool = True,
ensure_remote: bool = False, ensure_remote: bool = False,
ensure_visibility: str | None = None,
) -> None: ) -> None:
for repo in selected_repos: for repo in selected_repos:
if local: if local:
@@ -112,4 +220,5 @@ def setup_mirrors(
all_repos, all_repos,
preview, preview,
ensure_remote, ensure_remote,
ensure_visibility,
) )

View File

@@ -17,7 +17,7 @@ def hostport_from_git_url(url: str) -> Tuple[str, Optional[str]]:
netloc = netloc.split("@", 1)[1] netloc = netloc.split("@", 1)[1]
if netloc.startswith("[") and "]" in netloc: if netloc.startswith("[") and "]" in netloc:
host = netloc[1:netloc.index("]")] host = netloc[1 : netloc.index("]")]
rest = netloc[netloc.index("]") + 1 :] rest = netloc[netloc.index("]") + 1 :]
port = rest[1:] if rest.startswith(":") else None port = rest[1:] if rest.startswith(":") else None
return host.strip(), (port.strip() if port else None) return host.strip(), (port.strip() if port else None)
@@ -43,7 +43,7 @@ def normalize_provider_host(host: str) -> str:
return "" return ""
if host.startswith("[") and "]" in host: if host.startswith("[") and "]" in host:
host = host[1:host.index("]")] host = host[1 : host.index("]")]
if ":" in host and host.count(":") == 1: if ":" in host and host.count(":") == 1:
host = host.rsplit(":", 1)[0] host = host.rsplit(":", 1)[0]

View File

@@ -0,0 +1,134 @@
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
from .context import build_context
from .git_remote import determine_primary_remote_url
from .types import Repository
from .url_utils import normalize_provider_host, parse_repo_from_git_url
def _is_git_remote_url(url: str) -> bool:
# Keep same semantics as setup_cmd.py / git_remote.py
u = (url or "").strip()
if not u:
return False
if u.startswith("git@"):
return True
if u.startswith("ssh://"):
return True
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
return True
return False
def _provider_hint_from_host(host: str) -> str | None:
h = (host or "").lower()
if h == "github.com":
return "github"
# Best-effort default for self-hosted git domains
return "gitea" if h else None
def _apply_visibility_for_url(
*,
url: str,
private: bool,
description: str,
preview: bool,
) -> None:
host_raw, owner, name = parse_repo_from_git_url(url)
host = normalize_provider_host(host_raw)
if not host or not owner or not name:
print(f"[WARN] Could not parse repo from URL: {url}")
return
spec = RepoSpec(
host=host,
owner=owner,
name=name,
private=private,
description=description,
)
provider_kind = _provider_hint_from_host(host)
res = set_repo_visibility(
spec,
private=private,
provider_hint=ProviderHint(kind=provider_kind),
options=VisibilityOptions(preview=preview),
)
print(f"[REMOTE VISIBILITY] {res.status.upper()}: {res.message}")
def set_mirror_visibility(
selected_repos: List[Repository],
repositories_base_dir: str,
all_repos: List[Repository],
*,
visibility: str,
preview: bool = False,
) -> None:
"""
Set remote repository visibility for all git mirrors of each selected repo.
visibility:
- "private"
- "public"
"""
v = (visibility or "").strip().lower()
if v not in ("private", "public"):
raise ValueError("visibility must be 'private' or 'public'")
desired_private = v == "private"
for repo in selected_repos:
ctx = build_context(repo, repositories_base_dir, all_repos)
print("------------------------------------------------------------")
print(f"[MIRROR VISIBILITY] {ctx.identifier}")
print(f"[MIRROR VISIBILITY] dir: {ctx.repo_dir}")
print(f"[MIRROR VISIBILITY] target: {v}")
print("------------------------------------------------------------")
git_mirrors = {
name: url
for name, url in ctx.resolved_mirrors.items()
if url and _is_git_remote_url(url)
}
# If there are no git mirrors, fall back to primary (git) URL.
if not git_mirrors:
primary = determine_primary_remote_url(repo, ctx)
if not primary or not _is_git_remote_url(primary):
print(
"[INFO] No git mirrors found (and no primary git URL). Nothing to do."
)
print()
continue
print(f"[MIRROR VISIBILITY] applying to primary: {primary}")
_apply_visibility_for_url(
url=primary,
private=desired_private,
description=str(repo.get("description", "")),
preview=preview,
)
print()
continue
# Apply to ALL git mirrors
for name, url in git_mirrors.items():
print(f"[MIRROR VISIBILITY] applying to mirror {name!r}: {url}")
_apply_visibility_for_url(
url=url,
private=desired_private,
description=str(repo.get("description", "")),
preview=preview,
)
print()

View File

@@ -4,7 +4,16 @@ from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.command.run import run_command from pkgmgr.core.command.run import run_command
import sys import sys
def exec_proxy_command(proxy_prefix: str, selected_repos, repositories_base_dir, all_repos, proxy_command: str, extra_args, preview: bool):
def exec_proxy_command(
proxy_prefix: str,
selected_repos,
repositories_base_dir,
all_repos,
proxy_command: str,
extra_args,
preview: bool,
):
"""Execute a given proxy command with extra arguments for each repository.""" """Execute a given proxy command with extra arguments for each repository."""
error_repos = [] error_repos = []
max_exit_code = 0 max_exit_code = 0
@@ -22,7 +31,9 @@ def exec_proxy_command(proxy_prefix: str, selected_repos, repositories_base_dir,
try: try:
run_command(full_cmd, cwd=repo_dir, preview=preview) run_command(full_cmd, cwd=repo_dir, preview=preview)
except SystemExit as e: except SystemExit as e:
print(f"[ERROR] Command failed in {repo_identifier} with exit code {e.code}.") print(
f"[ERROR] Command failed in {repo_identifier} with exit code {e.code}."
)
error_repos.append((repo_identifier, e.code)) error_repos.append((repo_identifier, e.code))
max_exit_code = max(max_exit_code, e.code) max_exit_code = max(max_exit_code, e.code)
@@ -30,4 +41,4 @@ def exec_proxy_command(proxy_prefix: str, selected_repos, repositories_base_dir,
print("\nSummary of failed commands:") print("\nSummary of failed commands:")
for repo_identifier, exit_code in error_repos: for repo_identifier, exit_code in error_repos:
print(f"- {repo_identifier} failed with exit code {exit_code}") print(f"- {repo_identifier} failed with exit code {exit_code}")
sys.exit(max_exit_code) sys.exit(max_exit_code)

View File

@@ -1,519 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
File and metadata update helpers for the release workflow.
Responsibilities:
- Update pyproject.toml with the new version.
- Update flake.nix, PKGBUILD, RPM spec files where present.
- Prepend release entries to CHANGELOG.md.
- Maintain distribution-specific changelog files:
* debian/changelog
* RPM spec %changelog section
including maintainer metadata where applicable.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
from datetime import date, datetime
from typing import Optional, Tuple
from pkgmgr.core.git.queries import get_config_value
# ---------------------------------------------------------------------------
# Editor helper for interactive changelog messages
# ---------------------------------------------------------------------------
def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
"""
Open $EDITOR (fallback 'nano') so the user can enter a changelog message.
The temporary file is pre-filled with commented instructions and an
optional initial_message. Lines starting with '#' are ignored when the
message is read back.
Returns the final message (may be empty string if user leaves it blank).
"""
editor = os.environ.get("EDITOR", "nano")
with tempfile.NamedTemporaryFile(
mode="w+",
delete=False,
encoding="utf-8",
) 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"
)
if initial_message:
tmp.write(initial_message.strip() + "\n")
tmp.flush()
try:
subprocess.call([editor, tmp_path])
except FileNotFoundError:
print(
f"[WARN] Editor {editor!r} not found; proceeding without "
"interactive changelog message."
)
try:
with open(tmp_path, "r", encoding="utf-8") as f:
content = f.read()
finally:
try:
os.remove(tmp_path)
except OSError:
pass
lines = [line for line in content.splitlines() if not line.strip().startswith("#")]
return "\n".join(lines).strip()
# ---------------------------------------------------------------------------
# File update helpers (pyproject + extra packaging + changelog)
# ---------------------------------------------------------------------------
def update_pyproject_version(
pyproject_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in pyproject.toml with the new version.
The function looks for a line matching:
version = "X.Y.Z"
and replaces the version part with the given new_version string.
If the file does not exist, it is skipped without failing the release.
"""
if not os.path.exists(pyproject_path):
print(
f"[INFO] pyproject.toml not found at: {pyproject_path}, "
"skipping version update."
)
return
try:
with open(pyproject_path, "r", encoding="utf-8") as f:
content = f.read()
except OSError as exc:
print(
f"[WARN] Could not read pyproject.toml at {pyproject_path}: {exc}. "
"Skipping version update."
)
return
pattern = r'^(version\s*=\s*")([^"]+)(")'
new_content, count = re.subn(
pattern,
lambda m: f'{m.group(1)}{new_version}{m.group(3)}',
content,
flags=re.MULTILINE,
)
if count == 0:
print("[ERROR] Could not find version line in pyproject.toml")
sys.exit(1)
if preview:
print(f"[PREVIEW] Would update pyproject.toml version to {new_version}")
return
with open(pyproject_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated pyproject.toml version to {new_version}")
def update_flake_version(
flake_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in flake.nix, if present.
"""
if not os.path.exists(flake_path):
print("[INFO] flake.nix not found, skipping.")
return
try:
with open(flake_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read flake.nix: {exc}")
return
pattern = r'(version\s*=\s*")([^"]+)(")'
new_content, count = re.subn(
pattern,
lambda m: f'{m.group(1)}{new_version}{m.group(3)}',
content,
)
if count == 0:
print("[WARN] No version assignment found in flake.nix, skipping.")
return
if preview:
print(f"[PREVIEW] Would update flake.nix version to {new_version}")
return
with open(flake_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated flake.nix version to {new_version}")
def update_pkgbuild_version(
pkgbuild_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in PKGBUILD, if present.
Expects:
pkgver=1.2.3
pkgrel=1
"""
if not os.path.exists(pkgbuild_path):
print("[INFO] PKGBUILD not found, skipping.")
return
try:
with open(pkgbuild_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read PKGBUILD: {exc}")
return
ver_pattern = r"^(pkgver\s*=\s*)(.+)$"
new_content, ver_count = re.subn(
ver_pattern,
lambda m: f"{m.group(1)}{new_version}",
content,
flags=re.MULTILINE,
)
if ver_count == 0:
print("[WARN] No pkgver line found in PKGBUILD.")
new_content = content
rel_pattern = r"^(pkgrel\s*=\s*)(.+)$"
new_content, rel_count = re.subn(
rel_pattern,
lambda m: f"{m.group(1)}1",
new_content,
flags=re.MULTILINE,
)
if rel_count == 0:
print("[WARN] No pkgrel line found in PKGBUILD.")
if preview:
print(f"[PREVIEW] Would update PKGBUILD to pkgver={new_version}, pkgrel=1")
return
with open(pkgbuild_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated PKGBUILD to pkgver={new_version}, pkgrel=1")
def update_spec_version(
spec_path: str,
new_version: str,
preview: bool = False,
) -> None:
"""
Update the version in an RPM spec file, if present.
"""
if not os.path.exists(spec_path):
print("[INFO] RPM spec file not found, skipping.")
return
try:
with open(spec_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read spec file: {exc}")
return
ver_pattern = r"^(Version:\s*)(.+)$"
new_content, ver_count = re.subn(
ver_pattern,
lambda m: f"{m.group(1)}{new_version}",
content,
flags=re.MULTILINE,
)
if ver_count == 0:
print("[WARN] No 'Version:' line found in spec file.")
rel_pattern = r"^(Release:\s*)(.+)$"
def _release_repl(m: re.Match[str]) -> str: # type: ignore[name-defined]
rest = m.group(2).strip()
match = re.match(r"^(\d+)(.*)$", rest)
if match:
suffix = match.group(2)
else:
suffix = ""
return f"{m.group(1)}1{suffix}"
new_content, rel_count = re.subn(
rel_pattern,
_release_repl,
new_content,
flags=re.MULTILINE,
)
if rel_count == 0:
print("[WARN] No 'Release:' line found in spec file.")
if preview:
print(
"[PREVIEW] Would update spec file "
f"{os.path.basename(spec_path)} to Version: {new_version}, Release: 1..."
)
return
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(
f"Updated spec file {os.path.basename(spec_path)} "
f"to Version: {new_version}, Release: 1..."
)
def update_changelog(
changelog_path: str,
new_version: str,
message: Optional[str] = None,
preview: bool = False,
) -> str:
"""
Prepend a new release section to CHANGELOG.md with the new version,
current date, and a message.
"""
today = date.today().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
header = f"## [{new_version}] - {today}\n"
header += f"\n* {message}\n\n"
if os.path.exists(changelog_path):
try:
with open(changelog_path, "r", encoding="utf-8") as f:
changelog = f.read()
except Exception as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
changelog = ""
else:
changelog = ""
new_changelog = header + "\n" + changelog if changelog else header
print("\n================ CHANGELOG ENTRY ================")
print(header.rstrip())
print("=================================================\n")
if preview:
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
return message
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
# ---------------------------------------------------------------------------
# Debian changelog helpers (with Git config fallback for maintainer)
# ---------------------------------------------------------------------------
def _get_debian_author() -> Tuple[str, str]:
"""
Determine the maintainer name/email for debian/changelog entries.
"""
name = os.environ.get("DEBFULLNAME")
email = os.environ.get("DEBEMAIL")
if not name:
name = os.environ.get("GIT_AUTHOR_NAME")
if not email:
email = os.environ.get("GIT_AUTHOR_EMAIL")
if not name:
name = get_config_value("user.name")
if not email:
email = get_config_value("user.email")
if not name:
name = "Unknown Maintainer"
if not email:
email = "unknown@example.com"
return name, email
def update_debian_changelog(
debian_changelog_path: str,
package_name: str,
new_version: str,
message: Optional[str] = None,
preview: bool = False,
) -> None:
"""
Prepend a new entry to debian/changelog, if it exists.
"""
if not os.path.exists(debian_changelog_path):
print("[INFO] debian/changelog not found, skipping.")
return
debian_version = f"{new_version}-1"
now = datetime.now().astimezone()
date_str = now.strftime("%a, %d %b %Y %H:%M:%S %z")
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}."
stanza = (
f"{first_line}\n\n"
f" * {body_line}\n\n"
f" -- {author_name} <{author_email}> {date_str}\n\n"
)
if preview:
print(
"[PREVIEW] Would prepend the following stanza to debian/changelog:\n"
f"{stanza}"
)
return
try:
with open(debian_changelog_path, "r", encoding="utf-8") as f:
existing = f.read()
except Exception as exc:
print(f"[WARN] Could not read debian/changelog: {exc}")
existing = ""
new_content = stanza + existing
with open(debian_changelog_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated debian/changelog with version {debian_version}")
# ---------------------------------------------------------------------------
# Fedora / RPM spec %changelog helper
# ---------------------------------------------------------------------------
def update_spec_changelog(
spec_path: str,
package_name: str,
new_version: str,
message: Optional[str] = None,
preview: bool = False,
) -> None:
"""
Prepend a new entry to the %changelog section of an RPM spec file,
if present.
Typical RPM-style entry:
* Tue Dec 09 2025 John Doe <john@example.com> - 0.5.1-1
- Your changelog message
"""
if not os.path.exists(spec_path):
print("[INFO] RPM spec file not found, skipping spec changelog update.")
return
try:
with open(spec_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read spec file for changelog update: {exc}")
return
debian_version = f"{new_version}-1"
now = datetime.now().astimezone()
date_str = now.strftime("%a %b %d %Y")
# Reuse Debian maintainer discovery for author name/email.
author_name, author_email = _get_debian_author()
body_line = message.strip() if message else f"Automated release {new_version}."
stanza = (
f"* {date_str} {author_name} <{author_email}> - {debian_version}\n"
f"- {body_line}\n\n"
)
marker = "%changelog"
idx = content.find(marker)
if idx == -1:
# No %changelog section yet: append one at the end.
new_content = content.rstrip() + "\n\n%changelog\n" + stanza
else:
# Insert stanza right after the %changelog line.
before = content[: idx + len(marker)]
after = content[idx + len(marker) :]
new_content = before + "\n" + stanza + after.lstrip("\n")
if preview:
print(
"[PREVIEW] Would update RPM %changelog section with the following "
"stanza:\n"
f"{stanza}"
)
return
try:
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_content)
except Exception as exc:
print(f"[WARN] Failed to write updated spec changelog section: {exc}")
return
print(
f"Updated RPM %changelog section in {os.path.basename(spec_path)} "
f"for {package_name} {debian_version}"
)

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Backwards-compatible facade for the release file update helpers.
Implementations live in this package:
pkgmgr.actions.release.files.*
Keep this package stable so existing imports continue to work, e.g.:
from pkgmgr.actions.release.files import update_pyproject_version
"""
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 .changelog_md import update_changelog
from .debian import _get_debian_author, update_debian_changelog
from .rpm_changelog import update_spec_changelog
__all__ = [
"_open_editor_for_changelog",
"update_pyproject_version",
"update_flake_version",
"update_pkgbuild_version",
"update_spec_version",
"update_changelog",
"_get_debian_author",
"update_debian_changelog",
"update_spec_changelog",
]

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import os
import re
from datetime import date
from typing import Optional
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 _insert_after_h1(existing: str, entry: str) -> str:
"""Place *entry* after the H1 (and any intro prose), above the first H2.
If the file has no H1 we synthesise ``# Changelog`` so the resulting
document is markdown-lint-clean (MD041 first-line-h1).
If the file has no H2 yet we append *entry* after the H1 block.
Existing behaviour for legacy headerless files (file starts with
``## ``) is preserved: *entry* is prepended unchanged.
"""
if not existing.strip():
return f"# Changelog\n\n{entry}"
if not H1_RE.search(existing):
# Legacy layout: file starts with `## [version]` and has no H1.
# Synthesise the H1 so the merged file is lint-clean.
return f"# Changelog\n\n{entry}{existing.lstrip()}"
# File has an H1. Find the first H2 (existing release section).
h2_match = H2_RE.search(existing)
if h2_match is None:
# H1 + optional intro but no release entries yet — append entry
# after a single blank line.
suffix = (
""
if existing.endswith("\n\n")
else ("\n" if existing.endswith("\n") else "\n\n")
)
return f"{existing}{suffix}{entry}"
# Insert new entry just before the first H2.
head = existing[: h2_match.start()].rstrip("\n") + "\n\n"
tail = existing[h2_match.start() :]
return f"{head}{entry}{tail}"
def update_changelog(
changelog_path: str,
new_version: str,
message: Optional[str] = None,
preview: bool = False,
) -> str:
"""Insert a new release entry into CHANGELOG.md.
The entry is placed after the documents H1 heading (creating one if
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()
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
entry = f"## [{new_version}] - {today}\n\n* {message}\n\n"
if os.path.exists(changelog_path):
try:
with open(changelog_path, "r", encoding="utf-8") as f:
changelog = f.read()
except Exception as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
changelog = ""
else:
changelog = ""
new_changelog = _insert_after_h1(changelog, entry)
print("\n================ CHANGELOG ENTRY ================")
print(entry.rstrip())
print("=================================================\n")
if preview:
print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md")
return message
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

View File

@@ -0,0 +1,74 @@
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]:
name = os.environ.get("DEBFULLNAME")
email = os.environ.get("DEBEMAIL")
if not name:
name = os.environ.get("GIT_AUTHOR_NAME")
if not email:
email = os.environ.get("GIT_AUTHOR_EMAIL")
if not name:
name = get_config_value("user.name")
if not email:
email = get_config_value("user.email")
if not name:
name = "Unknown Maintainer"
if not email:
email = "unknown@example.com"
return name, email
def update_debian_changelog(
debian_changelog_path: str,
package_name: str,
new_version: str,
message: Optional[str] = None,
preview: bool = False,
) -> None:
if not os.path.exists(debian_changelog_path):
print("[INFO] debian/changelog not found, skipping.")
return
debian_version = f"{new_version}-1"
now = datetime.now().astimezone()
date_str = now.strftime("%a, %d %b %Y %H:%M:%S %z")
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}."
stanza = (
f"{first_line}\n\n"
f" * {body_line}\n\n"
f" -- {author_name} <{author_email}> {date_str}\n\n"
)
if preview:
print(
"[PREVIEW] Would prepend the following stanza to debian/changelog:\n"
f"{stanza}"
)
return
try:
with open(debian_changelog_path, "r", encoding="utf-8") as f:
existing = f.read()
except Exception as exc:
print(f"[WARN] Could not read debian/changelog: {exc}")
existing = ""
with open(debian_changelog_path, "w", encoding="utf-8") as f:
f.write(stanza + existing)
print(f"Updated debian/changelog with version {debian_version}")

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
import os
import subprocess
import tempfile
from typing import Optional
def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
editor = os.environ.get("EDITOR", "nano")
with tempfile.NamedTemporaryFile(
mode="w+",
delete=False,
encoding="utf-8",
) 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"
)
if initial_message:
tmp.write(initial_message.strip() + "\n")
tmp.flush()
try:
subprocess.call([editor, tmp_path])
except FileNotFoundError:
print(
f"[WARN] Editor {editor!r} not found; proceeding without "
"interactive changelog message."
)
try:
with open(tmp_path, "r", encoding="utf-8") as f:
content = f.read()
finally:
try:
os.remove(tmp_path)
except OSError:
pass
lines = [line for line in content.splitlines() if not line.strip().startswith("#")]
return "\n".join(lines).strip()

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
import os
import re
def update_flake_version(
flake_path: str, new_version: str, preview: bool = False
) -> None:
if not os.path.exists(flake_path):
print("[INFO] flake.nix not found, skipping.")
return
try:
with open(flake_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read flake.nix: {exc}")
return
pattern = r'(version\s*=\s*")([^"]+)(")'
new_content, count = re.subn(
pattern,
lambda m: f"{m.group(1)}{new_version}{m.group(3)}",
content,
)
if count == 0:
print("[WARN] No version found in flake.nix.")
return
if preview:
print(f"[PREVIEW] Would update flake.nix version to {new_version}")
return
with open(flake_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated flake.nix version to {new_version}")

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import os
import re
def update_pkgbuild_version(
pkgbuild_path: str, new_version: str, preview: bool = False
) -> None:
if not os.path.exists(pkgbuild_path):
print("[INFO] PKGBUILD not found, skipping.")
return
try:
with open(pkgbuild_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read PKGBUILD: {exc}")
return
content, _ = re.subn(
r"^(pkgver\s*=\s*)(.+)$",
lambda m: f"{m.group(1)}{new_version}",
content,
flags=re.MULTILINE,
)
content, _ = re.subn(
r"^(pkgrel\s*=\s*)(.+)$",
lambda m: f"{m.group(1)}1",
content,
flags=re.MULTILINE,
)
if preview:
print(f"[PREVIEW] Would update PKGBUILD to pkgver={new_version}, pkgrel=1")
return
with open(pkgbuild_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"Updated PKGBUILD to pkgver={new_version}, pkgrel=1")

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
import os
import re
def update_pyproject_version(
pyproject_path: str, new_version: str, preview: bool = False
) -> None:
if not os.path.exists(pyproject_path):
print(f"[INFO] pyproject.toml not found at: {pyproject_path}, skipping.")
return
try:
with open(pyproject_path, "r", encoding="utf-8") as f:
content = f.read()
except OSError as exc:
print(f"[WARN] Could not read pyproject.toml: {exc}")
return
m = re.search(r"(?ms)^\s*\[project\]\s*$.*?(?=^\s*\[|\Z)", content)
if not m:
raise RuntimeError("Missing [project] section in pyproject.toml")
project_block = m.group(0)
ver_pat = r'(?m)^(\s*version\s*=\s*")([^"]+)(")\s*$'
new_block, count = re.subn(
ver_pat,
lambda mm: f"{mm.group(1)}{new_version}{mm.group(3)}",
project_block,
)
if count == 0:
raise RuntimeError("Missing version key in [project] section")
new_content = content[: m.start()] + new_block + content[m.end() :]
if preview:
print(f"[PREVIEW] Would update pyproject.toml version to {new_version}")
return
with open(pyproject_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"Updated pyproject.toml version to {new_version}")

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import os
from datetime import datetime
from typing import Optional
from .debian import _get_debian_author
def update_spec_changelog(
spec_path: str,
package_name: str,
new_version: str,
message: Optional[str] = None,
preview: bool = False,
) -> None:
if not os.path.exists(spec_path):
print("[INFO] RPM spec file not found, skipping spec changelog update.")
return
try:
with open(spec_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read spec file for changelog update: {exc}")
return
debian_version = f"{new_version}-1"
now = datetime.now().astimezone()
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}."
stanza = (
f"* {date_str} {author_name} <{author_email}> - {debian_version}\n"
f"- {body_line}\n\n"
)
marker = "%changelog"
idx = content.find(marker)
if idx == -1:
new_content = content.rstrip() + "\n\n%changelog\n" + stanza
else:
before = content[: idx + len(marker)]
after = content[idx + len(marker) :]
new_content = before + "\n" + stanza + after.lstrip("\n")
if preview:
print(
"[PREVIEW] Would update RPM %changelog section with the following stanza:\n"
f"{stanza}"
)
return
try:
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_content)
except Exception as exc:
print(f"[WARN] Failed to write updated spec changelog section: {exc}")
return
print(
f"Updated RPM %changelog section in {os.path.basename(spec_path)} "
f"for {package_name} {debian_version}"
)

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
import os
import re
def update_spec_version(
spec_path: str, new_version: str, preview: bool = False
) -> None:
"""
Update the version in an RPM spec file, if present.
"""
if not os.path.exists(spec_path):
print("[INFO] RPM spec file not found, skipping.")
return
try:
with open(spec_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as exc:
print(f"[WARN] Could not read spec file: {exc}")
return
ver_pattern = r"^(Version:\s*)(.+)$"
new_content, ver_count = re.subn(
ver_pattern,
lambda m: f"{m.group(1)}{new_version}",
content,
flags=re.MULTILINE,
)
if ver_count == 0:
print("[WARN] No 'Version:' line found in spec file.")
rel_pattern = r"^(Release:\s*)(.+)$"
def _release_repl(m: re.Match[str]) -> str:
rest = m.group(2).strip()
match = re.match(r"^(\d+)(.*)$", rest)
suffix = match.group(2) if match else ""
return f"{m.group(1)}1{suffix}"
new_content, rel_count = re.subn(
rel_pattern,
_release_repl,
new_content,
flags=re.MULTILINE,
)
if rel_count == 0:
print("[WARN] No 'Release:' line found in spec file.")
if preview:
print(
"[PREVIEW] Would update spec file "
f"{os.path.basename(spec_path)} to Version: {new_version}, Release: 1..."
)
return
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(
f"Updated spec file {os.path.basename(spec_path)} "
f"to Version: {new_version}, Release: 1..."
)

View File

@@ -80,7 +80,9 @@ def is_highest_version_tag(tag: str) -> bool:
return True return True
latest = max(parsed_all) latest = max(parsed_all)
print(f"[INFO] Latest tag (parsed): v{'.'.join(map(str, latest))}, Current tag: {tag}") print(
f"[INFO] Latest tag (parsed): v{'.'.join(map(str, latest))}, Current tag: {tag}"
)
return parsed_current >= latest return parsed_current >= latest
@@ -93,7 +95,9 @@ def update_latest_tag(new_tag: str, *, preview: bool = False) -> None:
- 'latest' is forced (floating tag), therefore the push uses --force. - 'latest' is forced (floating tag), therefore the push uses --force.
""" """
target_ref = f"{new_tag}^{{}}" target_ref = f"{new_tag}^{{}}"
print(f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})...") print(
f"[INFO] Updating 'latest' tag to point at {new_tag} (commit {target_ref})..."
)
tag_force_annotated( tag_force_annotated(
name="latest", name="latest",

View File

@@ -0,0 +1,75 @@
"""Resolve the distro-package name for a release.
The release flow writes the package identifier into `debian/changelog`,
the RPM `%changelog` stanza, etc. Historically pkgmgr derived this
identifier from the repository folder name (`os.path.basename(repo_root)`),
which silently breaks when the repo is renamed but the existing packaging
files still ship the legacy name. Renaming the folder must not change the
distro-package identity — `apt`, `pacman`, `dnf`, and every downstream
manifest pin the old name.
The resolver therefore walks the existing packaging files in priority
order and only falls back to the folder name when none of them ship an
explicit name.
Priority:
1. `debian/control` `Package:` field (most authoritative — dpkg-source
refuses to build if changelog and control disagree)
2. `packaging/arch/PKGBUILD` `pkgname=` value
3. RPM spec `Name:` field
4. Repository folder basename (legacy fallback)
"""
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:
if not path or not os.path.isfile(path):
return ""
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except OSError:
return ""
def _extract(pattern: re.Pattern[str], text: str) -> Optional[str]:
if not text:
return None
match = pattern.search(text)
if not match:
return None
value = match.group(1).strip().strip('"').strip("'")
return value or None
def resolve_package_name(paths: RepoPaths) -> str:
"""Return the distro-package name for the repo, with a folder fallback.
The fallback uses `os.path.basename(paths.repo_dir)` so behaviour is
backwards-compatible for repos that ship no packaging metadata yet.
"""
debian_name = _extract(_DEBIAN_PACKAGE_RE, _read(paths.debian_control))
if debian_name:
return debian_name
pkgbuild_name = _extract(_PKGBUILD_NAME_RE, _read(paths.arch_pkgbuild))
if pkgbuild_name:
return pkgbuild_name
rpm_name = _extract(_RPM_NAME_RE, _read(paths.rpm_spec))
if rpm_name:
return rpm_name
return os.path.basename(paths.repo_dir) or "package"

View File

@@ -0,0 +1,67 @@
"""Re-deploy an existing release without modifying files or creating a new tag.
The release workflow normally bumps versions, rewrites packaging
manifests, commits, tags, pushes, and uploads to PyPI in one shot.
When a post-tag step fails mid-flight (typical examples: `git push`
rejected, `twine upload` aborted by a broken venv, `update-latest`
rejected by branch protection) the local tag still exists on HEAD but
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
* re-align the floating `latest` tag if HEAD tag is the highest
Publishing (PyPI etc.) stays the caller's responsibility — the publish
workflow is already idempotent (twine rejects duplicates per spec) and
can be invoked independently via the `publish` subcommand.
"""
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.core.git.queries import get_current_branch
from pkgmgr.core.version.semver import SemVer
from .git_ops import is_highest_version_tag, update_latest_tag
def retry_release(
pyproject_path: str = "pyproject.toml",
preview: bool = False,
) -> None:
"""Re-push the HEAD release without re-tagging or modifying any files."""
try:
branch = get_current_branch() or "main"
except GitRunError:
branch = "main"
print(f"Retrying release push on branch: {branch}")
tags = head_semver_tags(cwd=os.path.dirname(os.path.abspath(pyproject_path)))
if not tags:
raise RuntimeError(
"No version tag on HEAD. Nothing to retry — "
"run `pkgmgr release <type>` first to create a release."
)
tag = max(tags, key=SemVer.parse)
print(f"Re-pushing existing tag: {tag}")
run(["push", "origin", branch, tag], preview=preview)
try:
if is_highest_version_tag(tag):
update_latest_tag(tag, preview=preview)
else:
print(f"[INFO] Skipping 'latest' update (tag {tag} is not the highest).")
except GitRunError as exc:
print(f"[WARN] Failed to update floating 'latest' tag for {tag}: {exc}")
if preview:
print(f"[PREVIEW] Retry push for {tag} would now complete.")
return
print(f"Retry push completed for {tag}.")

View File

@@ -5,8 +5,8 @@ import sys
from typing import Optional from typing import Optional
from pkgmgr.actions.branch import close_branch from pkgmgr.actions.branch import close_branch
from pkgmgr.core.git import GitRunError from pkgmgr.core.git import GitRunError, run
from pkgmgr.core.git.commands import add, commit, push, tag_annotated from pkgmgr.core.git.commands import add, commit, tag_annotated
from pkgmgr.core.git.queries import get_current_branch from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.repository.paths import resolve_repo_paths from pkgmgr.core.repository.paths import resolve_repo_paths
@@ -24,7 +24,9 @@ from .git_ops import (
is_highest_version_tag, is_highest_version_tag,
update_latest_tag, update_latest_tag,
) )
from .package_name import resolve_package_name
from .prompts import confirm_proceed_release, should_delete_branch from .prompts import confirm_proceed_release, should_delete_branch
from .retry import retry_release
from .versioning import bump_semver, determine_current_version from .versioning import bump_semver, determine_current_version
@@ -76,7 +78,9 @@ def _release_impl(
if paths.arch_pkgbuild: if paths.arch_pkgbuild:
update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview) update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview)
else: else:
print("[INFO] No PKGBUILD found (packaging/arch/PKGBUILD or PKGBUILD). Skipping.") print(
"[INFO] No PKGBUILD found (packaging/arch/PKGBUILD or PKGBUILD). Skipping."
)
if paths.rpm_spec: if paths.rpm_spec:
update_spec_version(paths.rpm_spec, new_ver_str, preview=preview) update_spec_version(paths.rpm_spec, new_ver_str, preview=preview)
@@ -88,7 +92,7 @@ def _release_impl(
if changelog_message.strip(): if changelog_message.strip():
effective_message = changelog_message.strip() effective_message = changelog_message.strip()
package_name = os.path.basename(repo_root) or "package-manager" package_name = resolve_package_name(paths)
if paths.debian_changelog: if paths.debian_changelog:
update_debian_changelog( update_debian_changelog(
@@ -123,41 +127,47 @@ def _release_impl(
paths.rpm_spec, paths.rpm_spec,
paths.debian_changelog, paths.debian_changelog,
] ]
existing_files = [p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)] existing_files = [
p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)
]
if preview: if preview:
add(existing_files, preview=True) add(existing_files, preview=True)
commit(commit_msg, all=True, preview=True) commit(commit_msg, all=True, preview=True)
tag_annotated(new_tag, tag_msg, preview=True) tag_annotated(new_tag, tag_msg, preview=True)
push("origin", branch, preview=True) run(["push", "origin", branch, new_tag], preview=True)
push("origin", new_tag, preview=True)
if is_highest_version_tag(new_tag): if is_highest_version_tag(new_tag):
update_latest_tag(new_tag, preview=True) update_latest_tag(new_tag, preview=True)
else: else:
print(f"[PREVIEW] Skipping 'latest' update (tag {new_tag} is not the highest).") print(
f"[PREVIEW] Skipping 'latest' update (tag {new_tag} is not the highest)."
)
if close and branch not in ("main", "master"): if close and branch not in ("main", "master"):
if force: if force:
print(f"[PREVIEW] Would delete branch {branch} (forced).") print(f"[PREVIEW] Would delete branch {branch} (forced).")
else: else:
print(f"[PREVIEW] Would ask whether to delete branch {branch} after release.") print(
f"[PREVIEW] Would ask whether to delete branch {branch} after release."
)
return return
add(existing_files, preview=False) add(existing_files, preview=False)
commit(commit_msg, all=True, preview=False) commit(commit_msg, all=True, preview=False)
tag_annotated(new_tag, tag_msg, preview=False) tag_annotated(new_tag, tag_msg, preview=False)
# Push branch and ONLY the newly created version tag (no --tags) # Push branch and ONLY the newly created version tag in one command (no --tags)
push("origin", branch, preview=False) run(["push", "origin", branch, new_tag], preview=False)
push("origin", new_tag, preview=False)
# Update 'latest' only if this is the highest version tag # Update 'latest' only if this is the highest version tag
try: try:
if is_highest_version_tag(new_tag): if is_highest_version_tag(new_tag):
update_latest_tag(new_tag, preview=False) update_latest_tag(new_tag, preview=False)
else: else:
print(f"[INFO] Skipping 'latest' update (tag {new_tag} is not the highest).") print(
f"[INFO] Skipping 'latest' update (tag {new_tag} is not the highest)."
)
except GitRunError as exc: except GitRunError as exc:
print(f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}") print(f"[WARN] Failed to update floating 'latest' tag for {new_tag}: {exc}")
print("'latest' tag was not updated.") print("'latest' tag was not updated.")
@@ -166,7 +176,9 @@ def _release_impl(
if close: if close:
if branch in ("main", "master"): if branch in ("main", "master"):
print(f"[INFO] close=True but current branch is {branch}; skipping branch deletion.") print(
f"[INFO] close=True but current branch is {branch}; skipping branch deletion."
)
return return
if not should_delete_branch(force=force): if not should_delete_branch(force=force):
@@ -188,7 +200,12 @@ def release(
preview: bool = False, preview: bool = False,
force: bool = False, force: bool = False,
close: bool = False, close: bool = False,
retry: bool = False,
) -> None: ) -> None:
if retry:
retry_release(pyproject_path=pyproject_path, preview=preview)
return
if preview: if preview:
_release_impl( _release_impl(
pyproject_path=pyproject_path, pyproject_path=pyproject_path,

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Callable, Dict, List, Tuple
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]
RepoOp = Callable[[str], OpResult]
def resolve_repos(
selected_repos: List[Repository],
repositories_base_dir: str,
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] = []
for repo in selected_repos:
ident = get_repo_identifier(repo, all_repos)
rd = get_repo_dir(repositories_base_dir, repo)
if not os.path.exists(rd):
print(f"Repository directory '{rd}' not found for {ident}.")
continue
resolved.append((ident, rd))
return resolved
def run_on_repos(
repos: List[RepoRef],
op: RepoOp,
*,
jobs: int,
op_name: str,
) -> None:
"""
Run ``op(repo_dir) -> (ok, msg)`` for each repo, optionally in parallel.
- ``jobs == 1``: serial, quiet on success, prints ``msg`` on failure.
- ``jobs > 1``: parallel via ThreadPoolExecutor, prints a banner plus
``[OK]``/``[FAIL]`` per repo and a final summary.
- Exits with status 1 if any operation failed.
"""
if not repos:
return
effective_jobs = max(1, min(jobs, len(repos)))
failed: List[Tuple[str, str]] = []
if effective_jobs == 1:
for ident, rd in repos:
ok, msg = op(rd)
if not ok:
print(msg)
failed.append((ident, msg))
else:
print(
f"[{op_name.upper()}] Running {len(repos)} {op_name}(s) with up to "
f"{effective_jobs} parallel jobs..."
)
with ThreadPoolExecutor(max_workers=effective_jobs) as executor:
futures = {executor.submit(op, rd): ident for ident, rd in repos}
for future in as_completed(futures):
ident = futures[future]
ok, msg = future.result()
if ok:
print(f"[OK] {ident}")
else:
print(f"[FAIL] {ident}")
for line in msg.splitlines():
print(f" {line}")
failed.append((ident, msg))
if failed:
if effective_jobs > 1:
print(f"\n[SUMMARY] {len(failed)} of {len(repos)} {op_name}(s) failed:")
for ident, _msg in failed:
print(f" - {ident}")
sys.exit(1)

Some files were not shown because too many files have changed in this diff Show More