Compare commits

..

45 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
83 changed files with 2615 additions and 246 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:
push:
branches-ignore:
- main
branches:
- '**'
pull_request:
permissions:
contents: read
concurrency:
group: global-ci-${{ github.repository }}-${{ github.ref_name }}
cancel-in-progress: false
jobs:
security-codeql:
permissions:
contents: read
packages: read
security-events: write
uses: ./.github/workflows/security-codeql.yml
test-unit:
permissions:
contents: read
uses: ./.github/workflows/test-unit.yml
test-integration:
permissions:
contents: read
uses: ./.github/workflows/test-integration.yml
test-env-virtual:
permissions:
contents: read
uses: ./.github/workflows/test-env-virtual.yml
test-env-nix:
permissions:
contents: read
uses: ./.github/workflows/test-env-nix.yml
test-e2e:
permissions:
contents: read
uses: ./.github/workflows/test-e2e.yml
test-virgin-user:
permissions:
contents: read
uses: ./.github/workflows/test-virgin-user.yml
test-virgin-root:
permissions:
contents: read
uses: ./.github/workflows/test-virgin-root.yml
lint-shell:
permissions:
contents: read
uses: ./.github/workflows/lint-shell.yml
lint-python:
permissions:
contents: read
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:
workflow_call:
permissions:
contents: read
jobs:
lint-python:
runs-on: ubuntu-latest

View File

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

View File

@@ -1,110 +1,39 @@
name: Mark stable commit
concurrency:
group: mark-stable-${{ github.repository }}-main
cancel-in-progress: true
on:
push:
branches:
- main # still run tests for main
tags:
- 'v*' # run tests for version tags (e.g. v0.9.1)
- 'v*'
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:
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
# Only run this job if the push is for a version tag (v*)
if: startsWith(github.ref, 'refs/tags/v')
timeout-minutes: 330
permissions:
contents: write # Required to move/update the tag
actions: read
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
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
run: |
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}"
# 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}."
if: steps.branch-check.outputs.is_on_main == 'true'
run: bash scripts/github/mark-stable/mark-stable-if-highest-version.sh

View File

@@ -21,44 +21,30 @@ jobs:
fetch-depth: 0
- name: Checkout workflow_run commit and refresh tags
run: |
set -euo pipefail
git checkout -f "${{ github.event.workflow_run.head_sha }}"
git fetch --tags --force
git tag --list 'stable' 'v*' --sort=version:refname | tail -n 20
env:
WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }}
run: bash scripts/github/publish-containers/checkout-workflow-run-commit.sh
- 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
id: info
run: |
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" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "is_stable=${IS_STABLE}" >> "$GITHUB_OUTPUT"
if: steps.branch-check.outputs.is_on_main == 'true'
run: bash scripts/github/publish-containers/compute-publish-container-info.sh
- name: Set up Docker Buildx
if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
with:
use: true
- name: Login to GHCR
if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -66,9 +52,8 @@ jobs:
- name: Publish all images
if: ${{ steps.info.outputs.should_publish == 'true' }}
run: |
set -euo pipefail
OWNER="${{ github.repository_owner }}" \
VERSION="${{ steps.info.outputs.version }}" \
IS_STABLE="${{ steps.info.outputs.is_stable }}" \
bash scripts/build/publish.sh
env:
OWNER: ${{ github.repository_owner }}
VERSION: ${{ steps.info.outputs.version }}
IS_STABLE: ${{ steps.info.outputs.is_stable }}
run: bash scripts/github/publish-containers/publish-container-images.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:
workflow_call:
permissions:
contents: read
jobs:
test-e2e:
runs-on: ubuntu-latest

View File

@@ -3,6 +3,9 @@ name: Test Virgin Nix (flake only)
on:
workflow_call:
permissions:
contents: read
jobs:
test-env-nix:
runs-on: ubuntu-latest

View File

@@ -3,6 +3,9 @@ name: Test OS Containers
on:
workflow_call:
permissions:
contents: read
jobs:
test-env-virtual:
runs-on: ubuntu-latest

View File

@@ -3,6 +3,9 @@ name: Test Code Integration
on:
workflow_call:
permissions:
contents: read
jobs:
test-integration:
runs-on: ubuntu-latest

View File

@@ -3,6 +3,9 @@ name: Test Units
on:
workflow_call:
permissions:
contents: read
jobs:
test-unit:
runs-on: ubuntu-latest

View File

@@ -3,6 +3,9 @@ name: Test Virgin Root
on:
workflow_call:
permissions:
contents: read
jobs:
test-virgin-root:
runs-on: ubuntu-latest

View File

@@ -3,6 +3,9 @@ name: Test Virgin User
on:
workflow_call:
permissions:
contents: read
jobs:
test-virgin-user:
runs-on: ubuntu-latest

3
.gitignore vendored
View File

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

View File

@@ -1,3 +1,156 @@
# 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.

View File

@@ -43,10 +43,10 @@ WORKDIR /build
COPY . .
# Build and install distro-native package-manager package
RUN set -euo pipefail; \
RUN set -eu; \
echo "Building and installing package-manager via make install..."; \
make install; \
cd /; rm -rf /build
rm -rf /build
# Entry point
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
@@ -64,5 +64,4 @@ CMD ["pkgmgr", "--help"]
FROM full AS slim
COPY scripts/docker/slim.sh /usr/local/bin/slim.sh
RUN chmod +x /usr/local/bin/slim.sh
RUN /usr/local/bin/slim.sh
RUN chmod +x /usr/local/bin/slim.sh && /usr/local/bin/slim.sh

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 = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
};
outputs = { self, nixpkgs }:
@@ -32,7 +32,7 @@
rec {
pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager";
version = "1.11.0";
version = "1.15.2";
# Use the git repo as source
src = ./.;
@@ -40,6 +40,10 @@
# Build using pyproject.toml
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]
nativeBuildInputs = [
pyPkgs.setuptools
@@ -51,6 +55,8 @@
pyPkgs.pyyaml
pyPkgs.jinja2
pyPkgs.pip
pkgs.git
pkgs.gnupg
];
doCheck = false;
@@ -87,6 +93,7 @@
buildInputs = [
pythonWithDeps
pkgs.git
pkgs.gnupg
ansiblePkg
];

View File

@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager
pkgver=1.11.0
pkgver=1.15.2
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')

View File

@@ -1,9 +1,9 @@
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() {
/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() {

View File

@@ -1,3 +1,173 @@
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.

View File

@@ -3,7 +3,7 @@ set -e
case "$1" in
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

View File

@@ -1,5 +1,5 @@
Name: package-manager
Version: 1.11.0
Version: 1.15.2
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -62,7 +62,7 @@ rm -rf \
%{buildroot}/usr/lib/package-manager/.gitkeep || true
%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
echo ">>> package-manager removed. Nix itself was not removed."
@@ -74,6 +74,125 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%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.

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kpmx"
version = "1.11.0"
version = "1.15.2"
description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md"
requires-python = ">=3.9"

View File

@@ -5,7 +5,7 @@ set -euo pipefail
: "${BASE_IMAGE_DEBIAN:=debian:stable-slim}"
: "${BASE_IMAGE_UBUNTU:=ubuntu: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() {
local PKGMGR_DISTRO="$1"

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,7 +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} chmod 0440 /etc/sudoers.d/aur_builder
RUN_AS_AUR=(su - aur_builder -s /bin/bash -c)
RUN_AS_AUR=(runuser -u aur_builder -- bash -c)
echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..."

View File

@@ -16,6 +16,7 @@ fi
pacman -S --noconfirm --needed \
base-devel \
git \
gnupg \
rsync \
curl \
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}"
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..."
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 install \
git \
gnupg2 \
rsync \
rpm-build \
make \

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,10 +37,16 @@ fi
# ---------------------------------------------------------------------------
if ! command -v nix >/dev/null 2>&1; then
if [[ -x "${FLAKE_DIR}/nix/init.sh" ]]; then
"${FLAKE_DIR}/nix/init.sh" || true
"${FLAKE_DIR}/nix/init.sh"
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)
# ---------------------------------------------------------------------------
@@ -51,7 +57,3 @@ if declare -F run_with_github_403_retry >/dev/null; then
else
exec nix run "${FLAKE_DIR}#pkgmgr" -- "$@"
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

@@ -36,16 +36,17 @@ real_exe() {
# Resolve nix binary path robustly (works across distros + Arch /usr/sbin)
resolve_nix_bin() {
local nix_cmd=""
nix_cmd="$(command -v nix 2>/dev/null || true)"
[[ -n "$nix_cmd" ]] && real_exe "$nix_cmd" && return 0
# IMPORTANT: prefer system locations before /usr/local to avoid self-symlink traps
# IMPORTANT: prefer distro-managed locations first.
# This avoids pinning /usr/local/bin/nix to a stale user-profile nix binary.
[[ -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 /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
[[ -x /nix/var/nix/profiles/default/bin/nix ]] && {

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

@@ -1,11 +1,50 @@
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,
@@ -13,9 +52,11 @@ def update_changelog(
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.
"""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()
@@ -32,8 +73,7 @@ def update_changelog(
else:
message = editor_message
header = f"## [{new_version}] - {today}\n"
header += f"\n* {message}\n\n"
entry = f"## [{new_version}] - {today}\n\n* {message}\n\n"
if os.path.exists(changelog_path):
try:
@@ -45,14 +85,14 @@ def update_changelog(
else:
changelog = ""
new_changelog = header + "\n" + changelog if changelog else header
new_changelog = _insert_after_h1(changelog, entry)
print("\n================ CHANGELOG ENTRY ================")
print(header.rstrip())
print(entry.rstrip())
print("=================================================\n")
if preview:
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
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:

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 pkgmgr.actions.branch import close_branch
from pkgmgr.core.git import GitRunError
from pkgmgr.core.git.commands import add, commit, push, tag_annotated
from pkgmgr.core.git import GitRunError, run
from pkgmgr.core.git.commands import add, commit, tag_annotated
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.repository.paths import resolve_repo_paths
@@ -24,7 +24,9 @@ from .git_ops import (
is_highest_version_tag,
update_latest_tag,
)
from .package_name import resolve_package_name
from .prompts import confirm_proceed_release, should_delete_branch
from .retry import retry_release
from .versioning import bump_semver, determine_current_version
@@ -90,7 +92,7 @@ def _release_impl(
if 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:
update_debian_changelog(
@@ -133,8 +135,7 @@ def _release_impl(
add(existing_files, preview=True)
commit(commit_msg, all=True, preview=True)
tag_annotated(new_tag, tag_msg, preview=True)
push("origin", branch, preview=True)
push("origin", new_tag, preview=True)
run(["push", "origin", branch, new_tag], preview=True)
if is_highest_version_tag(new_tag):
update_latest_tag(new_tag, preview=True)
@@ -156,9 +157,8 @@ def _release_impl(
commit(commit_msg, all=True, preview=False)
tag_annotated(new_tag, tag_msg, preview=False)
# Push branch and ONLY the newly created version tag (no --tags)
push("origin", branch, preview=False)
push("origin", new_tag, preview=False)
# Push branch and ONLY the newly created version tag in one command (no --tags)
run(["push", "origin", branch, new_tag], preview=False)
# Update 'latest' only if this is the highest version tag
try:
@@ -200,7 +200,12 @@ def release(
preview: bool = False,
force: bool = False,
close: bool = False,
retry: bool = False,
) -> None:
if retry:
retry_release(pyproject_path=pyproject_path, preview=preview)
return
if preview:
_release_impl(
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)

View File

@@ -1,17 +1,67 @@
from __future__ import annotations
import os
import sys
from typing import List, Dict, Any
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Tuple
from pkgmgr.actions.repository._parallel import RepoRef, run_on_repos
from pkgmgr.core.git.commands import pull_args, GitPullArgsError
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.verify import verify_repository
Repository = Dict[str, Any]
def _pull_one(repo_dir: str, extra_args: List[str], preview: bool) -> Tuple[bool, str]:
try:
pull_args(extra_args, cwd=repo_dir, preview=preview)
return (True, "")
except GitPullArgsError as exc:
return (False, str(exc))
def _verify_one(
repo: Repository,
repo_dir: str,
no_verification: bool,
) -> Tuple[bool, bool, List[str]]:
"""Returns (has_verified_info, verified_ok, errors)."""
verified_ok, errors, _commit, _key = verify_repository(
repo,
repo_dir,
mode="pull",
no_verification=no_verification,
)
return (bool(repo.get("verified")), verified_ok, errors)
def _verify_all(
candidates: List[Tuple[Repository, str, str]],
no_verification: bool,
jobs: int,
) -> List[Tuple[str, str, bool, bool, List[str]]]:
"""
Verify all candidates (parallel if ``jobs > 1``), preserving input order.
Returns one tuple per candidate: ``(ident, repo_dir, has_verified_info,
verified_ok, errors)``.
"""
verify_jobs = max(1, min(jobs, len(candidates)))
if verify_jobs == 1:
return [
(ident, rd, *_verify_one(repo, rd, no_verification))
for repo, ident, rd in candidates
]
with ThreadPoolExecutor(max_workers=verify_jobs) as executor:
futures = [
executor.submit(_verify_one, repo, rd, no_verification)
for repo, _ident, rd in candidates
]
results = [f.result() for f in futures]
return [(ident, rd, *res) for (_repo, ident, rd), res in zip(candidates, results)]
def pull_with_verification(
selected_repos: List[Repository],
repositories_base_dir: str,
@@ -19,41 +69,50 @@ def pull_with_verification(
extra_args: List[str],
no_verification: bool,
preview: bool,
jobs: int = 1,
) -> None:
"""
Execute `git pull` for each repository with verification.
- If verification fails and verification is enabled, prompt user to continue.
- Uses core.git.commands.pull_args() (no raw subprocess usage).
- Verification (I/O-bound) runs in parallel when ``jobs > 1``.
- Interactive prompts for failed verifications are handled serially on the
main thread after parallel verification completes.
- Approved repos are then pulled in parallel when ``jobs > 1``.
- On any pull failure, prints a summary and exits with status 1.
"""
candidates: List[Tuple[Repository, str, str]] = []
for repo in selected_repos:
repo_identifier = get_repo_identifier(repo, all_repos)
repo_dir = get_repo_dir(repositories_base_dir, repo)
if not os.path.exists(repo_dir):
print(f"Repository directory '{repo_dir}' not found for {repo_identifier}.")
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
candidates.append((repo, ident, rd))
verified_info = repo.get("verified")
verified_ok, errors, _commit_hash, _signing_key = verify_repository(
repo,
repo_dir,
mode="pull",
no_verification=no_verification,
)
if not candidates:
return
if not preview and not no_verification and verified_info and not verified_ok:
print(f"Warning: Verification failed for {repo_identifier}:")
verify_results = _verify_all(candidates, no_verification, jobs)
approved: List[RepoRef] = []
for ident, rd, has_verified_info, verified_ok, errors in verify_results:
if (
not preview
and not no_verification
and has_verified_info
and not verified_ok
):
print(f"Warning: Verification failed for {ident}:")
for err in errors:
print(f" - {err}")
choice = input("Proceed with 'git pull'? (y/N): ").strip().lower()
if choice != "y":
continue
approved.append((ident, rd))
try:
pull_args(extra_args, cwd=repo_dir, preview=preview)
except GitPullArgsError as exc:
# Keep behavior consistent with previous implementation:
# stop on first failure and propagate return code as generic failure.
print(str(exc))
sys.exit(1)
run_on_repos(
approved,
lambda rd: _pull_one(rd, extra_args, preview),
jobs=jobs,
op_name="pull",
)

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from pkgmgr.actions.repository._parallel import (
resolve_repos,
run_on_repos,
)
from pkgmgr.core.git.commands import push_args, GitPushArgsError
Repository = Dict[str, Any]
def _push_one(repo_dir: str, extra_args: List[str], preview: bool) -> Tuple[bool, str]:
try:
push_args(extra_args, cwd=repo_dir, preview=preview)
return (True, "")
except GitPushArgsError as exc:
return (False, str(exc))
def push_in_parallel(
selected_repos: List[Repository],
repositories_base_dir: str,
all_repos: List[Repository],
extra_args: List[str],
preview: bool,
jobs: int = 1,
) -> None:
"""
Execute `git push` for each repository, optionally in parallel.
"""
repos = resolve_repos(selected_repos, repositories_base_dir, all_repos)
run_on_repos(
repos,
lambda rd: _push_one(rd, extra_args, preview),
jobs=jobs,
op_name="push",
)

View File

@@ -1,3 +1,4 @@
from .archive import handle_archive
from .repos import handle_repos_command
from .config import handle_config
from .tools import handle_tools_command
@@ -10,6 +11,7 @@ from .branch import handle_branch
from .mirror import handle_mirror_command
__all__ = [
"handle_archive",
"handle_repos_command",
"handle_config",
"handle_tools_command",

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
import sys
from pathlib import Path
from pkgmgr.actions.archive import ArchivePlan, run_archive
from pkgmgr.cli.context import CLIContext
def _print_summary(
directory: Path, readme: Path, plan: ArchivePlan, dry_run: bool
) -> None:
print(f"[archive] Directory: {directory}")
print(f"[archive] README: {readme}")
print(f"[archive] Files to process: {len(plan.archived)}")
print(f"[archive] Skipped (incomplete): {len(plan.skipped_incomplete)}")
print(f"[archive] New archive entries: {len(plan.new_entries)}")
print(f"[archive] Dry-run: {dry_run}")
def _print_skips(plan: ArchivePlan, cwd: Path) -> None:
if plan.skipped_incomplete:
print(
"[archive] SKIP: files with unchecked `- [ ]` items "
"(not archived, not deleted):"
)
for path, count in plan.skipped_incomplete:
suffix = "s" if count != 1 else ""
rel = _rel_or_abs(path, cwd)
print(f" - {rel} ({count} unchecked item{suffix})")
if plan.skipped_without_h1:
print("[archive] WARN: skipped files without an H1 heading:")
for path in plan.skipped_without_h1:
print(f" - {_rel_or_abs(path, cwd)}")
def _print_actions(plan: ArchivePlan, cwd: Path, dry_run: bool) -> None:
verb = "would archive" if dry_run else "archived"
rm_verb = "would delete" if dry_run else "deleted"
for path, title in plan.archived:
rel = _rel_or_abs(path, cwd)
print(f"[archive] {verb}: {rel} -> '{title}'")
if not dry_run:
print(f"[archive] {rm_verb}: {rel}")
def _rel_or_abs(path: Path, cwd: Path) -> str:
try:
return path.resolve().relative_to(cwd).as_posix()
except ValueError:
return path.as_posix()
def handle_archive(args, _ctx: CLIContext) -> None:
directory = Path(args.directory).resolve()
readme = (
Path(args.readme).resolve()
if args.readme
else (directory / "README.md").resolve()
)
try:
plan = run_archive(
directory=directory,
readme_path=readme,
dry_run=args.dry_run,
include_template=args.include_template,
)
except FileNotFoundError as exc:
print(f"[archive] ERROR: {exc}", file=sys.stderr)
sys.exit(1)
cwd = Path.cwd().resolve()
_print_summary(directory, readme, plan, args.dry_run)
_print_skips(plan, cwd)
_print_actions(plan, cwd, args.dry_run)

View File

@@ -49,7 +49,19 @@ def handle_release(
print(f"[WARN] Skipping repository {identifier}: directory missing.")
continue
print(f"[pkgmgr] Running release for repository {identifier}...")
retry = bool(getattr(args, "retry", False))
if retry and args.release_type:
print(
f"[WARN] Ignoring release_type '{args.release_type}' for {identifier} — --retry skips version bumps."
)
if not retry and not args.release_type:
print(
f"[WARN] Skipping {identifier}: release_type is required unless --retry is set."
)
continue
action_label = "retry-release" if retry else "release"
print(f"[pkgmgr] Running {action_label} for repository {identifier}...")
cwd_before = os.getcwd()
try:
@@ -58,11 +70,12 @@ def handle_release(
run_release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type=args.release_type,
release_type=args.release_type or "patch",
message=args.message or None,
preview=getattr(args, "preview", False),
force=getattr(args, "force", False),
close=getattr(args, "close", False),
retry=retry,
)
if not getattr(args, "no_publish", False):

View File

@@ -10,6 +10,7 @@ from pkgmgr.core.repository.selected import get_selected_repos
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.cli.commands import (
handle_archive,
handle_repos_command,
handle_tools_command,
handle_release,
@@ -60,6 +61,10 @@ def dispatch_command(args, ctx: CLIContext) -> None:
if maybe_handle_proxy(args, ctx):
return
if args.command == "archive":
handle_archive(args, ctx)
return
commands_with_selection = {
"install",
"update",

View File

@@ -4,6 +4,7 @@ import argparse
from pkgmgr.cli.proxy import register_proxy_commands
from .archive_cmd import add_archive_subparser
from .branch_cmd import add_branch_subparsers
from .changelog_cmd import add_changelog_subparser
from .common import SortedSubParsersAction
@@ -65,6 +66,7 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
add_make_subparsers(subparsers)
add_mirror_subparsers(subparsers)
add_archive_subparser(subparsers)
register_proxy_commands(subparsers)
return parser

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import argparse
def add_archive_subparser(subparsers: argparse._SubParsersAction) -> None:
"""Register the archive subcommand.
Walks a directory of numbered ``NNN-topic.md`` files, promotes every
file whose ``- [ ]`` checklist is fully checked into the directorys
README ``## Archive`` section, then deletes the source file.
"""
parser = subparsers.add_parser(
"archive",
help=(
"Archive fully-checked Markdown spec files (NNN-topic.md) "
"into the directorys README and delete them."
),
description=(
"Walk DIR for files that match the numbered "
"NNN-topic.md naming, promote every file with zero "
"unchecked `- [ ]` items into READMEs ## Archive section, "
"then delete the source file. Files with unchecked items are "
"skipped. Use --dry-run to preview without writing."
),
)
parser.add_argument(
"directory",
nargs="?",
default="docs/requirements",
help=(
"Directory to scan for archivable Markdown files. "
"Defaults to docs/requirements (relative to the current "
"working directory)."
),
)
parser.add_argument(
"--readme",
default=None,
help=(
"Path to the README that holds the ## Archive index. "
"Defaults to <directory>/README.md."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print planned changes without modifying or deleting anything.",
)
parser.add_argument(
"--include-template",
action="store_true",
help=(
"Also archive and delete 000-template.md. Off by default "
"because contributor guides typically reference it."
),
)

View File

@@ -24,8 +24,12 @@ def add_release_subparser(
release_parser.add_argument(
"release_type",
nargs="?",
choices=["major", "minor", "patch"],
help="Type of version increment for the release (major, minor, patch).",
help=(
"Type of version increment for the release (major, minor, patch). "
"Omit when `--retry` is set."
),
)
release_parser.add_argument(
@@ -61,3 +65,15 @@ def add_release_subparser(
action="store_true",
help="Do not run publish automatically after a successful release.",
)
release_parser.add_argument(
"--retry",
action="store_true",
help=(
"Re-deploy the existing HEAD release without re-tagging or "
"modifying any files: re-push the existing version tag, "
"re-align the floating `latest` tag, and (unless --no-publish) "
"re-run publish. Use this to recover from a release whose "
"post-tag push or PyPI upload failed mid-flight."
),
)

View File

@@ -12,6 +12,7 @@ from pkgmgr.cli.context import CLIContext
from pkgmgr.actions.repository.clone import clone_repos
from pkgmgr.actions.proxy import exec_proxy_command
from pkgmgr.actions.repository.pull import pull_with_verification
from pkgmgr.actions.repository.push import push_in_parallel
from pkgmgr.core.repository.selected import get_selected_repos
from pkgmgr.core.repository.dir import get_repo_dir
@@ -177,6 +178,17 @@ def register_proxy_commands(
default=False,
help="Disable verification via commit/gpg",
)
if subcommand in ("pull", "push"):
parser.add_argument(
"-j",
"--jobs",
type=int,
default=min(os.cpu_count() or 4, 8),
help=(
f"Number of parallel {subcommand}s "
"(default: min(cpu_count, 8)). Use 1 for sequential."
),
)
if subcommand == "clone":
parser.add_argument(
"--clone-mode",
@@ -234,6 +246,16 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool:
args.extra_args,
args.no_verification,
args.preview,
jobs=args.jobs,
)
elif args.command == "push":
push_in_parallel(
selected,
ctx.repositories_base_dir,
ctx.all_repositories,
args.extra_args,
args.preview,
jobs=args.jobs,
)
else:
exec_proxy_command(

View File

@@ -5,16 +5,6 @@ directories:
workspaces: ~/Workspaces/
binaries: ~/.local/bin/
repositories:
- account: kevinveenbirkenbach
alias: arc
provider: github.com
repository: analysis-ready-code
description: Analysis-Ready Code (ARC) is a Python utility that recursively scans directories and transforms source code into a streamlined, analysis-ready format by removing comments, filtering files, and compressing content—perfect for AI and automated code analysis.
homepage: https://github.com/kevinveenbirkenbach/analysis-ready-code
verified:
gpg_keys:
- 44D8F11FD62F878E
- B5690EEEBB952194
- account: kevinveenbirkenbach
description: A configurable Python package manager that automates repository tasks—including cloning, installation, updates, and status reporting—based on a YAML configuration file for streamlined software management which gives you access to the Kevin Veen-Birkenbach Code Universe.
homepage: https://github.com/kevinveenbirkenbach/package-manager
@@ -274,12 +264,12 @@ repositories:
gpg_keys:
- 44D8F11FD62F878E
- B5690EEEBB952194
- account: kevinveenbirkenbach
- account: infinito-nexus
alias: infinito
provider: github.com
description: Infinito.nexus streamlines Linux-based system setups and Docker image administration, perfect for servers and PCs. It offers extensive solutions for system initialization, admin tools, backups, monitoring, updates, driver management, security, and VPNs.
homepage: https://infinito.nexus
repository: infinito-nexus
repository: core
verified:
gpg_keys:
- 44D8F11FD62F878E
@@ -369,17 +359,6 @@ repositories:
- 44D8F11FD62F878E
- B5690EEEBB952194
- account: kevinveenbirkenbach
alias: infinito-sphinx
description: Contains the logic and configuration for generating documentation using Sphinx for Infinito.Nexus.
homepage: https://github.com/kevinveenbirkenbach/infinito-sphinx
provider: github.com
repository: infinito-sphinx
verified:
gpg_keys:
- 44D8F11FD62F878E
- B5690EEEBB952194
- account: kevinveenbirkenbach
description: A lightweight Python utility to generate dynamic color schemes from a single base color. Provides HSL-based color transformations for theming, UI design, and CSS variable generation. Optimized for integration in Python projects, Flask applications, and Ansible roles.
homepage: https://github.com/kevinveenbirkenbach/colorscheme-generator

View File

@@ -19,6 +19,7 @@ from .pull import GitPullError, pull
from .pull_args import GitPullArgsError, pull_args
from .pull_ff_only import GitPullFfOnlyError, pull_ff_only
from .push import GitPushError, push
from .push_args import GitPushArgsError, push_args
from .push_upstream import GitPushUpstreamError, push_upstream
from .set_remote_url import GitSetRemoteUrlError, set_remote_url
from .tag_annotated import GitTagAnnotatedError, tag_annotated
@@ -34,6 +35,7 @@ __all__ = [
"pull_ff_only",
"merge_no_ff",
"push",
"push_args",
"commit",
"delete_local_branch",
"delete_remote_branch",
@@ -56,6 +58,7 @@ __all__ = [
"GitPullFfOnlyError",
"GitMergeError",
"GitPushError",
"GitPushArgsError",
"GitCommitError",
"GitDeleteLocalBranchError",
"GitDeleteRemoteBranchError",

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import List
from ..errors import GitRunError, GitCommandError
from ..run import run
class GitPushArgsError(GitCommandError):
"""Raised when `git push` with arbitrary args fails."""
def push_args(
args: List[str] | None = None,
*,
cwd: str = ".",
preview: bool = False,
) -> None:
"""
Execute `git push` with caller-provided arguments.
Examples:
[] -> git push
["--force"] -> git push --force
["origin", "main"] -> git push origin main
["-u", "origin", "feature"] -> git push -u origin feature
"""
extra = args or []
try:
run(["push", *extra], cwd=cwd, preview=preview)
except GitRunError as exc:
details = getattr(exc, "output", None) or getattr(exc, "stderr", None) or ""
raise GitPushArgsError(
(
f"Failed to run `git push` with args={extra!r} "
f"in cwd={cwd!r}.\n{details}"
).rstrip(),
cwd=cwd,
) from exc

View File

@@ -1,13 +1,33 @@
from __future__ import annotations
from ..errors import GitQueryError, GitRunError
from ..run import run
import subprocess
from ..errors import GitNotRepositoryError, GitQueryError
class GitLatestSigningKeyQueryError(GitQueryError):
"""Raised when querying the latest commit signing key fails."""
def _is_not_repository(stderr: str) -> bool:
return "not a git repository" in (stderr or "").lower()
def _looks_like_gpg_runtime_error(stderr: str) -> bool:
lowered = (stderr or "").lower()
markers = (
"cannot run gpg",
"can't check signature",
"no public key",
"failed to create temporary file",
"can't connect to the keyboxd",
"error opening key db",
"gpg failed",
"no such file or directory",
)
return any(marker in lowered for marker in markers)
def get_latest_signing_key(*, cwd: str = ".") -> str:
"""
Return the GPG signing key ID of the latest commit, via:
@@ -17,9 +37,46 @@ def get_latest_signing_key(*, cwd: str = ".") -> str:
Returns:
The key id string (may be empty if commit is not signed).
"""
cmd = ["git", "log", "-1", "--format=%GK"]
try:
return run(["log", "-1", "--format=%GK"], cwd=cwd).strip()
except GitRunError as exc:
result = subprocess.run(
cmd,
cwd=cwd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except OSError as exc:
raise GitLatestSigningKeyQueryError(
"Failed to query latest signing key.",
"Failed to query latest signing key.\n"
f"Command: {' '.join(cmd)}\n"
f"Reason: {exc}"
) from exc
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()
if result.returncode != 0:
if _is_not_repository(stderr):
raise GitNotRepositoryError(
f"Not a git repository: {cwd!r}\n"
f"Command: {' '.join(cmd)}\n"
f"STDERR:\n{stderr}"
)
raise GitLatestSigningKeyQueryError(
"Failed to query latest signing key.\n"
f"Command: {' '.join(cmd)}\n"
f"Exit code: {result.returncode}\n"
f"STDOUT:\n{stdout}\n"
f"STDERR:\n{stderr}"
)
if not stdout and stderr and _looks_like_gpg_runtime_error(stderr):
raise GitLatestSigningKeyQueryError(
"Failed to query latest signing key.\n"
f"Command: {' '.join(cmd)}\n"
f"STDERR:\n{stderr}"
)
return stdout

View File

@@ -36,6 +36,7 @@ class RepoPaths:
# Packaging-related files
arch_pkgbuild: Optional[str]
debian_changelog: Optional[str]
debian_control: Optional[str]
rpm_spec: Optional[str]
@@ -102,6 +103,13 @@ def resolve_repo_paths(repo_dir: str) -> RepoPaths:
]
)
debian_control = _first_existing(
[
os.path.join(repo_dir, "packaging", "debian", "control"),
os.path.join(repo_dir, "debian", "control"),
]
)
# RPM spec: prefer the canonical file, else first spec in packaging/fedora, else first spec in repo root.
rpm_spec = _first_existing(
[
@@ -122,5 +130,6 @@ def resolve_repo_paths(repo_dir: str) -> RepoPaths:
changelog_md=changelog_md,
arch_pkgbuild=arch_pkgbuild,
debian_changelog=debian_changelog,
debian_control=debian_control,
rpm_spec=rpm_spec,
)

View File

@@ -16,6 +16,7 @@ def verify_repository(repo, repo_dir, mode="local", no_verification=False):
commit_hash = ""
signing_key = ""
signing_key_query_failed = False
# best-effort info collection
try:
@@ -59,6 +60,7 @@ def verify_repository(repo, repo_dir, mode="local", no_verification=False):
except GitLatestSigningKeyQueryError as exc:
error_details.append(str(exc))
signing_key = ""
signing_key_query_failed = True
commit_check_passed = True
gpg_check_passed = True
@@ -78,9 +80,10 @@ def verify_repository(repo, repo_dir, mode="local", no_verification=False):
if expected_gpg_keys:
if not signing_key:
gpg_check_passed = False
error_details.append(
f"Expected one of GPG keys: {expected_gpg_keys}, but no signing key was found."
)
if not signing_key_query_failed:
error_details.append(
f"Expected one of GPG keys: {expected_gpg_keys}, but no signing key was found."
)
elif signing_key not in expected_gpg_keys:
gpg_check_passed = False
error_details.append(

View File

@@ -0,0 +1 @@
"""GitHub-related Python helpers for pkgmgr."""

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Fail when a hadolint SARIF report contains warnings or errors."""
from __future__ import annotations
import json
import sys
from pathlib import Path
def main() -> int:
sarif_path = Path(sys.argv[1] if len(sys.argv) > 1 else "hadolint-results.sarif")
with sarif_path.open("r", encoding="utf-8") as handle:
sarif = json.load(handle)
results = sarif.get("runs", [{}])[0].get("results", [])
levels = [result.get("level", "") for result in results]
warnings = sum(1 for level in levels if level == "warning")
errors = sum(1 for level in levels if level == "error")
print(f"SARIF results: total={len(results)} warnings={warnings} errors={errors}")
return 1 if warnings + errors > 0 else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import re
import unittest
from pathlib import Path
def _find_repo_root() -> Path:
here = Path(__file__).resolve()
for parent in here.parents:
if (parent / "pyproject.toml").is_file() and (
parent / "src" / "pkgmgr"
).is_dir():
return parent
raise RuntimeError(
"Could not determine repository root for pkgmgr integration test"
)
class TestGitVerificationRuntimeDependencies(unittest.TestCase):
def test_flake_app_includes_git_and_gpg_runtime_tools(self) -> None:
repo_root = _find_repo_root()
flake_text = (repo_root / "flake.nix").read_text(encoding="utf-8")
self.assertIn("pkgs.git", flake_text)
self.assertIn("pkgs.gnupg", flake_text)
def test_distro_dependency_scripts_install_gpg_tools(self) -> None:
repo_root = _find_repo_root()
expected_packages = {
"arch": "gnupg",
"debian": "gnupg",
"ubuntu": "gnupg",
"fedora": "gnupg2",
"centos": "gnupg2",
}
missing: list[str] = []
for distro, package_name in expected_packages.items():
script_path = (
repo_root / "scripts" / "installation" / distro / "dependencies.sh"
)
content = script_path.read_text(encoding="utf-8")
if not re.search(rf"\b{re.escape(package_name)}\b", content):
missing.append(
f"{distro}: expected package {package_name} in {script_path}"
)
if missing:
self.fail(
"Git signature verification runtime dependencies are incomplete:\n"
+ "\n".join(f" - {item}" for item in missing)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,49 @@
"""Unit tests for `pkgmgr.actions.archive.discovery`."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from pkgmgr.actions.archive.discovery import iter_archivable_files
class TestIterArchivableFiles(unittest.TestCase):
def test_only_numbered_markdown_files_are_returned_and_sorted(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
(d / "001-alpha.md").write_text("# 001 - A")
(d / "002-beta.md").write_text("# 002 - B")
(d / "README.md").write_text("# X")
(d / "notes.md").write_text("# X")
(d / "001-alpha.txt").write_text("x")
result = [p.name for p in iter_archivable_files(d, include_template=True)]
self.assertEqual(result, ["001-alpha.md", "002-beta.md"])
def test_template_is_skipped_unless_opted_in(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
(d / "000-template.md").write_text("# 000 - Template")
(d / "001-alpha.md").write_text("# 001 - A")
self.assertEqual(
[p.name for p in iter_archivable_files(d, include_template=False)],
["001-alpha.md"],
)
self.assertEqual(
[p.name for p in iter_archivable_files(d, include_template=True)],
["000-template.md", "001-alpha.md"],
)
def test_missing_directory_returns_empty(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
self.assertEqual(
iter_archivable_files(Path(tmp) / "missing", include_template=False),
[],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,62 @@
"""Unit tests for `pkgmgr.actions.archive.inspect`."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from pkgmgr.actions.archive.inspect import count_unchecked_items, extract_h1
class TestExtractH1(unittest.TestCase):
def test_returns_first_h1_title(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "001-x.md"
path.write_text("\n# 001 - Title\n\n## Subsection\n# Later H1\n")
self.assertEqual(extract_h1(path), "001 - Title")
def test_returns_none_when_no_h1(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "001-x.md"
path.write_text("no heading here\n## h2 only\n")
self.assertIsNone(extract_h1(path))
class TestCountUncheckedItems(unittest.TestCase):
def test_zero_when_all_checked(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "001-x.md"
path.write_text(
"# 001 - Done\n\n## Acceptance Criteria\n\n- [x] A\n- [x] B\n"
)
self.assertEqual(count_unchecked_items(path), 0)
def test_counts_unchecked_anywhere_in_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "001-x.md"
path.write_text(
"# 001 - In progress\n\n"
"## Acceptance Criteria\n\n"
"- [x] A\n"
"- [ ] B\n\n"
"## Notes\n\n"
"- [ ] still tracking this one too\n"
" - [ ] nested unchecked\n"
)
self.assertEqual(count_unchecked_items(path), 3)
def test_ignores_non_task_dashes(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "001-x.md"
path.write_text(
"# 001 - X\n\n"
"- plain list item\n"
"- [x] checked\n"
"- not [ ] not a task marker\n"
)
self.assertEqual(count_unchecked_items(path), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,73 @@
"""Unit tests for `pkgmgr.actions.archive.readme`."""
from __future__ import annotations
import unittest
from pkgmgr.actions.archive.readme import (
existing_archive_entries,
merge_archive_section,
)
class TestExistingArchiveEntries(unittest.TestCase):
def test_parses_only_archive_section_items(self) -> None:
readme = (
"# Requirements\n\n"
"Intro paragraph.\n\n"
"## Other\n\n"
"- not an archive entry\n\n"
"## Archive\n\n"
"- 001 - One\n"
"- 002 - Two\n\n"
"## Trailing\n\n"
"- ignored too\n"
)
self.assertEqual(
existing_archive_entries(readme),
{"001 - One", "002 - Two"},
)
def test_returns_empty_when_section_missing(self) -> None:
self.assertEqual(existing_archive_entries("# Title\n\nBody.\n"), set())
class TestMergeArchiveSection(unittest.TestCase):
def test_creates_section_when_missing(self) -> None:
readme = "# Requirements\n\nIntro.\n"
merged = merge_archive_section(readme, ["001 - One", "002 - Two"])
self.assertEqual(
merged,
"# Requirements\n\nIntro.\n\n## Archive\n\n- 001 - One\n- 002 - Two\n",
)
def test_appends_to_existing_section_preserving_entries(self) -> None:
readme = "# Requirements\n\nIntro.\n\n## Archive\n\n- 001 - One\n"
merged = merge_archive_section(readme, ["002 - Two", "003 - Three"])
self.assertEqual(
merged,
(
"# Requirements\n\n"
"Intro.\n\n"
"## Archive\n\n"
"- 001 - One\n"
"- 002 - Two\n"
"- 003 - Three\n"
),
)
def test_appends_before_trailing_section(self) -> None:
readme = "## Archive\n\n- 001 - One\n\n## Trailing\n\nfooter\n"
merged = merge_archive_section(readme, ["002 - Two"])
self.assertEqual(
merged,
"## Archive\n\n- 001 - One\n- 002 - Two\n\n## Trailing\n\nfooter\n",
)
def test_empty_entries_returns_unchanged(self) -> None:
readme = "# X\n\nBody\n"
self.assertEqual(merge_archive_section(readme, []), readme)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,91 @@
"""Unit tests for `pkgmgr.actions.archive.workflow.run_archive`."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from pkgmgr.actions.archive.workflow import run_archive
class TestRunArchive(unittest.TestCase):
def test_dry_run_does_not_touch_files_or_readme(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
done = d / "001-done.md"
done.write_text("# 001 - Done\n\n- [x] ok\n")
readme = d / "README.md"
readme.write_text("# Specs\n\n## Archive\n")
plan = run_archive(d, readme, dry_run=True)
self.assertTrue(done.exists())
self.assertEqual(readme.read_text(), "# Specs\n\n## Archive\n")
self.assertEqual(plan.new_entries, ["001 - Done"])
self.assertEqual(plan.archived, [(done, "001 - Done")])
def test_real_run_archives_and_deletes_completed_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
done = d / "001-done.md"
done.write_text("# 001 - Done\n\n- [x] ok\n")
readme = d / "README.md"
readme.write_text("# Specs\n\n## Archive\n")
plan = run_archive(d, readme)
self.assertFalse(done.exists())
self.assertIn("- 001 - Done", readme.read_text())
self.assertEqual(plan.new_entries, ["001 - Done"])
def test_incomplete_files_are_skipped(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
wip = d / "002-wip.md"
wip.write_text("# 002 - WIP\n\n- [ ] open\n")
readme = d / "README.md"
readme.write_text("# Specs\n\n## Archive\n")
plan = run_archive(d, readme)
self.assertTrue(wip.exists())
self.assertEqual(plan.archived, [])
self.assertEqual(plan.skipped_incomplete, [(wip, 1)])
def test_already_archived_title_is_not_duplicated(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
done = d / "001-done.md"
done.write_text("# 001 - Done\n\n- [x] ok\n")
readme = d / "README.md"
readme.write_text("# Specs\n\n## Archive\n\n- 001 - Done\n")
plan = run_archive(d, readme)
self.assertEqual(plan.new_entries, [])
self.assertEqual(plan.archived, [(done, "001 - Done")])
# File still deleted; README unchanged.
self.assertFalse(done.exists())
self.assertEqual(
readme.read_text(),
"# Specs\n\n## Archive\n\n- 001 - Done\n",
)
def test_missing_directory_raises_filenotfound(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
readme = Path(tmp) / "README.md"
readme.write_text("# X\n## Archive\n")
with self.assertRaises(FileNotFoundError):
run_archive(Path(tmp) / "no-such-dir", readme)
def test_missing_readme_raises_filenotfound(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
(d / "001-done.md").write_text("# 001 - Done\n- [x] ok\n")
with self.assertRaises(FileNotFoundError):
run_archive(d, d / "missing.md")
if __name__ == "__main__":
unittest.main()

View File

@@ -256,7 +256,7 @@ class TestUpdateSpecVersion(unittest.TestCase):
class TestUpdateChangelog(unittest.TestCase):
def test_update_changelog_creates_file_if_missing(self) -> None:
def test_update_changelog_creates_file_with_h1_if_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
self.assertFalse(os.path.exists(path))
@@ -267,10 +267,35 @@ class TestUpdateChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("## [1.2.3]", content)
# New file must lead with an H1 so markdownlint MD041 is happy.
self.assertTrue(content.startswith("# Changelog\n\n## [1.2.3]"))
self.assertIn("First release", content)
def test_update_changelog_prepends_entry_to_existing_content(self) -> None:
def test_update_changelog_inserts_below_existing_h1(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
existing = "# Changelog\n\n## [0.1.0] - 2024-01-01\n\n* Initial content\n"
with open(path, "w", encoding="utf-8") as f:
f.write(existing)
update_changelog(path, "1.0.0", message="Second release", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# H1 still on top, new entry above the existing one.
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
# Exactly one H1.
self.assertEqual(
content.count("\n# Changelog\n") + content.startswith("# Changelog\n"), 1
)
# Old entry still present, after the new one.
self.assertLess(
content.index("## [1.0.0]"),
content.index("## [0.1.0]"),
)
def test_update_changelog_legacy_headerless_gets_h1_synthesized(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
with open(path, "w", encoding="utf-8") as f:
@@ -281,7 +306,8 @@ class TestUpdateChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertTrue(content.startswith("## [1.0.0]"))
# An H1 is added so MD041 is satisfied even for legacy files.
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
self.assertIn("## [0.1.0] - 2024-01-01", content)
def test_update_changelog_preview_does_not_write(self) -> None:

View File

@@ -0,0 +1,110 @@
"""Unit tests for `pkgmgr.actions.release.package_name.resolve_package_name`.
The resolver must prefer the explicit name from existing packaging files
over the repository folder name so that renaming the folder does not
silently rename the distro package.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from typing import Optional
from pkgmgr.actions.release.package_name import resolve_package_name
from pkgmgr.core.repository.paths import RepoPaths
def _paths(
repo_dir: str,
*,
debian_control: Optional[str] = None,
arch_pkgbuild: Optional[str] = None,
rpm_spec: Optional[str] = None,
) -> RepoPaths:
return RepoPaths(
repo_dir=repo_dir,
pyproject_toml=os.path.join(repo_dir, "pyproject.toml"),
flake_nix=os.path.join(repo_dir, "flake.nix"),
changelog_md=None,
arch_pkgbuild=arch_pkgbuild,
debian_changelog=None,
debian_control=debian_control,
rpm_spec=rpm_spec,
)
class TestResolvePackageName(unittest.TestCase):
def test_debian_control_wins_over_folder(self) -> None:
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
control = Path(repo) / "control"
control.write_text(
"Source: infinito-nexus\nPackage: infinito-nexus\n",
encoding="utf-8",
)
self.assertEqual(
resolve_package_name(_paths(repo, debian_control=str(control))),
"infinito-nexus",
)
def test_pkgbuild_used_when_no_control(self) -> None:
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
pkgbuild = Path(repo) / "PKGBUILD"
pkgbuild.write_text(
"pkgname=infinito-nexus\npkgver=1.0\n", encoding="utf-8"
)
self.assertEqual(
resolve_package_name(_paths(repo, arch_pkgbuild=str(pkgbuild))),
"infinito-nexus",
)
def test_rpm_spec_used_when_no_control_no_pkgbuild(self) -> None:
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
spec = Path(repo) / "pkg.spec"
spec.write_text("Name: infinito-nexus\n", encoding="utf-8")
self.assertEqual(
resolve_package_name(_paths(repo, rpm_spec=str(spec))),
"infinito-nexus",
)
def test_folder_fallback_when_no_packaging_metadata(self) -> None:
with tempfile.TemporaryDirectory(prefix="solo-tool_") as repo:
self.assertEqual(
resolve_package_name(_paths(repo)),
os.path.basename(repo),
)
def test_priority_debian_over_pkgbuild_over_spec(self) -> None:
with tempfile.TemporaryDirectory() as repo:
control = Path(repo) / "control"
control.write_text("Package: deb-name\n", encoding="utf-8")
pkgbuild = Path(repo) / "PKGBUILD"
pkgbuild.write_text("pkgname=arch-name\n", encoding="utf-8")
spec = Path(repo) / "x.spec"
spec.write_text("Name: rpm-name\n", encoding="utf-8")
self.assertEqual(
resolve_package_name(
_paths(
repo,
debian_control=str(control),
arch_pkgbuild=str(pkgbuild),
rpm_spec=str(spec),
)
),
"deb-name",
)
def test_strips_quotes_in_pkgbuild(self) -> None:
with tempfile.TemporaryDirectory() as repo:
pkgbuild = Path(repo) / "PKGBUILD"
pkgbuild.write_text("pkgname='quoted-name'\n", encoding="utf-8")
self.assertEqual(
resolve_package_name(_paths(repo, arch_pkgbuild=str(pkgbuild))),
"quoted-name",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,154 @@
"""Unit tests for the `release(retry=True)` re-deploy entry point.
`TestReleaseRetry` exercises the routing decision in `release()` —
that `--retry` skips the full `_release_impl` flow and dispatches to
`retry_release` instead.
`TestRetryRelease` exercises the standalone module `pkgmgr.actions.release.retry`:
HEAD-tag discovery, idempotent push, latest-tag re-alignment, and
fallback behaviour when the current branch cannot be detected.
"""
from __future__ import annotations
import unittest
from unittest.mock import patch
from pkgmgr.actions.release.retry import retry_release
from pkgmgr.actions.release.workflow import release
from pkgmgr.core.git import GitRunError
class TestReleaseRetry(unittest.TestCase):
@patch("pkgmgr.actions.release.workflow.retry_release")
@patch("pkgmgr.actions.release.workflow._release_impl")
def test_retry_routes_to_retry_release_only(
self, mock_release_impl, mock_retry_release
) -> None:
release(retry=True, preview=False)
mock_retry_release.assert_called_once()
mock_release_impl.assert_not_called()
self.assertFalse(mock_retry_release.call_args.kwargs["preview"])
@patch("pkgmgr.actions.release.workflow.retry_release")
@patch("pkgmgr.actions.release.workflow._release_impl")
def test_retry_preview_passthrough(
self, mock_release_impl, mock_retry_release
) -> None:
release(retry=True, preview=True)
mock_retry_release.assert_called_once()
mock_release_impl.assert_not_called()
self.assertTrue(mock_retry_release.call_args.kwargs["preview"])
@patch("pkgmgr.actions.release.workflow.retry_release")
@patch("pkgmgr.actions.release.workflow._release_impl")
def test_retry_ignores_release_args(
self, mock_release_impl, mock_retry_release
) -> None:
# release_type/message/close/force must NOT trigger the full release flow
# when retry=True.
release(
retry=True,
release_type="major",
message="ignored body",
preview=False,
force=True,
close=True,
)
mock_retry_release.assert_called_once()
mock_release_impl.assert_not_called()
class TestRetryRelease(unittest.TestCase):
@patch("pkgmgr.actions.release.retry.update_latest_tag")
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
@patch("pkgmgr.actions.release.retry.run")
@patch("pkgmgr.actions.release.retry.head_semver_tags")
@patch(
"pkgmgr.actions.release.retry.get_current_branch",
return_value="release-fix",
)
def test_retry_pushes_existing_tag_and_updates_latest(
self,
_mock_branch,
mock_head_tags,
mock_run,
_mock_highest,
mock_update_latest,
) -> None:
mock_head_tags.return_value = ["v1.13.4"]
retry_release(pyproject_path="pyproject.toml", preview=False)
mock_run.assert_called_once_with(
["push", "origin", "release-fix", "v1.13.4"], preview=False
)
mock_update_latest.assert_called_once_with("v1.13.4", preview=False)
@patch("pkgmgr.actions.release.retry.update_latest_tag")
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=False)
@patch("pkgmgr.actions.release.retry.run")
@patch("pkgmgr.actions.release.retry.head_semver_tags")
@patch(
"pkgmgr.actions.release.retry.get_current_branch",
return_value="release-fix",
)
def test_retry_skips_latest_for_non_highest_tag(
self,
_mock_branch,
mock_head_tags,
_mock_run,
_mock_highest,
mock_update_latest,
) -> None:
mock_head_tags.return_value = ["v1.0.0"]
retry_release(pyproject_path="pyproject.toml", preview=False)
mock_update_latest.assert_not_called()
@patch(
"pkgmgr.actions.release.retry.head_semver_tags",
return_value=[],
)
@patch(
"pkgmgr.actions.release.retry.get_current_branch",
return_value="main",
)
def test_retry_raises_when_head_has_no_tag(
self, _mock_branch, _mock_head_tags
) -> None:
with self.assertRaises(RuntimeError) as cm:
retry_release(pyproject_path="pyproject.toml", preview=False)
self.assertIn("No version tag on HEAD", str(cm.exception))
@patch("pkgmgr.actions.release.retry.update_latest_tag")
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
@patch("pkgmgr.actions.release.retry.run")
@patch("pkgmgr.actions.release.retry.head_semver_tags")
@patch(
"pkgmgr.actions.release.retry.get_current_branch",
side_effect=GitRunError("detached"),
)
def test_retry_falls_back_to_main_branch_when_detection_fails(
self,
_mock_branch,
mock_head_tags,
mock_run,
_mock_highest,
_mock_update_latest,
) -> None:
mock_head_tags.return_value = ["v2.0.0"]
retry_release(pyproject_path="pyproject.toml", preview=False)
mock_run.assert_called_once_with(
["push", "origin", "main", "v2.0.0"], preview=False
)
if __name__ == "__main__":
unittest.main()

View File

@@ -112,6 +112,7 @@ class TestReleaseCommand(unittest.TestCase):
preview=False,
force=True,
close=True,
retry=False,
)
@patch("pkgmgr.cli.commands.release.os.path.isdir", return_value=True)
@@ -160,6 +161,7 @@ class TestReleaseCommand(unittest.TestCase):
preview=True,
force=False,
close=False,
retry=False,
)
@patch("pkgmgr.cli.commands.release.run_release")

View File

@@ -1,7 +1,8 @@
import unittest
import subprocess
from unittest.mock import patch
from pkgmgr.core.git.errors import GitNotRepositoryError, GitRunError
from pkgmgr.core.git.errors import GitNotRepositoryError
from pkgmgr.core.git.queries.get_latest_signing_key import (
GitLatestSigningKeyQueryError,
get_latest_signing_key,
@@ -10,25 +11,53 @@ from pkgmgr.core.git.queries.get_latest_signing_key import (
class TestGetLatestSigningKey(unittest.TestCase):
@patch(
"pkgmgr.core.git.queries.get_latest_signing_key.run",
return_value="ABCDEF1234567890\n",
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
return_value=subprocess.CompletedProcess(
args=["git", "log", "-1", "--format=%GK"],
returncode=0,
stdout="ABCDEF1234567890\n",
stderr="",
),
)
def test_strips_output(self, _mock_run) -> None:
out = get_latest_signing_key(cwd="/tmp/repo")
self.assertEqual(out, "ABCDEF1234567890")
@patch(
"pkgmgr.core.git.queries.get_latest_signing_key.run",
side_effect=GitRunError("boom"),
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
return_value=subprocess.CompletedProcess(
args=["git", "log", "-1", "--format=%GK"],
returncode=1,
stdout="",
stderr="boom",
),
)
def test_wraps_git_run_error(self, _mock_run) -> None:
with self.assertRaises(GitLatestSigningKeyQueryError):
with self.assertRaisesRegex(GitLatestSigningKeyQueryError, "boom"):
get_latest_signing_key(cwd="/tmp/repo")
@patch(
"pkgmgr.core.git.queries.get_latest_signing_key.run",
side_effect=GitNotRepositoryError("no repo"),
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
return_value=subprocess.CompletedProcess(
args=["git", "log", "-1", "--format=%GK"],
returncode=128,
stdout="",
stderr="fatal: not a git repository",
),
)
def test_does_not_catch_not_repository_error(self, _mock_run) -> None:
with self.assertRaises(GitNotRepositoryError):
get_latest_signing_key(cwd="/tmp/no-repo")
@patch(
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
return_value=subprocess.CompletedProcess(
args=["git", "log", "-1", "--format=%GK"],
returncode=0,
stdout="",
stderr="error: cannot run gpg: No such file or directory",
),
)
def test_raises_when_git_reports_gpg_runtime_error(self, _mock_run) -> None:
with self.assertRaisesRegex(GitLatestSigningKeyQueryError, "cannot run gpg"):
get_latest_signing_key(cwd="/tmp/repo")

View File

@@ -77,6 +77,23 @@ class TestVerifyRepository(unittest.TestCase):
self.assertEqual(commit, "")
self.assertEqual(key, "")
def test_verified_gpg_query_error_does_not_add_missing_key_fallback(self) -> None:
repo = {"verified": {"commit": None, "gpg_keys": ["ABC"]}}
with (
patch("pkgmgr.core.repository.verify.get_head_commit", return_value=""),
patch(
"pkgmgr.core.repository.verify.get_latest_signing_key",
side_effect=GitLatestSigningKeyQueryError("cannot run gpg"),
),
):
ok, errors, commit, key = verify_repository(repo, "/tmp/repo", mode="local")
self.assertFalse(ok)
self.assertIn("cannot run gpg", " ".join(errors))
self.assertFalse(any("no signing key was found" in e for e in errors))
self.assertEqual(commit, "")
self.assertEqual(key, "")
def test_strict_pull_collects_remote_error_message(self) -> None:
repo = {"verified": {"commit": "expected", "gpg_keys": None}}
with (