Compare commits

..
2 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach c5c84704db Release version 1.8.5
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-17 22:15:48 +01:00
Kevin Veen-Birkenbach c46df92953 Release version 1.9.0
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-17 22:10:31 +01:00
454 changed files with 3125 additions and 22830 deletions

No files matched your search

-17
View File
@@ -1,17 +0,0 @@
{
"permissions": {
"ask": [
"Skill(update-config)",
"Skill(update-config:*)",
"Bash(git commit)",
"Bash(git commit *)",
"Bash(git push)",
"Bash(git push *)"
]
},
"sandbox": {
"enabled": true,
"failIfUnavailable": true,
"autoAllowBashIfSandboxed": true
}
}
-23
View File
@@ -1,23 +0,0 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "00:00"
rebase-strategy: "auto"
labels:
- "dependencies"
- "python"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "00:00"
labels:
- "dependencies"
- "ci"
+2 -40
View File
@@ -2,72 +2,34 @@ name: CI
on: on:
push: push:
branches: branches-ignore:
- '**' - main
pull_request: pull_request:
permissions:
contents: read
concurrency:
group: global-ci-${{ github.repository }}-${{ github.ref_name }}
cancel-in-progress: false
jobs: jobs:
security-codeql:
permissions:
contents: read
packages: read
security-events: write
uses: ./.github/workflows/security-codeql.yml
test-unit: test-unit:
permissions:
contents: read
uses: ./.github/workflows/test-unit.yml uses: ./.github/workflows/test-unit.yml
test-integration: test-integration:
permissions:
contents: read
uses: ./.github/workflows/test-integration.yml uses: ./.github/workflows/test-integration.yml
test-env-virtual: test-env-virtual:
permissions:
contents: read
uses: ./.github/workflows/test-env-virtual.yml uses: ./.github/workflows/test-env-virtual.yml
test-env-nix: test-env-nix:
permissions:
contents: read
uses: ./.github/workflows/test-env-nix.yml uses: ./.github/workflows/test-env-nix.yml
test-e2e: test-e2e:
permissions:
contents: read
uses: ./.github/workflows/test-e2e.yml uses: ./.github/workflows/test-e2e.yml
test-virgin-user: test-virgin-user:
permissions:
contents: read
uses: ./.github/workflows/test-virgin-user.yml uses: ./.github/workflows/test-virgin-user.yml
test-virgin-root: test-virgin-root:
permissions:
contents: read
uses: ./.github/workflows/test-virgin-root.yml uses: ./.github/workflows/test-virgin-root.yml
lint-shell: lint-shell:
permissions:
contents: read
uses: ./.github/workflows/lint-shell.yml uses: ./.github/workflows/lint-shell.yml
lint-python: lint-python:
permissions:
contents: read
uses: ./.github/workflows/lint-python.yml uses: ./.github/workflows/lint-python.yml
lint-docker:
permissions:
contents: read
security-events: write
uses: ./.github/workflows/lint-docker.yml
-40
View File
@@ -1,40 +0,0 @@
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@v7
- name: Run hadolint (produce SARIF)
id: hadolint
continue-on-error: true
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387
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
+2 -5
View File
@@ -3,18 +3,15 @@ name: Ruff (Python code sniffer)
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
lint-python: lint-python:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v7 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
+1 -4
View File
@@ -3,14 +3,11 @@ name: ShellCheck
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
lint-shell: lint-shell:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v4
- name: Install ShellCheck - name: Install ShellCheck
run: sudo apt-get update && sudo apt-get install -y shellcheck run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Run ShellCheck - name: Run ShellCheck
+93 -22
View File
@@ -1,39 +1,110 @@
name: Mark stable commit name: Mark stable commit
concurrency:
group: mark-stable-${{ github.repository }}-main
cancel-in-progress: true
on: on:
push: push:
branches:
- main # still run tests for main
tags: tags:
- 'v*' - 'v*' # run tests for version tags (e.g. v0.9.1)
jobs: jobs:
test-unit:
uses: ./.github/workflows/test-unit.yml
test-integration:
uses: ./.github/workflows/test-integration.yml
test-env-virtual:
uses: ./.github/workflows/test-env-virtual.yml
test-env-nix:
uses: ./.github/workflows/test-env-nix.yml
test-e2e:
uses: ./.github/workflows/test-e2e.yml
test-virgin-user:
uses: ./.github/workflows/test-virgin-user.yml
test-virgin-root:
uses: ./.github/workflows/test-virgin-root.yml
lint-shell:
uses: ./.github/workflows/lint-shell.yml
lint-python:
uses: ./.github/workflows/lint-python.yml
mark-stable: mark-stable:
needs:
- lint-shell
- lint-python
- test-unit
- test-integration
- test-env-nix
- test-env-virtual
- test-e2e
- test-virgin-user
- test-virgin-root
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 330
# Only run this job if the push is for a version tag (v*)
if: startsWith(github.ref, 'refs/tags/v')
permissions: permissions:
actions: read contents: write # Required to move/update the tag
contents: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true # We need tags and main history for version comparison fetch-tags: true # We need all tags for version comparison
- name: Check whether tagged commit is on main
id: branch-check
run: bash scripts/github/common/check-tagged-commit-on-main.sh
- name: Wait for CI success on main for this commit
if: steps.branch-check.outputs.is_on_main == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: bash scripts/github/mark-stable/wait-for-main-ci-success.sh
- name: Move 'stable' tag only if this version is the highest - name: Move 'stable' tag only if this version is the highest
if: steps.branch-check.outputs.is_on_main == 'true' run: |
run: bash scripts/github/mark-stable/mark-stable-if-highest-version.sh 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}."
+40 -87
View File
@@ -4,118 +4,71 @@ on:
workflow_run: workflow_run:
workflows: ["Mark stable commit"] workflows: ["Mark stable commit"]
types: [completed] types: [completed]
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs: jobs:
prepare: publish:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write
outputs:
releases: ${{ steps.info.outputs.releases }}
steps: steps:
- name: Checkout repository (with tags) - name: Checkout repository (with tags)
uses: actions/checkout@v7 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Resolve the releases this run publishes - 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
- name: Compute version and stable flag
id: info id: info
env: run: |
WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }} set -euo pipefail
run: bash scripts/github/publish-containers/compute-publish-container-info.sh SHA="$(git rev-parse HEAD)"
build: V_TAG="$(git tag --points-at "${SHA}" --list 'v*' | sort -V | tail -n1)"
needs: prepare if [[ -z "${V_TAG}" ]]; then
if: ${{ needs.prepare.result == 'success' && needs.prepare.outputs.releases != '[]' }} echo "No version tag found for ${SHA}. Skipping publish."
name: Build ${{ matrix.distro }} ${{ matrix.release.version }} for ${{ matrix.target.platform }} echo "should_publish=false" >> "$GITHUB_OUTPUT"
runs-on: ${{ matrix.target.runner }} exit 0
fi
permissions: VERSION="${V_TAG#v}"
contents: read
packages: write
strategy: STABLE_SHA="$(git rev-parse -q --verify refs/tags/stable^{commit} 2>/dev/null || true)"
fail-fast: false IS_STABLE=false
matrix: [[ -n "${STABLE_SHA}" && "${STABLE_SHA}" == "${SHA}" ]] && IS_STABLE=true
distro: &distros [arch, debian, ubuntu, fedora, centos]
target:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
release: ${{ fromJSON(needs.prepare.outputs.releases) }}
steps: echo "should_publish=true" >> "$GITHUB_OUTPUT"
- name: Checkout ${{ matrix.release.version }} echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
uses: actions/checkout@v7 echo "is_stable=${IS_STABLE}" >> "$GITHUB_OUTPUT"
with:
ref: ${{ matrix.release.sha }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/setup-buildx-action@v3
with: with:
use: true use: true
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push ${{ matrix.distro }} ${{ matrix.release.version }} for ${{ matrix.target.platform }} - name: Publish all images
env: if: ${{ steps.info.outputs.should_publish == 'true' }}
OWNER: ${{ github.repository_owner }} run: |
VERSION: ${{ matrix.release.version }} set -euo pipefail
PKGMGR_DISTRO: ${{ matrix.distro }} OWNER="${{ github.repository_owner }}" \
PLATFORM: ${{ matrix.target.platform }} VERSION="${{ steps.info.outputs.version }}" \
run: bash scripts/github/publish-containers/publish-container-images.sh IS_STABLE="${{ steps.info.outputs.is_stable }}" \
bash scripts/build/publish.sh
merge:
needs: [prepare, build]
if: ${{ !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.releases != '[]' }}
name: Merge ${{ matrix.distro }} ${{ matrix.release.version }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
distro: *distros
release: ${{ fromJSON(needs.prepare.outputs.releases) }}
steps:
- name: Checkout ${{ matrix.release.version }}
uses: actions/checkout@v7
with:
ref: ${{ matrix.release.sha }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
with:
use: true
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Join both platforms of ${{ matrix.distro }} ${{ matrix.release.version }} under its tags
env:
OWNER: ${{ github.repository_owner }}
VERSION: ${{ matrix.release.version }}
ALIASES: ${{ matrix.release.aliases }}
PKGMGR_DISTRO: ${{ matrix.distro }}
run: bash scripts/github/publish-containers/merge-container-images.sh
-47
View File
@@ -1,47 +0,0 @@
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@v7
- 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 }}"
+2 -7
View File
@@ -3,9 +3,6 @@ name: Test End-To-End
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-e2e: test-e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -14,12 +11,10 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [arch, debian, ubuntu, fedora, centos] distro: [arch, debian, ubuntu, fedora, centos]
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
+2 -7
View File
@@ -3,9 +3,6 @@ name: Test Virgin Nix (flake only)
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-env-nix: test-env-nix:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -15,12 +12,10 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [arch, debian, ubuntu, fedora, centos] distro: [arch, debian, ubuntu, fedora, centos]
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
+2 -7
View File
@@ -3,9 +3,6 @@ name: Test OS Containers
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-env-virtual: test-env-virtual:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -14,12 +11,10 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [arch, debian, ubuntu, fedora, centos] distro: [arch, debian, ubuntu, fedora, centos]
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Show commit SHA - name: Show commit SHA
run: git rev-parse HEAD run: git rev-parse HEAD
+2 -7
View File
@@ -3,19 +3,14 @@ name: Test Code Integration
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-integration: test-integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
+2 -7
View File
@@ -3,19 +3,14 @@ name: Test Units
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-unit: test-unit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
+9 -11
View File
@@ -3,9 +3,6 @@ name: Test Virgin Root
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-virgin-root: test-virgin-root:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -14,19 +11,21 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [arch, debian, ubuntu, fedora, centos] distro: [arch, debian, ubuntu, fedora, centos]
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
- name: Pull base image (${{ matrix.distro }}) # 🔹 BUILD virgin image if missing
run: docker pull "ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest" - name: Build virgin container (${{ matrix.distro }})
run: |
set -euo pipefail
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
# 🔹 RUN test inside virgin image
- name: Virgin ${{ matrix.distro }} pkgmgr test (root) - name: Virgin ${{ matrix.distro }} pkgmgr test (root)
run: | run: |
set -euo pipefail set -euo pipefail
@@ -35,9 +34,8 @@ jobs:
-v "$PWD":/opt/src/pkgmgr \ -v "$PWD":/opt/src/pkgmgr \
-v pkgmgr_repos:/root/Repositories \ -v pkgmgr_repos:/root/Repositories \
-v pkgmgr_pip_cache:/root/.cache/pip \ -v pkgmgr_pip_cache:/root/.cache/pip \
-e NIX_CONFIG="${NIX_CONFIG}" \
-w /opt/src/pkgmgr \ -w /opt/src/pkgmgr \
"ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest" \ "pkgmgr-${{ matrix.distro }}-virgin" \
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail
+20 -24
View File
@@ -3,9 +3,6 @@ name: Test Virgin User
on: on:
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
test-virgin-user: test-virgin-user:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -14,28 +11,29 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
distro: [arch, debian, ubuntu, fedora, centos] distro: [arch, debian, ubuntu, fedora, centos]
env:
NIX_CONFIG: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Show Docker version - name: Show Docker version
run: docker version run: docker version
- name: Pull base image (${{ matrix.distro }}) # 🔹 BUILD virgin image if missing
run: docker pull "ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest" - name: Build virgin container (${{ matrix.distro }})
run: |
set -euo pipefail
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
# 🔹 RUN test inside virgin image as non-root
- name: Virgin ${{ matrix.distro }} pkgmgr test (user) - name: Virgin ${{ matrix.distro }} pkgmgr test (user)
run: | run: |
set -euo pipefail set -euo pipefail
docker run --rm \ docker run --rm \
-v "$PWD":/opt/src/pkgmgr \ -v "$PWD":/opt/src/pkgmgr \
-e NIX_CONFIG="${NIX_CONFIG}" \
-w /opt/src/pkgmgr \ -w /opt/src/pkgmgr \
"ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest" \ "pkgmgr-${{ matrix.distro }}-virgin" \
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail
@@ -50,19 +48,17 @@ jobs:
chown -R dev:dev /nix chown -R dev:dev /nix
chmod 0755 /nix chmod 0755 /nix
chmod 1777 /nix/store chmod 1777 /nix/store
sudo -H -u dev env \
HOME=/home/dev \
NIX_CONFIG="$NIX_CONFIG" \
PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 \
bash -lc "
set -euo pipefail
cd /opt/src/pkgmgr
make setup-venv
. \"\$HOME/.venvs/pkgmgr/bin/activate\"
pkgmgr version pkgmgr sudo -H -u dev env HOME=/home/dev PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 bash -lc "
set -euo pipefail
cd /opt/src/pkgmgr
export NIX_REMOTE=local make setup-venv
nix run /opt/src/pkgmgr#pkgmgr -- version pkgmgr . \"\$HOME/.venvs/pkgmgr/bin/activate\"
"
pkgmgr version pkgmgr
export NIX_REMOTE=local
nix run /opt/src/pkgmgr#pkgmgr -- version pkgmgr
"
' '
-32
View File
@@ -1,32 +0,0 @@
name: Update flake.lock
on:
schedule:
- cron: "0 0 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
lock:
name: Update flake.lock
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v23
- name: Update flake.lock and open a pull request
uses: DeterminateSystems/update-flake-lock@v29
with:
pr-title: "chore(deps): update flake.lock"
pr-labels: |
dependencies
nix
+2 -1
View File
@@ -24,9 +24,10 @@ package-manager-*
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Nix cache to speed up tests # Nix Cache to speed up tests
.nix/ .nix/
.nix-dev-installed .nix-dev-installed
flake.lock
# Ignore logs # Ignore logs
*.log *.log
-22
View File
@@ -1,22 +0,0 @@
# Agent Instructions
## Scope against the skills repository
package-manager holds deterministic code that an operator can run on its own,
without an agent: repository, branch, worktree, release, packaging and
development tooling. Agent coordination (claims, messages, queues, scores,
prompts, session data) belongs to the
[skills repository](https://github.com/kevinveenbirkenbach/skills), whose skills
stay thin wrappers that call `pkgmgr`.
- A new helper that a human could sensibly run alone goes here as a `pkgmgr`
command, with its tests, never as a script inside a skill.
- Output that an agent filters further (claims, busy agents, scores) is
emitted as `--json`; the filtering stays in the skill.
- When the split of a helper is unclear, the operator decides.
## Working copy
The installed `pkgmgr` may run straight from the main checkout, so a
half-edited tree breaks it for everyone. Edit only in a worktree
(`pkgmgr worktree up <branch>`), never in the main checkout.
-417
View File
@@ -1,420 +1,3 @@
# Changelog
## [2.4.0] - 2026-11-07
Added:
- Worktree: *worktree up* and *down* give each branch its own slot
- Worktree: *worktree flow* keeps pull sources and push targets per worktree
- Worktree: *up --pull* and *--push* record the flow; default is the base
- Branch: *branch tree* writes a hardened meta-tree of all repositories
- Branch: the tree carries each worktree's merge flow
- GC: *gc --report* prints a read-only disk inventory
- Review: a branch review renders as a local HTML page
- Publish: releases rebuild nightly; stable follows the tested build
Changed:
- Push: signs only an unsigned HEAD instead of re-signing the whole range
Fixed:
- Push, release, gc: refuse a repository config that would run a program
- Git: hooks named in the config no longer run in hardened git calls
- Tests: the trusted PATH fixture comes from os.defpath, not the host
Upgrade notes:
- Push: move repo-local sshCommand, credential helpers, hooks to global config
## [2.3.0] - 2026-11-01
Added:
- Branch: *gc --merged-into REF* treats items merged into REF as merged
- Branch: *--merged-into* is repeatable, protects REF and works for *purge*
- Branch: *--sizes* adds each worktree's disk usage to the plan
- Branch: *--min-age HOURS* skips items with a commit or index that recent
## [2.2.0] - 2026-11-01
Added:
- Push: *--no-sign* pushes unsigned commits as they are; leases still apply
- Push: *--rewrite* replaces commits a remote already has (amend, squash)
Changed:
- Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
- Push: each pushurl gets its own lease; mirrors no longer fail as stale
- Push: a commit that exists only on a mirror stops the push, nothing lost
- Push: without a selection it pushes the current checkout or worktree
- Release: signs before tagging and pushes branch and tag like *pkgmgr push*
Fixed:
- Container: images build on native amd64 and arm64 runners, not QEMU
Upgrade notes:
- Push: needs a working signing key; pass *--no-sign* to push unsigned
- Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
## [2.1.1] - 2026-10-31
Fixed:
- Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
Upgrade notes:
- Container: no 2.1.0 image reached GHCR; the two notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
## [2.1.0] - 2026-10-31
Added:
- Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
- Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
- Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
- Release: Cargo.toml version and its Cargo.lock entry follow the release
Changed:
- Release: starts from the highest version in the tag and all version files
Upgrade notes:
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
## [2.0.5] - 2026-10-29
Fixed:
- Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
- Config: *pkgmgr config edit* exits with the editor's code when it fails
## [2.0.4] - 2026-10-19
Fixed:
- CI: Python lint passes again on ruff 0.17 and still on 0.16
- CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
Upgrade notes:
- 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
- Release: write list items with dash bullets when markdownlint-cli2 is absent
## [2.0.3] - 2026-10-18
Fixed:
- Release: the entry is linted inside the CHANGELOG.md it lands in
- Release: a list marker that breaks the file's consistency is caught (MD004)
- Release: findings the file already has are not blamed on the entry
- Release: *--no-globs* keeps the target repo's other files out of the lint
- Release: without markdownlint-cli2, list items must use dash bullets
Upgrade notes:
- Release: write list items with dash bullets when markdownlint-cli2 is absent
## [2.0.2] - 2026-10-01
Fixes a release flow that could reject every entry written for it.
### Fixed
- A heading in a changelog message became bold, which markdown-lint reads
back as a heading and refuses with MD036
- Since the editor reopens on failure, no structured entry could ever pass
- Headings now move below the release heading they are filed under
- The lint findings reached nobody: the editor took over the terminal in the
same breath they were printed
- They are held until Enter and repeated inside the editor, above the text
- The Makefile installer accepted install-* as proof of an install target but
always ran plain make install
- A repository with install-python and no install died on "No rule to make
target" instead of being skipped
### Tests
- transform and lint were covered separately, so a transform producing lint
errors went unseen; one test now runs the first through the second
- Three tests were checking behaviour the code had already left behind
### Dependencies
- docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
- actions/setup-python 5 to 7
- DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
### Upgrading
- Nothing to do; a changelog entry written with headings now keeps them
## [2.0.1] - 2026-09-18
* A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
* The entry's separator only belongs there when another entry follows it.
* Affects the first entry a file receives, so it shows on an opening release.
* Four tests cover the placements in *_insert_after_h1*, one per branch.
## [2.0.0] - 2026-09-18
Breaking
* The *virgin* and *slim* image variants are gone; both tag suffixes with them.
* Building an image now requires the base images to exist in the registry.
* *make build-missing-virgin* is removed; *build* targets build the one image.
* The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
* *scripts/build/image.sh* loses its *--target* axis.
* A repository is installed by one hook, no longer by pip and *make* both.
Changed
* *INSTALLERS* is a preference order led by *make install*, first success wins.
* A failed hook falls back to the next; all failing exits with what was tried.
* The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
* Publishing pushes *--platform* manifest lists for amd64 and arm64.
* *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
* The virgin workflows pull the base image instead of building it.
* The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
* It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
* *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
* The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
* The README drops the *Virgin* type and the Nix claim the image never met.
* It now lists the tags *compute_publish_tags()* pushes, alias included.
* Dependabot covers pip and github-actions weekly on Monday.
* A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
* *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
* *scripts/docker/slim.sh* is deleted with the stage that ran it.
## [1.16.1] - 2026-07-29
* *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
* Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
* Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
* The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
* New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
* New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
## [1.16.0] - 2026-06-28
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
* The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
* The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
## [1.15.2] - 2026-05-28
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
## [1.15.1] - 2026-05-28
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
## [1.15.0] - 2026-05-28
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
## [1.14.0] - 2026-05-27
* Added
* New release --retry mode re-deploys the HEAD release without
re-tagging or modifying any files. It re-pushes the existing version
tag, re-aligns the floating latest tag, and (unless --no-publish)
re-runs publish. Use this to recover from a release whose post-tag
push or PyPI upload failed mid-flight. The release_type argument
becomes optional under --retry.
* New module pkgmgr.actions.release.retry hosts the retry_release
helper so the workflow orchestrator stays focused on the forward
path.
* RepoPaths now exposes a debian_control slot, discovered alongside
debian_changelog under both packaging/debian and the legacy debian
layout.
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the distro-name lookup chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
idempotent push, latest-tag re-alignment, missing-tag error path,
and branch-detection fallback.
Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
then folder basename as legacy fallback. Renaming a repository
folder no longer silently flips the debian/changelog top entry and
the RPM changelog stanza to a new identifier. Those keep matching
the authoritative value in the packaging files, which is what apt,
pacman, and dnf index against.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement
on the next release.
## [1.13.4] - 2026-05-27
* Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
folder basename as legacy fallback. Renaming a repository folder (for
example infinito-nexus to infinito-nexus-core) no longer silently
flips the debian/changelog top entry and the RPM changelog stanza to
a new identifier. Those keep matching the authoritative Package,
pkgname, or Name value in the packaging files, which is what apt,
pacman, and dnf index against.
Added
* RepoPaths gains a debian_control slot that is discovered alongside
debian_changelog under both packaging/debian (new layout) and debian
(legacy layout).
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the priority chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement.
## [1.13.3] - 2026-03-26
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
## [1.13.2] - 2026-03-26
* Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
## [1.13.1] - 2026-03-20
* Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
## [1.13.0] - 2026-03-20
* Set CentOS docker image to latest
## [1.12.5] - 2026-02-24
* The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
## [1.12.4] - 2026-02-24
* The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
## [1.12.3] - 2026-02-24
* Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
## [1.12.2] - 2026-02-24
* Removed infinito-sphinx package
## [1.12.1] - 2026-02-14
* pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
## [1.12.0] - 2026-02-08
* Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
## [1.11.2] - 2026-02-08
* Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
## [1.11.1] - 2026-02-08
* Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
## [1.11.0] - 2026-01-21
* Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
## [1.10.0] - 2026-01-20
* Introduce safe verbose image cleanup to reduce Docker image size and build artifacts
## [1.9.5] - 2026-01-16
* Release patch: improve git pull error diagnostics
## [1.9.4] - 2026-01-13
* fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
## [1.9.3] - 2026-01-07
* Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
## [1.9.2] - 2025-12-21
* Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
## [1.9.1] - 2025-12-21
* Fixed installation issues and improved loading of default configuration files.
## [1.9.0] - 2025-12-20
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
* All configured git mirrors are now provisioned.
## [1.8.7] - 2025-12-19
* * **Release version updates now correctly modify ***pyproject.toml*** files that follow PEP 621**, ensuring the ***[project].version*** field is updated as expected.
* **Invalid or incomplete ***pyproject.toml*** files are now handled gracefully** with clear error messages instead of abrupt process termination.
* **RPM spec files remain compatible during releases**: existing macros such as ***%{?dist}*** are preserved and no longer accidentally modified.
## [1.8.6] - 2025-12-17
* Prevent Rate Limits during GitHub Nix Setups
## [1.8.5] - 2025-12-17 ## [1.8.5] - 2025-12-17
* * Clearer Git error handling, especially when a directory is not a Git repository. * * Clearer Git error handling, especially when a directory is not a Git repository.
+37 -7
View File
@@ -1,23 +1,53 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# hadolint ignore=DL3006,InvalidDefaultArgInFrom # ------------------------------------------------------------
# BASE_IMAGE carries no default on purpose: a default would build one # Base image selector — overridden by build args / Makefile
# distribution's image under another distribution's tag whenever the build arg # ------------------------------------------------------------
# is forgotten. scripts/build/base.sh is the only place that resolves it.
ARG BASE_IMAGE ARG BASE_IMAGE
FROM ${BASE_IMAGE}
# ============================================================
# Target: virgin
# - installs distro deps (incl. make)
# - no pkgmgr build
# - no entrypoint
# ============================================================
FROM ${BASE_IMAGE} AS virgin
SHELL ["/bin/bash", "-lc"] SHELL ["/bin/bash", "-lc"]
RUN echo "BASE_IMAGE=${BASE_IMAGE}" && cat /etc/os-release || true
WORKDIR /build WORKDIR /build
# Copy scripts first so dependency installation can be cached
COPY scripts/installation/ scripts/installation/
# Install distro-specific build dependencies (including make)
RUN bash scripts/installation/dependencies.sh
# Virgin default
CMD ["bash"]
# ============================================================
# Target: full
# - inherits from virgin
# - builds + installs pkgmgr
# - sets entrypoint + default cmd
# ============================================================
FROM virgin AS full
WORKDIR /build
# Copy full repository for build
COPY . . COPY . .
RUN set -eu; \ # Build and install distro-native package-manager package
RUN set -euo pipefail; \
echo "Building and installing package-manager via make install..."; \ echo "Building and installing package-manager via make install..."; \
make install; \ make install; \
rm -rf /build cd /; rm -rf /build
# Entry point
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
WORKDIR /opt/src/pkgmgr WORKDIR /opt/src/pkgmgr
+21 -17
View File
@@ -2,7 +2,6 @@
build build-no-cache build-no-cache-all build-missing \ build build-no-cache build-no-cache-all build-missing \
delete-volumes purge \ delete-volumes purge \
test test-unit test-e2e test-integration test-env-virtual test-env-nix \ test test-unit test-e2e test-integration test-env-virtual test-env-nix \
lint \
setup setup-venv setup-nix setup setup-venv setup-nix
# Distro # Distro
@@ -11,15 +10,22 @@ DISTROS ?= arch debian ubuntu fedora centos
PKGMGR_DISTRO ?= arch PKGMGR_DISTRO ?= arch
export PKGMGR_DISTRO export PKGMGR_DISTRO
# Nix Config Variable (To avoid rate limit)
NIX_CONFIG ?=
export NIX_CONFIG
# ------------------------------------------------------------ # ------------------------------------------------------------
# Base images # Base images
# (kept for documentation/reference; actual build logic is in scripts/build)
# ------------------------------------------------------------ # ------------------------------------------------------------
BASE_IMAGES_TAG ?= latest BASE_IMAGE_ARCH := archlinux:latest
export BASE_IMAGES_TAG BASE_IMAGE_DEBIAN := debian:stable-slim
BASE_IMAGE_UBUNTU := ubuntu:latest
BASE_IMAGE_FEDORA := fedora:latest
BASE_IMAGE_CENTOS := quay.io/centos/centos:stream9
# Make them available in scripts
export BASE_IMAGE_ARCH
export BASE_IMAGE_DEBIAN
export BASE_IMAGE_UBUNTU
export BASE_IMAGE_FEDORA
export BASE_IMAGE_CENTOS
# PYthon Unittest Pattern # PYthon Unittest Pattern
TEST_PATTERN := test_*.py TEST_PATTERN := test_*.py
@@ -52,12 +58,17 @@ setup-nix:
# Docker build targets (delegated to scripts/build) # Docker build targets (delegated to scripts/build)
# ------------------------------------------------------------ # ------------------------------------------------------------
build: build:
@bash scripts/build/image.sh --target virgin
@bash scripts/build/image.sh @bash scripts/build/image.sh
build-missing: build-missing-virgin:
@bash scripts/build/image.sh --target virgin --missing
build-missing: build-missing-virgin
@bash scripts/build/image.sh --missing @bash scripts/build/image.sh --missing
build-no-cache: build-no-cache:
@bash scripts/build/image.sh --target virgin --no-cache
@bash scripts/build/image.sh --no-cache @bash scripts/build/image.sh --no-cache
build-no-cache-all: build-no-cache-all:
@@ -67,13 +78,6 @@ build-no-cache-all:
PKGMGR_DISTRO="$$d" $(MAKE) build-no-cache; \ PKGMGR_DISTRO="$$d" $(MAKE) build-no-cache; \
done done
# ------------------------------------------------------------
# Lint targets (run on the host, not in a container)
# ------------------------------------------------------------
lint:
@bash scripts/lint/python.sh
# ------------------------------------------------------------ # ------------------------------------------------------------
# Test targets (delegated to scripts/test) # Test targets (delegated to scripts/test)
# ------------------------------------------------------------ # ------------------------------------------------------------
@@ -93,8 +97,8 @@ test-env-virtual: build-missing
test-env-nix: build-missing test-env-nix: build-missing
@bash scripts/test/test-env-nix.sh @bash scripts/test/test-env-nix.sh
# Combined test target for local + CI (lint + unit + integration + e2e) # Combined test target for local + CI (unit + integration + e2e)
test: lint test-env-virtual test-unit test-integration test-e2e test: test-env-virtual test-unit test-integration test-e2e
delete-volumes: delete-volumes:
@docker volume rm "pkgmgr_nix_store_${PKGMGR_DISTRO}" "pkgmgr_nix_cache_${PKGMGR_DISTRO}" || echo "No volumes to delete." @docker volume rm "pkgmgr_nix_store_${PKGMGR_DISTRO}" "pkgmgr_nix_cache_${PKGMGR_DISTRO}" || echo "No volumes to delete."
+11 -133
View File
@@ -70,89 +70,6 @@ versioning features it can drive **end-to-end workflows**:
--- ---
### Worktrees and garbage collection
| Command | Does |
| --- | --- |
| `pkgmgr worktree up <branch> [--base REF] [--path DIR] [--pull REF ...] [--push BRANCH ...]` | Checks the branch out under `<directories.worktrees>/<host>/<owner>/<repo>/<branch>` or at `--path`, assigns a free slot, records the merge flow and runs the pinned `worktree-init` hook |
| `pkgmgr worktree flow [PATH] [--json]` | Shows the merge flow of the worktree at `PATH` (default: `.`) |
| `pkgmgr worktree flow set [PATH] (--pull REF ... \| --no-pull) (--push BRANCH ... \| --no-push) [--json]` | Replaces both lists of the merge flow |
| `pkgmgr worktree flow clear [PATH] [--json]` | Removes the merge flow |
| `pkgmgr worktree cleanup [--apply]` | Removes clean worktrees whose commits are merged or pushed: pinned `worktree-fini` hook, compose stack, checkout; the branch stays |
| `pkgmgr branch cleanup [--apply]` | Removes merged branches that no worktree holds, locally and on the remotes |
| `pkgmgr branch tree [--format json\|yaml] [--output FILE]` | Writes every managed repository's branches and worktrees with their cleanup state, without fetching |
| `pkgmgr gc [--all] [--apply]` | Both cleanups in one pass, for the current repository or every managed one |
| `pkgmgr gc --report [--all] [--root DIR] [--threshold-mb MB] [--docker] [--json]` | Read-only disk inventory: worktree sizes, changes a cleanup would keep, `--root` children, core dumps, docker usage, free space per filesystem |
| `pkgmgr worktree orphans --stale-days N --top N` | Lists merged and abandoned branches and worktrees with a proposal each |
A hook runs only when the `worktree_hooks` config pins its command under the
`<provider>/<account>/<repository>` of a repository managed in the pkgmgr
config, and only for that repository's directory; origin's URL, which the
repository's own config sets, never selects a pin. `{path}` and `{slot}` are
replaced; a
`worktree-init` or `worktree-fini` target in the repository's Makefile is
reported, never run, because an agent may have written it. The pinned program
must be an absolute path or a root-owned command on a `PATH` entry that root
owns and no group or other user can write, up to `/`, so a binary planted in a
user-writable directory such as a virtualenv never runs. The Nix store never
counts: it is group-writable, and any user can have the daemon build
root-owned content there, so pin an absolute path on Nix. A bare name runs as
the resolved file that was checked. Inside a user namespace other than the
initial one, root means the overflow uid, under which the real root's files
show there, as do those of every other unmapped owner. Outside such a
namespace a directory on a nosuid mount, where udisks2 and FUSE put user
mounts, never counts as root's. The hook runs with
stdin closed, only those `PATH` entries and a 30 minute timeout. Removal walks to
the worktree without following a symbolic link and skips a worktree path that
is not its own realpath. A pinned command that reads repository files, such as `make` or a
script in the repository, trusts the worktree's content, tracked or ignored.
Every cleanup is a dry run until `--apply`, and `--json` prints the plan for
other tools. The [agent skills](https://github.com/kevinveenbirkenbach/skills)
wrap these commands; see [AGENTS.md](AGENTS.md) for the split.
#### Merge flow
Every worktree can carry a merge flow that scripts and skills read: `pull`
lists the refs merged into the branch, in order, before a gate, deploy or
merge; `push` lists the branches the branch is merged into, in order, when it
is finished, each then pushed by the operator. It lives in
`pkgmgr/flow.yml` inside the worktree's own git directory (`.git` of the main
worktree, `<common>/worktrees/<id>` of a linked one), so it is never committed
and goes with the worktree:
```yaml
version: 1
branch: feature/x
pull:
- origin/main
- store-i18n
push:
- feature/iam-profile-rbac
- production
```
`branch` is the branch the flow was set on; another current branch shows as a
warning. `worktree up` records `--pull` and `--push`; without them pull is
the base the worktree came from and push that base's local branch
(`origin/main` gives `main`). `flow set` needs each list or
its `--no-pull`/`--no-push`, and writes atomically into a `pkgmgr/` of mode
0700. Every entry must pass `git check-ref-format --branch` and must not start
with `-`; a remote-tracking name such as `origin/main` passes. An invalid name
exits 2 and writes nothing. A flow file that is a symbolic link, no regular
file, larger than 64 KiB or malformed reads as `invalid`, with empty lists and
a warning, and is never followed.
`flow --json` prints `worktree` and `git_dir` (absolute paths), `branch` (the
current branch, `null` when detached), `flow_branch` (the file's `branch`),
`pull`, `push`, `source` (`file`, `none` or `invalid`) and `warnings`; it
exits 0 also without a flow, 2 when `PATH` is not a git worktree. In
`pkgmgr branch tree` every item carries `flow`: `{"pull", "push", "source"}`,
or `null` without a worktree or a flow; flow warnings join the repository's
`warnings`.
---
## Architecture & Setup Map 🗺️ ## Architecture & Setup Map 🗺️
The following diagram gives a full overview of: The following diagram gives a full overview of:
@@ -252,65 +169,26 @@ nix run github:kevinveenbirkenbach/package-manager#pkgmgr -- --help
PKGMGR can be executed **inside Docker containers** for CI, testing and isolated PKGMGR can be executed **inside Docker containers** for CI, testing and isolated
workflows. workflows.
--- ---
#### Container images #### Container types
One image per distribution, each installing PKGMGR as a distro-native package Two container types are available.
on top of the matching base image from
[base-images](https://github.com/kevinveenbirkenbach/base-images). Every image
is a manifest list covering `linux/amd64` and `linux/arm64`.
All images live under `ghcr.io/kevinveenbirkenbach/`:
| Tag | Contains | | Image type | Contains | Typical use |
| ------------------------------------- | --------------------------------------------------------- | | ---------- | ----------------------------- | ----------------------- |
| `pkgmgr-<distro>:latest` | newest build of the newest release on `main` | | **Virgin** | Base OS + system dependencies | Clean test environments |
| `pkgmgr-<distro>:stable` | newest build of the release the git tag `stable` marks | | **Stable** | PKGMGR + Nix (flakes enabled) | Ready-to-use workflows |
| `pkgmgr-<distro>:<major>` | newest build of the newest release of that major |
| `pkgmgr-<distro>:<major>.<minor>` | newest build of the newest release of that minor |
| `pkgmgr-<distro>:<version>` | newest build of that release |
| `pkgmgr-<distro>:<version>.<unix-ts>` | one build, never moves |
`<distro>` is one of `arch`, `debian`, `ubuntu`, `fedora`, `centos`. The Arch Example images:
image is published without the suffix as well, so `pkgmgr:stable` and
`pkgmgr-arch:stable` are the same image.
`<version>` is the PKGMGR release and `<unix-ts>` the build time, so * Virgin: `pkgmgr-arch-virgin`
`2.3.0.1767225600` is a full version an updater can order and pin. Every tag of * Stable: `ghcr.io/kevinveenbirkenbach/pkgmgr:stable`
one build points at the same image.
`stable` moves only once "Mark stable commit" has moved the git tag `stable`,
which it does after every test of that release passed. Every day at 03:00 UTC,
after the daily 00:00 UTC build of base-images, and on demand through the
workflow's manual trigger, two releases are rebuilt on the newest base image:
- the newest `vX.Y.Z` release on `main`, moving `latest`, its prefixes and Use **virgin images** for isolated test runs,
`<version>`, and adding `<version>.<unix-ts>`; use the **stable image** for fast, reproducible execution.
- the release the git tag `stable` marks, when that is an older one, moving
`stable` and `<version>`, adding its own `<version>.<unix-ts>`, and moving a
prefix only while it is still the newest release of that prefix.
With newest `2.4.0` and stable `2.3.0`, `2.4.0` takes `latest`, `2` and `2.4`,
and `2.3.0` takes `stable` and `2.3`. A security update in base-images thereby
reaches whoever pinned a major, a minor, a release or follows `stable`.
Each release is compared with its own published `<version>`. A rebuild whose
installed packages equal it pushes nothing, since a new timestamp tag would make
every pin bump for no change; a tag of that release that does not show the
published build yet, such as `stable` right after "Mark stable commit", is moved
without a new timestamp.
A rebuild runs the build scripts of the release it rebuilds, not those of
`main`, so the skip and the tag rules above only hold for releases tagged after
this scheme landed. The scripts of an earlier release know no aliases: its merge
step fails and leaves its tags where they are, until a newer release replaces
it as the newest and the stable one.
For a clean environment that carries the system dependencies but no PKGMGR,
pull `base-<distro>` from
[base-images](https://github.com/kevinveenbirkenbach/base-images) directly.
--- ---
File renamed without changes.
@@ -4,14 +4,17 @@ directories:
repositories: ~/Repositories/ repositories: ~/Repositories/
workspaces: ~/Workspaces/ workspaces: ~/Workspaces/
binaries: ~/.local/bin/ binaries: ~/.local/bin/
worktrees: ~/.local/share/worktrees/
# worktree_hooks: <provider>/<account>/<repository> of an entry under
# repositories -> worktree-init / worktree-fini -> argv, with {path} and {slot}
# replaced; it runs only for that entry's directory, never selected by origin's
# URL, and without an entry no hook runs, whatever the repository's Makefile
# defines
worktree_hooks: {}
repositories: 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 - 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. 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 homepage: https://github.com/kevinveenbirkenbach/package-manager
@@ -271,12 +274,12 @@ repositories:
gpg_keys: gpg_keys:
- 44D8F11FD62F878E - 44D8F11FD62F878E
- B5690EEEBB952194 - B5690EEEBB952194
- account: infinito-nexus - account: kevinveenbirkenbach
alias: infinito alias: infinito
provider: github.com 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. 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 homepage: https://infinito.nexus
repository: core repository: infinito-nexus
verified: verified:
gpg_keys: gpg_keys:
- 44D8F11FD62F878E - 44D8F11FD62F878E
@@ -366,6 +369,17 @@ repositories:
- 44D8F11FD62F878E - 44D8F11FD62F878E
- B5690EEEBB952194 - 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 - 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. 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 homepage: https://github.com/kevinveenbirkenbach/colorscheme-generator
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"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
}
+6 -16
View File
@@ -6,7 +6,7 @@
}; };
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
}; };
outputs = { self, nixpkgs }: outputs = { self, nixpkgs }:
@@ -26,13 +26,13 @@
packages = forAllSystems (system: packages = forAllSystems (system:
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
python = pkgs.python312; python = pkgs.python311;
pyPkgs = pkgs.python312Packages; pyPkgs = pkgs.python311Packages;
in in
rec { rec {
pkgmgr = pyPkgs.buildPythonApplication { pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager"; pname = "package-manager";
version = "2.4.0"; version = "1.8.5";
# Use the git repo as source # Use the git repo as source
src = ./.; src = ./.;
@@ -40,10 +40,6 @@
# Build using pyproject.toml # Build using pyproject.toml
format = "pyproject"; format = "pyproject";
# Clear any stale wheels carried in from the source tree so
# pypaInstallPhase doesn't collide on bin/pkgmgr.
preBuild = "rm -rf dist";
# Build backend requirements from [build-system] # Build backend requirements from [build-system]
nativeBuildInputs = [ nativeBuildInputs = [
pyPkgs.setuptools pyPkgs.setuptools
@@ -55,8 +51,6 @@
pyPkgs.pyyaml pyPkgs.pyyaml
pyPkgs.jinja2 pyPkgs.jinja2
pyPkgs.pip pyPkgs.pip
pkgs.git
pkgs.gnupg
]; ];
doCheck = false; doCheck = false;
@@ -79,8 +73,8 @@
if pkgs ? ansible-core then pkgs.ansible-core if pkgs ? ansible-core then pkgs.ansible-core
else pkgs.ansible; else pkgs.ansible;
# Use the same Python version as the package # Use the same Python version as the package (3.11)
python = pkgs.python312; python = pkgs.python311;
pythonWithDeps = python.withPackages (ps: [ pythonWithDeps = python.withPackages (ps: [
ps.pip ps.pip
@@ -93,11 +87,7 @@
buildInputs = [ buildInputs = [
pythonWithDeps pythonWithDeps
pkgs.git pkgs.git
pkgs.gnupg
ansiblePkg ansiblePkg
pkgs.pkg-config
pkgs.openldap
pkgs.cyrus_sasl
]; ];
shellHook = '' shellHook = ''
+3 -13
View File
@@ -1,25 +1,15 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world> # Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager pkgname=package-manager
pkgver=2.4.0 pkgver=1.8.5
pkgrel=1 pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any') arch=('any')
url="https://github.com/kevinveenbirkenbach/package-manager" url="https://github.com/kevinveenbirkenbach/package-manager"
license=('MIT') license=('MIT')
# Nix is required at runtime to run pkgmgr via the flake. # Nix is the only runtime dependency; Python is provided by the Nix closure.
# On Arch x86_64 we can depend on the distro package. depends=('nix')
# On other arches (e.g. ARM) we only declare it as optional because the
# repo package may be broken/out-of-sync; installation can be done via the official installer.
depends=()
optdepends=('nix: required to run pkgmgr via flake')
if [[ "${CARCH}" == "x86_64" ]]; then
depends=('nix')
optdepends=()
fi
makedepends=('rsync') makedepends=('rsync')
install=${pkgname}.install install=${pkgname}.install
+2 -2
View File
@@ -1,9 +1,9 @@
post_install() { post_install() {
/usr/lib/package-manager/nix/init.sh /usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
} }
post_upgrade() { post_upgrade() {
/usr/lib/package-manager/nix/init.sh /usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
} }
post_remove() { post_remove() {
-471
View File
@@ -1,474 +1,3 @@
package-manager (2.4.0-1) unstable; urgency=medium
Added:
- Worktree: *worktree up* and *down* give each branch its own slot
- Worktree: *worktree flow* keeps pull sources and push targets per worktree
- Worktree: *up --pull* and *--push* record the flow; default is the base
- Branch: *branch tree* writes a hardened meta-tree of all repositories
- Branch: the tree carries each worktree's merge flow
- GC: *gc --report* prints a read-only disk inventory
- Review: a branch review renders as a local HTML page
- Publish: releases rebuild nightly; stable follows the tested build
Changed:
- Push: signs only an unsigned HEAD instead of re-signing the whole range
Fixed:
- Push, release, gc: refuse a repository config that would run a program
- Git: hooks named in the config no longer run in hardened git calls
- Tests: the trusted PATH fixture comes from os.defpath, not the host
Upgrade notes:
- Push: move repo-local sshCommand, credential helpers, hooks to global config
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 07 Nov 2026 15:45:28 +0000
package-manager (2.3.0-1) unstable; urgency=medium
Added:
- Branch: *gc --merged-into REF* treats items merged into REF as merged
- Branch: *--merged-into* is repeatable, protects REF and works for *purge*
- Branch: *--sizes* adds each worktree's disk usage to the plan
- Branch: *--min-age HOURS* skips items with a commit or index that recent
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 01 Nov 2026 15:14:53 +0100
package-manager (2.2.0-1) unstable; urgency=medium
Added:
- Push: *--no-sign* pushes unsigned commits as they are; leases still apply
- Push: *--rewrite* replaces commits a remote already has (amend, squash)
Changed:
- Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
- Push: each pushurl gets its own lease; mirrors no longer fail as stale
- Push: a commit that exists only on a mirror stops the push, nothing lost
- Push: without a selection it pushes the current checkout or worktree
- Release: signs before tagging and pushes branch and tag like *pkgmgr push*
Fixed:
- Container: images build on native amd64 and arm64 runners, not QEMU
Upgrade notes:
- Push: needs a working signing key; pass *--no-sign* to push unsigned
- Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 01 Nov 2026 01:55:29 +0100
package-manager (2.1.1-1) unstable; urgency=medium
Fixed:
- Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
Upgrade notes:
- Container: no 2.1.0 image reached GHCR; the two notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 31 Oct 2026 18:47:35 +0100
package-manager (2.1.0-1) unstable; urgency=medium
Added:
- Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
- Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
- Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
- Release: Cargo.toml version and its Cargo.lock entry follow the release
Changed:
- Release: starts from the highest version in the tag and all version files
Upgrade notes:
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 31 Oct 2026 14:38:54 +0100
package-manager (2.0.5-1) unstable; urgency=medium
Fixed:
- Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
- Config: *pkgmgr config edit* exits with the editor's code when it fails
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 29 Oct 2026 13:57:31 +0100
package-manager (2.0.4-1) unstable; urgency=medium
Fixed:
- CI: Python lint passes again on ruff 0.17 and still on 0.16
- CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
Upgrade notes:
- 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
- Release: write list items with dash bullets when markdownlint-cli2 is absent
-- Kevin Veen-Birkenbach <kevin@veen.world> Mon, 19 Oct 2026 10:06:02 +0200
package-manager (2.0.3-1) unstable; urgency=medium
Fixed:
- Release: the entry is linted inside the CHANGELOG.md it lands in
- Release: a list marker that breaks the file's consistency is caught (MD004)
- Release: findings the file already has are not blamed on the entry
- Release: *--no-globs* keeps the target repo's other files out of the lint
- Release: without markdownlint-cli2, list items must use dash bullets
Upgrade notes:
- Release: write list items with dash bullets when markdownlint-cli2 is absent
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 18 Oct 2026 23:33:52 +0200
package-manager (2.0.2-1) unstable; urgency=medium
Fixes a release flow that could reject every entry written for it.
### Fixed
- A heading in a changelog message became bold, which markdown-lint reads
back as a heading and refuses with MD036
- Since the editor reopens on failure, no structured entry could ever pass
- Headings now move below the release heading they are filed under
- The lint findings reached nobody: the editor took over the terminal in the
same breath they were printed
- They are held until Enter and repeated inside the editor, above the text
- The Makefile installer accepted install-* as proof of an install target but
always ran plain make install
- A repository with install-python and no install died on "No rule to make
target" instead of being skipped
### Tests
- transform and lint were covered separately, so a transform producing lint
errors went unseen; one test now runs the first through the second
- Three tests were checking behaviour the code had already left behind
### Dependencies
- docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
- actions/setup-python 5 to 7
- DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
### Upgrading
- Nothing to do; a changelog entry written with headings now keeps them
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 01 Oct 2026 17:47:12 +0200
package-manager (2.0.1-1) unstable; urgency=medium
* A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
* The entry's separator only belongs there when another entry follows it.
* Affects the first entry a file receives, so it shows on an opening release.
* Four tests cover the placements in *_insert_after_h1*, one per branch.
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 18 Sep 2026 17:15:35 +0200
package-manager (2.0.0-1) unstable; urgency=medium
Breaking
* The *virgin* and *slim* image variants are gone; both tag suffixes with them.
* Building an image now requires the base images to exist in the registry.
* *make build-missing-virgin* is removed; *build* targets build the one image.
* The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
* *scripts/build/image.sh* loses its *--target* axis.
* A repository is installed by one hook, no longer by pip and *make* both.
Changed
* *INSTALLERS* is a preference order led by *make install*, first success wins.
* A failed hook falls back to the next; all failing exits with what was tried.
* The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
* Publishing pushes *--platform* manifest lists for amd64 and arm64.
* *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
* The virgin workflows pull the base image instead of building it.
* The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
* It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
* *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
* The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
* The README drops the *Virgin* type and the Nix claim the image never met.
* It now lists the tags *compute_publish_tags()* pushes, alias included.
* Dependabot covers pip and github-actions weekly on Monday.
* A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
* *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
* *scripts/docker/slim.sh* is deleted with the stage that ran it.
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 18 Sep 2026 16:29:32 +0200
package-manager (1.16.1-1) unstable; urgency=medium
* *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
* Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
* Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
* The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
* New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
* New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 29 Jul 2026 16:41:21 +0200
package-manager (1.16.0-1) unstable; urgency=medium
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
* The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
* The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 28 Jun 2026 16:54:16 +0200
package-manager (1.15.2-1) unstable; urgency=medium
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 11:06:43 +0200
package-manager (1.15.1-1) unstable; urgency=medium
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 08:18:23 +0200
package-manager (1.15.0-1) unstable; urgency=medium
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 07:56:07 +0200
package-manager (1.14.0-1) unstable; urgency=medium
* Added
* New release --retry mode re-deploys the HEAD release without
re-tagging or modifying any files. It re-pushes the existing version
tag, re-aligns the floating latest tag, and (unless --no-publish)
re-runs publish. Use this to recover from a release whose post-tag
push or PyPI upload failed mid-flight. The release_type argument
becomes optional under --retry.
* New module pkgmgr.actions.release.retry hosts the retry_release
helper so the workflow orchestrator stays focused on the forward
path.
* RepoPaths now exposes a debian_control slot, discovered alongside
debian_changelog under both packaging/debian and the legacy debian
layout.
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the distro-name lookup chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
idempotent push, latest-tag re-alignment, missing-tag error path,
and branch-detection fallback.
Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
then folder basename as legacy fallback. Renaming a repository
folder no longer silently flips the debian/changelog top entry and
the RPM changelog stanza to a new identifier. Those keep matching
the authoritative value in the packaging files, which is what apt,
pacman, and dnf index against.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement
on the next release.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:53:14 +0200
package-manager (1.13.4-1) unstable; urgency=medium
* Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
folder basename as legacy fallback. Renaming a repository folder (for
example infinito-nexus to infinito-nexus-core) no longer silently
flips the debian/changelog top entry and the RPM changelog stanza to
a new identifier. Those keep matching the authoritative Package,
pkgname, or Name value in the packaging files, which is what apt,
pacman, and dnf index against.
Added
* RepoPaths gains a debian_control slot that is discovered alongside
debian_changelog under both packaging/debian (new layout) and debian
(legacy layout).
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the priority chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:32:39 +0200
package-manager (1.13.3-1) unstable; urgency=medium
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 26 Mar 2026 17:10:21 +0100
package-manager (1.13.2-1) unstable; urgency=medium
* Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 26 Mar 2026 12:26:55 +0100
package-manager (1.13.1-1) unstable; urgency=medium
* Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 20 Mar 2026 02:57:25 +0100
package-manager (1.13.0-1) unstable; urgency=medium
* Set CentOS docker image to latest
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 20 Mar 2026 01:29:38 +0100
package-manager (1.12.5-1) unstable; urgency=medium
* The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 09:35:39 +0100
package-manager (1.12.4-1) unstable; urgency=medium
* The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 09:32:01 +0100
package-manager (1.12.3-1) unstable; urgency=medium
* Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 08:29:34 +0100
package-manager (1.12.2-1) unstable; urgency=medium
* Removed infinito-sphinx package
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 07:40:55 +0100
package-manager (1.12.1-1) unstable; urgency=medium
* pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 14 Feb 2026 23:26:17 +0100
package-manager (1.12.0-1) unstable; urgency=medium
* Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 08 Feb 2026 18:26:25 +0100
package-manager (1.11.2-1) unstable; urgency=medium
* Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 08 Feb 2026 18:21:50 +0100
package-manager (1.11.1-1) unstable; urgency=medium
* Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 08 Feb 2026 18:18:09 +0100
package-manager (1.11.0-1) unstable; urgency=medium
* Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 21 Jan 2026 01:18:31 +0100
package-manager (1.10.0-1) unstable; urgency=medium
* Automated release.
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 20 Jan 2026 10:44:58 +0100
package-manager (1.9.5-1) unstable; urgency=medium
* Release patch: improve git pull error diagnostics
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 16 Jan 2026 10:09:43 +0100
package-manager (1.9.4-1) unstable; urgency=medium
* fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 13 Jan 2026 14:48:50 +0100
package-manager (1.9.3-1) unstable; urgency=medium
* Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 07 Jan 2026 13:44:40 +0100
package-manager (1.9.2-1) unstable; urgency=medium
* Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 21 Dec 2025 15:30:22 +0100
package-manager (1.9.1-1) unstable; urgency=medium
* Fixed installation issues and improved loading of default configuration files.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 21 Dec 2025 13:38:58 +0100
package-manager (1.9.0-1) unstable; urgency=medium
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
* All configured git mirrors are now provisioned.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 20 Dec 2025 14:37:58 +0100
package-manager (1.8.7-1) unstable; urgency=medium
* * **Release version updates now correctly modify ***pyproject.toml*** files that follow PEP 621**, ensuring the ***[project].version*** field is updated as expected.
* **Invalid or incomplete ***pyproject.toml*** files are now handled gracefully** with clear error messages instead of abrupt process termination.
* **RPM spec files remain compatible during releases**: existing macros such as ***%{?dist}*** are preserved and no longer accidentally modified.
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 19 Dec 2025 14:15:47 +0100
package-manager (1.8.6-1) unstable; urgency=medium
* Prevent Rate Limits during GitHub Nix Setups
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 17 Dec 2025 23:50:31 +0100
package-manager (1.8.5-1) unstable; urgency=medium package-manager (1.8.5-1) unstable; urgency=medium
* * Clearer Git error handling, especially when a directory is not a Git repository. * * Clearer Git error handling, especially when a directory is not a Git repository.
+1 -1
View File
@@ -3,7 +3,7 @@ set -e
case "$1" in case "$1" in
configure) configure)
/usr/lib/package-manager/nix/init.sh /usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
;; ;;
esac esac
+2 -353
View File
@@ -1,5 +1,5 @@
Name: package-manager Name: package-manager
Version: 2.4.0 Version: 1.8.5
Release: 1%{?dist} Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -62,7 +62,7 @@ rm -rf \
%{buildroot}/usr/lib/package-manager/.gitkeep || true %{buildroot}/usr/lib/package-manager/.gitkeep || true
%post %post
/usr/lib/package-manager/nix/init.sh /usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
%postun %postun
echo ">>> package-manager removed. Nix itself was not removed." echo ">>> package-manager removed. Nix itself was not removed."
@@ -74,357 +74,6 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/ /usr/lib/package-manager/
%changelog %changelog
* Sat Nov 07 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.4.0-1
- Added:
- - Worktree: *worktree up* and *down* give each branch its own slot
- - Worktree: *worktree flow* keeps pull sources and push targets per worktree
- - Worktree: *up --pull* and *--push* record the flow; default is the base
- - Branch: *branch tree* writes a hardened meta-tree of all repositories
- - Branch: the tree carries each worktree's merge flow
- - GC: *gc --report* prints a read-only disk inventory
- - Review: a branch review renders as a local HTML page
- - Publish: releases rebuild nightly; stable follows the tested build
- Changed:
- - Push: signs only an unsigned HEAD instead of re-signing the whole range
- Fixed:
- - Push, release, gc: refuse a repository config that would run a program
- - Git: hooks named in the config no longer run in hardened git calls
- - Tests: the trusted PATH fixture comes from os.defpath, not the host
- Upgrade notes:
- - Push: move repo-local sshCommand, credential helpers, hooks to global config
* Sun Nov 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.3.0-1
- Added:
- - Branch: *gc --merged-into REF* treats items merged into REF as merged
- - Branch: *--merged-into* is repeatable, protects REF and works for *purge*
- - Branch: *--sizes* adds each worktree's disk usage to the plan
- - Branch: *--min-age HOURS* skips items with a commit or index that recent
* Sun Nov 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.2.0-1
- Added:
- - Push: *--no-sign* pushes unsigned commits as they are; leases still apply
- - Push: *--rewrite* replaces commits a remote already has (amend, squash)
- Changed:
- - Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
- - Push: each pushurl gets its own lease; mirrors no longer fail as stale
- - Push: a commit that exists only on a mirror stops the push, nothing lost
- - Push: without a selection it pushes the current checkout or worktree
- - Release: signs before tagging and pushes branch and tag like *pkgmgr push*
- Fixed:
- - Container: images build on native amd64 and arm64 runners, not QEMU
- Upgrade notes:
- - Push: needs a working signing key; pass *--no-sign* to push unsigned
- - Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
- - Release: a version file ahead of the newest tag now raises the next version
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.1-1
- Fixed:
- - Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
- Upgrade notes:
- - Container: no 2.1.0 image reached GHCR; the two notes below carry over
- - Release: a version file ahead of the newest tag now raises the next version
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.0-1
- Added:
- - Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
- - Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
- - Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
- - Release: Cargo.toml version and its Cargo.lock entry follow the release
- Changed:
- - Release: starts from the highest version in the tag and all version files
- Upgrade notes:
- - Release: a version file ahead of the newest tag now raises the next version
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
* Thu Oct 29 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.5-1
- Fixed:
- - Config: *pkgmgr config edit* opens nano instead of aborting without a TTY
- - Config: *pkgmgr config edit* exits with the editor's code when it fails
* Mon Oct 19 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.4-1
- Fixed:
- - CI: Python lint passes again on ruff 0.17 and still on 0.16
- - CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
- Upgrade notes:
- - 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
- - Release: write list items with dash bullets when markdownlint-cli2 is absent
* Sun Oct 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.3-1
- Fixed:
- - Release: the entry is linted inside the CHANGELOG.md it lands in
- - Release: a list marker that breaks the file's consistency is caught (MD004)
- - Release: findings the file already has are not blamed on the entry
- - Release: *--no-globs* keeps the target repo's other files out of the lint
- - Release: without markdownlint-cli2, list items must use dash bullets
- Upgrade notes:
- - Release: write list items with dash bullets when markdownlint-cli2 is absent
* Thu Oct 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.2-1
- Fixes a release flow that could reject every entry written for it.
- ### Fixed
- - A heading in a changelog message became bold, which markdown-lint reads
- back as a heading and refuses with MD036
- - Since the editor reopens on failure, no structured entry could ever pass
- - Headings now move below the release heading they are filed under
- - The lint findings reached nobody: the editor took over the terminal in the
- same breath they were printed
- - They are held until Enter and repeated inside the editor, above the text
- - The Makefile installer accepted install-* as proof of an install target but
- always ran plain make install
- - A repository with install-python and no install died on "No rule to make
- target" instead of being skipped
- ### Tests
- - transform and lint were covered separately, so a transform producing lint
- errors went unseen; one test now runs the first through the second
- - Three tests were checking behaviour the code had already left behind
- ### Dependencies
- - docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
- - actions/setup-python 5 to 7
- - DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
- ### Upgrading
- - Nothing to do; a changelog entry written with headings now keeps them
* Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.1-1
- * A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
- * The entry's separator only belongs there when another entry follows it.
- * Affects the first entry a file receives, so it shows on an opening release.
- * Four tests cover the placements in *_insert_after_h1*, one per branch.
* Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.0-1
- Breaking
- * The *virgin* and *slim* image variants are gone; both tag suffixes with them.
- * Building an image now requires the base images to exist in the registry.
- * *make build-missing-virgin* is removed; *build* targets build the one image.
- * The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
- * *scripts/build/image.sh* loses its *--target* axis.
- * A repository is installed by one hook, no longer by pip and *make* both.
- Changed
- * *INSTALLERS* is a preference order led by *make install*, first success wins.
- * A failed hook falls back to the next; all failing exits with what was tried.
- * The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
- * Publishing pushes *--platform* manifest lists for amd64 and arm64.
- * *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
- * The virgin workflows pull the base image instead of building it.
- * The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
- * It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
- * *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
- * The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
- * The README drops the *Virgin* type and the Nix claim the image never met.
- * It now lists the tags *compute_publish_tags()* pushes, alias included.
- * Dependabot covers pip and github-actions weekly on Monday.
- * A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
- * *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
- * *scripts/docker/slim.sh* is deleted with the stage that ran it.
* Wed Jul 29 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.1-1
- * *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
- * Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
- * Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
- * The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
- * New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
- * New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
* Sun Jun 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.0-1
- * New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
- * The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
- * The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.2-1
- Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.1-1
- Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.0-1
- Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.14.0-1
- Added
* New release --retry mode re-deploys the HEAD release without
re-tagging or modifying any files. It re-pushes the existing version
tag, re-aligns the floating latest tag, and (unless --no-publish)
re-runs publish. Use this to recover from a release whose post-tag
push or PyPI upload failed mid-flight. The release_type argument
becomes optional under --retry.
* New module pkgmgr.actions.release.retry hosts the retry_release
helper so the workflow orchestrator stays focused on the forward
path.
* RepoPaths now exposes a debian_control slot, discovered alongside
debian_changelog under both packaging/debian and the legacy debian
layout.
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the distro-name lookup chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
idempotent push, latest-tag re-alignment, missing-tag error path,
and branch-detection fallback.
Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
then folder basename as legacy fallback. Renaming a repository
folder no longer silently flips the debian/changelog top entry and
the RPM changelog stanza to a new identifier. Those keep matching
the authoritative value in the packaging files, which is what apt,
pacman, and dnf index against.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement
on the next release.
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.4-1
- Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
folder basename as legacy fallback. Renaming a repository folder (for
example infinito-nexus to infinito-nexus-core) no longer silently
flips the debian/changelog top entry and the RPM changelog stanza to
a new identifier. Those keep matching the authoritative Package,
pkgname, or Name value in the packaging files, which is what apt,
pacman, and dnf index against.
Added
* RepoPaths gains a debian_control slot that is discovered alongside
debian_changelog under both packaging/debian (new layout) and debian
(legacy layout).
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the priority chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement.
* Thu Mar 26 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.3-1
- CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
* Thu Mar 26 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.2-1
- Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
* Fri Mar 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.1-1
- Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
* Fri Mar 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.0-1
- Set CentOS docker image to latest
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.5-1
- The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.4-1
- The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.3-1
- Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.2-1
- Removed infinito-sphinx package
* Sat Feb 14 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.1-1
- pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.0-1
- Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.2-1
- Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.1-1
- Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
* Wed Jan 21 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.0-1
- Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
* Tue Jan 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.10.0-1
- Automated release.
* Fri Jan 16 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.5-1
- Release patch: improve git pull error diagnostics
* Tue Jan 13 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.4-1
- fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
* Wed Jan 07 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.3-1
- Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
* Sun Dec 21 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.2-1
- Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
* Sun Dec 21 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.1-1
- Fixed installation issues and improved loading of default configuration files.
* Sat Dec 20 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.0-1
- * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
* All configured git mirrors are now provisioned.
* Fri Dec 19 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.7-1
- * **Release version updates now correctly modify ***pyproject.toml*** files that follow PEP 621**, ensuring the ***[project].version*** field is updated as expected.
* **Invalid or incomplete ***pyproject.toml*** files are now handled gracefully** with clear error messages instead of abrupt process termination.
* **RPM spec files remain compatible during releases**: existing macros such as ***%{?dist}*** are preserved and no longer accidentally modified.
* Wed Dec 17 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.6-1
- Prevent Rate Limits during GitHub Nix Setups
* Wed Dec 17 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.5-1 * Wed Dec 17 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.8.5-1
- * Clearer Git error handling, especially when a directory is not a Git repository. - * Clearer Git error handling, especially when a directory is not a Git repository.
* More reliable repository verification with improved commit and GPG signature checks. * More reliable repository verification with improved commit and GPG signature checks.
+6 -8
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "kpmx" name = "kpmx"
version = "2.4.0" version = "1.8.5"
description = "Kevin's package-manager tool (pkgmgr)" description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
@@ -21,7 +21,7 @@ authors = [
dependencies = [ dependencies = [
"PyYAML>=6.0", "PyYAML>=6.0",
"tomli; python_version < \"3.11\"", "tomli; python_version < \"3.11\"",
"jinja2>=3.1.6" "jinja2>=3.1"
] ]
[project.urls] [project.urls]
@@ -43,13 +43,11 @@ pkgmgr = "pkgmgr.cli:main"
# ----------------------------- # -----------------------------
# Source layout: all packages live under "src/" # Source layout: all packages live under "src/"
[tool.setuptools] [tool.setuptools]
package-dir = { "" = "src" } package-dir = { "" = "src", "config" = "config" }
include-package-data = true
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src", "."]
include = ["pkgmgr*"] include = ["pkgmgr*", "config*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
"pkgmgr.config" = ["*.yml", "*.yaml"] "config" = ["defaults.yaml"]
"pkgmgr.actions.review" = ["*.j2"]
+10 -43
View File
@@ -1,53 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# The distribution bases this repository builds on. They are published by : "${BASE_IMAGE_ARCH:=archlinux:latest}"
# https://github.com/kevinveenbirkenbach/base-images, one multi-platform image : "${BASE_IMAGE_DEBIAN:=debian:stable-slim}"
# per distribution, and carry the build dependencies the package build needs. : "${BASE_IMAGE_UBUNTU:=ubuntu:latest}"
# : "${BASE_IMAGE_FEDORA:=fedora:latest}"
# Env overrides exist for testing an unpublished base; the registry namespace : "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:stream9}"
# follows the images repository, not this one.
: "${BASE_IMAGES_REGISTRY:=ghcr.io}"
: "${BASE_IMAGES_OWNER:=kevinveenbirkenbach}"
: "${BASE_IMAGES_TAG:=latest}"
resolve_base_image() { resolve_base_image() {
local PKGMGR_DISTRO="$1" local PKGMGR_DISTRO="$1"
case "$PKGMGR_DISTRO" in case "$PKGMGR_DISTRO" in
arch|manjaro|debian|ubuntu|fedora|centos) arch) echo "$BASE_IMAGE_ARCH" ;;
echo "${BASE_IMAGES_REGISTRY}/${BASE_IMAGES_OWNER}/base-${PKGMGR_DISTRO}:${BASE_IMAGES_TAG}" debian) echo "$BASE_IMAGE_DEBIAN" ;;
;; ubuntu) echo "$BASE_IMAGE_UBUNTU" ;;
fedora) echo "$BASE_IMAGE_FEDORA" ;;
centos) echo "$BASE_IMAGE_CENTOS" ;;
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;; *) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
esac esac
} }
# Platforms each distribution's base offers; see base-images/scripts/build/distros.sh.
resolve_platforms() {
local PKGMGR_DISTRO="$1"
case "$PKGMGR_DISTRO" in
arch|manjaro|debian|ubuntu|fedora|centos) echo "linux/amd64,linux/arm64" ;;
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
esac
}
package_set_cmd() {
local PKGMGR_DISTRO="$1"
case "$PKGMGR_DISTRO" in
arch|manjaro) echo 'pacman -Q' ;;
debian|ubuntu) echo 'dpkg-query -W' ;;
fedora|centos) echo 'rpm -qa | sort' ;;
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
esac
}
version_prefixes() {
local -a parts
IFS=. read -ra parts <<< "$1"
local acc="${parts[0]}" i
echo "${acc}"
for ((i = 1; i < ${#parts[@]}; i++)); do
acc+=".${parts[i]}"
echo "${acc}"
done
}
+60 -141
View File
@@ -10,21 +10,22 @@ source "${SCRIPT_DIR}/base.sh"
NO_CACHE=0 NO_CACHE=0
MISSING_ONLY=0 MISSING_ONLY=0
TARGET=""
IMAGE_TAG="" # local image name or base tag (without registry) IMAGE_TAG="" # local image name or base tag (without registry)
PUSH=0 # if 1 -> use buildx and push (requires docker buildx) PUSH=0 # if 1 -> use buildx and push (requires docker buildx)
PUBLISH=0 # if 1 -> push <version>, <version>.<unix-ts> and the --aliases, plus arch aliases PUBLISH=0 # if 1 -> push with semantic tags (latest/version/stable + arch aliases)
REGISTRY="" # e.g. ghcr.io REGISTRY="" # e.g. ghcr.io
OWNER="" # e.g. github org/user OWNER="" # e.g. github org/user
REPO_PREFIX="pkgmgr" # image base name (pkgmgr) REPO_PREFIX="pkgmgr" # image base name (pkgmgr)
VERSION="" # X.Y.Z (required for --publish) VERSION="" # X.Y.Z (required for --publish)
ALIASES="" # moving tags besides <version>, e.g. "latest 2 2.4 stable" IS_STABLE="false" # "true" -> publish stable tags
ALIASES_SET=0 # 1 once --aliases was given, also when empty
PLATFORM="" # e.g. linux/arm64 -> build and push only this platform
MERGE=0 # if 1 -> join the per-platform tags of --version under the publish tags
DEFAULT_DISTRO="arch" DEFAULT_DISTRO="arch"
usage() { usage() {
local default_tag="pkgmgr-${PKGMGR_DISTRO}" local default_tag="pkgmgr-${PKGMGR_DISTRO}"
if [[ -n "${TARGET:-}" ]]; then
default_tag="${default_tag}-${TARGET}"
fi
cat <<EOF cat <<EOF
Usage: PKGMGR_DISTRO=<distro> $0 [options] Usage: PKGMGR_DISTRO=<distro> $0 [options]
@@ -32,29 +33,21 @@ Usage: PKGMGR_DISTRO=<distro> $0 [options]
Build options: Build options:
--missing Build only if the image does not already exist (local build only) --missing Build only if the image does not already exist (local build only)
--no-cache Build with --no-cache --no-cache Build with --no-cache
--target <name> Build a specific Dockerfile target (e.g. virgin)
--tag <image> Override the output image tag (default: ${default_tag}) --tag <image> Override the output image tag (default: ${default_tag})
Publish options: Publish options:
--push Push the built image (uses docker buildx build --push) --push Push the built image (uses docker buildx build --push)
--publish Publish <version>, <version>.<unix-ts> and every --aliases tag, plus arch --publish Publish semantic tags (latest, <version>, optional stable) + arch aliases
aliases
--registry <reg> Registry (e.g. ghcr.io) --registry <reg> Registry (e.g. ghcr.io)
--owner <owner> Registry namespace (e.g. \${GITHUB_REPOSITORY_OWNER}) --owner <owner> Registry namespace (e.g. \${GITHUB_REPOSITORY_OWNER})
--repo-prefix <name> Image base name (default: pkgmgr) --repo-prefix <name> Image base name (default: pkgmgr)
--version <X.Y.Z> Version for --publish --version <X.Y.Z> Version for --publish
--aliases "<tags>" Moving tags besides <version>, space separated, may be empty --stable <true|false> Whether to publish :stable tags (default: false)
(e.g. "latest 2 2.4 stable")
--platform <os/arch> Build and push only this platform; with --publish as <version>-<arch>,
skipped when its package set equals the published <version>
--merge Join the <version>-<arch> tags of all platforms under the publish tags;
when <version> already joins exactly these images, only the aliases
that do not are moved and no <version>.<unix-ts> is added
Notes: Notes:
- --publish implies --push and requires --registry, --owner, and --version. - --publish implies --push and requires --registry, --owner, and --version.
- --publish without --platform requires --aliases. - Local build (no --push) uses "docker build" and creates local images like "pkgmgr-arch" / "pkgmgr-arch-virgin".
- --platform requires --push; --merge requires --publish and builds nothing.
- Local build (no --push) uses "docker build" and creates a local image like "pkgmgr-arch".
EOF EOF
} }
@@ -62,6 +55,11 @@ while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--no-cache) NO_CACHE=1; shift ;; --no-cache) NO_CACHE=1; shift ;;
--missing) MISSING_ONLY=1; shift ;; --missing) MISSING_ONLY=1; shift ;;
--target)
TARGET="${2:-}"
[[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin)"; exit 2; }
shift 2
;;
--tag) --tag)
IMAGE_TAG="${2:-}" IMAGE_TAG="${2:-}"
[[ -n "${IMAGE_TAG}" ]] || { echo "ERROR: --tag requires a value"; exit 2; } [[ -n "${IMAGE_TAG}" ]] || { echo "ERROR: --tag requires a value"; exit 2; }
@@ -89,18 +87,11 @@ while [[ $# -gt 0 ]]; do
[[ -n "${VERSION}" ]] || { echo "ERROR: --version requires a value"; exit 2; } [[ -n "${VERSION}" ]] || { echo "ERROR: --version requires a value"; exit 2; }
shift 2 shift 2
;; ;;
--aliases) --stable)
[[ $# -ge 2 ]] || { echo "ERROR: --aliases requires a value, empty for none"; exit 2; } IS_STABLE="${2:-}"
ALIASES="$2" [[ -n "${IS_STABLE}" ]] || { echo "ERROR: --stable requires a value (true|false)"; exit 2; }
ALIASES_SET=1
shift 2 shift 2
;; ;;
--platform)
PLATFORM="${2:-}"
[[ -n "${PLATFORM}" ]] || { echo "ERROR: --platform requires a value"; exit 2; }
shift 2
;;
--merge) MERGE=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) *)
echo "ERROR: Unknown argument: $1" >&2 echo "ERROR: Unknown argument: $1" >&2
@@ -113,6 +104,9 @@ done
# Derive default local tag if not provided # Derive default local tag if not provided
if [[ -z "${IMAGE_TAG}" ]]; then if [[ -z "${IMAGE_TAG}" ]]; then
IMAGE_TAG="${REPO_PREFIX}-${PKGMGR_DISTRO}" IMAGE_TAG="${REPO_PREFIX}-${PKGMGR_DISTRO}"
if [[ -n "${TARGET}" ]]; then
IMAGE_TAG="${IMAGE_TAG}-${TARGET}"
fi
fi fi
BASE_IMAGE="$(resolve_base_image "$PKGMGR_DISTRO")" BASE_IMAGE="$(resolve_base_image "$PKGMGR_DISTRO")"
@@ -134,24 +128,8 @@ if [[ "${PUBLISH}" == "1" ]]; then
[[ -n "${REGISTRY}" ]] || { echo "ERROR: --publish requires --registry"; exit 2; } [[ -n "${REGISTRY}" ]] || { echo "ERROR: --publish requires --registry"; exit 2; }
[[ -n "${OWNER}" ]] || { echo "ERROR: --publish requires --owner"; exit 2; } [[ -n "${OWNER}" ]] || { echo "ERROR: --publish requires --owner"; exit 2; }
[[ -n "${VERSION}" ]] || { echo "ERROR: --publish requires --version"; exit 2; } [[ -n "${VERSION}" ]] || { echo "ERROR: --publish requires --version"; exit 2; }
[[ -n "${PLATFORM}" || "${ALIASES_SET}" == "1" ]] \
|| { echo "ERROR: --publish without --platform requires --aliases"; exit 2; }
fi fi
if [[ -n "${PLATFORM}" ]]; then
[[ "${PUSH}" == "1" ]] || { echo "ERROR: --platform requires --push or --publish"; exit 2; }
[[ ",$(resolve_platforms "$PKGMGR_DISTRO")," == *",${PLATFORM},"* ]] \
|| { echo "ERROR: ${PKGMGR_DISTRO} offers no platform ${PLATFORM}"; exit 2; }
fi
if [[ "${MERGE}" == "1" ]]; then
[[ "${PUBLISH}" == "1" ]] || { echo "ERROR: --merge requires --publish"; exit 2; }
[[ -z "${PLATFORM}" ]] || { echo "ERROR: --merge and --platform exclude each other"; exit 2; }
fi
DISTRO_TAG_BASE="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
STAMP="$(date -u +%s)"
# Guard: --push without --publish requires fully-qualified --tag # Guard: --push without --publish requires fully-qualified --tag
if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then
if [[ "${IMAGE_TAG}" != */* ]]; then if [[ "${IMAGE_TAG}" != */* ]]; then
@@ -165,7 +143,7 @@ echo "------------------------------------------------------------"
echo "[build] Building image" echo "[build] Building image"
echo "distro = ${PKGMGR_DISTRO}" echo "distro = ${PKGMGR_DISTRO}"
echo "BASE_IMAGE = ${BASE_IMAGE}" echo "BASE_IMAGE = ${BASE_IMAGE}"
echo "platforms = ${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}" if [[ -n "${TARGET}" ]]; then echo "target = ${TARGET}"; fi
if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi
if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi
if [[ "${PUBLISH}" == "1" ]]; then if [[ "${PUBLISH}" == "1" ]]; then
@@ -173,8 +151,7 @@ if [[ "${PUBLISH}" == "1" ]]; then
echo "registry = ${REGISTRY}" echo "registry = ${REGISTRY}"
echo "owner = ${OWNER}" echo "owner = ${OWNER}"
echo "version = ${VERSION}" echo "version = ${VERSION}"
echo "stamp = ${STAMP}" echo "stable = ${IS_STABLE}"
echo "aliases = ${ALIASES}"
fi fi
echo "------------------------------------------------------------" echo "------------------------------------------------------------"
@@ -185,113 +162,55 @@ if [[ "${NO_CACHE}" == "1" ]]; then
build_args+=(--no-cache) build_args+=(--no-cache)
fi fi
publish_bases() { if [[ -n "${TARGET}" ]]; then
echo "${DISTRO_TAG_BASE}" build_args+=(--target "${TARGET}")
fi
compute_publish_tags() {
local distro_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
local alias_tag_base=""
if [[ -n "${TARGET}" ]]; then
distro_tag_base="${distro_tag_base}-${TARGET}"
fi
if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then
echo "${REGISTRY}/${OWNER}/${REPO_PREFIX}" alias_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}"
fi if [[ -n "${TARGET}" ]]; then
} alias_tag_base="${alias_tag_base}-${TARGET}"
moving_tags() {
local base alias
local -a aliases
read -ra aliases <<< "${ALIASES}"
while read -r base; do
echo "${base}:${VERSION}"
for alias in "${aliases[@]}"; do
echo "${base}:${alias}"
done
done < <(publish_bases)
}
stamp_tags() {
local base
while read -r base; do
echo "${base}:${VERSION}.${STAMP}"
done < <(publish_bases)
}
raw_index() {
docker buildx imagetools inspect --raw "$1" 2>/dev/null || true
}
digest_of() {
docker buildx imagetools inspect --format '{{json .Manifest.Digest}}' "$1"
}
if [[ "${MERGE}" == "1" ]]; then
sources=()
IFS=',' read -ra platforms <<< "$(resolve_platforms "$PKGMGR_DISTRO")"
for p in "${platforms[@]}"; do
sources+=("${DISTRO_TAG_BASE}:${VERSION}-${p#*/}")
done
mapfile -t moving < <(moving_tags)
planned="$(docker buildx imagetools create --dry-run -t "${DISTRO_TAG_BASE}:${VERSION}" "${sources[@]}")"
tags=()
if [[ "$(raw_index "${DISTRO_TAG_BASE}:${VERSION}")" == "${planned}" ]]; then
for t in "${moving[@]}"; do
if [[ "$(raw_index "${t}")" != "${planned}" ]]; then
tags+=("${t}")
fi
done
else
mapfile -t stamps < <(stamp_tags)
tags=("${moving[@]}" "${stamps[@]}")
fi
if ((${#tags[@]} == 0)); then
echo "[build] ${PKGMGR_DISTRO} ${VERSION}: every tag already joins these platform images, nothing to push."
exit 0
fi
create_args=()
for t in "${tags[@]}"; do
create_args+=(-t "$t")
done
echo "[build] Running: docker buildx imagetools create ${create_args[*]} ${sources[*]}"
docker buildx imagetools create "${create_args[@]}" "${sources[@]}"
version_digest="$(digest_of "${DISTRO_TAG_BASE}:${VERSION}")"
for t in "${moving[@]}"; do
if [[ "$(digest_of "${t}")" != "${version_digest}" ]]; then
echo "[build] ❌ ${PKGMGR_DISTRO}: :${VERSION} and ${t} point at different images." >&2
echo "[build] Whoever pinned ${t##*:} stopped receiving what ${VERSION} gets." >&2
exit 1
fi
done
echo "[build] ✅ ${PKGMGR_DISTRO}: every tag of ${VERSION} == ${version_digest}"
elif [[ "${PUSH}" == "1" ]]; then
if [[ "${PUBLISH}" == "1" && -n "${PLATFORM}" ]]; then
probe="${REPO_PREFIX}-${PKGMGR_DISTRO}:probe"
docker buildx build --load --platform "${PLATFORM}" -t "${probe}" "${build_args[@]}" .
package_set="$(package_set_cmd "${PKGMGR_DISTRO}")"
built_set="$(docker run --rm --platform "${PLATFORM}" --entrypoint bash "${probe}" -lc "${package_set}" | sha256sum)"
published_set="$(docker run --rm --pull always --platform "${PLATFORM}" --entrypoint bash "${DISTRO_TAG_BASE}:${VERSION}" -lc "${package_set}" 2>/dev/null | sha256sum || true)"
if [[ "${built_set}" == "${published_set}" ]]; then
echo "[build] ${PKGMGR_DISTRO} ${PLATFORM} unchanged since the published ${VERSION}, nothing to push."
exit 0
fi fi
fi fi
local tags=()
tags+=("${distro_tag_base}:latest")
tags+=("${distro_tag_base}:${VERSION}")
if [[ "${IS_STABLE}" == "true" ]]; then
tags+=("${distro_tag_base}:stable")
fi
if [[ -n "${alias_tag_base}" ]]; then
tags+=("${alias_tag_base}:latest")
tags+=("${alias_tag_base}:${VERSION}")
if [[ "${IS_STABLE}" == "true" ]]; then
tags+=("${alias_tag_base}:stable")
fi
fi
printf '%s\n' "${tags[@]}"
}
if [[ "${PUSH}" == "1" ]]; then
bx_args=(docker buildx build --push) bx_args=(docker buildx build --push)
if [[ "${PUBLISH}" == "1" && -n "${PLATFORM}" ]]; then if [[ "${PUBLISH}" == "1" ]]; then
bx_args+=(-t "${DISTRO_TAG_BASE}:${VERSION}-${PLATFORM#*/}")
elif [[ "${PUBLISH}" == "1" ]]; then
while IFS= read -r t; do while IFS= read -r t; do
bx_args+=(-t "$t") bx_args+=(-t "$t")
done < <(moving_tags) done < <(compute_publish_tags)
while IFS= read -r t; do
bx_args+=(-t "$t")
done < <(stamp_tags)
else else
bx_args+=(-t "${IMAGE_TAG}") bx_args+=(-t "${IMAGE_TAG}")
fi fi
bx_args+=(--platform "${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}")
bx_args+=("${build_args[@]}") bx_args+=("${build_args[@]}")
bx_args+=(.) bx_args+=(.)
+15 -19
View File
@@ -1,33 +1,30 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Publish one image per distro, each covering every platform its base offers. # Publish all distro images (full + virgin) to a registry via image.sh --publish
# #
# Required env: # Required env:
# OWNER (e.g. GITHUB_REPOSITORY_OWNER) # OWNER (e.g. GITHUB_REPOSITORY_OWNER)
# VERSION (e.g. 1.2.3) # VERSION (e.g. 1.2.3)
# ALIASES moving tags besides VERSION, space separated, may be empty (e.g. "latest 1 1.2 stable")
# #
# Optional env: # Optional env:
# REGISTRY (default: ghcr.io) # REGISTRY (default: ghcr.io)
# IS_STABLE (default: false)
# DISTROS (default: "arch debian ubuntu fedora centos") # DISTROS (default: "arch debian ubuntu fedora centos")
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=./scripts/build/base.sh
source "${SCRIPT_DIR}/base.sh"
REGISTRY="${REGISTRY:-ghcr.io}" REGISTRY="${REGISTRY:-ghcr.io}"
IS_STABLE="${IS_STABLE:-false}"
DISTROS="${DISTROS:-arch debian ubuntu fedora centos}" DISTROS="${DISTROS:-arch debian ubuntu fedora centos}"
: "${OWNER:?Environment variable OWNER must be set (e.g. github.repository_owner)}" : "${OWNER:?Environment variable OWNER must be set (e.g. github.repository_owner)}"
: "${VERSION:?Environment variable VERSION must be set (e.g. 1.2.3)}" : "${VERSION:?Environment variable VERSION must be set (e.g. 1.2.3)}"
: "${ALIASES?Environment variable ALIASES must be set, empty for none (e.g. \"latest 1 1.2 stable\")}"
echo "[publish] REGISTRY=${REGISTRY}" echo "[publish] REGISTRY=${REGISTRY}"
echo "[publish] OWNER=${OWNER}" echo "[publish] OWNER=${OWNER}"
echo "[publish] VERSION=${VERSION}" echo "[publish] VERSION=${VERSION}"
echo "[publish] ALIASES=${ALIASES}" echo "[publish] IS_STABLE=${IS_STABLE}"
echo "[publish] DISTROS=${DISTROS}" echo "[publish] DISTROS=${DISTROS}"
for d in ${DISTROS}; do for d in ${DISTROS}; do
@@ -36,23 +33,22 @@ for d in ${DISTROS}; do
echo "[publish] PKGMGR_DISTRO=${d}" echo "[publish] PKGMGR_DISTRO=${d}"
echo "============================================================" echo "============================================================"
IFS=',' read -ra platforms <<< "$(resolve_platforms "${d}")" # virgin
for p in "${platforms[@]}"; do
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \
--registry "${REGISTRY}" \
--owner "${OWNER}" \
--version "${VERSION}" \
--platform "${p}"
done
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \ PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \ --publish \
--registry "${REGISTRY}" \ --registry "${REGISTRY}" \
--owner "${OWNER}" \ --owner "${OWNER}" \
--version "${VERSION}" \ --version "${VERSION}" \
--aliases "${ALIASES}" \ --stable "${IS_STABLE}" \
--merge --target virgin
# full (default target)
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \
--registry "${REGISTRY}" \
--owner "${OWNER}" \
--version "${VERSION}" \
--stable "${IS_STABLE}"
done done
echo echo
@@ -1,14 +0,0 @@
#!/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
@@ -1,43 +0,0 @@
#!/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}."
@@ -1,43 +0,0 @@
#!/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
@@ -1,91 +0,0 @@
#!/usr/bin/env bash
# Param: WORKFLOW_RUN_SHA commit the "Mark stable commit" run tagged; empty on a scheduled or
# manual rebuild, which publishes the newest vX.Y.Z release on origin/main
# and the release the git tag stable marks
# Output: releases=<JSON list of {"sha", "version", "aliases"}> in GITHUB_OUTPUT; aliases are the
# moving tags a release takes besides <version> and <version>.<unix-ts>
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/build/base.sh
source "${SCRIPT_DIR}/../../build/base.sh"
: "${WORKFLOW_RUN_SHA?WORKFLOW_RUN_SHA must be set, empty for a rebuild}"
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
RELEASE_TAG='^v[0-9]+\.[0-9]+\.[0-9]+$'
release_at() {
local listed tag
listed="$(git tag --points-at "$1" --sort=-v:refname)"
tag="$(grep -m1 -E "${RELEASE_TAG}" <<< "${listed}" || true)"
echo "${tag#v}"
}
newest_of() {
local prefix="$1" version
for version in "${RELEASES[@]}"; do
if [[ "${version}." == "${prefix}."* ]]; then
echo "${version}"
return 0
fi
done
}
aliases_of() {
local version="$1" prefix
local -a aliases=()
if [[ "${version}" == "${RELEASES[0]}" ]]; then
aliases+=(latest)
fi
while read -r prefix; do
if [[ "${prefix}" != "${version}" && "$(newest_of "${prefix}")" == "${version}" ]]; then
aliases+=("${prefix}")
fi
done < <(version_prefixes "${version}")
if [[ "${version}" == "${STABLE}" ]]; then
aliases+=(stable)
fi
echo "${aliases[*]}"
}
listed="$(git tag --merged origin/main --sort=-v:refname)"
mapfile -t tags < <(grep -E "${RELEASE_TAG}" <<< "${listed}")
RELEASES=("${tags[@]#v}")
STABLE=""
stable_sha="$(git rev-parse -q --verify 'refs/tags/stable^{commit}' || true)"
if [[ -n "${stable_sha}" ]]; then
STABLE="$(release_at "${stable_sha}")"
fi
targets=()
if [[ -n "${WORKFLOW_RUN_SHA}" ]]; then
version="$(release_at "${WORKFLOW_RUN_SHA}")"
if ! git merge-base --is-ancestor "${WORKFLOW_RUN_SHA}" origin/main; then
echo "${WORKFLOW_RUN_SHA} is not on origin/main. Skipping publish."
elif [[ -z "${version}" ]]; then
echo "No vX.Y.Z tag found for ${WORKFLOW_RUN_SHA}. Skipping publish."
else
targets+=("${version}")
fi
else
((${#RELEASES[@]} > 0)) || { echo "No vX.Y.Z tag is reachable from origin/main." >&2; exit 1; }
targets+=("${RELEASES[0]}")
if [[ -z "${STABLE}" ]]; then
echo "The git tag stable marks no vX.Y.Z release; the stable image stays where it is."
elif [[ "${STABLE}" != "${RELEASES[0]}" ]]; then
targets+=("${STABLE}")
fi
fi
json=""
for version in "${targets[@]}"; do
sha="$(git rev-parse "v${version}^{commit}")"
moving="$(aliases_of "${version}")"
echo "Publishing ${version} at ${sha}, moving: ${moving}"
json+="${json:+,}{\"sha\":\"${sha}\",\"version\":\"${version}\",\"aliases\":\"${moving}\"}"
done
echo "releases=[${json}]" >> "${GITHUB_OUTPUT}"
@@ -1,15 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
: "${OWNER:?OWNER must be set}"
: "${VERSION:?VERSION must be set}"
: "${ALIASES?ALIASES must be set, empty for none}"
: "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
bash scripts/build/image.sh \
--publish \
--registry ghcr.io \
--owner "${OWNER}" \
--version "${VERSION}" \
--aliases "${ALIASES}" \
--merge
@@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
: "${OWNER:?OWNER must be set}"
: "${VERSION:?VERSION must be set}"
: "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
: "${PLATFORM:?PLATFORM must be set}"
bash scripts/build/image.sh \
--publish \
--registry ghcr.io \
--owner "${OWNER}" \
--version "${VERSION}" \
--platform "${PLATFORM}"
@@ -38,7 +38,11 @@ echo "[aur-builder-setup] Configuring sudoers for aur_builder..."
${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder" ${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder"
${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder ${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder
RUN_AS_AUR=(runuser -u aur_builder -- bash -c) if command -v sudo >/dev/null 2>&1; then
RUN_AS_AUR=(sudo -u aur_builder bash -lc)
else
RUN_AS_AUR=(su - aur_builder -c)
fi
echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..." echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..."
@@ -16,7 +16,6 @@ fi
pacman -S --noconfirm --needed \ pacman -S --noconfirm --needed \
base-devel \ base-devel \
git \ git \
gnupg \
rsync \ rsync \
curl \ curl \
ca-certificates \ ca-certificates \
+1 -1
View File
@@ -47,7 +47,7 @@ echo "[arch/package] Using 'aur_builder' user for makepkg..."
chown -R aur_builder:aur_builder "${BUILD_ROOT}" chown -R aur_builder:aur_builder "${BUILD_ROOT}"
echo "[arch/package] Running makepkg in: ${PKG_BUILD_DIR}" echo "[arch/package] Running makepkg in: ${PKG_BUILD_DIR}"
runuser -u aur_builder -- bash -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps" su aur_builder -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
echo "[arch/package] Installing generated Arch package..." echo "[arch/package] Installing generated Arch package..."
pkg_path="$(find "${PKG_BUILD_DIR}" -maxdepth 1 -type f -name 'package-manager-*.pkg.tar.*' | head -n1)" pkg_path="$(find "${PKG_BUILD_DIR}" -maxdepth 1 -type f -name 'package-manager-*.pkg.tar.*' | head -n1)"
@@ -6,7 +6,6 @@ echo "[centos/dependencies] Installing CentOS build dependencies..."
dnf -y update dnf -y update
dnf -y install \ dnf -y install \
git \ git \
gnupg2 \
rsync \ rsync \
rpm-build \ rpm-build \
make \ make \
@@ -9,7 +9,6 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
debhelper \ debhelper \
dpkg-dev \ dpkg-dev \
git \ git \
gnupg \
rsync \ rsync \
bash \ bash \
curl \ curl \
@@ -6,7 +6,6 @@ echo "[fedora/dependencies] Installing Fedora build dependencies..."
dnf -y update dnf -y update
dnf -y install \ dnf -y install \
git \ git \
gnupg2 \
rsync \ rsync \
rpm-build \ rpm-build \
make \ make \
+2 -2
View File
@@ -2,8 +2,8 @@
set -euo pipefail set -euo pipefail
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "[installation/install] ERROR: Installation requires root. Re-run with sudo." >&2 echo "[installation/install] Warning: Installation is just possible via root."
exit 1 exit 0
fi fi
echo "[installation] Running as root (EUID=0)." echo "[installation] Running as root (EUID=0)."
@@ -9,7 +9,6 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
debhelper \ debhelper \
dpkg-dev \ dpkg-dev \
git \ git \
gnupg \
tzdata \ tzdata \
lsb-release \ lsb-release \
rsync \ rsync \
+5 -7
View File
@@ -37,16 +37,10 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if ! command -v nix >/dev/null 2>&1; then if ! command -v nix >/dev/null 2>&1; then
if [[ -x "${FLAKE_DIR}/nix/init.sh" ]]; then if [[ -x "${FLAKE_DIR}/nix/init.sh" ]]; then
"${FLAKE_DIR}/nix/init.sh" "${FLAKE_DIR}/nix/init.sh" || true
fi fi
fi fi
if ! command -v nix >/dev/null 2>&1; then
echo "[launcher] ERROR: 'nix' binary not found on PATH after init." >&2
echo "[launcher] Nix is required to run pkgmgr (no Python fallback)." >&2
exit 1
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Primary path: use Nix flake if available (with GitHub 403 retry) # Primary path: use Nix flake if available (with GitHub 403 retry)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -57,3 +51,7 @@ if declare -F run_with_github_403_retry >/dev/null; then
else else
exec nix run "${FLAKE_DIR}#pkgmgr" -- "$@" exec nix run "${FLAKE_DIR}#pkgmgr" -- "$@"
fi fi
echo "[launcher] ERROR: 'nix' binary not found on PATH after init."
echo "[launcher] Nix is required to run pkgmgr (no Python fallback)."
exit 1
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "============================================================"
echo ">>> Running RUFF lint on src and tests (local, no container)"
echo "============================================================"
if ! command -v ruff >/dev/null 2>&1; then
echo "ruff is not installed or not on PATH." >&2
echo "Install it with: pip install ruff" >&2
exit 127
fi
ruff --version
ruff check src tests
+5 -1
View File
@@ -49,7 +49,11 @@ install_nix_with_retry() {
if [[ -n "$run_as" ]]; then if [[ -n "$run_as" ]]; then
chown "$run_as:$run_as" "$installer" 2>/dev/null || true chown "$run_as:$run_as" "$installer" 2>/dev/null || true
echo "[init-nix] Running installer as user '$run_as' ($mode_flag)..." echo "[init-nix] Running installer as user '$run_as' ($mode_flag)..."
su - "$run_as" -s /bin/bash -c "bash -lc \"sh '$installer' $mode_flag\"" if command -v sudo >/dev/null 2>&1; then
sudo -u "$run_as" bash -lc "sh '$installer' $mode_flag"
else
su - "$run_as" -c "sh '$installer' $mode_flag"
fi
else else
echo "[init-nix] Running installer as current user ($mode_flag)..." echo "[init-nix] Running installer as current user ($mode_flag)..."
sh "$installer" "$mode_flag" sh "$installer" "$mode_flag"
+6 -7
View File
@@ -36,17 +36,16 @@ real_exe() {
# Resolve nix binary path robustly (works across distros + Arch /usr/sbin) # Resolve nix binary path robustly (works across distros + Arch /usr/sbin)
resolve_nix_bin() { resolve_nix_bin() {
# 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; }
local nix_cmd="" local nix_cmd=""
nix_cmd="$(command -v nix 2>/dev/null || true)" nix_cmd="$(command -v nix 2>/dev/null || true)"
[[ -n "$nix_cmd" ]] && real_exe "$nix_cmd" && return 0 [[ -n "$nix_cmd" ]] && real_exe "$nix_cmd" && return 0
# /usr/local after system locations, and only if it resolves to a real executable # IMPORTANT: prefer system locations before /usr/local to avoid self-symlink traps
[[ -x /usr/sbin/nix ]] && { echo "/usr/sbin/nix"; return 0; } # Arch package can land here
[[ -x /usr/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
[[ -e /usr/local/bin/nix ]] && real_exe "/usr/local/bin/nix" && return 0 [[ -e /usr/local/bin/nix ]] && real_exe "/usr/local/bin/nix" && return 0
[[ -x /nix/var/nix/profiles/default/bin/nix ]] && { [[ -x /nix/var/nix/profiles/default/bin/nix ]] && {
-1
View File
@@ -11,7 +11,6 @@ docker run --rm \
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \ -v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
-e REINSTALL_PKGMGR=1 \ -e REINSTALL_PKGMGR=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \ -e TEST_PATTERN="${TEST_PATTERN}" \
-e NIX_CONFIG="${NIX_CONFIG}" \
--workdir /opt/src/pkgmgr \ --workdir /opt/src/pkgmgr \
"pkgmgr-${PKGMGR_DISTRO}" \ "pkgmgr-${PKGMGR_DISTRO}" \
bash -lc ' bash -lc '
-1
View File
@@ -14,7 +14,6 @@ docker run --rm \
-v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \ -v "pkgmgr_nix_cache_${PKGMGR_DISTRO}:/root/.cache/nix" \
--workdir /opt/src/pkgmgr \ --workdir /opt/src/pkgmgr \
-e REINSTALL_PKGMGR=1 \ -e REINSTALL_PKGMGR=1 \
-e NIX_CONFIG="${NIX_CONFIG}" \
"${IMAGE}" \ "${IMAGE}" \
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail
-1
View File
@@ -19,7 +19,6 @@ if OUTPUT=$(docker run --rm \
-e REINSTALL_PKGMGR=1 \ -e REINSTALL_PKGMGR=1 \
-v "$(pwd):/opt/src/pkgmgr" \ -v "$(pwd):/opt/src/pkgmgr" \
-w /opt/src/pkgmgr \ -w /opt/src/pkgmgr \
-e NIX_CONFIG="${NIX_CONFIG}" \
"${IMAGE}" \ "${IMAGE}" \
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail
-1
View File
@@ -12,7 +12,6 @@ docker run --rm \
--workdir /opt/src/pkgmgr \ --workdir /opt/src/pkgmgr \
-e REINSTALL_PKGMGR=1 \ -e REINSTALL_PKGMGR=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \ -e TEST_PATTERN="${TEST_PATTERN}" \
-e NIX_CONFIG="${NIX_CONFIG}" \
"pkgmgr-${PKGMGR_DISTRO}" \ "pkgmgr-${PKGMGR_DISTRO}" \
bash -lc ' bash -lc '
set -e; set -e;
-1
View File
@@ -12,7 +12,6 @@ docker run --rm \
--workdir /opt/src/pkgmgr \ --workdir /opt/src/pkgmgr \
-e REINSTALL_PKGMGR=1 \ -e REINSTALL_PKGMGR=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \ -e TEST_PATTERN="${TEST_PATTERN}" \
-e NIX_CONFIG="${NIX_CONFIG}" \
"pkgmgr-${PKGMGR_DISTRO}" \ "pkgmgr-${PKGMGR_DISTRO}" \
bash -lc ' bash -lc '
set -e; set -e;
+11 -8
View File
@@ -1,3 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
Top-level pkgmgr package. Top-level pkgmgr package.
@@ -22,12 +25,12 @@ __all__ = ["cli"]
def __getattr__(name: str) -> Any: def __getattr__(name: str) -> Any:
""" """
Lazily expose ``pkgmgr.cli`` as attribute on the top-level package. Lazily expose ``pkgmgr.cli`` as attribute on the top-level package.
This keeps ``import pkgmgr`` lightweight while still allowing This keeps ``import pkgmgr`` lightweight while still allowing
``from pkgmgr import cli`` in tests and entry points. ``from pkgmgr import cli`` in tests and entry points.
""" """
if name == "cli": if name == "cli":
return import_module("pkgmgr.cli") return import_module("pkgmgr.cli")
raise AttributeError(f"module 'pkgmgr' has no attribute {name!r}") raise AttributeError(f"module 'pkgmgr' has no attribute {name!r}")
+1 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
# expose subpackages for patch() / resolve_name() friendliness # expose subpackages for patch() / resolve_name() friendliness
from . import release as release from . import release as release # noqa: F401
__all__ = ["release"] __all__ = ["release"]
-31
View File
@@ -1,31 +0,0 @@
"""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",
]
-53
View File
@@ -1,53 +0,0 @@
"""Locate archivable Markdown files under a target directory."""
from __future__ import annotations
import re
from collections.abc import Iterable
from pathlib import Path
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
-35
View File
@@ -1,35 +0,0 @@
"""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
-76
View File
@@ -1,76 +0,0 @@
"""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
-114
View File
@@ -1,114 +0,0 @@
"""Orchestrator for archiving fully-checked Markdown files."""
from __future__ import annotations
import contextlib
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:
with contextlib.suppress(FileNotFoundError):
path.unlink()
return ArchivePlan(
archived=archived,
skipped_incomplete=skipped_incomplete,
skipped_without_h1=skipped_without_h1,
new_entries=new_entries,
existing_entries=already_archived,
)
+3 -8
View File
@@ -1,19 +1,14 @@
# -*- coding: utf-8 -*-
""" """
Public API for branch actions. Public API for branch actions.
""" """
from .open_branch import open_branch
from .close_branch import close_branch from .close_branch import close_branch
from .drop_branch import drop_branch from .drop_branch import drop_branch
from .gc import branch_purge, cleanup
from .open_branch import open_branch
from .tree import branch_tree, refresh_tree_entry
__all__ = [ __all__ = [
"branch_purge", "open_branch",
"branch_tree",
"cleanup",
"close_branch", "close_branch",
"drop_branch", "drop_branch",
"open_branch",
"refresh_tree_entry",
] ]
+10 -10
View File
@@ -1,5 +1,9 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.git.commands import ( from pkgmgr.core.git.commands import (
GitDeleteRemoteBranchError, GitDeleteRemoteBranchError,
checkout, checkout,
@@ -10,12 +14,12 @@ from pkgmgr.core.git.commands import (
pull, pull,
push, push,
) )
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import get_current_branch, resolve_base_branch from pkgmgr.core.git.queries import resolve_base_branch
def close_branch( def close_branch(
name: str | None, name: Optional[str],
base_branch: str = "main", base_branch: str = "main",
fallback_base: str = "master", fallback_base: str = "master",
cwd: str = ".", cwd: str = ".",
@@ -44,13 +48,9 @@ def close_branch(
# Confirmation # Confirmation
if not force: if not force:
answer = ( answer = input(
input( f"Merge branch '{name}' into '{target_base}' and delete it afterwards? (y/N): "
f"Merge branch '{name}' into '{target_base}' and delete it afterwards? (y/N): " ).strip().lower()
)
.strip()
.lower()
)
if answer != "y": if answer != "y":
print("Aborted closing branch.") print("Aborted closing branch.")
return return
+10 -10
View File
@@ -1,16 +1,20 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.git.commands import ( from pkgmgr.core.git.commands import (
GitDeleteRemoteBranchError, GitDeleteRemoteBranchError,
delete_local_branch, delete_local_branch,
delete_remote_branch, delete_remote_branch,
) )
from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import get_current_branch, resolve_base_branch from pkgmgr.core.git.queries import resolve_base_branch
def drop_branch( def drop_branch(
name: str | None, name: Optional[str],
base_branch: str = "main", base_branch: str = "main",
fallback_base: str = "master", fallback_base: str = "master",
cwd: str = ".", cwd: str = ".",
@@ -37,13 +41,9 @@ def drop_branch(
# Confirmation # Confirmation
if not force: if not force:
answer = ( answer = input(
input( f"Delete branch '{name}' locally and on origin? This is destructive! (y/N): "
f"Delete branch '{name}' locally and on origin? This is destructive! (y/N): " ).strip().lower()
)
.strip()
.lower()
)
if answer != "y": if answer != "y":
print("Aborted dropping branch.") print("Aborted dropping branch.")
return return
File diff suppressed because it is too large. Load diff
-116
View File
@@ -1,116 +0,0 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pkgmgr.actions.mirror.url_utils import parse_repo_from_git_url
from pkgmgr.core.credentials.resolver import ResolutionOptions, TokenResolver
from pkgmgr.core.credentials.types import CredentialError
from pkgmgr.core.remote_provisioning.http.client import HttpClient
from pkgmgr.core.remote_provisioning.http.errors import HttpError
GITHUB_HOST = "github.com"
GITHUB_API = "https://api.github.com"
PAGE_SIZE = 100
@dataclass
class ForgeFacts:
"""
Pull-request and protection facts of every GitHub remote of one repository.
Attributes:
merged_heads: head SHA of a merged PR -> PR number.
open_heads: head branch name of an open PR -> PR number.
protected: branch names protected on any of the remotes.
unavailable: why the facts could not be fetched, or None.
"""
merged_heads: dict[str, int] = field(default_factory=dict)
open_heads: dict[str, int] = field(default_factory=dict)
protected: set[str] = field(default_factory=set)
unavailable: str | None = None
def github_slugs(remote_urls: dict[str, str]) -> list[tuple[str, str]]:
slugs: list[tuple[str, str]] = []
for url in remote_urls.values():
host, owner, name = parse_repo_from_git_url(url)
if (
host.lower() == GITHUB_HOST
and owner
and name
and (owner, name) not in slugs
):
slugs.append((owner, name))
return slugs
def _pages(http: HttpClient, headers: dict[str, str], url: str) -> list[dict]:
items: list[dict] = []
page = 1
while True:
resp = http.request_json(
"GET", f"{url}&per_page={PAGE_SIZE}&page={page}", headers=headers
)
batch = json.loads(resp.text or "[]")
items.extend(batch)
if len(batch) < PAGE_SIZE:
return items
page += 1
def fetch_forge_facts(remote_urls: dict[str, str]) -> ForgeFacts:
facts = ForgeFacts()
slugs = github_slugs(remote_urls)
if not slugs:
return facts
try:
token = (
TokenResolver()
.get_token(
"github",
GITHUB_HOST,
options=ResolutionOptions(interactive=False, allow_prompt=False),
)
.token
)
except CredentialError as exc:
facts.unavailable = f"no GitHub token ({exc})"
return facts
return facts_with_token(slugs, token)
def facts_with_token(slugs: list[tuple[str, str]], token: str) -> ForgeFacts:
"""
Fetch the pull-request and protection facts of GitHub repositories.
Args:
slugs: (owner, name) of each repository.
token: the GitHub token sent as bearer.
Returns:
The facts, or facts whose unavailable names the failing repository.
"""
facts = ForgeFacts()
http = HttpClient(timeout_s=30)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"User-Agent": "pkgmgr",
}
for owner, name in slugs:
base = f"{GITHUB_API}/repos/{owner}/{name}"
try:
for pr in _pages(http, headers, f"{base}/pulls?state=all"):
if pr["merged_at"]:
facts.merged_heads[pr["head"]["sha"]] = pr["number"]
elif pr["state"] == "open":
facts.open_heads[pr["head"]["ref"]] = pr["number"]
for branch in _pages(http, headers, f"{base}/branches?protected=true"):
facts.protected.add(branch["name"])
except (HttpError, ValueError, KeyError, TypeError) as exc:
facts.unavailable = f"GitHub API failed for {owner}/{name}: {exc}"
return facts
return facts
-44
View File
@@ -1,44 +0,0 @@
from __future__ import annotations
import os
import shutil
DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
def canonical(path: str) -> bool:
"""
Args:
path: a path as git or a plan names it.
Returns:
True when path is absolute and already its own realpath: no symbolic
link and no `..` component that the kernel could resolve elsewhere.
"""
return os.path.isabs(path) and os.path.realpath(path) == path
def remove_tree(path: str) -> None:
"""
Delete a directory tree without following a symbolic link anywhere on
the way to it, even one swapped in after the caller checked the path.
Args:
path: absolute, canonical directory to delete.
Raises:
OSError: a component of path is a symbolic link or missing, or the
deletion failed.
"""
parent, name = os.path.split(path)
fd = os.open(os.sep, os.O_RDONLY | os.O_DIRECTORY)
try:
for component in [c for c in parent.split(os.sep) if c]:
child = os.open(component, DIR_FLAGS, dir_fd=fd)
os.close(fd)
fd = child
# Trip-wire: rmtree(dir_fd=) needs Python 3.11; the fd link pins the
# opened parent on every version, and rmtree refuses a symlinked name.
shutil.rmtree(f"/proc/self/fd/{fd}/{name}")
finally:
os.close(fd)
+3 -1
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from pkgmgr.core.git.commands import ( from pkgmgr.core.git.commands import (
checkout, checkout,
create_branch, create_branch,
@@ -11,7 +13,7 @@ from pkgmgr.core.git.queries import resolve_base_branch
def open_branch( def open_branch(
name: str | None, name: Optional[str],
base_branch: str = "main", base_branch: str = "main",
fallback_base: str = "master", fallback_base: str = "master",
cwd: str = ".", cwd: str = ".",
-372
View File
@@ -1,372 +0,0 @@
from __future__ import annotations
import fcntl
import json
import os
import re
import sys
import tempfile
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import asdict
from datetime import datetime, timezone
import yaml
from pkgmgr.actions.worktree import flow
from pkgmgr.core.credentials.store_keys import build_keyring_key
from pkgmgr.core.git.errors import GitBaseError
from . import gc
from .gc_forge import GITHUB_HOST, ForgeFacts, facts_with_token, github_slugs
WORKTREES_ROOT = os.path.join(
os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"),
"worktrees",
)
WORKTREE_DEPTH = 6
CACHE_TTL = 600
SLUG = re.compile(r"^(?!\.\.?$)[\w.-]+$")
def now() -> float:
return time.time()
def _iso(stamp: float) -> str:
return datetime.fromtimestamp(stamp, timezone.utc).isoformat(timespec="seconds")
def tree_file() -> str:
state = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state")
return os.environ.get("PKGMGR_TREE_FILE") or os.path.join(
state, "pkgmgr", "tree", "branch-tree.json"
)
def _cache_path(owner: str, name: str) -> str:
base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
return os.path.join(base, "pkgmgr", "forge", owner, f"{name}.json")
def secret_service_token() -> tuple[str | None, str | None]:
"""
Read the GitHub token from the Secret Service without unlocking it.
Returns:
(token, None), or (None, why no token is available).
"""
try:
import secretstorage
except ImportError:
return None, "secretstorage not installed"
key = build_keyring_key("github", GITHUB_HOST, None)
try:
connection = secretstorage.dbus_init()
try:
items = list(
secretstorage.search_items(
connection, {"service": key.service, "username": key.username}
)
)
unlocked = [item for item in items if not item.is_locked()]
if not items:
return None, "no GitHub token in the Secret Service"
if not unlocked:
return None, "Secret Service locked"
return unlocked[0].get_secret().decode(), None
finally:
connection.close()
except (secretstorage.exceptions.SecretStorageException, OSError) as exc:
return None, f"Secret Service unavailable: {exc}"
@contextmanager
def _locked(path: str) -> Iterator[None]:
folder, name = os.path.split(os.path.abspath(path))
os.makedirs(folder, exist_ok=True)
with open(os.path.join(folder, f".{name}.lock"), "w", encoding="utf-8") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
yield
def _replace(path: str, text: str) -> None:
folder, name = os.path.split(os.path.abspath(path))
fd, tmp = tempfile.mkstemp(dir=folder, prefix=f".{name}.")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def _write(path: str, text: str) -> None:
with _locked(path):
_replace(path, text)
def _cached(path: str) -> ForgeFacts | None:
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
if now() - data["fetched_at"] >= CACHE_TTL:
return None
return ForgeFacts(
merged_heads=data["merged_heads"],
open_heads=data["open_heads"],
protected=set(data["protected"]),
)
except (OSError, ValueError, KeyError, TypeError):
return None
def _store(path: str, facts: ForgeFacts) -> None:
data = {
"fetched_at": now(),
"merged_heads": facts.merged_heads,
"open_heads": facts.open_heads,
"protected": sorted(facts.protected),
}
_write(path, json.dumps(data))
def _unavailable(reason: str | None) -> tuple[ForgeFacts, str]:
return ForgeFacts(unavailable=reason), f"unavailable: {reason}"
def forge_facts(
urls: dict[str, str], token: str | None, reason: str | None
) -> tuple[ForgeFacts, str]:
"""
Forge facts of one repository from the cache, or live once the cache is stale.
Args:
urls: remote name -> URL.
token: GitHub token from the Secret Service, or None.
reason: why the token is None.
Returns:
The facts and their source: none, cached, live or unavailable: <why>.
"""
slugs = github_slugs(urls)
if not slugs:
return ForgeFacts(), "none"
invalid = [f"{o}/{n}" for o, n in slugs if not (SLUG.match(o) and SLUG.match(n))]
if invalid:
return _unavailable(f"invalid GitHub slug {invalid[0]}")
merged, label = ForgeFacts(), "cached"
for owner, name in slugs:
path = _cache_path(owner, name)
facts = _cached(path)
if facts is None:
if token is None:
return _unavailable(reason)
facts = facts_with_token([(owner, name)], token)
if facts.unavailable:
return _unavailable(facts.unavailable)
try:
_store(path, facts)
label = "live" if label == "cached" else label
except OSError as exc:
label = f"live: cache not written ({exc})"
merged.merged_heads.update(facts.merged_heads)
merged.open_heads.update(facts.open_heads)
merged.protected |= facts.protected
return merged, label
def _missing(root: str, known: bool, scanned_at: str) -> dict:
return {
"path": root,
"remote": None,
"default": None,
"missing": True,
"known": known,
"forge": None,
"scanned_at": scanned_at,
"warnings": [],
"items": [],
}
def _git_dir(
root: str, registrations: gc.Registrations, worktree: str | None
) -> str | None:
if worktree is None:
return None
if worktree == root:
return os.path.dirname(registrations[0])
return gc._linked_meta(registrations, worktree)
def _flow(git_dir: str | None, item: dict, warnings: list[str]) -> dict | None:
found = flow.read_flow(git_dir) if git_dir else None
if found is None or found.source == "none":
return None
warnings += [
f"flow of {item['worktree']}: {warning}"
for warning in found.warnings + flow.mismatch(found, item["branch"])
]
return {"pull": found.pull, "push": found.push, "source": found.source}
def _scan(
root: str,
known: bool,
token: str | None,
reason: str | None,
containers: list[gc.Container] | None,
host_ns: bool,
) -> dict:
scanned_at = _iso(now())
entry = _missing(root, known, scanned_at)
if not os.path.exists(os.path.join(root, ".git")):
return entry
sources: list[str] = []
def source(urls: dict[str, str]) -> ForgeFacts:
facts, label = forge_facts(urls, token, reason)
sources.append(label)
return facts
entry["missing"] = False
try:
repo = gc.plan_repo(
root,
containers,
host_ns,
merged_into=[],
sizes=False,
min_age=None,
scope="all",
activity=True,
hooks={},
fetch=False,
forge_source=source,
)
registrations = gc._registrations(root)
except GitBaseError as exc:
entry["warnings"] = [f"scan failed: {str(exc).strip().splitlines()[0]}"]
return entry
owner = next((r for r in repo.remotes if repo.default.startswith(f"{r}/")), None)
items = [asdict(item) for item in repo.items]
for item in items:
git_dir = _git_dir(root, registrations, item["worktree"])
item["flow"] = _flow(git_dir, item, repo.warnings)
entry.update(
remote=repo.urls[repo.remotes[0]] if repo.remotes else None,
default=repo.default.removeprefix(f"{owner}/") if owner else repo.default,
forge=sources[0],
warnings=repo.warnings,
items=items,
)
return entry
def _owner_root(checkout: str) -> str | None:
try:
with open(os.path.join(checkout, ".git"), encoding="utf-8") as fh:
gitdir = fh.read().strip().removeprefix("gitdir: ")
except OSError:
return None
parent = os.path.dirname(os.path.realpath(os.path.join(checkout, gitdir)))
if os.path.basename(parent) != "worktrees":
return None
common = os.path.dirname(parent)
return os.path.dirname(common) if os.path.basename(common) == ".git" else common
def _shared_owners(worktrees_root: str) -> list[str]:
owners: set[str] = set()
base = worktrees_root.rstrip("/").count("/")
for path, dirs, files in os.walk(worktrees_root):
dirs.sort()
owner = _owner_root(path) if ".git" in files else None
if owner:
owners.add(owner)
if (
".git" in files
or ".git" in dirs
or path.count("/") - base >= WORKTREE_DEPTH
):
dirs.clear()
return sorted(owners)
def branch_tree(
repo_dirs: list[str], *, fmt: str, output: str | None, worktrees_root: str
) -> int:
"""
Print or write the branch and worktree state of every repository, without fetching.
Args:
repo_dirs: directories of the repositories pkgmgr knows.
fmt: json or yaml.
output: file to write atomically; None prints to stdout.
worktrees_root: shared worktree root whose unknown owners are added.
Returns:
0 on success, 1 when the output could not be written.
"""
known = list(dict.fromkeys(os.path.realpath(d) for d in repo_dirs))
orphans = [d for d in _shared_owners(worktrees_root) if d not in known]
token, reason = secret_service_token()
host_ns = gc.host_pid_namespace()
containers = gc.list_compose_containers()
data = {
"generated_at": _iso(now()),
"host_pid_namespace": host_ns,
"delete_ready": token is not None,
"delete_reason": reason,
"repos": [
_scan(root, root in known, token, reason, containers, host_ns)
for root in known + orphans
],
}
text = (
yaml.safe_dump(data, sort_keys=False)
if fmt == "yaml"
else json.dumps(data, indent=2) + "\n"
)
if output is None:
sys.stdout.write(text)
return 0
try:
_write(output, text)
except OSError as exc:
print(f"Cannot write {output}: {exc}", file=sys.stderr)
return 1
return 0
def refresh_tree_entry(repo_root: str) -> None:
"""
Rescan one repository into the tree file, if the file exists and holds it.
Args:
repo_root: main worktree root of the repository.
Skipped outside the host PID namespace and when the file's entry is newer.
"""
path = tree_file()
if not os.path.isfile(path) or not gc.host_pid_namespace():
return
root = os.path.realpath(repo_root)
token, reason = secret_service_token()
fresh = _scan(root, True, token, reason, gc.list_compose_containers(), True)
with _locked(path):
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
index = next(
(i for i, entry in enumerate(data["repos"]) if entry["path"] == root), None
)
if index is None or data["repos"][index]["scanned_at"] > fresh["scanned_at"]:
return
fresh["known"] = data["repos"][index]["known"]
data["repos"][index] = fresh
_replace(path, json.dumps(data, indent=2) + "\n")
+8 -3
View File
@@ -1,19 +1,24 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
Helpers to generate changelog information from Git history. Helpers to generate changelog information from Git history.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Optional
from pkgmgr.core.git.queries import ( from pkgmgr.core.git.queries import (
GitChangelogQueryError,
get_changelog, get_changelog,
GitChangelogQueryError,
) )
def generate_changelog( def generate_changelog(
cwd: str, cwd: str,
from_ref: str | None = None, from_ref: Optional[str] = None,
to_ref: str | None = None, to_ref: Optional[str] = None,
include_merges: bool = False, include_merges: bool = False,
) -> str: ) -> str:
""" """
@@ -1,161 +0,0 @@
"""Download GitHub code scanning results via the ``gh`` CLI.
Fetches all code scanning alerts (and the analysis metadata) for a
repository and writes them, plus a readable digest, into a timestamped
directory so they can be read and analysed offline. Authentication is
delegated to ``gh`` (credentials from its keyring/login).
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
class CodeScanningError(RuntimeError):
"""Raised when code scanning results cannot be downloaded."""
@dataclass
class CodeScanningResult:
repo: str
output_dir: str
alert_count: int
files: list[str] = field(default_factory=list)
def _gh(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(["gh", *args], capture_output=True, text=True, check=False)
def _resolve_repo(repo: str | None) -> str:
if repo:
return repo
proc = _gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
name = proc.stdout.strip()
if proc.returncode != 0 or not name:
raise CodeScanningError(
"could not resolve the current repository; run inside a GitHub "
"repo or pass --repo OWNER/REPO "
f"({proc.stderr.strip()})"
)
return name
def _fetch_json(endpoint: str, params: list[str] | None = None) -> Any:
args = ["api", endpoint, "--paginate"]
for param in params or []:
args += ["-f", param]
proc = _gh(args)
if proc.returncode != 0:
raise CodeScanningError(
f"gh api {endpoint} failed: {proc.stderr.strip() or 'unknown error'}"
)
body = proc.stdout.strip()
return json.loads(body) if body else []
def _alert_row(alert: dict) -> str:
rule = alert.get("rule") or {}
instance = alert.get("most_recent_instance") or {}
location = instance.get("location") or {}
message = (instance.get("message") or {}).get("text", "").strip().replace("\n", " ")
severity = rule.get("security_severity_level") or rule.get("severity") or "unknown"
path = location.get("path", "?")
line = location.get("start_line", "?")
state = alert.get("state", "?")
rule_id = rule.get("id", "?")
return f"- [{severity}] {rule_id} — {path}:{line} ({state})\n {message}"
def _build_summary(repo: str, generated_at: str, alerts: list[dict]) -> str:
by_severity: Counter = Counter()
by_state: Counter = Counter()
by_rule: Counter = Counter()
for alert in alerts:
rule = alert.get("rule") or {}
by_severity[
rule.get("security_severity_level") or rule.get("severity") or "unknown"
] += 1
by_state[alert.get("state", "unknown")] += 1
by_rule[rule.get("id", "unknown")] += 1
lines = [
f"# Code scanning summary — {repo}",
"",
f"- Generated: {generated_at}",
f"- Total alerts: {len(alerts)}",
"",
"## By severity",
"",
]
lines += [f"- {sev}: {count}" for sev, count in by_severity.most_common()] or [
"- none"
]
lines += ["", "## By state", ""]
lines += [f"- {state}: {count}" for state, count in by_state.most_common()] or [
"- none"
]
lines += ["", "## By rule", ""]
lines += [f"- {rule}: {count}" for rule, count in by_rule.most_common()] or [
"- none"
]
lines += ["", "## Alerts", ""]
lines += [_alert_row(alert) for alert in alerts] or ["- none"]
return "\n".join(lines) + "\n"
def download_code_scanning(
repo: str | None = None,
output_dir: str | None = None,
state: str | None = None,
) -> CodeScanningResult:
if not shutil.which("gh"):
raise CodeScanningError("the GitHub CLI 'gh' is not installed or not on PATH")
repo = _resolve_repo(repo)
repo_name = repo.rstrip("/").split("/")[-1]
if output_dir is None:
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
output_dir = os.path.join("/tmp", repo_name, "code-scanner", timestamp)
output_dir = os.path.abspath(os.path.expanduser(output_dir))
os.makedirs(output_dir, exist_ok=True)
generated_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
alerts = _fetch_json(
f"repos/{repo}/code-scanning/alerts",
params=[f"state={state}"] if state else None,
)
files: list[str] = []
def _write(name: str, content: str) -> None:
path = os.path.join(output_dir, name)
with open(path, "w", encoding="utf-8") as handle:
handle.write(content)
files.append(path)
_write("alerts.json", json.dumps(alerts, indent=2, ensure_ascii=False) + "\n")
_write("summary.md", _build_summary(repo, generated_at, alerts))
try:
analyses = _fetch_json(f"repos/{repo}/code-scanning/analyses")
_write(
"analyses.json", json.dumps(analyses, indent=2, ensure_ascii=False) + "\n"
)
except CodeScanningError as exc:
_write("analyses.json", json.dumps({"error": str(exc)}, indent=2) + "\n")
return CodeScanningResult(
repo=repo,
output_dir=output_dir,
alert_count=len(alerts),
files=files,
)
+5 -10
View File
@@ -1,20 +1,15 @@
import os
import yaml import yaml
import os
from pkgmgr.core.config.save import save_user_config from pkgmgr.core.config.save import save_user_config
def interactive_add(config,USER_CONFIG_PATH:str):
def interactive_add(config, USER_CONFIG_PATH: str):
"""Interactively prompt the user to add a new repository entry to the user config.""" """Interactively prompt the user to add a new repository entry to the user config."""
print("Adding a new repository configuration entry.") print("Adding a new repository configuration entry.")
new_entry = {} new_entry = {}
new_entry["provider"] = input("Provider (e.g., github.com): ").strip() new_entry["provider"] = input("Provider (e.g., github.com): ").strip()
new_entry["account"] = input("Account (e.g., yourusername): ").strip() new_entry["account"] = input("Account (e.g., yourusername): ").strip()
new_entry["repository"] = input("Repository name (e.g., mytool): ").strip() new_entry["repository"] = input("Repository name (e.g., mytool): ").strip()
new_entry["command"] = input( new_entry["command"] = input("Command (optional, leave blank to auto-detect): ").strip()
"Command (optional, leave blank to auto-detect): "
).strip()
new_entry["description"] = input("Description (optional): ").strip() new_entry["description"] = input("Description (optional): ").strip()
new_entry["replacement"] = input("Replacement (optional): ").strip() new_entry["replacement"] = input("Replacement (optional): ").strip()
new_entry["alias"] = input("Alias (optional): ").strip() new_entry["alias"] = input("Alias (optional): ").strip()
@@ -30,12 +25,12 @@ def interactive_add(config, USER_CONFIG_PATH: str):
confirm = input("Add this entry to user config? (y/N): ").strip().lower() confirm = input("Add this entry to user config? (y/N): ").strip().lower()
if confirm == "y": if confirm == "y":
if os.path.exists(USER_CONFIG_PATH): if os.path.exists(USER_CONFIG_PATH):
with open(USER_CONFIG_PATH) as f: with open(USER_CONFIG_PATH, 'r') as f:
user_config = yaml.safe_load(f) or {} user_config = yaml.safe_load(f) or {}
else: else:
user_config = {"repositories": []} user_config = {"repositories": []}
user_config.setdefault("repositories", []) user_config.setdefault("repositories", [])
user_config["repositories"].append(new_entry) user_config["repositories"].append(new_entry)
save_user_config(user_config, USER_CONFIG_PATH) save_user_config(user_config,USER_CONFIG_PATH)
else: else:
print("Entry not added.") print("Entry not added.")
+12 -15
View File
@@ -1,3 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
Initialize user configuration by scanning the repositories base directory. Initialize user configuration by scanning the repositories base directory.
@@ -20,7 +23,7 @@ For each discovered repository, the function:
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any from typing import Any, Dict
from pkgmgr.core.command.alias import generate_alias from pkgmgr.core.command.alias import generate_alias
from pkgmgr.core.config.save import save_user_config from pkgmgr.core.config.save import save_user_config
@@ -28,8 +31,8 @@ from pkgmgr.core.git.queries import get_latest_commit
def config_init( def config_init(
user_config: dict[str, Any], user_config: Dict[str, Any],
defaults_config: dict[str, Any], defaults_config: Dict[str, Any],
bin_dir: str, bin_dir: str,
user_config_path: str, user_config_path: str,
) -> None: ) -> None:
@@ -52,7 +55,7 @@ def config_init(
print("[INIT] Scanning repository base directory:") print("[INIT] Scanning repository base directory:")
print(f" {repositories_base_dir}") print(f" {repositories_base_dir}")
print() print("")
if not os.path.isdir(repositories_base_dir): if not os.path.isdir(repositories_base_dir):
print(f"[ERROR] Base directory does not exist: {repositories_base_dir}") print(f"[ERROR] Base directory does not exist: {repositories_base_dir}")
@@ -104,15 +107,11 @@ def config_init(
# Already known? # Already known?
if key in default_keys: if key in default_keys:
skipped += 1 skipped += 1
print( print(f"[SKIP] (defaults) {provider}/{account}/{repo_name}")
f"[SKIP] (defaults) {provider}/{account}/{repo_name}"
)
continue continue
if key in existing_keys: if key in existing_keys:
skipped += 1 skipped += 1
print( print(f"[SKIP] (user-config) {provider}/{account}/{repo_name}")
f"[SKIP] (user-config) {provider}/{account}/{repo_name}"
)
continue continue
print(f"[ADD] {provider}/{account}/{repo_name}") print(f"[ADD] {provider}/{account}/{repo_name}")
@@ -122,11 +121,9 @@ def config_init(
if verified_commit: if verified_commit:
print(f"[INFO] Latest commit: {verified_commit}") print(f"[INFO] Latest commit: {verified_commit}")
else: else:
print( print("[WARN] Could not read commit (not a git repo or no commits).")
"[WARN] Could not read commit (not a git repo or no commits)."
)
entry: dict[str, Any] = { entry: Dict[str, Any] = {
"provider": provider, "provider": provider,
"account": account, "account": account,
"repository": repo_name, "repository": repo_name,
@@ -150,7 +147,7 @@ def config_init(
new_entries.append(entry) new_entries.append(entry)
print() # blank line between accounts print("") # blank line between accounts
# ------------------------------------------------------------ # ------------------------------------------------------------
# Summary # Summary
+1 -5
View File
@@ -1,8 +1,6 @@
import yaml import yaml
from pkgmgr.core.config.load import load_config from pkgmgr.core.config.load import load_config
def show_config(selected_repos, user_config_path, full_config=False): def show_config(selected_repos, user_config_path, full_config=False):
"""Display configuration for one or more repositories, or the entire merged config.""" """Display configuration for one or more repositories, or the entire merged config."""
if full_config: if full_config:
@@ -10,9 +8,7 @@ def show_config(selected_repos, user_config_path, full_config=False):
print(yaml.dump(merged, default_flow_style=False)) print(yaml.dump(merged, default_flow_style=False))
else: else:
for repo in selected_repos: for repo in selected_repos:
identifier = ( identifier = f'{repo.get("provider")}/{repo.get("account")}/{repo.get("repository")}'
f"{repo.get('provider')}/{repo.get('account')}/{repo.get('repository')}"
)
print(f"Repository: {identifier}") print(f"Repository: {identifier}")
for key, value in repo.items(): for key, value in repo.items():
print(f" {key}: {value}") print(f" {key}: {value}")
-4
View File
@@ -1,4 +0,0 @@
from .render import render_text
from .report import disk_report
__all__ = ["disk_report", "render_text"]
-54
View File
@@ -1,54 +0,0 @@
from __future__ import annotations
import os
from pkgmgr.actions.branch.gc import _inside
def _mount(path: str) -> str:
path = os.path.realpath(path)
while not os.path.ismount(path):
path = os.path.dirname(path)
return path
def balance(paths: list[str], items: list[tuple[str, int]]) -> list[dict]:
"""
Set what the report attributes against each filesystem's capacity.
Args:
paths: the roots and repository roots; each one's filesystem gets a row.
items: (path, allocated bytes) of every listed entry; an entry inside
another listed entry is counted only through the outer one.
Returns:
Per filesystem (grouped by st_dev): its mount point, total and free
bytes (as available to unprivileged users) and the attributed bytes.
"""
filesystems: dict[int, dict] = {}
for path in paths:
try:
dev = os.stat(path).st_dev
if dev in filesystems:
continue
vfs = os.statvfs(path)
except OSError:
continue
filesystems[dev] = {
"filesystem": _mount(path),
"total_bytes": vfs.f_blocks * vfs.f_frsize,
"free_bytes": vfs.f_bavail * vfs.f_frsize,
"attributed_bytes": 0,
}
kept: list[str] = []
for path, size in sorted((os.path.realpath(p), s) for p, s in items):
if any(_inside(path, outer) for outer in kept):
continue
kept.append(path)
try:
dev = os.lstat(path).st_dev
except OSError:
continue
if dev in filesystems:
filesystems[dev]["attributed_bytes"] += size
return sorted(filesystems.values(), key=lambda fs: fs["filesystem"])
@@ -1,58 +0,0 @@
from __future__ import annotations
import os
from .walk import BLOCK
ELF_MAGIC = b"\x7fELF"
ELF_HEADER = 18
ET_CORE = 4
BYTE_ORDER = {1: "little", 2: "big"}
def is_core_dump(path: str) -> bool:
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except OSError:
return False
try:
head = os.read(fd, ELF_HEADER)
except OSError:
return False
finally:
os.close(fd)
order = BYTE_ORDER.get(head[5]) if len(head) == ELF_HEADER else None
return (
head[:4] == ELF_MAGIC
and order is not None
and int.from_bytes(head[16:18], order) == ET_CORE
)
def core_dumps(candidates: list[str]) -> list[dict]:
"""
Keep the candidates whose ELF header says ET_CORE.
Args:
candidates: paths of regular files named like core dumps.
Returns:
One entry per dump with its allocated and apparent size and mtime,
biggest first.
"""
dumps: dict[str, dict] = {}
for path in candidates:
real = os.path.realpath(path)
if real in dumps or not is_core_dump(path):
continue
try:
st = os.lstat(path)
except OSError:
continue
dumps[real] = {
"path": path,
"size_bytes": st.st_blocks * BLOCK,
"apparent_bytes": st.st_size,
"mtime": st.st_mtime,
}
return sorted(dumps.values(), key=lambda d: (-d["size_bytes"], d["path"]))
@@ -1,50 +0,0 @@
from __future__ import annotations
import json
import subprocess
from pkgmgr.actions.branch.gc import _describe, _docker
DF_ARGS = ["system", "df", "--format", "{{json .}}"]
def _count(value: object) -> int | None:
text = str(value)
return int(text) if text.isdigit() else None
def docker_usage() -> tuple[list[dict] | None, str | None]:
"""
Read `docker system df` per type.
Returns:
The rows (type, total and active counts, size and reclaimable as docker
prints them) and None, or None and a warning when docker is missing,
fails or prints something else than JSON lines.
"""
try:
out = _docker(DF_ARGS)
except FileNotFoundError:
return None, "docker unavailable: not installed"
except subprocess.CalledProcessError as exc:
return None, f"docker unavailable: {_describe(exc)}"
rows: list[dict] = []
for line in out.splitlines():
if not line.strip():
continue
try:
data = json.loads(line)
except ValueError:
data = None
if not isinstance(data, dict):
return None, "docker system df: output is not JSON lines"
rows.append(
{
"type": data.get("Type"),
"total": _count(data.get("TotalCount")),
"active": _count(data.get("Active")),
"size": data.get("Size"),
"reclaimable": data.get("Reclaimable"),
}
)
return rows, None
-186
View File
@@ -1,186 +0,0 @@
from __future__ import annotations
import os
import stat
from pkgmgr.actions.branch.gc import _describe, _git, _has_submodules
from pkgmgr.core.git.errors import GitBaseError
from pkgmgr.core.git.queries import FILTER_CONFIG, local_config
from pkgmgr.core.git.run import run
REF_NAMESPACES = ("refs/heads", "refs/remotes", "refs/tags")
PEELED = "%(if)%(*objectname)%(then)%(*objectname)%(else)%(objectname)%(end)"
UNMERGED = "unmerged"
DELETED = "deleted"
DIRECTORY = "directory"
SPECIAL = "special"
UNHASHABLE = {
DIRECTORY: "untracked directory or gitlink",
SPECIAL: "not a regular file",
}
MAX_REASONS = 5
def _tips(repo: str) -> list[str]:
out = _git(["for-each-ref", f"--format={PEELED}", *REF_NAMESPACES], repo)
return sorted(set(out.split()))
def _status_entries(tree: str) -> list[tuple[str, str | None]]:
raw = _git(
[
"status",
"--porcelain=v2",
"-z",
"--untracked-files=all",
"--no-renames",
],
tree,
)
entries: list[tuple[str, str | None]] = []
for record in raw.split("\0"):
kind = record[:1]
if kind == "1":
parts = record.split(" ", 8)
entries.append((parts[8], parts[7]))
elif kind == "u":
entries.append((record.split(" ", 10)[10], UNMERGED))
elif kind == "?":
entries.append((record[2:], None))
return entries
def _hashes(tree: str, paths: list[str]) -> dict[str, str]:
hashes: dict[str, str] = {}
files: list[str] = []
for path in paths:
full = os.path.join(tree, path)
try:
mode = os.lstat(full).st_mode
except FileNotFoundError:
hashes[path] = DELETED
continue
if stat.S_ISREG(mode):
files.append(path)
elif stat.S_ISLNK(mode):
hashes[path] = run(
["hash-object", "-t", "blob", "--no-filters", "--stdin"],
cwd=tree,
scan=True,
input_text=os.readlink(full),
)
else:
hashes[path] = DIRECTORY if stat.S_ISDIR(mode) else SPECIAL
if files:
out = run(
["hash-object", "--no-filters", "--stdin-paths"],
cwd=tree,
scan=True,
input_text="".join(f"{path}\n" for path in files),
)
hashes.update(zip(files, out.split("\n")))
return hashes
def _absent(
repo: str, tips: list[str], wanted: list[tuple[str, set[str]]]
) -> list[str]:
query = "".join(f"{tip}:{path}\n" for path, _ in wanted for tip in tips)
if not query:
return [path for path, _ in wanted]
found = run(
["cat-file", "--batch-check=%(objectname)"],
cwd=repo,
scan=True,
input_text=query,
).split("\n")
width = len(tips)
return [
path
for index, (path, blobs) in enumerate(wanted)
if blobs - set(found[index * width : (index + 1) * width])
]
def _reachable(repo: str, head: str | None) -> bool:
if not head:
return False
return bool(
_git(["for-each-ref", "--count=1", f"--contains={head}", *REF_NAMESPACES], repo)
)
def _unpreserved(
repo: str, record: dict, tips: list[str]
) -> tuple[int | None, list[str]]:
tree = record["path"]
if _has_submodules(tree):
return None, ["submodules, whose config the scan cannot vet"]
unsafe = local_config(tree, FILTER_CONFIG)
if unsafe:
return None, [f"{', '.join(unsafe)} in the repository config"]
entries = _status_entries(tree)
reasons: list[str] = []
if entries and record["branch"] is None and not _reachable(repo, record["head"]):
reasons.append("detached HEAD reachable from no ref")
hashable: list[tuple[str, str | None]] = []
for path, index_blob in entries:
if "\n" in path:
reasons.append(f"{path!r}: path with a newline")
elif index_blob == UNMERGED:
reasons.append(f"{path}: unmerged")
else:
hashable.append((path, index_blob))
hashes = _hashes(tree, [path for path, _ in hashable])
wanted: list[tuple[str, set[str]]] = []
for path, index_blob in hashable:
current = hashes[path]
if current in UNHASHABLE:
reasons.append(f"{path}: {UNHASHABLE[current]}")
continue
blobs = {current} - {DELETED}
if index_blob and set(index_blob) != {"0"}:
blobs.add(index_blob)
if blobs:
wanted.append((path, blobs))
reasons += [f"{path}: content in no ref" for path in _absent(repo, tips, wanted)]
return len(entries), reasons
def preserved(repo: str, records: list[dict]) -> list[dict]:
"""
Decide for each dirty worktree whether all its work survives its deletion.
Args:
repo: main worktree root of the repository.
records: its worktree records from list_worktrees.
Returns:
One entry per dirty or unreadable worktree; `preserved` is true only
when every changed, staged and untracked file's content is a blob at
the same path of some branch, remote or tag tip, and a detached HEAD is
reachable from a ref. `reasons` lists what is not, capped.
"""
live = [r for r in records if not r["orphaned"] and not r["missing"]]
tips = _tips(repo) if live else []
results: list[dict] = []
for record in live:
try:
paths, reasons = _unpreserved(repo, record, tips)
except (GitBaseError, OSError, UnicodeError) as exc:
paths, reasons = None, [f"status failed: {_describe(exc)}"]
if paths == 0:
continue
extra = len(reasons) - MAX_REASONS
results.append(
{
"repo": repo,
"path": record["path"],
"branch": record["branch"],
"paths": paths,
"preserved": not reasons,
"reasons": reasons[:MAX_REASONS]
+ ([f"... {extra} more"] if extra > 0 else []),
}
)
return results
-132
View File
@@ -1,132 +0,0 @@
from __future__ import annotations
from pkgmgr.actions.branch.gc import human_size
UNITS = (("d", 86400), ("h", 3600), ("m", 60))
DOCKER_COLUMNS = ("type", "total", "active", "size", "reclaimable")
def _size(value: int | None) -> str:
return "-" if value is None else human_size(value)
def _age(now: float, stamp: float | None) -> str:
if not stamp:
return "-"
seconds = max(0.0, now - stamp)
for unit, length in UNITS:
if seconds >= length:
return f"{seconds / length:.1f}{unit}"
return f"{seconds:.0f}s"
def _section(title: str, headers: list[str], rows: list[list[str]]) -> list[str]:
if not rows:
return [f"== {title}", "(none)"]
widths = [max(len(cell) for cell in column) for column in zip(headers, *rows)]
return [f"== {title}"] + [
" ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip()
for row in (headers, *rows)
]
def _branch(tree: dict) -> str:
if tree["orphaned"]:
return "-"
return tree["branch"] or "(detached)"
def _state(tree: dict) -> str:
if tree["orphaned"]:
return "orphaned"
return "missing" if tree["missing"] else "linked"
def render_text(report: dict) -> str:
"""
Render a disk_report result as aligned text tables.
Args:
report: the dict disk_report returned.
Returns:
The tables, one section per report key, newline-joined.
"""
now = report["generated_at"]
lines = _section(
"worktrees",
["SIZE", "ACTIVE", "FOREIGN", "STATE", "BRANCH", "PATH"],
[
[
_size(t["size_bytes"]),
_age(now, t["last_activity"]),
str(t["foreign_entries"]),
_state(t),
_branch(t),
t["path"],
]
for t in report["worktrees"]
],
)
lines += _section(
"preserved (dirty worktrees)",
["PRESERVED", "PATHS", "BRANCH", "PATH", "REASONS"],
[
[
"yes" if p["preserved"] else "no",
"-" if p["paths"] is None else str(p["paths"]),
p["branch"] or "(detached)",
p["path"],
"; ".join(p["reasons"]),
]
for p in report["preserved"]
],
)
for root in report["roots"]:
lines += _section(
f"root {root['root']} (over {human_size(report['threshold_bytes'])})",
["SIZE", "AGE", "KIND", "PATH"],
[
[
_size(c["size_bytes"]),
_age(now, c["newest_mtime"]),
c["kind"],
c["path"],
]
for c in root["children"]
],
)
lines += _section(
"core dumps",
["SIZE", "APPARENT", "AGE", "PATH"],
[
[
_size(d["size_bytes"]),
_size(d["apparent_bytes"]),
_age(now, d["mtime"]),
d["path"],
]
for d in report["core_dumps"]
],
)
if report["docker"] is not None:
lines += _section(
"docker",
["TYPE", "TOTAL", "ACTIVE", "SIZE", "RECLAIMABLE"],
[[str(row[key]) for key in DOCKER_COLUMNS] for row in report["docker"]],
)
lines += _section(
"balance",
["FILESYSTEM", "TOTAL", "FREE", "ATTRIBUTED"],
[
[
fs["filesystem"],
_size(fs["total_bytes"]),
_size(fs["free_bytes"]),
_size(fs["attributed_bytes"]),
]
for fs in report["balance"]
],
)
lines += [f"WARN {warning}" for warning in report["warnings"]]
return "\n".join(lines)
-97
View File
@@ -1,97 +0,0 @@
from __future__ import annotations
import time
from pkgmgr.actions.branch.gc import _describe
from pkgmgr.core.git.errors import GitBaseError
from .balance import balance
from .core_dumps import core_dumps
from .docker_usage import docker_usage
from .preserved import preserved
from .roots import scan_root
from .worktrees import list_worktrees
def disk_report(
roots: list[str], repo_roots: list[str], threshold_bytes: int, docker: bool
) -> dict:
"""
Inventory disk usage read-only: it deletes and writes nothing.
Args:
roots: directories whose direct children are measured.
repo_roots: main worktree roots of the repositories whose linked
worktrees are listed (pkgmgr passes every managed repository).
threshold_bytes: list only root children larger than this.
docker: also read `docker system df`.
Returns:
A JSON-serialisable dict. Sizes are allocated bytes (st_blocks * 512,
sparse files and hard links counted as stored), measured without
following symlinks or leaving the filesystem a walk starts on; times
are epoch seconds.
generated_at: when the scan started.
threshold_bytes: as given.
worktrees: per linked worktree and orphaned `.claude/worktrees/agent-*`
dir: repo, path, branch, head, orphaned, missing, size_bytes,
last_activity (newest mtime inside or of its index/HEAD),
foreign_entries (not owned by the current uid, pruned at the first
per subtree), unreadable (directories that could not be listed).
preserved: per dirty worktree: repo, path, branch, paths (changed,
staged and untracked; None when status was not read), preserved,
reasons.
roots: per root: root, children over the threshold with path, kind
(worktree, git-repo, venv, node_modules, cache, core-dump, dir,
file), size_bytes, newest_mtime, unreadable; biggest first.
core_dumps: files named `core` or `core.<digits>` under the roots and
worktrees whose ELF e_type is ET_CORE: path, size_bytes,
apparent_bytes (st_size), mtime; biggest first.
docker: rows of type, total, active, size, reclaimable; None when not
asked for or unavailable.
balance: per filesystem of the roots and repository roots: filesystem
(mount point), total_bytes, free_bytes, attributed_bytes.
warnings: what could not be read.
"""
started = time.time()
warnings: list[str] = []
cores: list[str] = []
trees: list[dict] = []
kept: list[dict] = []
for repo in repo_roots:
try:
records = list_worktrees(repo, cores)
except (GitBaseError, OSError) as exc:
warnings.append(f"{repo}: worktrees not listed: {_describe(exc)}")
continue
trees += records
try:
kept += preserved(repo, records)
except (GitBaseError, OSError) as exc:
warnings.append(f"{repo}: preservation not checked: {_describe(exc)}")
scanned: list[dict] = []
for root in roots:
try:
scanned.append(scan_root(root, threshold_bytes, cores))
except OSError as exc:
warnings.append(f"{root}: not scanned: {_describe(exc)}")
dumps = core_dumps(cores)
usage = None
if docker:
usage, warning = docker_usage()
if warning:
warnings.append(warning)
items = [(t["path"], t["size_bytes"]) for t in trees if t["size_bytes"]]
items += [(c["path"], c["size_bytes"]) for s in scanned for c in s["children"]]
items += [(d["path"], d["size_bytes"]) for d in dumps]
return {
"generated_at": started,
"threshold_bytes": threshold_bytes,
"worktrees": trees,
"preserved": kept,
"roots": scanned,
"core_dumps": dumps,
"docker": usage,
"balance": balance([*roots, *repo_roots], items),
"warnings": warnings,
}
-77
View File
@@ -1,77 +0,0 @@
from __future__ import annotations
import os
import stat
from .core_dumps import is_core_dump
from .walk import walk
CACHE_TAG = "CACHEDIR.TAG"
NODE_MODULES = "node_modules"
def _has(path: str, name: str, check=os.path.isfile) -> bool:
return check(os.path.join(path, name))
def _kind(path: str, st: os.stat_result) -> str:
name = os.path.basename(path)
if not stat.S_ISDIR(st.st_mode):
regular = stat.S_ISREG(st.st_mode)
return "core-dump" if regular and is_core_dump(path) else "file"
if name == NODE_MODULES:
return "node_modules"
if _has(path, ".git"):
return "worktree"
if _has(path, ".git", os.path.isdir) or (
_has(path, "HEAD") and _has(path, "objects", os.path.isdir)
):
return "git-repo"
if _has(path, "pyvenv.cfg"):
return "venv"
if _has(path, CACHE_TAG) or name.lower().endswith("cache"):
return "cache"
return "dir"
def scan_root(root: str, threshold_bytes: int, cores: list[str]) -> dict:
"""
Measure the direct children of one root on the root's own filesystem.
Args:
root: the directory to inventory.
threshold_bytes: keep only children whose allocated size is above it.
cores: receives the core dump candidates found while measuring.
Returns:
The root with its children over the threshold: path, kind, allocated
size, newest mtime inside and unlistable directories, biggest first.
Raises:
OSError: when the root itself cannot be listed.
"""
dev = os.stat(root).st_dev
children: list[dict] = []
with os.scandir(root) as entries:
listing = sorted(entries, key=lambda entry: entry.name)
for entry in listing:
try:
st = entry.stat(follow_symlinks=False)
except OSError:
continue
if st.st_dev != dev:
continue
stats = walk(entry.path, dev)
cores.extend(stats.cores)
if stats.size_bytes > threshold_bytes:
children.append(
{
"path": entry.path,
"kind": _kind(entry.path, st),
"size_bytes": stats.size_bytes,
"newest_mtime": stats.newest_mtime,
"unreadable": stats.unreadable,
}
)
children.sort(key=lambda child: (-child["size_bytes"], child["path"]))
return {"root": root, "children": children}
-86
View File
@@ -1,86 +0,0 @@
from __future__ import annotations
import os
import re
import stat
from dataclasses import dataclass, field
BLOCK = 512
CORE_NAME = re.compile(r"core(\.\d+)?")
@dataclass
class TreeStats:
size_bytes: int = 0
newest_mtime: float = 0.0
foreign_entries: int = 0
unreadable: int = 0
cores: list[str] = field(default_factory=list)
def is_foreign(path: str, st: os.stat_result) -> bool:
return st.st_uid != os.getuid()
def _account(
stats: TreeStats, path: str, st: os.stat_result, seen: set[tuple[int, int]]
) -> None:
if st.st_nlink > 1 and not stat.S_ISDIR(st.st_mode):
if (st.st_dev, st.st_ino) in seen:
return
seen.add((st.st_dev, st.st_ino))
stats.size_bytes += st.st_blocks * BLOCK
stats.newest_mtime = max(stats.newest_mtime, st.st_mtime)
if stat.S_ISREG(st.st_mode) and CORE_NAME.fullmatch(os.path.basename(path)):
stats.cores.append(path)
def _children(path: str, stats: TreeStats) -> list[os.DirEntry]:
try:
with os.scandir(path) as entries:
return list(entries)
except OSError:
stats.unreadable += 1
return []
def walk(path: str, dev: int | None = None) -> TreeStats:
"""
Measure a tree like `du -x`: allocated blocks, symlinks never followed.
Args:
path: the tree root, measured itself as well.
dev: the filesystem (st_dev) to stay on; that of path when None.
Returns:
Allocated bytes (st_blocks * 512, each hard-linked inode once), the
newest mtime, entries not owned by the current uid (none counted below a
foreign one), directories that could not be listed, and the regular
files named `core` or `core.<digits>`.
"""
stats = TreeStats()
try:
top = os.lstat(path)
except OSError:
stats.unreadable += 1
return stats
dev = top.st_dev if dev is None else dev
if top.st_dev != dev:
return stats
seen: set[tuple[int, int]] = set()
stack: list[tuple[str, os.stat_result, bool]] = [(path, top, False)]
while stack:
current, st, pruned = stack.pop()
_account(stats, current, st, seen)
foreign = not pruned and is_foreign(current, st)
stats.foreign_entries += foreign
if not stat.S_ISDIR(st.st_mode):
continue
for entry in _children(current, stats):
try:
child = entry.stat(follow_symlinks=False)
except OSError:
continue
if child.st_dev == dev:
stack.append((entry.path, child, pruned or foreign))
return stats
-96
View File
@@ -1,96 +0,0 @@
from __future__ import annotations
import os
from pkgmgr.actions.branch.gc import _worktrees
from .walk import TreeStats, walk
AGENT_DIR = os.path.join(".claude", "worktrees")
AGENT_PREFIX = "agent-"
METADATA_FILES = ("index", "HEAD")
def _metadata_mtime(path: str) -> float:
try:
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
gitdir = fh.read().strip().removeprefix("gitdir: ")
except OSError:
return 0.0
meta = os.path.normpath(os.path.join(path, gitdir))
stamps = [0.0]
for name in METADATA_FILES:
try:
stamps.append(os.stat(os.path.join(meta, name)).st_mtime)
except OSError:
continue
return max(stamps)
def _record(
repo: str,
path: str,
branch: str | None,
head: str | None,
orphaned: bool,
cores: list[str],
) -> dict:
present = os.path.isdir(path)
stats = walk(path) if present else TreeStats()
cores.extend(stats.cores)
activity = max(stats.newest_mtime, _metadata_mtime(path)) if present else 0.0
return {
"repo": repo,
"path": path,
"branch": branch,
"head": head,
"orphaned": orphaned,
"missing": not present,
"size_bytes": stats.size_bytes if present else None,
"last_activity": activity or None,
"foreign_entries": stats.foreign_entries,
"unreadable": stats.unreadable,
}
def _agent_dirs(repo: str) -> list[str]:
base = os.path.join(repo, AGENT_DIR)
try:
names = sorted(os.listdir(base))
except OSError:
return []
return [
os.path.join(base, name)
for name in names
if name.startswith(AGENT_PREFIX)
and os.path.isdir(os.path.join(base, name))
and not os.path.islink(os.path.join(base, name))
]
def list_worktrees(repo: str, cores: list[str]) -> list[dict]:
"""
Inventory the linked worktrees of one repository, orphaned agent dirs included.
Args:
repo: main worktree root of the repository.
cores: receives the core dump candidates found while measuring.
Returns:
One record per linked worktree git lists and per `.claude/worktrees/agent-*`
directory it does not list (orphaned), sorted by path.
Raises:
GitBaseError: when `git worktree list` fails.
"""
trees = [tree for tree in _worktrees(repo) if not tree.main]
listed = {os.path.realpath(tree.path) for tree in trees}
records = [
_record(repo, tree.path, tree.branch, tree.head, False, cores) for tree in trees
]
records += [
_record(repo, path, None, None, True, cores)
for path in _agent_dirs(repo)
if os.path.realpath(path) not in listed
]
return sorted(records, key=lambda record: record["path"])
+33 -32
View File
@@ -1,4 +1,6 @@
# src/pkgmgr/actions/install/__init__.py # src/pkgmgr/actions/install/__init__.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
High-level entry point for repository installation. High-level entry point for repository installation.
@@ -14,49 +16,48 @@ Responsibilities:
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any from typing import Any, Dict, List, Optional, Tuple
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.verify import verify_repository
from pkgmgr.actions.repository.clone import clone_repos
from pkgmgr.actions.install.context import RepoContext from pkgmgr.actions.install.context import RepoContext
from pkgmgr.actions.install.installers.makefile import (
MakefileInstaller,
)
from pkgmgr.actions.install.installers.nix import (
NixFlakeInstaller,
)
from pkgmgr.actions.install.installers.os_packages import ( from pkgmgr.actions.install.installers.os_packages import (
ArchPkgbuildInstaller, ArchPkgbuildInstaller,
DebianControlInstaller, DebianControlInstaller,
RpmSpecInstaller, RpmSpecInstaller,
) )
from pkgmgr.actions.install.installers.nix import (
NixFlakeInstaller,
)
from pkgmgr.actions.install.installers.python import PythonInstaller from pkgmgr.actions.install.installers.python import PythonInstaller
from pkgmgr.actions.install.installers.makefile import (
MakefileInstaller,
)
from pkgmgr.actions.install.pipeline import InstallationPipeline from pkgmgr.actions.install.pipeline import InstallationPipeline
from pkgmgr.actions.repository.clone import clone_repos
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.verify import verify_repository
Repository = dict[str, Any] Repository = Dict[str, Any]
INSTALLERS = [ INSTALLERS = [
MakefileInstaller(),
ArchPkgbuildInstaller(), ArchPkgbuildInstaller(),
DebianControlInstaller(), DebianControlInstaller(),
RpmSpecInstaller(), RpmSpecInstaller(),
NixFlakeInstaller(), NixFlakeInstaller(),
PythonInstaller(), PythonInstaller(),
MakefileInstaller(),
] ]
"""Preference order: the pipeline runs the first supported hook and stops."""
def _ensure_repo_dir( def _ensure_repo_dir(
repo: Repository, repo: Repository,
repositories_base_dir: str, repositories_base_dir: str,
all_repos: list[Repository], all_repos: List[Repository],
preview: bool, preview: bool,
no_verification: bool, no_verification: bool,
clone_mode: str, clone_mode: str,
identifier: str, identifier: str,
) -> str | None: ) -> Optional[str]:
""" """
Compute and, if necessary, clone the repository directory. Compute and, if necessary, clone the repository directory.
@@ -65,7 +66,10 @@ def _ensure_repo_dir(
repo_dir = get_repo_dir(repositories_base_dir, repo) repo_dir = get_repo_dir(repositories_base_dir, repo)
if not os.path.exists(repo_dir): if not os.path.exists(repo_dir):
print(f"Repository directory '{repo_dir}' does not exist. Cloning it now...") print(
f"Repository directory '{repo_dir}' does not exist. "
"Cloning it now..."
)
clone_repos( clone_repos(
[repo], [repo],
repositories_base_dir, repositories_base_dir,
@@ -75,7 +79,10 @@ def _ensure_repo_dir(
clone_mode, clone_mode,
) )
if not os.path.exists(repo_dir): if not os.path.exists(repo_dir):
print(f"Cloning failed for repository {identifier}. Skipping installation.") print(
f"Cloning failed for repository {identifier}. "
"Skipping installation."
)
return None return None
return repo_dir return repo_dir
@@ -108,9 +115,7 @@ def _verify_repo(
if silent: if silent:
# Non-interactive mode: continue with a warning. # Non-interactive mode: continue with a warning.
print( print(f"[Warning] Continuing despite verification failure for {identifier} (--silent).")
f"[Warning] Continuing despite verification failure for {identifier} (--silent)."
)
else: else:
choice = input("Continue anyway? [y/N]: ").strip().lower() choice = input("Continue anyway? [y/N]: ").strip().lower()
if choice != "y": if choice != "y":
@@ -126,7 +131,7 @@ def _create_context(
repo_dir: str, repo_dir: str,
repositories_base_dir: str, repositories_base_dir: str,
bin_dir: str, bin_dir: str,
all_repos: list[Repository], all_repos: List[Repository],
no_verification: bool, no_verification: bool,
preview: bool, preview: bool,
quiet: bool, quiet: bool,
@@ -154,10 +159,10 @@ def _create_context(
def install_repos( def install_repos(
selected_repos: list[Repository], selected_repos: List[Repository],
repositories_base_dir: str, repositories_base_dir: str,
bin_dir: str, bin_dir: str,
all_repos: list[Repository], all_repos: List[Repository],
no_verification: bool, no_verification: bool,
preview: bool, preview: bool,
quiet: bool, quiet: bool,
@@ -178,7 +183,7 @@ def install_repos(
overall command never exits non-zero because of per-repository failures. overall command never exits non-zero because of per-repository failures.
""" """
pipeline = InstallationPipeline(INSTALLERS) pipeline = InstallationPipeline(INSTALLERS)
failures: list[tuple[str, str]] = [] failures: List[Tuple[str, str]] = []
for repo in selected_repos: for repo in selected_repos:
identifier = get_repo_identifier(repo, all_repos) identifier = get_repo_identifier(repo, all_repos)
@@ -227,16 +232,12 @@ def install_repos(
code = exc.code if isinstance(exc.code, int) else str(exc.code) code = exc.code if isinstance(exc.code, int) else str(exc.code)
failures.append((identifier, f"installer failed (exit={code})")) failures.append((identifier, f"installer failed (exit={code})"))
if not quiet: if not quiet:
print( print(f"[Warning] install: repository {identifier} failed (exit={code}). Continuing...")
f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..."
)
continue continue
except Exception as exc: # noqa: BLE001 - batch boundary: one repository must never abort the run except Exception as exc:
failures.append((identifier, f"unexpected error: {exc}")) failures.append((identifier, f"unexpected error: {exc}"))
if not quiet: if not quiet:
print( print(f"[Warning] install: repository {identifier} hit an unexpected error: {exc}. Continuing...")
f"[Warning] install: repository {identifier} hit an unexpected error: {exc}. Continuing..."
)
continue continue
if failures and emit_summary and not quiet: if failures and emit_summary and not quiet:
+20 -15
View File
@@ -1,3 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
Capability detection for pkgmgr. Capability detection for pkgmgr.
@@ -32,8 +35,7 @@ from __future__ import annotations
import glob import glob
import os import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Iterable from typing import Iterable, TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from pkgmgr.actions.install.context import RepoContext from pkgmgr.actions.install.context import RepoContext
@@ -44,12 +46,12 @@ if TYPE_CHECKING:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _read_text_if_exists(path: str) -> str | None: def _read_text_if_exists(path: str) -> Optional[str]:
"""Read a file as UTF-8 text, returning None if it does not exist or fails.""" """Read a file as UTF-8 text, returning None if it does not exist or fails."""
if not os.path.exists(path): if not os.path.exists(path):
return None return None
try: try:
with open(path, encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
return f.read() return f.read()
except OSError: except OSError:
return None return None
@@ -73,12 +75,12 @@ def _scan_files_for_patterns(files: Iterable[str], patterns: Iterable[str]) -> b
return False return False
def _first_spec_file(repo_dir: str) -> str | None: def _first_spec_file(repo_dir: str) -> Optional[str]:
"""Return the first *.spec file in repo_dir, if any.""" """Return the first *.spec file in repo_dir, if any."""
matches = glob.glob(os.path.join(repo_dir, "*.spec")) matches = glob.glob(os.path.join(repo_dir, "*.spec"))
if not matches: if not matches:
return None return None
return min(matches) return sorted(matches)[0]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -98,7 +100,7 @@ class CapabilityMatcher(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
def is_provided(self, ctx: RepoContext, layer: str) -> bool: def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
""" """
Return True if this capability is actually provided by the given layer Return True if this capability is actually provided by the given layer
for this repository. for this repository.
@@ -131,7 +133,7 @@ class PythonRuntimeCapability(CapabilityMatcher):
# OS packages may wrap Python builds, but must explicitly prove it # OS packages may wrap Python builds, but must explicitly prove it
return layer in {"python", "nix", "os-packages"} return layer in {"python", "nix", "os-packages"}
def is_provided(self, ctx: RepoContext, layer: str) -> bool: def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
repo_dir = ctx.repo_dir repo_dir = ctx.repo_dir
if layer == "python": if layer == "python":
@@ -206,7 +208,7 @@ class MakeInstallCapability(CapabilityMatcher):
def applies_to_layer(self, layer: str) -> bool: def applies_to_layer(self, layer: str) -> bool:
return layer in {"makefile", "python", "nix", "os-packages"} return layer in {"makefile", "python", "nix", "os-packages"}
def is_provided(self, ctx: RepoContext, layer: str) -> bool: def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
repo_dir = ctx.repo_dir repo_dir = ctx.repo_dir
if layer == "makefile": if layer == "makefile":
@@ -214,7 +216,7 @@ class MakeInstallCapability(CapabilityMatcher):
if not os.path.exists(makefile): if not os.path.exists(makefile):
return False return False
try: try:
with open(makefile, encoding="utf-8") as f: with open(makefile, "r", encoding="utf-8") as f:
for line in f: for line in f:
if line.strip().startswith("install:"): if line.strip().startswith("install:"):
return True return True
@@ -272,7 +274,7 @@ class NixFlakeCapability(CapabilityMatcher):
# Only Nix itself and OS packages that explicitly wrap Nix # Only Nix itself and OS packages that explicitly wrap Nix
return layer in {"nix", "os-packages"} return layer in {"nix", "os-packages"}
def is_provided(self, ctx: RepoContext, layer: str) -> bool: def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
repo_dir = ctx.repo_dir repo_dir = ctx.repo_dir
if layer == "nix": if layer == "nix":
@@ -326,7 +328,7 @@ LAYER_ORDER: list[str] = [
def detect_capabilities( def detect_capabilities(
ctx: RepoContext, ctx: "RepoContext",
layers: Iterable[str], layers: Iterable[str],
) -> dict[str, set[str]]: ) -> dict[str, set[str]]:
""" """
@@ -357,8 +359,8 @@ def detect_capabilities(
def resolve_effective_capabilities( def resolve_effective_capabilities(
ctx: RepoContext, ctx: "RepoContext",
layers: Iterable[str] | None = None, layers: Optional[Iterable[str]] = None,
) -> dict[str, set[str]]: ) -> dict[str, set[str]]:
""" """
Resolve *effective* capabilities for each layer using a bottom-up strategy. Resolve *effective* capabilities for each layer using a bottom-up strategy.
@@ -379,7 +381,10 @@ def resolve_effective_capabilities(
This means *any* higher layer can overshadow a lower layer, not just This means *any* higher layer can overshadow a lower layer, not just
a specific one like Nix. The resolver is completely generic. a specific one like Nix. The resolver is completely generic.
""" """
layers_list = list(LAYER_ORDER) if layers is None else list(layers) if layers is None:
layers_list = list(LAYER_ORDER)
else:
layers_list = list(layers)
raw_caps = detect_capabilities(ctx, layers_list) raw_caps = detect_capabilities(ctx, layers_list)
effective: dict[str, set[str]] = {layer: set() for layer in layers_list} effective: dict[str, set[str]] = {layer: set() for layer in layers_list}
+5 -3
View File
@@ -1,4 +1,6 @@
# src/pkgmgr/actions/install/context.py # src/pkgmgr/actions/install/context.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
Shared context object for repository installation steps. Shared context object for repository installation steps.
@@ -8,19 +10,19 @@ they do not depend on global state or long parameter lists.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any, Dict, List
@dataclass @dataclass
class RepoContext: class RepoContext:
"""Container for all repository-related data used during installation.""" """Container for all repository-related data used during installation."""
repo: dict[str, Any] repo: Dict[str, Any]
identifier: str identifier: str
repo_dir: str repo_dir: str
repositories_base_dir: str repositories_base_dir: str
bin_dir: str bin_dir: str
all_repos: list[dict[str, Any]] all_repos: List[Dict[str, Any]]
no_verification: bool no_verification: bool
preview: bool preview: bool
@@ -1,3 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
Installer package for pkgmgr. Installer package for pkgmgr.
@@ -6,17 +9,11 @@ pkgmgr.actions.install.installers.
""" """
from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401 from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401 from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
# OS-specific installers # OS-specific installers
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import ( from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401
ArchPkgbuildInstaller as ArchPkgbuildInstaller, from pkgmgr.actions.install.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401
) from pkgmgr.actions.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
from pkgmgr.actions.install.installers.os_packages.debian_control import (
DebianControlInstaller as DebianControlInstaller,
)
from pkgmgr.actions.install.installers.os_packages.rpm_spec import (
RpmSpecInstaller, # noqa: F401
)
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
@@ -1,12 +1,15 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
Base interface for all installer components in the pkgmgr installation pipeline. Base interface for all installer components in the pkgmgr installation pipeline.
""" """
from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Set, Optional
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
from pkgmgr.actions.install.context import RepoContext from pkgmgr.actions.install.context import RepoContext
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
class BaseInstaller(ABC): class BaseInstaller(ABC):
@@ -21,9 +24,9 @@ class BaseInstaller(ABC):
# Examples: "nix", "python", "makefile". # Examples: "nix", "python", "makefile".
# This is used by capability matchers to decide which patterns to # This is used by capability matchers to decide which patterns to
# search for in the repository. # search for in the repository.
layer: str | None = None layer: Optional[str] = None
def discover_capabilities(self, ctx: RepoContext) -> set[str]: def discover_capabilities(self, ctx: RepoContext) -> Set[str]:
""" """
Determine which logical capabilities this installer will provide Determine which logical capabilities this installer will provide
for this specific repository instance. for this specific repository instance.
@@ -33,14 +36,12 @@ class BaseInstaller(ABC):
Makefile, etc.) and decide, via string matching, whether a given Makefile, etc.) and decide, via string matching, whether a given
capability is actually provided by this layer. capability is actually provided by this layer.
""" """
caps: set[str] = set() caps: Set[str] = set()
if not self.layer: if not self.layer:
return caps return caps
for matcher in CAPABILITY_MATCHERS: for matcher in CAPABILITY_MATCHERS:
if matcher.applies_to_layer(self.layer) and matcher.is_provided( if matcher.applies_to_layer(self.layer) and matcher.is_provided(ctx, self.layer):
ctx, self.layer
):
caps.add(matcher.name) caps.add(matcher.name)
return caps return caps
@@ -16,9 +16,7 @@ class MakefileInstaller(BaseInstaller):
def supports(self, ctx: RepoContext) -> bool: def supports(self, ctx: RepoContext) -> bool:
if os.environ.get("PKGMGR_DISABLE_MAKEFILE_INSTALLER") == "1": if os.environ.get("PKGMGR_DISABLE_MAKEFILE_INSTALLER") == "1":
if not ctx.quiet: if not ctx.quiet:
print( print("[INFO] PKGMGR_DISABLE_MAKEFILE_INSTALLER=1 – skipping MakefileInstaller.")
"[INFO] PKGMGR_DISABLE_MAKEFILE_INSTALLER=1 – skipping MakefileInstaller."
)
return False return False
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME) makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
@@ -26,12 +24,16 @@ class MakefileInstaller(BaseInstaller):
def _has_install_target(self, makefile_path: str) -> bool: def _has_install_target(self, makefile_path: str) -> bool:
try: try:
with open(makefile_path, encoding="utf-8", errors="ignore") as f: with open(makefile_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read() content = f.read()
except OSError: except OSError:
return False return False
return bool(re.search(r"^install\s*:", content, flags=re.MULTILINE)) if re.search(r"^install\s*:", content, flags=re.MULTILINE):
return True
if re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE):
return True
return False
def run(self, ctx: RepoContext) -> None: def run(self, ctx: RepoContext) -> None:
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME) makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
@@ -44,9 +46,7 @@ class MakefileInstaller(BaseInstaller):
return return
if not ctx.quiet: if not ctx.quiet:
print( print(f"[pkgmgr] Running make install for {ctx.identifier} (MakefileInstaller)")
f"[pkgmgr] Running make install for {ctx.identifier} (MakefileInstaller)"
)
run_command("make install", cwd=ctx.repo_dir, preview=ctx.preview) run_command("make install", cwd=ctx.repo_dir, preview=ctx.preview)
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, List
from .profile import NixProfileInspector from .profile import NixProfileInspector
from .retry import GitHubRateLimitRetry from .retry import GitHubRateLimitRetry
@@ -33,7 +33,7 @@ class NixConflictResolver:
def resolve( def resolve(
self, self,
ctx: RepoContext, ctx: "RepoContext",
install_cmd: str, install_cmd: str,
stdout: str, stdout: str,
stderr: str, stderr: str,
@@ -49,7 +49,7 @@ class NixConflictResolver:
store_prefixes = self._parser.existing_store_prefixes(combined) store_prefixes = self._parser.existing_store_prefixes(combined)
# 2) Resolve them to concrete remove tokens # 2) Resolve them to concrete remove tokens
tokens: list[str] = self._profile.find_remove_tokens_for_store_prefixes( tokens: List[str] = self._profile.find_remove_tokens_for_store_prefixes(
ctx, ctx,
self._runner, self._runner,
store_prefixes, store_prefixes,
@@ -57,9 +57,7 @@ class NixConflictResolver:
# 3) Fallback: output-name based lookup (also covers nix suggesting: `nix profile remove pkgmgr`) # 3) Fallback: output-name based lookup (also covers nix suggesting: `nix profile remove pkgmgr`)
if not tokens: if not tokens:
tokens = self._profile.find_remove_tokens_for_output( tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output)
ctx, self._runner, output
)
if tokens: if tokens:
if not quiet: if not quiet:
@@ -96,9 +94,7 @@ class NixConflictResolver:
continue continue
if not quiet: if not quiet:
print( print("[nix] conflict detected but could not resolve profile entries to remove.")
"[nix] conflict detected but could not resolve profile entries to remove."
)
return False return False
return False return False
@@ -2,7 +2,7 @@ from __future__ import annotations
import os import os
import shutil import shutil
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, List, Tuple
from pkgmgr.actions.install.installers.base import BaseInstaller from pkgmgr.actions.install.installers.base import BaseInstaller
@@ -28,7 +28,7 @@ class NixFlakeInstaller(BaseInstaller):
# Newer nix rejects numeric indices; we learn this at runtime and cache the decision. # Newer nix rejects numeric indices; we learn this at runtime and cache the decision.
self._indices_supported: bool | None = None self._indices_supported: bool | None = None
def supports(self, ctx: RepoContext) -> bool: def supports(self, ctx: "RepoContext") -> bool:
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1": if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
if not ctx.quiet: if not ctx.quiet:
print( print(
@@ -42,13 +42,13 @@ class NixFlakeInstaller(BaseInstaller):
return os.path.exists(os.path.join(ctx.repo_dir, self.FLAKE_FILE)) return os.path.exists(os.path.join(ctx.repo_dir, self.FLAKE_FILE))
def _profile_outputs(self, ctx: RepoContext) -> list[tuple[str, bool]]: def _profile_outputs(self, ctx: "RepoContext") -> List[Tuple[str, bool]]:
# (output_name, allow_failure) # (output_name, allow_failure)
if ctx.identifier in {"pkgmgr", "package-manager"}: if ctx.identifier in {"pkgmgr", "package-manager"}:
return [("pkgmgr", False), ("default", True)] return [("pkgmgr", False), ("default", True)]
return [("default", False)] return [("default", False)]
def run(self, ctx: RepoContext) -> None: def run(self, ctx: "RepoContext") -> None:
if not self.supports(ctx): if not self.supports(ctx):
return return
@@ -68,16 +68,14 @@ class NixFlakeInstaller(BaseInstaller):
else: else:
self._install_only(ctx, output, allow_failure) self._install_only(ctx, output, allow_failure)
def _installable(self, ctx: RepoContext, output: str) -> str: def _installable(self, ctx: "RepoContext", output: str) -> str:
return f"{ctx.repo_dir}#{output}" return f"{ctx.repo_dir}#{output}"
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# Core install path # Core install path
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def _install_only( def _install_only(self, ctx: "RepoContext", output: str, allow_failure: bool) -> None:
self, ctx: RepoContext, output: str, allow_failure: bool
) -> None:
install_cmd = f"nix profile install {self._installable(ctx, output)}" install_cmd = f"nix profile install {self._installable(ctx, output)}"
if not ctx.quiet: if not ctx.quiet:
@@ -98,9 +96,7 @@ class NixFlakeInstaller(BaseInstaller):
output=output, output=output,
): ):
if not ctx.quiet: if not ctx.quiet:
print( print(f"[nix] output '{output}' successfully installed after conflict cleanup.")
f"[nix] output '{output}' successfully installed after conflict cleanup."
)
return return
if not ctx.quiet: if not ctx.quiet:
@@ -111,26 +107,20 @@ class NixFlakeInstaller(BaseInstaller):
# If indices are supported, try legacy index-upgrade path. # If indices are supported, try legacy index-upgrade path.
if self._indices_supported is not False: if self._indices_supported is not False:
indices = self._profile.find_installed_indices_for_output( indices = self._profile.find_installed_indices_for_output(ctx, self._runner, output)
ctx, self._runner, output
)
upgraded = False upgraded = False
for idx in indices: for idx in indices:
if self._upgrade_index(ctx, idx): if self._upgrade_index(ctx, idx):
upgraded = True upgraded = True
if not ctx.quiet: if not ctx.quiet:
print( print(f"[nix] output '{output}' successfully upgraded (index {idx}).")
f"[nix] output '{output}' successfully upgraded (index {idx})."
)
if upgraded: if upgraded:
return return
if indices and not ctx.quiet: if indices and not ctx.quiet:
print( print(f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'.")
f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'."
)
for idx in indices: for idx in indices:
self._remove_index(ctx, idx) self._remove_index(ctx, idx)
@@ -149,9 +139,7 @@ class NixFlakeInstaller(BaseInstaller):
print(f"[nix] output '{output}' successfully re-installed.") print(f"[nix] output '{output}' successfully re-installed.")
return return
print( print(f"[ERROR] Failed to install Nix flake output '{output}' (exit {final.returncode})")
f"[ERROR] Failed to install Nix flake output '{output}' (exit {final.returncode})"
)
if not allow_failure: if not allow_failure:
raise SystemExit(final.returncode) raise SystemExit(final.returncode)
@@ -161,9 +149,7 @@ class NixFlakeInstaller(BaseInstaller):
# force_update path # force_update path
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def _force_upgrade_output( def _force_upgrade_output(self, ctx: "RepoContext", output: str, allow_failure: bool) -> None:
self, ctx: RepoContext, output: str, allow_failure: bool
) -> None:
# Prefer token path if indices unsupported (new nix) # Prefer token path if indices unsupported (new nix)
if self._indices_supported is False: if self._indices_supported is False:
self._remove_tokens_for_output(ctx, output) self._remove_tokens_for_output(ctx, output)
@@ -172,18 +158,14 @@ class NixFlakeInstaller(BaseInstaller):
print(f"[nix] output '{output}' successfully upgraded.") print(f"[nix] output '{output}' successfully upgraded.")
return return
indices = self._profile.find_installed_indices_for_output( indices = self._profile.find_installed_indices_for_output(ctx, self._runner, output)
ctx, self._runner, output
)
upgraded_any = False upgraded_any = False
for idx in indices: for idx in indices:
if self._upgrade_index(ctx, idx): if self._upgrade_index(ctx, idx):
upgraded_any = True upgraded_any = True
if not ctx.quiet: if not ctx.quiet:
print( print(f"[nix] output '{output}' successfully upgraded (index {idx}).")
f"[nix] output '{output}' successfully upgraded (index {idx})."
)
if upgraded_any: if upgraded_any:
if not ctx.quiet: if not ctx.quiet:
@@ -191,9 +173,7 @@ class NixFlakeInstaller(BaseInstaller):
return return
if indices and not ctx.quiet: if indices and not ctx.quiet:
print( print(f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'.")
f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'."
)
for idx in indices: for idx in indices:
self._remove_index(ctx, idx) self._remove_index(ctx, idx)
@@ -215,7 +195,7 @@ class NixFlakeInstaller(BaseInstaller):
s = (stderr or "").lower() s = (stderr or "").lower()
return "no longer supports indices" in s or "does not support indices" in s return "no longer supports indices" in s or "does not support indices" in s
def _upgrade_index(self, ctx: RepoContext, idx: int) -> bool: def _upgrade_index(self, ctx: "RepoContext", idx: int) -> bool:
cmd = f"nix profile upgrade --refresh {idx}" cmd = f"nix profile upgrade --refresh {idx}"
res = self._runner.run(ctx, cmd, allow_failure=True) res = self._runner.run(ctx, cmd, allow_failure=True)
@@ -228,7 +208,7 @@ class NixFlakeInstaller(BaseInstaller):
return res.returncode == 0 return res.returncode == 0
def _remove_index(self, ctx: RepoContext, idx: int) -> None: def _remove_index(self, ctx: "RepoContext", idx: int) -> None:
res = self._runner.run(ctx, f"nix profile remove {idx}", allow_failure=True) res = self._runner.run(ctx, f"nix profile remove {idx}", allow_failure=True)
if self._stderr_says_indices_unsupported(getattr(res, "stderr", "")): if self._stderr_says_indices_unsupported(getattr(res, "stderr", "")):
@@ -237,15 +217,13 @@ class NixFlakeInstaller(BaseInstaller):
if self._indices_supported is None: if self._indices_supported is None:
self._indices_supported = True self._indices_supported = True
def _remove_tokens_for_output(self, ctx: RepoContext, output: str) -> None: def _remove_tokens_for_output(self, ctx: "RepoContext", output: str) -> None:
tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output) tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output)
if not tokens: if not tokens:
return return
if not ctx.quiet: if not ctx.quiet:
print( print(f"[nix] indices unsupported; removing by token(s): {', '.join(tokens)}")
f"[nix] indices unsupported; removing by token(s): {', '.join(tokens)}"
)
for t in tokens: for t in tokens:
self._runner.run(ctx, f"nix profile remove {t}", allow_failure=True) self._runner.run(ctx, f"nix profile remove {t}", allow_failure=True)
@@ -1,4 +1,4 @@
from .inspector import NixProfileInspector from .inspector import NixProfileInspector
from .models import NixProfileEntry from .models import NixProfileEntry
__all__ = ["NixProfileEntry", "NixProfileInspector"] __all__ = ["NixProfileInspector", "NixProfileEntry"]
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import Any, List, TYPE_CHECKING
from .matcher import ( from .matcher import (
entry_matches_output, entry_matches_output,
@@ -29,7 +29,7 @@ class NixProfileInspector:
- find_remove_tokens_for_store_prefixes() - find_remove_tokens_for_store_prefixes()
""" """
def list_json(self, ctx: RepoContext, runner: CommandRunner) -> dict[str, Any]: def list_json(self, ctx: "RepoContext", runner: "CommandRunner") -> dict[str, Any]:
res = runner.run(ctx, "nix profile list --json", allow_failure=False) res = runner.run(ctx, "nix profile list --json", allow_failure=False)
raw = extract_stdout_text(res) raw = extract_stdout_text(res)
return parse_profile_list_json(raw) return parse_profile_list_json(raw)
@@ -40,14 +40,14 @@ class NixProfileInspector:
def find_installed_indices_for_output( def find_installed_indices_for_output(
self, self,
ctx: RepoContext, ctx: "RepoContext",
runner: CommandRunner, runner: "CommandRunner",
output: str, output: str,
) -> list[int]: ) -> List[int]:
data = self.list_json(ctx, runner) data = self.list_json(ctx, runner)
entries = normalize_elements(data) entries = normalize_elements(data)
hits: list[int] = [] hits: List[int] = []
for e in entries: for e in entries:
if e.index is None: if e.index is None:
continue continue
@@ -58,10 +58,10 @@ class NixProfileInspector:
def find_indices_by_store_path( def find_indices_by_store_path(
self, self,
ctx: RepoContext, ctx: "RepoContext",
runner: CommandRunner, runner: "CommandRunner",
store_path: str, store_path: str,
) -> list[int]: ) -> List[int]:
needle = (store_path or "").strip() needle = (store_path or "").strip()
if not needle: if not needle:
return [] return []
@@ -69,7 +69,7 @@ class NixProfileInspector:
data = self.list_json(ctx, runner) data = self.list_json(ctx, runner)
entries = normalize_elements(data) entries = normalize_elements(data)
hits: list[int] = [] hits: List[int] = []
for e in entries: for e in entries:
if e.index is None: if e.index is None:
continue continue
@@ -84,10 +84,10 @@ class NixProfileInspector:
def find_remove_tokens_for_output( def find_remove_tokens_for_output(
self, self,
ctx: RepoContext, ctx: "RepoContext",
runner: CommandRunner, runner: "CommandRunner",
output: str, output: str,
) -> list[str]: ) -> List[str]:
""" """
Returns profile remove tokens to remove entries matching a given output. Returns profile remove tokens to remove entries matching a given output.
@@ -101,9 +101,7 @@ class NixProfileInspector:
data = self.list_json(ctx, runner) data = self.list_json(ctx, runner)
entries = normalize_elements(data) entries = normalize_elements(data)
tokens: list[str] = [ tokens: List[str] = [out] # critical: matches nix's own suggestion for conflicts
out
] # critical: matches nix's own suggestion for conflicts
for e in entries: for e in entries:
if entry_matches_output(e, out): if entry_matches_output(e, out):
@@ -119,7 +117,7 @@ class NixProfileInspector:
# stable unique preserving order # stable unique preserving order
seen: set[str] = set() seen: set[str] = set()
uniq: list[str] = [] uniq: List[str] = []
for t in tokens: for t in tokens:
if t and t not in seen: if t and t not in seen:
uniq.append(t) uniq.append(t)
@@ -128,10 +126,10 @@ class NixProfileInspector:
def find_remove_tokens_for_store_prefixes( def find_remove_tokens_for_store_prefixes(
self, self,
ctx: RepoContext, ctx: "RepoContext",
runner: CommandRunner, runner: "CommandRunner",
prefixes: list[str], prefixes: List[str],
) -> list[str]: ) -> List[str]:
""" """
Returns remove tokens for entries whose store path matches any prefix. Returns remove tokens for entries whose store path matches any prefix.
""" """
@@ -143,7 +141,7 @@ class NixProfileInspector:
data = self.list_json(ctx, runner) data = self.list_json(ctx, runner)
entries = normalize_elements(data) entries = normalize_elements(data)
tokens: list[str] = [] tokens: List[str] = []
for e in entries: for e in entries:
if not e.store_paths: if not e.store_paths:
continue continue
@@ -156,7 +154,7 @@ class NixProfileInspector:
tokens.append(n) tokens.append(n)
seen: set[str] = set() seen: set[str] = set()
uniq: list[str] = [] uniq: List[str] = []
for t in tokens: for t in tokens:
if t and t not in seen: if t and t not in seen:
uniq.append(t) uniq.append(t)
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import List
from .models import NixProfileEntry from .models import NixProfileEntry
@@ -49,9 +51,9 @@ def entry_matches_store_path(entry: NixProfileEntry, store_path: str) -> bool:
return any((p or "") == needle for p in entry.store_paths) return any((p or "") == needle for p in entry.store_paths)
def stable_unique_ints(values: list[int]) -> list[int]: def stable_unique_ints(values: List[int]) -> List[int]:
seen: set[int] = set() seen: set[int] = set()
uniq: list[int] = [] uniq: List[int] = []
for v in values: for v in values:
if v in seen: if v in seen:
continue continue
Loaded 100 of 454 files, more files were not shown because too many files have changed in this diff. Show more