Compare commits
60 Commits
v1.9.0
...
f6228988e1
| Author | SHA1 | Date | |
|---|---|---|---|
| f6228988e1 | |||
| 5c7171acd9 | |||
| 06cc5b6725 | |||
| ece575cc73 | |||
| a4099717be | |||
| a37b9ed8a7 | |||
| a4a5b661b9 | |||
| 43fbcfb227 | |||
| a6c40451fe | |||
| 5fa2709a84 | |||
| 386d8aa2f2 | |||
| 70b06d2b3a | |||
| 00c668b595 | |||
| 12a38b7e6a | |||
| 37fd2192a5 | |||
| 607102e7f8 | |||
| 133cf63b9f | |||
| 6334936e8a | |||
| 946965f016 | |||
| 541a7f679f | |||
| 128f71745a | |||
| df2ce636c8 | |||
| 3b0dabf2a7 | |||
| 697370c906 | |||
| bc57172d92 | |||
| 0e7e23dce5 | |||
| 9d53f4c6f5 | |||
| a46d85b541 | |||
| acaea11eb6 | |||
| 056d21a859 | |||
| 612ba5069d | |||
| 551e245218 | |||
| 814523eac2 | |||
| 4f2c5013a7 | |||
| e01bb8c39a | |||
| 461a3c334d | |||
| e3de46c6a4 | |||
| b20882f492 | |||
| 430f21735e | |||
| acf1b69b70 | |||
| 7d574e67ec | |||
| aad6814fc5 | |||
| 411cd2df66 | |||
| 849d29c044 | |||
| 0947dea01e | |||
| 5d7e1fdbb3 | |||
| ac6981ad4d | |||
| f3a7b69bac | |||
| 5bcad7f5f3 | |||
| d39582d1da | |||
| 043d389a76 | |||
| cc1e543ebc | |||
| 25a0579809 | |||
| d4e461bb63 | |||
| 1864d0700e | |||
| a9bd8d202f | |||
| 28df54503e | |||
| aa489811e3 | |||
| f66af0157b | |||
| b0b3ccf5aa |
16
.claude/settings.json
Normal file
16
.claude/settings.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(*)"
|
||||||
|
],
|
||||||
|
"ask": [
|
||||||
|
"Skill(update-config)",
|
||||||
|
"Skill(update-config:*)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"enabled": true,
|
||||||
|
"failIfUnavailable": true,
|
||||||
|
"autoAllowBashIfSandboxed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
@@ -2,34 +2,72 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches:
|
||||||
- main
|
- '**'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: global-ci-${{ github.repository }}-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
security-codeql:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
security-events: write
|
||||||
|
uses: ./.github/workflows/security-codeql.yml
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
uses: ./.github/workflows/test-unit.yml
|
uses: ./.github/workflows/test-unit.yml
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
uses: ./.github/workflows/test-integration.yml
|
uses: ./.github/workflows/test-integration.yml
|
||||||
|
|
||||||
test-env-virtual:
|
test-env-virtual:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
uses: ./.github/workflows/test-env-virtual.yml
|
uses: ./.github/workflows/test-env-virtual.yml
|
||||||
|
|
||||||
test-env-nix:
|
test-env-nix:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
uses: ./.github/workflows/test-env-nix.yml
|
uses: ./.github/workflows/test-env-nix.yml
|
||||||
|
|
||||||
test-e2e:
|
test-e2e:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
uses: ./.github/workflows/test-e2e.yml
|
uses: ./.github/workflows/test-e2e.yml
|
||||||
|
|
||||||
test-virgin-user:
|
test-virgin-user:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
uses: ./.github/workflows/test-virgin-user.yml
|
uses: ./.github/workflows/test-virgin-user.yml
|
||||||
|
|
||||||
test-virgin-root:
|
test-virgin-root:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
uses: ./.github/workflows/test-virgin-root.yml
|
uses: ./.github/workflows/test-virgin-root.yml
|
||||||
|
|
||||||
lint-shell:
|
lint-shell:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
uses: ./.github/workflows/lint-shell.yml
|
uses: ./.github/workflows/lint-shell.yml
|
||||||
|
|
||||||
lint-python:
|
lint-python:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
uses: ./.github/workflows/lint-python.yml
|
uses: ./.github/workflows/lint-python.yml
|
||||||
|
|
||||||
|
lint-docker:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
uses: ./.github/workflows/lint-docker.yml
|
||||||
|
|||||||
40
.github/workflows/lint-docker.yml
vendored
Normal file
40
.github/workflows/lint-docker.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Docker Linter
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-docker:
|
||||||
|
name: Lint Dockerfile
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run hadolint (produce SARIF)
|
||||||
|
id: hadolint
|
||||||
|
continue-on-error: true
|
||||||
|
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
|
||||||
|
with:
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
format: sarif
|
||||||
|
output-file: hadolint-results.sarif
|
||||||
|
failure-threshold: warning
|
||||||
|
|
||||||
|
- name: Upload analysis results to GitHub
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
|
with:
|
||||||
|
sarif_file: hadolint-results.sarif
|
||||||
|
wait-for-processing: true
|
||||||
|
category: hadolint
|
||||||
|
|
||||||
|
- name: Fail if SARIF contains warnings or errors
|
||||||
|
if: always()
|
||||||
|
run: python3 src/pkgmgr/github/check_hadolint_sarif.py hadolint-results.sarif
|
||||||
3
.github/workflows/lint-python.yml
vendored
3
.github/workflows/lint-python.yml
vendored
@@ -3,6 +3,9 @@ name: Ruff (Python code sniffer)
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-python:
|
lint-python:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
3
.github/workflows/lint-shell.yml
vendored
3
.github/workflows/lint-shell.yml
vendored
@@ -3,6 +3,9 @@ name: ShellCheck
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-shell:
|
lint-shell:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
113
.github/workflows/mark-stable.yml
vendored
113
.github/workflows/mark-stable.yml
vendored
@@ -1,110 +1,39 @@
|
|||||||
name: Mark stable commit
|
name: Mark stable commit
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: mark-stable-${{ github.repository }}-main
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main # still run tests for main
|
|
||||||
tags:
|
tags:
|
||||||
- 'v*' # run tests for version tags (e.g. v0.9.1)
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-unit:
|
|
||||||
uses: ./.github/workflows/test-unit.yml
|
|
||||||
|
|
||||||
test-integration:
|
|
||||||
uses: ./.github/workflows/test-integration.yml
|
|
||||||
|
|
||||||
test-env-virtual:
|
|
||||||
uses: ./.github/workflows/test-env-virtual.yml
|
|
||||||
|
|
||||||
test-env-nix:
|
|
||||||
uses: ./.github/workflows/test-env-nix.yml
|
|
||||||
|
|
||||||
test-e2e:
|
|
||||||
uses: ./.github/workflows/test-e2e.yml
|
|
||||||
|
|
||||||
test-virgin-user:
|
|
||||||
uses: ./.github/workflows/test-virgin-user.yml
|
|
||||||
|
|
||||||
test-virgin-root:
|
|
||||||
uses: ./.github/workflows/test-virgin-root.yml
|
|
||||||
|
|
||||||
lint-shell:
|
|
||||||
uses: ./.github/workflows/lint-shell.yml
|
|
||||||
|
|
||||||
lint-python:
|
|
||||||
uses: ./.github/workflows/lint-python.yml
|
|
||||||
|
|
||||||
mark-stable:
|
mark-stable:
|
||||||
needs:
|
|
||||||
- lint-shell
|
|
||||||
- lint-python
|
|
||||||
- test-unit
|
|
||||||
- test-integration
|
|
||||||
- test-env-nix
|
|
||||||
- test-env-virtual
|
|
||||||
- test-e2e
|
|
||||||
- test-virgin-user
|
|
||||||
- test-virgin-root
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 330
|
||||||
# Only run this job if the push is for a version tag (v*)
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # Required to move/update the tag
|
actions: read
|
||||||
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true # We need all tags for version comparison
|
fetch-tags: true # We need tags and main history for version comparison
|
||||||
|
|
||||||
|
- name: Check whether tagged commit is on main
|
||||||
|
id: branch-check
|
||||||
|
run: bash scripts/github/common/check-tagged-commit-on-main.sh
|
||||||
|
|
||||||
|
- name: Wait for CI success on main for this commit
|
||||||
|
if: steps.branch-check.outputs.is_on_main == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: bash scripts/github/mark-stable/wait-for-main-ci-success.sh
|
||||||
|
|
||||||
- name: Move 'stable' tag only if this version is the highest
|
- name: Move 'stable' tag only if this version is the highest
|
||||||
run: |
|
if: steps.branch-check.outputs.is_on_main == 'true'
|
||||||
set -euo pipefail
|
run: bash scripts/github/mark-stable/mark-stable-if-highest-version.sh
|
||||||
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
echo "Ref: $GITHUB_REF"
|
|
||||||
echo "SHA: $GITHUB_SHA"
|
|
||||||
|
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
|
||||||
echo "Current version tag: ${VERSION}"
|
|
||||||
|
|
||||||
echo "Collecting all version tags..."
|
|
||||||
ALL_V_TAGS="$(git tag --list 'v*' || true)"
|
|
||||||
|
|
||||||
if [[ -z "${ALL_V_TAGS}" ]]; then
|
|
||||||
echo "No version tags found. Skipping stable update."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "All version tags:"
|
|
||||||
echo "${ALL_V_TAGS}"
|
|
||||||
|
|
||||||
# Determine highest version using natural version sorting
|
|
||||||
LATEST_TAG="$(printf '%s\n' ${ALL_V_TAGS} | sort -V | tail -n1)"
|
|
||||||
|
|
||||||
echo "Highest version tag: ${LATEST_TAG}"
|
|
||||||
|
|
||||||
if [[ "${VERSION}" != "${LATEST_TAG}" ]]; then
|
|
||||||
echo "Current version ${VERSION} is NOT the highest version."
|
|
||||||
echo "Stable tag will NOT be updated."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Current version ${VERSION} IS the highest version."
|
|
||||||
echo "Updating 'stable' tag..."
|
|
||||||
|
|
||||||
# Delete existing stable tag (local + remote)
|
|
||||||
git tag -d stable 2>/dev/null || true
|
|
||||||
git push origin :refs/tags/stable || true
|
|
||||||
|
|
||||||
# Create new stable tag
|
|
||||||
git tag stable "$GITHUB_SHA"
|
|
||||||
git push origin stable
|
|
||||||
|
|
||||||
echo "✅ Stable tag updated to ${VERSION}."
|
|
||||||
|
|||||||
51
.github/workflows/publish-containers.yml
vendored
51
.github/workflows/publish-containers.yml
vendored
@@ -21,44 +21,30 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Checkout workflow_run commit and refresh tags
|
- name: Checkout workflow_run commit and refresh tags
|
||||||
run: |
|
env:
|
||||||
set -euo pipefail
|
WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
git checkout -f "${{ github.event.workflow_run.head_sha }}"
|
run: bash scripts/github/publish-containers/checkout-workflow-run-commit.sh
|
||||||
git fetch --tags --force
|
|
||||||
git tag --list 'stable' 'v*' --sort=version:refname | tail -n 20
|
- name: Check whether tagged commit is on main
|
||||||
|
id: branch-check
|
||||||
|
env:
|
||||||
|
TARGET_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
run: bash scripts/github/common/check-tagged-commit-on-main.sh
|
||||||
|
|
||||||
- name: Compute version and stable flag
|
- name: Compute version and stable flag
|
||||||
id: info
|
id: info
|
||||||
run: |
|
if: steps.branch-check.outputs.is_on_main == 'true'
|
||||||
set -euo pipefail
|
run: bash scripts/github/publish-containers/compute-publish-container-info.sh
|
||||||
SHA="$(git rev-parse HEAD)"
|
|
||||||
|
|
||||||
V_TAG="$(git tag --points-at "${SHA}" --list 'v*' | sort -V | tail -n1)"
|
|
||||||
if [[ -z "${V_TAG}" ]]; then
|
|
||||||
echo "No version tag found for ${SHA}. Skipping publish."
|
|
||||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION="${V_TAG#v}"
|
|
||||||
|
|
||||||
STABLE_SHA="$(git rev-parse -q --verify refs/tags/stable^{commit} 2>/dev/null || true)"
|
|
||||||
IS_STABLE=false
|
|
||||||
[[ -n "${STABLE_SHA}" && "${STABLE_SHA}" == "${SHA}" ]] && IS_STABLE=true
|
|
||||||
|
|
||||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "is_stable=${IS_STABLE}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
|
||||||
with:
|
with:
|
||||||
use: true
|
use: true
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -66,9 +52,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish all images
|
- name: Publish all images
|
||||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||||
run: |
|
env:
|
||||||
set -euo pipefail
|
OWNER: ${{ github.repository_owner }}
|
||||||
OWNER="${{ github.repository_owner }}" \
|
VERSION: ${{ steps.info.outputs.version }}
|
||||||
VERSION="${{ steps.info.outputs.version }}" \
|
IS_STABLE: ${{ steps.info.outputs.is_stable }}
|
||||||
IS_STABLE="${{ steps.info.outputs.is_stable }}" \
|
run: bash scripts/github/publish-containers/publish-container-images.sh
|
||||||
bash scripts/build/publish.sh
|
|
||||||
|
|||||||
47
.github/workflows/security-codeql.yml
vendored
Normal file
47
.github/workflows/security-codeql.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: CodeQL Advanced
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Check security
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write
|
||||||
|
packages: read
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: actions
|
||||||
|
build-mode: none
|
||||||
|
- language: python
|
||||||
|
build-mode: none
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
- name: Run manual build steps
|
||||||
|
if: matrix.build-mode == 'manual'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||||
|
'languages you are analyzing, replace this with the commands to build' \
|
||||||
|
'your code.'
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
category: "/language:${{ matrix.language }}"
|
||||||
3
.github/workflows/test-e2e.yml
vendored
3
.github/workflows/test-e2e.yml
vendored
@@ -3,6 +3,9 @@ name: Test End-To-End
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-e2e:
|
test-e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
3
.github/workflows/test-env-nix.yml
vendored
3
.github/workflows/test-env-nix.yml
vendored
@@ -3,6 +3,9 @@ name: Test Virgin Nix (flake only)
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-env-nix:
|
test-env-nix:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
3
.github/workflows/test-env-virtual.yml
vendored
3
.github/workflows/test-env-virtual.yml
vendored
@@ -3,6 +3,9 @@ name: Test OS Containers
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-env-virtual:
|
test-env-virtual:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
3
.github/workflows/test-integration.yml
vendored
3
.github/workflows/test-integration.yml
vendored
@@ -3,6 +3,9 @@ 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
|
||||||
|
|||||||
3
.github/workflows/test-unit.yml
vendored
3
.github/workflows/test-unit.yml
vendored
@@ -3,6 +3,9 @@ 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
|
||||||
|
|||||||
3
.github/workflows/test-virgin-root.yml
vendored
3
.github/workflows/test-virgin-root.yml
vendored
@@ -3,6 +3,9 @@ name: Test Virgin Root
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-virgin-root:
|
test-virgin-root:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
3
.github/workflows/test-virgin-user.yml
vendored
3
.github/workflows/test-virgin-user.yml
vendored
@@ -3,6 +3,9 @@ name: Test Virgin User
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-virgin-user:
|
test-virgin-user:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,10 +24,9 @@ package-manager-*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Nix Cache to speed up tests
|
# Nix cache to speed up tests
|
||||||
.nix/
|
.nix/
|
||||||
.nix-dev-installed
|
.nix-dev-installed
|
||||||
flake.lock
|
|
||||||
|
|
||||||
# Ignore logs
|
# Ignore logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
187
CHANGELOG.md
187
CHANGELOG.md
@@ -1,3 +1,190 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [1.15.2] - 2026-05-28
|
||||||
|
|
||||||
|
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
|
||||||
|
|
||||||
|
## [1.15.1] - 2026-05-28
|
||||||
|
|
||||||
|
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
|
||||||
|
|
||||||
|
## [1.15.0] - 2026-05-28
|
||||||
|
|
||||||
|
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.14.0] - 2026-05-27
|
||||||
|
|
||||||
|
* Added
|
||||||
|
|
||||||
|
* New release --retry mode re-deploys the HEAD release without
|
||||||
|
re-tagging or modifying any files. It re-pushes the existing version
|
||||||
|
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||||
|
re-runs publish. Use this to recover from a release whose post-tag
|
||||||
|
push or PyPI upload failed mid-flight. The release_type argument
|
||||||
|
becomes optional under --retry.
|
||||||
|
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||||
|
helper so the workflow orchestrator stays focused on the forward
|
||||||
|
path.
|
||||||
|
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||||
|
debian_changelog under both packaging/debian and the legacy debian
|
||||||
|
layout.
|
||||||
|
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||||
|
the distro-name lookup chain and is unit-tested under
|
||||||
|
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||||
|
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||||
|
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||||
|
and branch-detection fallback.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
|
||||||
|
* pkgmgr release now derives the distro-package name from existing
|
||||||
|
packaging metadata instead of the repository folder name. The lookup
|
||||||
|
order is packaging/debian/control Package field, then
|
||||||
|
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||||
|
then folder basename as legacy fallback. Renaming a repository
|
||||||
|
folder no longer silently flips the debian/changelog top entry and
|
||||||
|
the RPM changelog stanza to a new identifier. Those keep matching
|
||||||
|
the authoritative value in the packaging files, which is what apt,
|
||||||
|
pacman, and dnf index against.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
|
||||||
|
* dpkg-source --before-build no longer fails with the message about
|
||||||
|
source package having two conflicting values after a repo-folder
|
||||||
|
rename, because the changelog and control file stay in agreement
|
||||||
|
on the next release.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.13.4] - 2026-05-27
|
||||||
|
|
||||||
|
* Changed
|
||||||
|
|
||||||
|
* pkgmgr release now derives the distro-package name from existing
|
||||||
|
packaging metadata instead of the repository folder name. The lookup
|
||||||
|
order is packaging/debian/control Package field, then
|
||||||
|
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||||
|
folder basename as legacy fallback. Renaming a repository folder (for
|
||||||
|
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||||
|
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||||
|
a new identifier. Those keep matching the authoritative Package,
|
||||||
|
pkgname, or Name value in the packaging files, which is what apt,
|
||||||
|
pacman, and dnf index against.
|
||||||
|
|
||||||
|
Added
|
||||||
|
|
||||||
|
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||||
|
debian_changelog under both packaging/debian (new layout) and debian
|
||||||
|
(legacy layout).
|
||||||
|
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||||
|
the priority chain and is unit-tested under
|
||||||
|
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
|
||||||
|
* dpkg-source --before-build no longer fails with the message about
|
||||||
|
source package having two conflicting values after a repo-folder
|
||||||
|
rename, because the changelog and control file stay in agreement.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.13.3] - 2026-03-26
|
||||||
|
|
||||||
|
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||||
|
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
|
||||||
|
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
|
||||||
|
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
|
||||||
|
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
|
||||||
|
|
||||||
|
|
||||||
|
## [1.13.2] - 2026-03-26
|
||||||
|
|
||||||
|
* Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.13.1] - 2026-03-20
|
||||||
|
|
||||||
|
* Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.13.0] - 2026-03-20
|
||||||
|
|
||||||
|
* Set CentOS docker image to latest
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.5] - 2026-02-24
|
||||||
|
|
||||||
|
* The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.4] - 2026-02-24
|
||||||
|
|
||||||
|
* The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.3] - 2026-02-24
|
||||||
|
|
||||||
|
* Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.2] - 2026-02-24
|
||||||
|
|
||||||
|
* Removed infinito-sphinx package
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.1] - 2026-02-14
|
||||||
|
|
||||||
|
* pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.0] - 2026-02-08
|
||||||
|
|
||||||
|
* Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.11.2] - 2026-02-08
|
||||||
|
|
||||||
|
* Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.11.1] - 2026-02-08
|
||||||
|
|
||||||
|
* Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-01-21
|
||||||
|
|
||||||
|
* Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.10.0] - 2026-01-20
|
||||||
|
|
||||||
|
* Introduce safe verbose image cleanup to reduce Docker image size and build artifacts
|
||||||
|
|
||||||
|
## [1.9.5] - 2026-01-16
|
||||||
|
|
||||||
|
* Release patch: improve git pull error diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
## [1.9.4] - 2026-01-13
|
||||||
|
|
||||||
|
* fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
|
||||||
|
|
||||||
|
|
||||||
|
## [1.9.3] - 2026-01-07
|
||||||
|
|
||||||
|
* Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.9.2] - 2025-12-21
|
||||||
|
|
||||||
|
* Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.9.1] - 2025-12-21
|
||||||
|
|
||||||
|
* Fixed installation issues and improved loading of default configuration files.
|
||||||
|
|
||||||
|
|
||||||
## [1.9.0] - 2025-12-20
|
## [1.9.0] - 2025-12-20
|
||||||
|
|
||||||
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
|
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -33,6 +33,7 @@ CMD ["bash"]
|
|||||||
# - inherits from virgin
|
# - inherits from virgin
|
||||||
# - builds + installs pkgmgr
|
# - builds + installs pkgmgr
|
||||||
# - sets entrypoint + default cmd
|
# - sets entrypoint + default cmd
|
||||||
|
# - NOTE: does NOT run slim.sh (that is done in slim stage)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
FROM virgin AS full
|
FROM virgin AS full
|
||||||
|
|
||||||
@@ -42,10 +43,10 @@ WORKDIR /build
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build and install distro-native package-manager package
|
# Build and install distro-native package-manager package
|
||||||
RUN set -euo pipefail; \
|
RUN set -eu; \
|
||||||
echo "Building and installing package-manager via make install..."; \
|
echo "Building and installing package-manager via make install..."; \
|
||||||
make install; \
|
make install; \
|
||||||
cd /; rm -rf /build
|
rm -rf /build
|
||||||
|
|
||||||
# Entry point
|
# Entry point
|
||||||
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
||||||
@@ -53,3 +54,14 @@ COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
|||||||
WORKDIR /opt/src/pkgmgr
|
WORKDIR /opt/src/pkgmgr
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
||||||
CMD ["pkgmgr", "--help"]
|
CMD ["pkgmgr", "--help"]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Target: slim
|
||||||
|
# - based on full
|
||||||
|
# - runs slim.sh
|
||||||
|
# ============================================================
|
||||||
|
FROM full AS slim
|
||||||
|
|
||||||
|
COPY scripts/docker/slim.sh /usr/local/bin/slim.sh
|
||||||
|
RUN chmod +x /usr/local/bin/slim.sh && /usr/local/bin/slim.sh
|
||||||
|
|||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1771714954,
|
||||||
|
"narHash": "sha256-nhZJPnBavtu40/L2aqpljrfUNb2rxmWTmSjK2c9UKds=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "afbbf774e2087c3d734266c22f96fca2e78d3620",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-25.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
11
flake.nix
11
flake.nix
@@ -6,7 +6,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs }:
|
outputs = { self, nixpkgs }:
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "1.9.0";
|
version = "1.15.2";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
@@ -40,6 +40,10 @@
|
|||||||
# Build using pyproject.toml
|
# Build using pyproject.toml
|
||||||
format = "pyproject";
|
format = "pyproject";
|
||||||
|
|
||||||
|
# Clear any stale wheels carried in from the source tree so
|
||||||
|
# pypaInstallPhase doesn't collide on bin/pkgmgr.
|
||||||
|
preBuild = "rm -rf dist";
|
||||||
|
|
||||||
# Build backend requirements from [build-system]
|
# Build backend requirements from [build-system]
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
pyPkgs.setuptools
|
pyPkgs.setuptools
|
||||||
@@ -51,6 +55,8 @@
|
|||||||
pyPkgs.pyyaml
|
pyPkgs.pyyaml
|
||||||
pyPkgs.jinja2
|
pyPkgs.jinja2
|
||||||
pyPkgs.pip
|
pyPkgs.pip
|
||||||
|
pkgs.git
|
||||||
|
pkgs.gnupg
|
||||||
];
|
];
|
||||||
|
|
||||||
doCheck = false;
|
doCheck = false;
|
||||||
@@ -87,6 +93,7 @@
|
|||||||
buildInputs = [
|
buildInputs = [
|
||||||
pythonWithDeps
|
pythonWithDeps
|
||||||
pkgs.git
|
pkgs.git
|
||||||
|
pkgs.gnupg
|
||||||
ansiblePkg
|
ansiblePkg
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||||
|
|
||||||
pkgname=package-manager
|
pkgname=package-manager
|
||||||
pkgver=1.9.0
|
pkgver=1.15.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||||
arch=('any')
|
arch=('any')
|
||||||
url="https://github.com/kevinveenbirkenbach/package-manager"
|
url="https://github.com/kevinveenbirkenbach/package-manager"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
|
|
||||||
# Nix is the only runtime dependency; Python is provided by the Nix closure.
|
# Nix is required at runtime to run pkgmgr via the flake.
|
||||||
depends=('nix')
|
# On Arch x86_64 we can depend on the distro package.
|
||||||
|
# On other arches (e.g. ARM) we only declare it as optional because the
|
||||||
|
# repo package may be broken/out-of-sync; installation can be done via the official installer.
|
||||||
|
depends=()
|
||||||
|
optdepends=('nix: required to run pkgmgr via flake')
|
||||||
|
|
||||||
|
if [[ "${CARCH}" == "x86_64" ]]; then
|
||||||
|
depends=('nix')
|
||||||
|
optdepends=()
|
||||||
|
fi
|
||||||
|
|
||||||
makedepends=('rsync')
|
makedepends=('rsync')
|
||||||
|
|
||||||
install=${pkgname}.install
|
install=${pkgname}.install
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
post_install() {
|
post_install() {
|
||||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
/usr/lib/package-manager/nix/init.sh
|
||||||
}
|
}
|
||||||
|
|
||||||
post_upgrade() {
|
post_upgrade() {
|
||||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
/usr/lib/package-manager/nix/init.sh
|
||||||
}
|
}
|
||||||
|
|
||||||
post_remove() {
|
post_remove() {
|
||||||
|
|||||||
@@ -1,3 +1,215 @@
|
|||||||
|
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
|
package-manager (1.9.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
|
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -e
|
|||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
configure)
|
configure)
|
||||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
/usr/lib/package-manager/nix/init.sh
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: package-manager
|
Name: package-manager
|
||||||
Version: 1.9.0
|
Version: 1.15.2
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ rm -rf \
|
|||||||
%{buildroot}/usr/lib/package-manager/.gitkeep || true
|
%{buildroot}/usr/lib/package-manager/.gitkeep || true
|
||||||
|
|
||||||
%post
|
%post
|
||||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
/usr/lib/package-manager/nix/init.sh
|
||||||
|
|
||||||
%postun
|
%postun
|
||||||
echo ">>> package-manager removed. Nix itself was not removed."
|
echo ">>> package-manager removed. Nix itself was not removed."
|
||||||
@@ -74,6 +74,146 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
|||||||
/usr/lib/package-manager/
|
/usr/lib/package-manager/
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.2-1
|
||||||
|
- Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
|
||||||
|
|
||||||
|
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.1-1
|
||||||
|
- Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
|
||||||
|
|
||||||
|
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.0-1
|
||||||
|
- Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
|
||||||
|
|
||||||
|
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.14.0-1
|
||||||
|
- Added
|
||||||
|
|
||||||
|
* New release --retry mode re-deploys the HEAD release without
|
||||||
|
re-tagging or modifying any files. It re-pushes the existing version
|
||||||
|
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||||
|
re-runs publish. Use this to recover from a release whose post-tag
|
||||||
|
push or PyPI upload failed mid-flight. The release_type argument
|
||||||
|
becomes optional under --retry.
|
||||||
|
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||||
|
helper so the workflow orchestrator stays focused on the forward
|
||||||
|
path.
|
||||||
|
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||||
|
debian_changelog under both packaging/debian and the legacy debian
|
||||||
|
layout.
|
||||||
|
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||||
|
the distro-name lookup chain and is unit-tested under
|
||||||
|
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||||
|
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||||
|
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||||
|
and branch-detection fallback.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
|
||||||
|
* pkgmgr release now derives the distro-package name from existing
|
||||||
|
packaging metadata instead of the repository folder name. The lookup
|
||||||
|
order is packaging/debian/control Package field, then
|
||||||
|
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||||
|
then folder basename as legacy fallback. Renaming a repository
|
||||||
|
folder no longer silently flips the debian/changelog top entry and
|
||||||
|
the RPM changelog stanza to a new identifier. Those keep matching
|
||||||
|
the authoritative value in the packaging files, which is what apt,
|
||||||
|
pacman, and dnf index against.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
|
||||||
|
* dpkg-source --before-build no longer fails with the message about
|
||||||
|
source package having two conflicting values after a repo-folder
|
||||||
|
rename, because the changelog and control file stay in agreement
|
||||||
|
on the next release.
|
||||||
|
|
||||||
|
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.4-1
|
||||||
|
- Changed
|
||||||
|
|
||||||
|
* pkgmgr release now derives the distro-package name from existing
|
||||||
|
packaging metadata instead of the repository folder name. The lookup
|
||||||
|
order is packaging/debian/control Package field, then
|
||||||
|
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||||
|
folder basename as legacy fallback. Renaming a repository folder (for
|
||||||
|
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||||
|
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||||
|
a new identifier. Those keep matching the authoritative Package,
|
||||||
|
pkgname, or Name value in the packaging files, which is what apt,
|
||||||
|
pacman, and dnf index against.
|
||||||
|
|
||||||
|
Added
|
||||||
|
|
||||||
|
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||||
|
debian_changelog under both packaging/debian (new layout) and debian
|
||||||
|
(legacy layout).
|
||||||
|
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||||
|
the priority chain and is unit-tested under
|
||||||
|
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
|
||||||
|
* dpkg-source --before-build no longer fails with the message about
|
||||||
|
source package having two conflicting values after a repo-folder
|
||||||
|
rename, because the changelog and control file stay in agreement.
|
||||||
|
|
||||||
|
* Thu Mar 26 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.3-1
|
||||||
|
- CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||||
|
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
|
||||||
|
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
|
||||||
|
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
|
||||||
|
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
|
||||||
|
|
||||||
|
* Thu Mar 26 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.2-1
|
||||||
|
- Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
|
||||||
|
|
||||||
|
* Fri Mar 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.1-1
|
||||||
|
- Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
|
||||||
|
|
||||||
|
* Fri Mar 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.0-1
|
||||||
|
- Set CentOS docker image to latest
|
||||||
|
|
||||||
|
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.5-1
|
||||||
|
- The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
|
||||||
|
|
||||||
|
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.4-1
|
||||||
|
- The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
|
||||||
|
|
||||||
|
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.3-1
|
||||||
|
- Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
|
||||||
|
|
||||||
|
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.2-1
|
||||||
|
- Removed infinito-sphinx package
|
||||||
|
|
||||||
|
* Sat Feb 14 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.1-1
|
||||||
|
- pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
|
||||||
|
|
||||||
|
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.0-1
|
||||||
|
- Adds explicit concurrency groups to the CI and mark-stable workflows to prevent overlapping runs on the same branch and make pipeline execution more predictable.
|
||||||
|
|
||||||
|
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.2-1
|
||||||
|
- Removes the v* tag trigger from the mark-stable workflow so it runs only on branch pushes and avoids duplicate executions during releases.
|
||||||
|
|
||||||
|
* Sun Feb 08 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.1-1
|
||||||
|
- Implements pushing the branch and the version tag together in a single command so the CI release workflow can reliably detect the version tag on HEAD.
|
||||||
|
|
||||||
|
* Wed Jan 21 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.11.0-1
|
||||||
|
- Adds a dedicated slim Docker image for pkgmgr and publishes slim variants for all supported distros.
|
||||||
|
|
||||||
|
* Tue Jan 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.10.0-1
|
||||||
|
- Automated release.
|
||||||
|
|
||||||
|
* Fri Jan 16 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.5-1
|
||||||
|
- Release patch: improve git pull error diagnostics
|
||||||
|
|
||||||
|
* Tue Jan 13 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.4-1
|
||||||
|
- fix(ci): replace sudo with su for user switching to avoid PAM failures in minimal container images
|
||||||
|
|
||||||
|
* Wed Jan 07 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.3-1
|
||||||
|
- Made the Nix dependency optional on non-x86_64 architectures to avoid broken Arch Linux ARM repository packages.
|
||||||
|
|
||||||
|
* Sun Dec 21 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.2-1
|
||||||
|
- Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
|
||||||
|
|
||||||
|
* Sun Dec 21 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.1-1
|
||||||
|
- Fixed installation issues and improved loading of default configuration files.
|
||||||
|
|
||||||
* Sat Dec 20 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.0-1
|
* 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 ***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.
|
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kpmx"
|
name = "kpmx"
|
||||||
version = "1.9.0"
|
version = "1.15.2"
|
||||||
description = "Kevin's package-manager tool (pkgmgr)"
|
description = "Kevin's package-manager tool (pkgmgr)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
@@ -43,11 +43,12 @@ pkgmgr = "pkgmgr.cli:main"
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Source layout: all packages live under "src/"
|
# Source layout: all packages live under "src/"
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = { "" = "src", "config" = "config" }
|
package-dir = { "" = "src" }
|
||||||
|
include-package-data = true
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src", "."]
|
where = ["src"]
|
||||||
include = ["pkgmgr*", "config*"]
|
include = ["pkgmgr*"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
"config" = ["defaults.yaml"]
|
"pkgmgr.config" = ["*.yml", "*.yaml"]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
|||||||
: "${BASE_IMAGE_DEBIAN:=debian:stable-slim}"
|
: "${BASE_IMAGE_DEBIAN:=debian:stable-slim}"
|
||||||
: "${BASE_IMAGE_UBUNTU:=ubuntu:latest}"
|
: "${BASE_IMAGE_UBUNTU:=ubuntu:latest}"
|
||||||
: "${BASE_IMAGE_FEDORA:=fedora:latest}"
|
: "${BASE_IMAGE_FEDORA:=fedora:latest}"
|
||||||
: "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:stream9}"
|
: "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:latest}"
|
||||||
|
|
||||||
resolve_base_image() {
|
resolve_base_image() {
|
||||||
local PKGMGR_DISTRO="$1"
|
local PKGMGR_DISTRO="$1"
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Usage: PKGMGR_DISTRO=<distro> $0 [options]
|
|||||||
Build options:
|
Build options:
|
||||||
--missing Build only if the image does not already exist (local build only)
|
--missing Build only if the image does not already exist (local build only)
|
||||||
--no-cache Build with --no-cache
|
--no-cache Build with --no-cache
|
||||||
--target <name> Build a specific Dockerfile target (e.g. virgin)
|
--target <name> Build a specific Dockerfile target (e.g. virgin, slim)
|
||||||
--tag <image> Override the output image tag (default: ${default_tag})
|
--tag <image> Override the output image tag (default: ${default_tag})
|
||||||
|
|
||||||
Publish options:
|
Publish options:
|
||||||
@@ -47,7 +47,7 @@ Publish options:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- --publish implies --push and requires --registry, --owner, and --version.
|
- --publish implies --push and requires --registry, --owner, and --version.
|
||||||
- Local build (no --push) uses "docker build" and creates local images like "pkgmgr-arch" / "pkgmgr-arch-virgin".
|
- Local build (no --push) uses "docker build" and creates local images like "pkgmgr-arch" / "pkgmgr-arch-virgin" / "pkgmgr-arch-slim".
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--missing) MISSING_ONLY=1; shift ;;
|
--missing) MISSING_ONLY=1; shift ;;
|
||||||
--target)
|
--target)
|
||||||
TARGET="${2:-}"
|
TARGET="${2:-}"
|
||||||
[[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin)"; exit 2; }
|
[[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin|slim)"; exit 2; }
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--tag)
|
--tag)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Publish all distro images (full + virgin) to a registry via image.sh --publish
|
# Publish all distro images (full + virgin + slim) to a registry via image.sh --publish
|
||||||
#
|
#
|
||||||
# Required env:
|
# Required env:
|
||||||
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
|
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
|
||||||
@@ -11,6 +11,9 @@ set -euo pipefail
|
|||||||
# REGISTRY (default: ghcr.io)
|
# REGISTRY (default: ghcr.io)
|
||||||
# IS_STABLE (default: false)
|
# IS_STABLE (default: false)
|
||||||
# DISTROS (default: "arch debian ubuntu fedora centos")
|
# DISTROS (default: "arch debian ubuntu fedora centos")
|
||||||
|
#
|
||||||
|
# Notes:
|
||||||
|
# - This expects Dockerfile targets: virgin, full (default), slim
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
@@ -33,7 +36,10 @@ for d in ${DISTROS}; do
|
|||||||
echo "[publish] PKGMGR_DISTRO=${d}"
|
echo "[publish] PKGMGR_DISTRO=${d}"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
# virgin
|
# virgin
|
||||||
|
# -> ghcr.io/<owner>/pkgmgr-<distro>-virgin:{latest,<version>,stable?}
|
||||||
|
# ----------------------------------------------------------
|
||||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||||
--publish \
|
--publish \
|
||||||
--registry "${REGISTRY}" \
|
--registry "${REGISTRY}" \
|
||||||
@@ -42,13 +48,29 @@ for d in ${DISTROS}; do
|
|||||||
--stable "${IS_STABLE}" \
|
--stable "${IS_STABLE}" \
|
||||||
--target virgin
|
--target virgin
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
# full (default target)
|
# full (default target)
|
||||||
|
# -> ghcr.io/<owner>/pkgmgr-<distro>:{latest,<version>,stable?}
|
||||||
|
# ----------------------------------------------------------
|
||||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||||
--publish \
|
--publish \
|
||||||
--registry "${REGISTRY}" \
|
--registry "${REGISTRY}" \
|
||||||
--owner "${OWNER}" \
|
--owner "${OWNER}" \
|
||||||
--version "${VERSION}" \
|
--version "${VERSION}" \
|
||||||
--stable "${IS_STABLE}"
|
--stable "${IS_STABLE}"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# slim
|
||||||
|
# -> ghcr.io/<owner>/pkgmgr-<distro>-slim:{latest,<version>,stable?}
|
||||||
|
# + alias for default distro: ghcr.io/<owner>/pkgmgr-slim:{...}
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||||
|
--publish \
|
||||||
|
--registry "${REGISTRY}" \
|
||||||
|
--owner "${OWNER}" \
|
||||||
|
--version "${VERSION}" \
|
||||||
|
--stable "${IS_STABLE}" \
|
||||||
|
--target slim
|
||||||
done
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
130
scripts/docker/slim.sh
Normal file
130
scripts/docker/slim.sh
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { echo "[cleanup] $*"; }
|
||||||
|
warn() { echo "[cleanup][WARN] $*" >&2; }
|
||||||
|
|
||||||
|
MODE="${MODE:-safe}" # safe | aggressive
|
||||||
|
# safe: caches/logs/tmp only
|
||||||
|
# aggressive: safe + docs/man/info (optional)
|
||||||
|
|
||||||
|
ID="unknown"
|
||||||
|
if [ -f /etc/os-release ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. /etc/os-release
|
||||||
|
ID="${ID:-unknown}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Starting image cleanup"
|
||||||
|
log "Mode: ${MODE}"
|
||||||
|
log "Detected OS: ${ID}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Package manager caches (SAFE)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
case "${ID}" in
|
||||||
|
alpine)
|
||||||
|
log "Cleaning apk cache"
|
||||||
|
if [ -d /var/cache/apk ]; then
|
||||||
|
du -sh /var/cache/apk || true
|
||||||
|
rm -rvf /var/cache/apk/* || true
|
||||||
|
else
|
||||||
|
log "apk cache directory not present (already clean)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
arch)
|
||||||
|
log "Cleaning pacman cache"
|
||||||
|
du -sh /var/cache/pacman/pkg 2>/dev/null || true
|
||||||
|
pacman -Scc --noconfirm || true
|
||||||
|
rm -rvf /var/cache/pacman/pkg/* || true
|
||||||
|
;;
|
||||||
|
debian|ubuntu)
|
||||||
|
log "Cleaning apt cache"
|
||||||
|
du -sh /var/lib/apt/lists 2>/dev/null || true
|
||||||
|
apt-get clean || true
|
||||||
|
rm -rvf /var/lib/apt/lists/* || true
|
||||||
|
;;
|
||||||
|
fedora)
|
||||||
|
log "Cleaning dnf cache"
|
||||||
|
du -sh /var/cache/dnf 2>/dev/null || true
|
||||||
|
dnf clean all || true
|
||||||
|
rm -rvf /var/cache/dnf/* || true
|
||||||
|
;;
|
||||||
|
centos|rhel)
|
||||||
|
log "Cleaning yum/dnf cache"
|
||||||
|
du -sh /var/cache/yum /var/cache/dnf 2>/dev/null || true
|
||||||
|
(command -v dnf >/dev/null 2>&1 && dnf clean all) || true
|
||||||
|
(command -v yum >/dev/null 2>&1 && yum clean all) || true
|
||||||
|
rm -rvf /var/cache/yum/* /var/cache/dnf/* || true
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warn "Unknown distro '${ID}' — skipping package manager cleanup"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Python caches (SAFE)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
log "Cleaning pip cache"
|
||||||
|
du -sh /root/.cache/pip 2>/dev/null || true
|
||||||
|
rm -rvf /root/.cache/pip 2>/dev/null || true
|
||||||
|
rm -rvf /home/*/.cache/pip 2>/dev/null || true
|
||||||
|
|
||||||
|
log "Cleaning __pycache__ directories"
|
||||||
|
find /opt /usr /root /home -type d -name "__pycache__" -print -prune 2>/dev/null || true
|
||||||
|
find /opt /usr /root /home -type d -name "__pycache__" -prune -exec rm -rvf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Logs (SAFE)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
log "Truncating log files (keeping paths intact)"
|
||||||
|
if [ -d /var/log ]; then
|
||||||
|
find /var/log -type f -name "*.log" -print 2>/dev/null || true
|
||||||
|
find /var/log -type f -name "*.log" -exec sh -lc ': > "$1" 2>/dev/null || true' _ {} \; 2>/dev/null || true
|
||||||
|
|
||||||
|
find /var/log -type f -name "*.out" -print 2>/dev/null || true
|
||||||
|
find /var/log -type f -name "*.out" -exec sh -lc ': > "$1" 2>/dev/null || true' _ {} \; 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v journalctl >/dev/null 2>&1; then
|
||||||
|
log "Vacuuming journald logs"
|
||||||
|
journalctl --disk-usage || true
|
||||||
|
journalctl --vacuum-size=10M || true
|
||||||
|
journalctl --vacuum-time=1s || true
|
||||||
|
journalctl --disk-usage || true
|
||||||
|
else
|
||||||
|
log "journald not present (skipping)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Temporary files (SAFE)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
log "Cleaning temporary directories"
|
||||||
|
if [ -d /tmp ]; then
|
||||||
|
du -sh /tmp 2>/dev/null || true
|
||||||
|
rm -rvf /tmp/* || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d /var/tmp ]; then
|
||||||
|
du -sh /var/tmp 2>/dev/null || true
|
||||||
|
rm -rvf /var/tmp/* || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Generic caches (SAFE)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
log "Cleaning generic caches"
|
||||||
|
du -sh /root/.cache 2>/dev/null || true
|
||||||
|
rm -rvf /root/.cache/* 2>/dev/null || true
|
||||||
|
rm -rvf /home/*/.cache/* 2>/dev/null || true
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Optional aggressive extras (still safe for runtime)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
if [[ "${MODE}" == "aggressive" ]]; then
|
||||||
|
log "Aggressive mode enabled: removing docs/man/info"
|
||||||
|
du -sh /usr/share/doc /usr/share/man /usr/share/info 2>/dev/null || true
|
||||||
|
rm -rvf /usr/share/doc/* /usr/share/man/* /usr/share/info/* 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Cleanup finished successfully"
|
||||||
14
scripts/github/common/check-tagged-commit-on-main.sh
Normal file
14
scripts/github/common/check-tagged-commit-on-main.sh
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TARGET_SHA="${TARGET_SHA:-${GITHUB_SHA:?GITHUB_SHA must be set}}"
|
||||||
|
|
||||||
|
git fetch --no-tags origin main
|
||||||
|
|
||||||
|
if git merge-base --is-ancestor "${TARGET_SHA}" "origin/main"; then
|
||||||
|
echo "is_on_main=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Target commit ${TARGET_SHA} is contained in origin/main."
|
||||||
|
else
|
||||||
|
echo "is_on_main=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Target commit ${TARGET_SHA} is not contained in origin/main. Skipping main-only action."
|
||||||
|
fi
|
||||||
43
scripts/github/mark-stable/mark-stable-if-highest-version.sh
Normal file
43
scripts/github/mark-stable/mark-stable-if-highest-version.sh
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
echo "Ref: $GITHUB_REF"
|
||||||
|
echo "SHA: $GITHUB_SHA"
|
||||||
|
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
|
echo "Current version tag: ${VERSION}"
|
||||||
|
|
||||||
|
echo "Collecting all version tags..."
|
||||||
|
ALL_V_TAGS="$(git tag --list 'v*' || true)"
|
||||||
|
|
||||||
|
if [[ -z "${ALL_V_TAGS}" ]]; then
|
||||||
|
echo "No version tags found. Skipping stable update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "All version tags:"
|
||||||
|
echo "${ALL_V_TAGS}"
|
||||||
|
|
||||||
|
LATEST_TAG="$(printf '%s\n' "${ALL_V_TAGS}" | sort -V | tail -n1)"
|
||||||
|
|
||||||
|
echo "Highest version tag: ${LATEST_TAG}"
|
||||||
|
|
||||||
|
if [[ "${VERSION}" != "${LATEST_TAG}" ]]; then
|
||||||
|
echo "Current version ${VERSION} is NOT the highest version."
|
||||||
|
echo "Stable tag will NOT be updated."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Current version ${VERSION} IS the highest version."
|
||||||
|
echo "Updating 'stable' tag..."
|
||||||
|
|
||||||
|
git tag -d stable 2>/dev/null || true
|
||||||
|
git push origin :refs/tags/stable || true
|
||||||
|
|
||||||
|
git tag stable "$GITHUB_SHA"
|
||||||
|
git push origin stable
|
||||||
|
|
||||||
|
echo "Stable tag updated to ${VERSION}."
|
||||||
43
scripts/github/mark-stable/wait-for-main-ci-success.sh
Normal file
43
scripts/github/mark-stable/wait-for-main-ci-success.sh
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SHA="${GITHUB_SHA}"
|
||||||
|
API_URL="https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${SHA}&event=push&per_page=20"
|
||||||
|
WAIT_INTERVAL_SECONDS=20
|
||||||
|
MAX_ATTEMPTS=990 # 5 hours 30 minutes max wait
|
||||||
|
|
||||||
|
STATUS=""
|
||||||
|
CONCLUSION=""
|
||||||
|
|
||||||
|
echo "Waiting for CI on main for ${SHA} (up to 5 hours 30 minutes)..."
|
||||||
|
for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do
|
||||||
|
RESPONSE="$(curl -fsSL \
|
||||||
|
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"${API_URL}")"
|
||||||
|
|
||||||
|
STATUS="$(printf '%s' "${RESPONSE}" | jq -r '.workflow_runs[] | select(.head_branch=="main") | .status' | head -n1)"
|
||||||
|
CONCLUSION="$(printf '%s' "${RESPONSE}" | jq -r '.workflow_runs[] | select(.head_branch=="main") | .conclusion' | head -n1)"
|
||||||
|
|
||||||
|
if [[ -n "${STATUS}" ]]; then
|
||||||
|
echo "CI status=${STATUS} conclusion=${CONCLUSION:-none} (attempt ${attempt}/${MAX_ATTEMPTS})"
|
||||||
|
else
|
||||||
|
echo "No CI run for main found yet (attempt ${attempt}/${MAX_ATTEMPTS})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${STATUS}" == "completed" ]]; then
|
||||||
|
if [[ "${CONCLUSION}" == "success" ]]; then
|
||||||
|
echo "CI succeeded for ${SHA}."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "CI failed for ${SHA} (conclusion=${CONCLUSION})."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep "${WAIT_INTERVAL_SECONDS}"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${STATUS}" != "completed" || "${CONCLUSION}" != "success" ]]; then
|
||||||
|
echo "Timed out waiting for successful CI on main for ${SHA}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WORKFLOW_RUN_SHA="${WORKFLOW_RUN_SHA:?WORKFLOW_RUN_SHA must be set}"
|
||||||
|
|
||||||
|
git checkout -f "${WORKFLOW_RUN_SHA}"
|
||||||
|
git fetch --tags --force
|
||||||
|
git tag --list 'stable' 'v*' --sort=version:refname | tail -n 20
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
V_TAG="$(git tag --points-at "${SHA}" --list 'v*' | sort -V | tail -n1)"
|
||||||
|
if [[ -z "${V_TAG}" ]]; then
|
||||||
|
echo "No version tag found for ${SHA}. Skipping publish."
|
||||||
|
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${V_TAG#v}"
|
||||||
|
|
||||||
|
STABLE_SHA="$(git rev-parse -q --verify 'refs/tags/stable^{commit}' 2>/dev/null || true)"
|
||||||
|
IS_STABLE=false
|
||||||
|
[[ -n "${STABLE_SHA}" && "${STABLE_SHA}" == "${SHA}" ]] && IS_STABLE=true
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "should_publish=true"
|
||||||
|
echo "version=${VERSION}"
|
||||||
|
echo "is_stable=${IS_STABLE}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${OWNER:?OWNER must be set}"
|
||||||
|
: "${VERSION:?VERSION must be set}"
|
||||||
|
: "${IS_STABLE:?IS_STABLE must be set}"
|
||||||
|
|
||||||
|
bash scripts/build/publish.sh
|
||||||
@@ -38,11 +38,7 @@ echo "[aur-builder-setup] Configuring sudoers for aur_builder..."
|
|||||||
${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder"
|
${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder"
|
||||||
${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder
|
${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder
|
||||||
|
|
||||||
if command -v sudo >/dev/null 2>&1; then
|
RUN_AS_AUR=(runuser -u aur_builder -- bash -c)
|
||||||
RUN_AS_AUR=(sudo -u aur_builder bash -lc)
|
|
||||||
else
|
|
||||||
RUN_AS_AUR=(su - aur_builder -c)
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..."
|
echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..."
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ fi
|
|||||||
pacman -S --noconfirm --needed \
|
pacman -S --noconfirm --needed \
|
||||||
base-devel \
|
base-devel \
|
||||||
git \
|
git \
|
||||||
|
gnupg \
|
||||||
rsync \
|
rsync \
|
||||||
curl \
|
curl \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ echo "[arch/package] Using 'aur_builder' user for makepkg..."
|
|||||||
chown -R aur_builder:aur_builder "${BUILD_ROOT}"
|
chown -R aur_builder:aur_builder "${BUILD_ROOT}"
|
||||||
|
|
||||||
echo "[arch/package] Running makepkg in: ${PKG_BUILD_DIR}"
|
echo "[arch/package] Running makepkg in: ${PKG_BUILD_DIR}"
|
||||||
su aur_builder -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
|
runuser -u aur_builder -- bash -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
|
||||||
|
|
||||||
echo "[arch/package] Installing generated Arch package..."
|
echo "[arch/package] Installing generated Arch package..."
|
||||||
pkg_path="$(find "${PKG_BUILD_DIR}" -maxdepth 1 -type f -name 'package-manager-*.pkg.tar.*' | head -n1)"
|
pkg_path="$(find "${PKG_BUILD_DIR}" -maxdepth 1 -type f -name 'package-manager-*.pkg.tar.*' | head -n1)"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ echo "[centos/dependencies] Installing CentOS build dependencies..."
|
|||||||
dnf -y update
|
dnf -y update
|
||||||
dnf -y install \
|
dnf -y install \
|
||||||
git \
|
git \
|
||||||
|
gnupg2 \
|
||||||
rsync \
|
rsync \
|
||||||
rpm-build \
|
rpm-build \
|
||||||
make \
|
make \
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|||||||
debhelper \
|
debhelper \
|
||||||
dpkg-dev \
|
dpkg-dev \
|
||||||
git \
|
git \
|
||||||
|
gnupg \
|
||||||
rsync \
|
rsync \
|
||||||
bash \
|
bash \
|
||||||
curl \
|
curl \
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ echo "[fedora/dependencies] Installing Fedora build dependencies..."
|
|||||||
dnf -y update
|
dnf -y update
|
||||||
dnf -y install \
|
dnf -y install \
|
||||||
git \
|
git \
|
||||||
|
gnupg2 \
|
||||||
rsync \
|
rsync \
|
||||||
rpm-build \
|
rpm-build \
|
||||||
make \
|
make \
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||||
echo "[installation/install] Warning: Installation is just possible via root."
|
echo "[installation/install] ERROR: Installation requires root. Re-run with sudo." >&2
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[installation] Running as root (EUID=0)."
|
echo "[installation] Running as root (EUID=0)."
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|||||||
debhelper \
|
debhelper \
|
||||||
dpkg-dev \
|
dpkg-dev \
|
||||||
git \
|
git \
|
||||||
|
gnupg \
|
||||||
tzdata \
|
tzdata \
|
||||||
lsb-release \
|
lsb-release \
|
||||||
rsync \
|
rsync \
|
||||||
|
|||||||
@@ -37,10 +37,16 @@ fi
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if ! command -v nix >/dev/null 2>&1; then
|
if ! command -v nix >/dev/null 2>&1; then
|
||||||
if [[ -x "${FLAKE_DIR}/nix/init.sh" ]]; then
|
if [[ -x "${FLAKE_DIR}/nix/init.sh" ]]; then
|
||||||
"${FLAKE_DIR}/nix/init.sh" || true
|
"${FLAKE_DIR}/nix/init.sh"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! command -v nix >/dev/null 2>&1; then
|
||||||
|
echo "[launcher] ERROR: 'nix' binary not found on PATH after init." >&2
|
||||||
|
echo "[launcher] Nix is required to run pkgmgr (no Python fallback)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Primary path: use Nix flake if available (with GitHub 403 retry)
|
# Primary path: use Nix flake if available (with GitHub 403 retry)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -51,7 +57,3 @@ if declare -F run_with_github_403_retry >/dev/null; then
|
|||||||
else
|
else
|
||||||
exec nix run "${FLAKE_DIR}#pkgmgr" -- "$@"
|
exec nix run "${FLAKE_DIR}#pkgmgr" -- "$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[launcher] ERROR: 'nix' binary not found on PATH after init."
|
|
||||||
echo "[launcher] Nix is required to run pkgmgr (no Python fallback)."
|
|
||||||
exit 1
|
|
||||||
|
|||||||
@@ -49,11 +49,7 @@ install_nix_with_retry() {
|
|||||||
if [[ -n "$run_as" ]]; then
|
if [[ -n "$run_as" ]]; then
|
||||||
chown "$run_as:$run_as" "$installer" 2>/dev/null || true
|
chown "$run_as:$run_as" "$installer" 2>/dev/null || true
|
||||||
echo "[init-nix] Running installer as user '$run_as' ($mode_flag)..."
|
echo "[init-nix] Running installer as user '$run_as' ($mode_flag)..."
|
||||||
if command -v sudo >/dev/null 2>&1; then
|
su - "$run_as" -s /bin/bash -c "bash -lc \"sh '$installer' $mode_flag\""
|
||||||
sudo -u "$run_as" bash -lc "sh '$installer' $mode_flag"
|
|
||||||
else
|
|
||||||
su - "$run_as" -c "sh '$installer' $mode_flag"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo "[init-nix] Running installer as current user ($mode_flag)..."
|
echo "[init-nix] Running installer as current user ($mode_flag)..."
|
||||||
sh "$installer" "$mode_flag"
|
sh "$installer" "$mode_flag"
|
||||||
|
|||||||
@@ -36,16 +36,17 @@ real_exe() {
|
|||||||
|
|
||||||
# Resolve nix binary path robustly (works across distros + Arch /usr/sbin)
|
# Resolve nix binary path robustly (works across distros + Arch /usr/sbin)
|
||||||
resolve_nix_bin() {
|
resolve_nix_bin() {
|
||||||
local nix_cmd=""
|
# IMPORTANT: prefer distro-managed locations first.
|
||||||
nix_cmd="$(command -v nix 2>/dev/null || true)"
|
# This avoids pinning /usr/local/bin/nix to a stale user-profile nix binary.
|
||||||
[[ -n "$nix_cmd" ]] && real_exe "$nix_cmd" && return 0
|
|
||||||
|
|
||||||
# IMPORTANT: prefer system locations before /usr/local to avoid self-symlink traps
|
|
||||||
[[ -x /usr/sbin/nix ]] && { echo "/usr/sbin/nix"; return 0; } # Arch package can land here
|
[[ -x /usr/sbin/nix ]] && { echo "/usr/sbin/nix"; return 0; } # Arch package can land here
|
||||||
[[ -x /usr/bin/nix ]] && { echo "/usr/bin/nix"; return 0; }
|
[[ -x /usr/bin/nix ]] && { echo "/usr/bin/nix"; return 0; }
|
||||||
[[ -x /bin/nix ]] && { echo "/bin/nix"; return 0; }
|
[[ -x /bin/nix ]] && { echo "/bin/nix"; return 0; }
|
||||||
|
|
||||||
# /usr/local last, and only if it resolves to a real executable
|
local nix_cmd=""
|
||||||
|
nix_cmd="$(command -v nix 2>/dev/null || true)"
|
||||||
|
[[ -n "$nix_cmd" ]] && real_exe "$nix_cmd" && return 0
|
||||||
|
|
||||||
|
# /usr/local after system locations, and only if it resolves to a real executable
|
||||||
[[ -e /usr/local/bin/nix ]] && real_exe "/usr/local/bin/nix" && return 0
|
[[ -e /usr/local/bin/nix ]] && real_exe "/usr/local/bin/nix" && return 0
|
||||||
|
|
||||||
[[ -x /nix/var/nix/profiles/default/bin/nix ]] && {
|
[[ -x /nix/var/nix/profiles/default/bin/nix ]] && {
|
||||||
|
|||||||
31
src/pkgmgr/actions/archive/__init__.py
Normal file
31
src/pkgmgr/actions/archive/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Archive fully-checked Markdown files into a README index, then delete them.
|
||||||
|
|
||||||
|
The archive action walks a directory for numbered ``NNN-topic.md`` files
|
||||||
|
(default pattern ``^\\d{3}-[^/]+\\.md$``), promotes every file with zero
|
||||||
|
unchecked ``- [ ]`` task-list markers into a ``## Archive`` index inside
|
||||||
|
the directory README, and deletes the per-file source. Useful for
|
||||||
|
keeping ``docs/requirements/`` (or any other task-tracked spec folder)
|
||||||
|
short and focused on open work.
|
||||||
|
|
||||||
|
The module was extracted from
|
||||||
|
``cli/contributing/requirements/archive`` in infinito-nexus-core so
|
||||||
|
every kpmx-managed repository can rely on the same archival convention
|
||||||
|
without copy-pasting the helpers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .discovery import iter_archivable_files
|
||||||
|
from .inspect import count_unchecked_items, extract_h1
|
||||||
|
from .readme import existing_archive_entries, merge_archive_section
|
||||||
|
from .workflow import ArchivePlan, run_archive
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ArchivePlan",
|
||||||
|
"count_unchecked_items",
|
||||||
|
"existing_archive_entries",
|
||||||
|
"extract_h1",
|
||||||
|
"iter_archivable_files",
|
||||||
|
"merge_archive_section",
|
||||||
|
"run_archive",
|
||||||
|
]
|
||||||
53
src/pkgmgr/actions/archive/discovery.py
Normal file
53
src/pkgmgr/actions/archive/discovery.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Locate archivable Markdown files under a target directory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
DEFAULT_FILENAME_PATTERN = re.compile(r"^\d{3}-[^/]+\.md$")
|
||||||
|
TEMPLATE_FILENAME = "000-template.md"
|
||||||
|
|
||||||
|
|
||||||
|
def iter_archivable_files(
|
||||||
|
directory: Path,
|
||||||
|
*,
|
||||||
|
include_template: bool = False,
|
||||||
|
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
|
||||||
|
template_filename: str = TEMPLATE_FILENAME,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Return all files in *directory* whose name matches *pattern*, sorted.
|
||||||
|
|
||||||
|
``000-template.md`` (or whatever *template_filename* matches) is
|
||||||
|
excluded unless *include_template* is true. The check is filename
|
||||||
|
based; nested directories are not traversed.
|
||||||
|
"""
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
files: list[Path] = []
|
||||||
|
for path in sorted(directory.iterdir()):
|
||||||
|
if not path.is_file() or not pattern.match(path.name):
|
||||||
|
continue
|
||||||
|
if not include_template and path.name == template_filename:
|
||||||
|
continue
|
||||||
|
files.append(path)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def filter_archivable_files(
|
||||||
|
paths: Iterable[Path],
|
||||||
|
*,
|
||||||
|
include_template: bool = False,
|
||||||
|
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
|
||||||
|
template_filename: str = TEMPLATE_FILENAME,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Same predicate as :func:`iter_archivable_files`, applied to an iterable."""
|
||||||
|
result: list[Path] = []
|
||||||
|
for path in paths:
|
||||||
|
if not path.is_file() or not pattern.match(path.name):
|
||||||
|
continue
|
||||||
|
if not include_template and path.name == template_filename:
|
||||||
|
continue
|
||||||
|
result.append(path)
|
||||||
|
return result
|
||||||
35
src/pkgmgr/actions/archive/inspect.py
Normal file
35
src/pkgmgr/actions/archive/inspect.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Parse a single Markdown file: H1 heading and task-list completeness."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
H1_RE = re.compile(r"^#\s+(?P<title>\S.*?)\s*$")
|
||||||
|
UNCHECKED_TASK_RE = re.compile(r"^\s*[-*+]\s+\[\s\]\s")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_h1(path: Path) -> str | None:
|
||||||
|
"""Return the first H1 title in *path* or ``None`` if there is none."""
|
||||||
|
try:
|
||||||
|
with path.open(encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
match = H1_RE.match(line.rstrip("\n"))
|
||||||
|
if match:
|
||||||
|
return match.group("title")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def count_unchecked_items(path: Path) -> int:
|
||||||
|
"""Return the number of ``- [ ]`` task-list markers anywhere in *path*.
|
||||||
|
|
||||||
|
A non-zero count means the file is not yet fully complete and MUST
|
||||||
|
NOT be archived.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with path.open(encoding="utf-8") as fh:
|
||||||
|
return sum(1 for line in fh if UNCHECKED_TASK_RE.match(line))
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
76
src/pkgmgr/actions/archive/readme.py
Normal file
76
src/pkgmgr/actions/archive/readme.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Read and update the ``## Archive`` section of a directory README."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
ARCHIVE_HEADING = "## Archive"
|
||||||
|
LIST_ITEM_RE = re.compile(r"^\s*-\s+(?P<body>\S.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def existing_archive_entries(readme_text: str) -> set[str]:
|
||||||
|
"""Return the deduplicated set of list-item bodies under ``## Archive``."""
|
||||||
|
lines = readme_text.splitlines()
|
||||||
|
in_archive = False
|
||||||
|
entries: set[str] = set()
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.rstrip()
|
||||||
|
if stripped == ARCHIVE_HEADING:
|
||||||
|
in_archive = True
|
||||||
|
continue
|
||||||
|
if in_archive and stripped.startswith("## "):
|
||||||
|
break
|
||||||
|
if not in_archive:
|
||||||
|
continue
|
||||||
|
match = LIST_ITEM_RE.match(stripped)
|
||||||
|
if match:
|
||||||
|
entries.add(match.group("body").strip())
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def merge_archive_section(readme_text: str, new_entries: list[str]) -> str:
|
||||||
|
"""Return ``readme_text`` with *new_entries* appended under ``## Archive``.
|
||||||
|
|
||||||
|
Existing entries are preserved verbatim. If the section is missing it
|
||||||
|
is created at the end of the document.
|
||||||
|
"""
|
||||||
|
if not new_entries:
|
||||||
|
return readme_text
|
||||||
|
|
||||||
|
lines = readme_text.splitlines()
|
||||||
|
archive_index = next(
|
||||||
|
(i for i, line in enumerate(lines) if line.rstrip() == ARCHIVE_HEADING),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if archive_index is None:
|
||||||
|
suffix = [""] if (lines and lines[-1] != "") else []
|
||||||
|
suffix.append(ARCHIVE_HEADING)
|
||||||
|
suffix.append("")
|
||||||
|
suffix.extend(f"- {entry}" for entry in new_entries)
|
||||||
|
merged = lines + suffix
|
||||||
|
return "\n".join(merged) + "\n"
|
||||||
|
|
||||||
|
section_end = next(
|
||||||
|
(i for i in range(archive_index + 1, len(lines)) if lines[i].startswith("## ")),
|
||||||
|
len(lines),
|
||||||
|
)
|
||||||
|
|
||||||
|
body_start = archive_index + 1
|
||||||
|
while body_start < section_end and not lines[body_start].strip():
|
||||||
|
body_start += 1
|
||||||
|
|
||||||
|
last_item = body_start - 1
|
||||||
|
for i in range(body_start, section_end):
|
||||||
|
if LIST_ITEM_RE.match(lines[i]):
|
||||||
|
last_item = i
|
||||||
|
|
||||||
|
insertion_point = (last_item + 1) if last_item >= body_start else body_start
|
||||||
|
if insertion_point == body_start and body_start == archive_index + 1:
|
||||||
|
new_block = ["", *[f"- {entry}" for entry in new_entries]]
|
||||||
|
else:
|
||||||
|
new_block = [f"- {entry}" for entry in new_entries]
|
||||||
|
|
||||||
|
merged = lines[:insertion_point] + new_block + lines[insertion_point:]
|
||||||
|
trailing = "\n" if readme_text.endswith("\n") else ""
|
||||||
|
return "\n".join(merged) + trailing
|
||||||
115
src/pkgmgr/actions/archive/workflow.py
Normal file
115
src/pkgmgr/actions/archive/workflow.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Orchestrator for archiving fully-checked Markdown files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .discovery import iter_archivable_files
|
||||||
|
from .inspect import count_unchecked_items, extract_h1
|
||||||
|
from .readme import existing_archive_entries, merge_archive_section
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArchivePlan:
|
||||||
|
"""Outcome of an archive analysis run.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
archived: ``(source_path, title)`` for every file that was (or
|
||||||
|
would be) archived. Order matches the original directory
|
||||||
|
listing.
|
||||||
|
skipped_incomplete: ``(source_path, unchecked_count)`` for files
|
||||||
|
that still hold ``- [ ]`` markers.
|
||||||
|
skipped_without_h1: files that had no H1 heading to use as title.
|
||||||
|
new_entries: titles that will be appended to the README index.
|
||||||
|
existing_entries: titles already present in the README index.
|
||||||
|
"""
|
||||||
|
|
||||||
|
archived: list[tuple[Path, str]]
|
||||||
|
skipped_incomplete: list[tuple[Path, int]]
|
||||||
|
skipped_without_h1: list[Path]
|
||||||
|
new_entries: list[str]
|
||||||
|
existing_entries: set[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_files(
|
||||||
|
files: list[Path],
|
||||||
|
) -> tuple[
|
||||||
|
list[tuple[Path, str]],
|
||||||
|
list[tuple[Path, int]],
|
||||||
|
list[Path],
|
||||||
|
]:
|
||||||
|
plan: list[tuple[Path, str]] = []
|
||||||
|
skipped_incomplete: list[tuple[Path, int]] = []
|
||||||
|
skipped_without_h1: list[Path] = []
|
||||||
|
for path in files:
|
||||||
|
unchecked = count_unchecked_items(path)
|
||||||
|
if unchecked > 0:
|
||||||
|
skipped_incomplete.append((path, unchecked))
|
||||||
|
continue
|
||||||
|
title = extract_h1(path)
|
||||||
|
if title is None:
|
||||||
|
skipped_without_h1.append(path)
|
||||||
|
continue
|
||||||
|
plan.append((path, title))
|
||||||
|
return plan, skipped_incomplete, skipped_without_h1
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_titles(
|
||||||
|
plan: list[tuple[Path, str]], already_archived: set[str]
|
||||||
|
) -> list[str]:
|
||||||
|
new_entries: list[str] = []
|
||||||
|
for _path, title in plan:
|
||||||
|
if title in already_archived or title in new_entries:
|
||||||
|
continue
|
||||||
|
new_entries.append(title)
|
||||||
|
return new_entries
|
||||||
|
|
||||||
|
|
||||||
|
def run_archive(
|
||||||
|
directory: Path,
|
||||||
|
readme_path: Path,
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
include_template: bool = False,
|
||||||
|
) -> ArchivePlan:
|
||||||
|
"""Walk *directory* and archive every fully-checked file into *readme_path*.
|
||||||
|
|
||||||
|
Returns an :class:`ArchivePlan` describing the outcome. When
|
||||||
|
``dry_run`` is true no files are deleted and the README is not
|
||||||
|
rewritten — the plan still reflects what *would* happen.
|
||||||
|
|
||||||
|
Raises ``FileNotFoundError`` if *directory* or *readme_path* does
|
||||||
|
not exist.
|
||||||
|
"""
|
||||||
|
if not directory.is_dir():
|
||||||
|
raise FileNotFoundError(f"Archive directory not found: {directory}")
|
||||||
|
if not readme_path.is_file():
|
||||||
|
raise FileNotFoundError(f"README not found: {readme_path}")
|
||||||
|
|
||||||
|
files = iter_archivable_files(directory, include_template=include_template)
|
||||||
|
readme_text = readme_path.read_text(encoding="utf-8")
|
||||||
|
already_archived = existing_archive_entries(readme_text)
|
||||||
|
|
||||||
|
archived, skipped_incomplete, skipped_without_h1 = _bucket_files(files)
|
||||||
|
new_entries = _dedupe_titles(archived, already_archived)
|
||||||
|
|
||||||
|
if not dry_run and new_entries:
|
||||||
|
merged_text = merge_archive_section(readme_text, new_entries)
|
||||||
|
if merged_text != readme_text:
|
||||||
|
readme_path.write_text(merged_text, encoding="utf-8")
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
for path, _title in archived:
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return ArchivePlan(
|
||||||
|
archived=archived,
|
||||||
|
skipped_incomplete=skipped_incomplete,
|
||||||
|
skipped_without_h1=skipped_without_h1,
|
||||||
|
new_entries=new_entries,
|
||||||
|
existing_entries=already_archived,
|
||||||
|
)
|
||||||
@@ -1,11 +1,50 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .editor import _open_editor_for_changelog
|
from .editor import _open_editor_for_changelog
|
||||||
|
|
||||||
|
H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
|
||||||
|
H2_RE = re.compile(r"^##\s+\S", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_after_h1(existing: str, entry: str) -> str:
|
||||||
|
"""Place *entry* after the H1 (and any intro prose), above the first H2.
|
||||||
|
|
||||||
|
If the file has no H1 we synthesise ``# Changelog`` so the resulting
|
||||||
|
document is markdown-lint-clean (MD041 first-line-h1).
|
||||||
|
If the file has no H2 yet we append *entry* after the H1 block.
|
||||||
|
Existing behaviour for legacy headerless files (file starts with
|
||||||
|
``## ``) is preserved: *entry* is prepended unchanged.
|
||||||
|
"""
|
||||||
|
if not existing.strip():
|
||||||
|
return f"# Changelog\n\n{entry}"
|
||||||
|
|
||||||
|
if not H1_RE.search(existing):
|
||||||
|
# Legacy layout: file starts with `## [version]` and has no H1.
|
||||||
|
# Synthesise the H1 so the merged file is lint-clean.
|
||||||
|
return f"# Changelog\n\n{entry}{existing.lstrip()}"
|
||||||
|
|
||||||
|
# File has an H1. Find the first H2 (existing release section).
|
||||||
|
h2_match = H2_RE.search(existing)
|
||||||
|
if h2_match is None:
|
||||||
|
# H1 + optional intro but no release entries yet — append entry
|
||||||
|
# after a single blank line.
|
||||||
|
suffix = (
|
||||||
|
""
|
||||||
|
if existing.endswith("\n\n")
|
||||||
|
else ("\n" if existing.endswith("\n") else "\n\n")
|
||||||
|
)
|
||||||
|
return f"{existing}{suffix}{entry}"
|
||||||
|
|
||||||
|
# Insert new entry just before the first H2.
|
||||||
|
head = existing[: h2_match.start()].rstrip("\n") + "\n\n"
|
||||||
|
tail = existing[h2_match.start() :]
|
||||||
|
return f"{head}{entry}{tail}"
|
||||||
|
|
||||||
|
|
||||||
def update_changelog(
|
def update_changelog(
|
||||||
changelog_path: str,
|
changelog_path: str,
|
||||||
@@ -13,9 +52,11 @@ def update_changelog(
|
|||||||
message: Optional[str] = None,
|
message: Optional[str] = None,
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""Insert a new release entry into CHANGELOG.md.
|
||||||
Prepend a new release section to CHANGELOG.md with the new version,
|
|
||||||
current date, and a message.
|
The entry is placed after the documents H1 heading (creating one if
|
||||||
|
missing) and above any existing release entries, so the result stays
|
||||||
|
markdown-lint-clean (MD041 first-line-h1, MD012 no-multiple-blanks).
|
||||||
"""
|
"""
|
||||||
today = date.today().isoformat()
|
today = date.today().isoformat()
|
||||||
|
|
||||||
@@ -32,8 +73,7 @@ def update_changelog(
|
|||||||
else:
|
else:
|
||||||
message = editor_message
|
message = editor_message
|
||||||
|
|
||||||
header = f"## [{new_version}] - {today}\n"
|
entry = f"## [{new_version}] - {today}\n\n* {message}\n\n"
|
||||||
header += f"\n* {message}\n\n"
|
|
||||||
|
|
||||||
if os.path.exists(changelog_path):
|
if os.path.exists(changelog_path):
|
||||||
try:
|
try:
|
||||||
@@ -45,14 +85,14 @@ def update_changelog(
|
|||||||
else:
|
else:
|
||||||
changelog = ""
|
changelog = ""
|
||||||
|
|
||||||
new_changelog = header + "\n" + changelog if changelog else header
|
new_changelog = _insert_after_h1(changelog, entry)
|
||||||
|
|
||||||
print("\n================ CHANGELOG ENTRY ================")
|
print("\n================ CHANGELOG ENTRY ================")
|
||||||
print(header.rstrip())
|
print(entry.rstrip())
|
||||||
print("=================================================\n")
|
print("=================================================\n")
|
||||||
|
|
||||||
if preview:
|
if preview:
|
||||||
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
|
print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md")
|
||||||
return message
|
return message
|
||||||
|
|
||||||
with open(changelog_path, "w", encoding="utf-8") as f:
|
with open(changelog_path, "w", encoding="utf-8") as f:
|
||||||
|
|||||||
75
src/pkgmgr/actions/release/package_name.py
Normal file
75
src/pkgmgr/actions/release/package_name.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Resolve the distro-package name for a release.
|
||||||
|
|
||||||
|
The release flow writes the package identifier into `debian/changelog`,
|
||||||
|
the RPM `%changelog` stanza, etc. Historically pkgmgr derived this
|
||||||
|
identifier from the repository folder name (`os.path.basename(repo_root)`),
|
||||||
|
which silently breaks when the repo is renamed but the existing packaging
|
||||||
|
files still ship the legacy name. Renaming the folder must not change the
|
||||||
|
distro-package identity — `apt`, `pacman`, `dnf`, and every downstream
|
||||||
|
manifest pin the old name.
|
||||||
|
|
||||||
|
The resolver therefore walks the existing packaging files in priority
|
||||||
|
order and only falls back to the folder name when none of them ship an
|
||||||
|
explicit name.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. `debian/control` `Package:` field (most authoritative — dpkg-source
|
||||||
|
refuses to build if changelog and control disagree)
|
||||||
|
2. `packaging/arch/PKGBUILD` `pkgname=` value
|
||||||
|
3. RPM spec `Name:` field
|
||||||
|
4. Repository folder basename (legacy fallback)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pkgmgr.core.repository.paths import RepoPaths
|
||||||
|
|
||||||
|
|
||||||
|
_DEBIAN_PACKAGE_RE = re.compile(r"^Package:\s*(\S+)\s*$", re.MULTILINE)
|
||||||
|
_PKGBUILD_NAME_RE = re.compile(r"^pkgname=([^\s#]+)\s*$", re.MULTILINE)
|
||||||
|
_RPM_NAME_RE = re.compile(r"^Name:\s*(\S+)\s*$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Optional[str]) -> str:
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract(pattern: re.Pattern[str], text: str) -> Optional[str]:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
match = pattern.search(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
value = match.group(1).strip().strip('"').strip("'")
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_package_name(paths: RepoPaths) -> str:
|
||||||
|
"""Return the distro-package name for the repo, with a folder fallback.
|
||||||
|
|
||||||
|
The fallback uses `os.path.basename(paths.repo_dir)` so behaviour is
|
||||||
|
backwards-compatible for repos that ship no packaging metadata yet.
|
||||||
|
"""
|
||||||
|
debian_name = _extract(_DEBIAN_PACKAGE_RE, _read(paths.debian_control))
|
||||||
|
if debian_name:
|
||||||
|
return debian_name
|
||||||
|
|
||||||
|
pkgbuild_name = _extract(_PKGBUILD_NAME_RE, _read(paths.arch_pkgbuild))
|
||||||
|
if pkgbuild_name:
|
||||||
|
return pkgbuild_name
|
||||||
|
|
||||||
|
rpm_name = _extract(_RPM_NAME_RE, _read(paths.rpm_spec))
|
||||||
|
if rpm_name:
|
||||||
|
return rpm_name
|
||||||
|
|
||||||
|
return os.path.basename(paths.repo_dir) or "package"
|
||||||
67
src/pkgmgr/actions/release/retry.py
Normal file
67
src/pkgmgr/actions/release/retry.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Re-deploy an existing release without modifying files or creating a new tag.
|
||||||
|
|
||||||
|
The release workflow normally bumps versions, rewrites packaging
|
||||||
|
manifests, commits, tags, pushes, and uploads to PyPI in one shot.
|
||||||
|
When a post-tag step fails mid-flight (typical examples: `git push`
|
||||||
|
rejected, `twine upload` aborted by a broken venv, `update-latest`
|
||||||
|
rejected by branch protection) the local tag still exists on HEAD but
|
||||||
|
the side effects downstream are incomplete.
|
||||||
|
|
||||||
|
`retry_release` re-runs the idempotent tail of that flow so a botched
|
||||||
|
release can be re-pushed without touching code or recreating tags:
|
||||||
|
|
||||||
|
* `git push origin <branch> <tag>` for the existing HEAD tag
|
||||||
|
* re-align the floating `latest` tag if HEAD tag is the highest
|
||||||
|
|
||||||
|
Publishing (PyPI etc.) stays the caller's responsibility — the publish
|
||||||
|
workflow is already idempotent (twine rejects duplicates per spec) and
|
||||||
|
can be invoked independently via the `publish` subcommand.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pkgmgr.actions.publish.git_tags import head_semver_tags
|
||||||
|
from pkgmgr.core.git import GitRunError, run
|
||||||
|
from pkgmgr.core.git.queries import get_current_branch
|
||||||
|
from pkgmgr.core.version.semver import SemVer
|
||||||
|
|
||||||
|
from .git_ops import is_highest_version_tag, update_latest_tag
|
||||||
|
|
||||||
|
|
||||||
|
def retry_release(
|
||||||
|
pyproject_path: str = "pyproject.toml",
|
||||||
|
preview: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Re-push the HEAD release without re-tagging or modifying any files."""
|
||||||
|
try:
|
||||||
|
branch = get_current_branch() or "main"
|
||||||
|
except GitRunError:
|
||||||
|
branch = "main"
|
||||||
|
print(f"Retrying release push on branch: {branch}")
|
||||||
|
|
||||||
|
tags = head_semver_tags(cwd=os.path.dirname(os.path.abspath(pyproject_path)))
|
||||||
|
if not tags:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No version tag on HEAD. Nothing to retry — "
|
||||||
|
"run `pkgmgr release <type>` first to create a release."
|
||||||
|
)
|
||||||
|
tag = max(tags, key=SemVer.parse)
|
||||||
|
print(f"Re-pushing existing tag: {tag}")
|
||||||
|
|
||||||
|
run(["push", "origin", branch, tag], preview=preview)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_highest_version_tag(tag):
|
||||||
|
update_latest_tag(tag, preview=preview)
|
||||||
|
else:
|
||||||
|
print(f"[INFO] Skipping 'latest' update (tag {tag} is not the highest).")
|
||||||
|
except GitRunError as exc:
|
||||||
|
print(f"[WARN] Failed to update floating 'latest' tag for {tag}: {exc}")
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
print(f"[PREVIEW] Retry push for {tag} would now complete.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Retry push completed for {tag}.")
|
||||||
@@ -5,8 +5,8 @@ import sys
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pkgmgr.actions.branch import close_branch
|
from pkgmgr.actions.branch import close_branch
|
||||||
from pkgmgr.core.git import GitRunError
|
from pkgmgr.core.git import GitRunError, run
|
||||||
from pkgmgr.core.git.commands import add, commit, push, tag_annotated
|
from pkgmgr.core.git.commands import add, commit, tag_annotated
|
||||||
from pkgmgr.core.git.queries import get_current_branch
|
from pkgmgr.core.git.queries import get_current_branch
|
||||||
from pkgmgr.core.repository.paths import resolve_repo_paths
|
from pkgmgr.core.repository.paths import resolve_repo_paths
|
||||||
|
|
||||||
@@ -24,7 +24,9 @@ from .git_ops import (
|
|||||||
is_highest_version_tag,
|
is_highest_version_tag,
|
||||||
update_latest_tag,
|
update_latest_tag,
|
||||||
)
|
)
|
||||||
|
from .package_name import resolve_package_name
|
||||||
from .prompts import confirm_proceed_release, should_delete_branch
|
from .prompts import confirm_proceed_release, should_delete_branch
|
||||||
|
from .retry import retry_release
|
||||||
from .versioning import bump_semver, determine_current_version
|
from .versioning import bump_semver, determine_current_version
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ def _release_impl(
|
|||||||
if changelog_message.strip():
|
if changelog_message.strip():
|
||||||
effective_message = changelog_message.strip()
|
effective_message = changelog_message.strip()
|
||||||
|
|
||||||
package_name = os.path.basename(repo_root) or "package-manager"
|
package_name = resolve_package_name(paths)
|
||||||
|
|
||||||
if paths.debian_changelog:
|
if paths.debian_changelog:
|
||||||
update_debian_changelog(
|
update_debian_changelog(
|
||||||
@@ -133,8 +135,7 @@ def _release_impl(
|
|||||||
add(existing_files, preview=True)
|
add(existing_files, preview=True)
|
||||||
commit(commit_msg, all=True, preview=True)
|
commit(commit_msg, all=True, preview=True)
|
||||||
tag_annotated(new_tag, tag_msg, preview=True)
|
tag_annotated(new_tag, tag_msg, preview=True)
|
||||||
push("origin", branch, preview=True)
|
run(["push", "origin", branch, new_tag], preview=True)
|
||||||
push("origin", new_tag, preview=True)
|
|
||||||
|
|
||||||
if is_highest_version_tag(new_tag):
|
if is_highest_version_tag(new_tag):
|
||||||
update_latest_tag(new_tag, preview=True)
|
update_latest_tag(new_tag, preview=True)
|
||||||
@@ -156,9 +157,8 @@ def _release_impl(
|
|||||||
commit(commit_msg, all=True, preview=False)
|
commit(commit_msg, all=True, preview=False)
|
||||||
tag_annotated(new_tag, tag_msg, preview=False)
|
tag_annotated(new_tag, tag_msg, preview=False)
|
||||||
|
|
||||||
# Push branch and ONLY the newly created version tag (no --tags)
|
# Push branch and ONLY the newly created version tag in one command (no --tags)
|
||||||
push("origin", branch, preview=False)
|
run(["push", "origin", branch, new_tag], preview=False)
|
||||||
push("origin", new_tag, preview=False)
|
|
||||||
|
|
||||||
# Update 'latest' only if this is the highest version tag
|
# Update 'latest' only if this is the highest version tag
|
||||||
try:
|
try:
|
||||||
@@ -200,7 +200,12 @@ def release(
|
|||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
close: bool = False,
|
close: bool = False,
|
||||||
|
retry: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if retry:
|
||||||
|
retry_release(pyproject_path=pyproject_path, preview=preview)
|
||||||
|
return
|
||||||
|
|
||||||
if preview:
|
if preview:
|
||||||
_release_impl(
|
_release_impl(
|
||||||
pyproject_path=pyproject_path,
|
pyproject_path=pyproject_path,
|
||||||
|
|||||||
89
src/pkgmgr/actions/repository/_parallel.py
Normal file
89
src/pkgmgr/actions/repository/_parallel.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from typing import Any, Callable, Dict, List, Tuple
|
||||||
|
|
||||||
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||||
|
|
||||||
|
Repository = Dict[str, Any]
|
||||||
|
RepoRef = Tuple[str, str]
|
||||||
|
OpResult = Tuple[bool, str]
|
||||||
|
RepoOp = Callable[[str], OpResult]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repos(
|
||||||
|
selected_repos: List[Repository],
|
||||||
|
repositories_base_dir: str,
|
||||||
|
all_repos: List[Repository],
|
||||||
|
) -> List[RepoRef]:
|
||||||
|
"""
|
||||||
|
Resolve ``(identifier, repo_dir)`` pairs for ``selected_repos``.
|
||||||
|
|
||||||
|
Repositories whose directory does not exist on disk are reported and
|
||||||
|
skipped, matching the prior behavior of pull/push handlers.
|
||||||
|
"""
|
||||||
|
resolved: List[RepoRef] = []
|
||||||
|
for repo in selected_repos:
|
||||||
|
ident = get_repo_identifier(repo, all_repos)
|
||||||
|
rd = get_repo_dir(repositories_base_dir, repo)
|
||||||
|
if not os.path.exists(rd):
|
||||||
|
print(f"Repository directory '{rd}' not found for {ident}.")
|
||||||
|
continue
|
||||||
|
resolved.append((ident, rd))
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def run_on_repos(
|
||||||
|
repos: List[RepoRef],
|
||||||
|
op: RepoOp,
|
||||||
|
*,
|
||||||
|
jobs: int,
|
||||||
|
op_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Run ``op(repo_dir) -> (ok, msg)`` for each repo, optionally in parallel.
|
||||||
|
|
||||||
|
- ``jobs == 1``: serial, quiet on success, prints ``msg`` on failure.
|
||||||
|
- ``jobs > 1``: parallel via ThreadPoolExecutor, prints a banner plus
|
||||||
|
``[OK]``/``[FAIL]`` per repo and a final summary.
|
||||||
|
- Exits with status 1 if any operation failed.
|
||||||
|
"""
|
||||||
|
if not repos:
|
||||||
|
return
|
||||||
|
|
||||||
|
effective_jobs = max(1, min(jobs, len(repos)))
|
||||||
|
failed: List[Tuple[str, str]] = []
|
||||||
|
|
||||||
|
if effective_jobs == 1:
|
||||||
|
for ident, rd in repos:
|
||||||
|
ok, msg = op(rd)
|
||||||
|
if not ok:
|
||||||
|
print(msg)
|
||||||
|
failed.append((ident, msg))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[{op_name.upper()}] Running {len(repos)} {op_name}(s) with up to "
|
||||||
|
f"{effective_jobs} parallel jobs..."
|
||||||
|
)
|
||||||
|
with ThreadPoolExecutor(max_workers=effective_jobs) as executor:
|
||||||
|
futures = {executor.submit(op, rd): ident for ident, rd in repos}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
ident = futures[future]
|
||||||
|
ok, msg = future.result()
|
||||||
|
if ok:
|
||||||
|
print(f"[OK] {ident}")
|
||||||
|
else:
|
||||||
|
print(f"[FAIL] {ident}")
|
||||||
|
for line in msg.splitlines():
|
||||||
|
print(f" {line}")
|
||||||
|
failed.append((ident, msg))
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
if effective_jobs > 1:
|
||||||
|
print(f"\n[SUMMARY] {len(failed)} of {len(repos)} {op_name}(s) failed:")
|
||||||
|
for ident, _msg in failed:
|
||||||
|
print(f" - {ident}")
|
||||||
|
sys.exit(1)
|
||||||
@@ -1,17 +1,67 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import List, Dict, Any
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
from pkgmgr.actions.repository._parallel import RepoRef, run_on_repos
|
||||||
from pkgmgr.core.git.commands import pull_args, GitPullArgsError
|
from pkgmgr.core.git.commands import pull_args, GitPullArgsError
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
|
||||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||||
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
from pkgmgr.core.repository.verify import verify_repository
|
from pkgmgr.core.repository.verify import verify_repository
|
||||||
|
|
||||||
Repository = Dict[str, Any]
|
Repository = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _pull_one(repo_dir: str, extra_args: List[str], preview: bool) -> Tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
pull_args(extra_args, cwd=repo_dir, preview=preview)
|
||||||
|
return (True, "")
|
||||||
|
except GitPullArgsError as exc:
|
||||||
|
return (False, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_one(
|
||||||
|
repo: Repository,
|
||||||
|
repo_dir: str,
|
||||||
|
no_verification: bool,
|
||||||
|
) -> Tuple[bool, bool, List[str]]:
|
||||||
|
"""Returns (has_verified_info, verified_ok, errors)."""
|
||||||
|
verified_ok, errors, _commit, _key = verify_repository(
|
||||||
|
repo,
|
||||||
|
repo_dir,
|
||||||
|
mode="pull",
|
||||||
|
no_verification=no_verification,
|
||||||
|
)
|
||||||
|
return (bool(repo.get("verified")), verified_ok, errors)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_all(
|
||||||
|
candidates: List[Tuple[Repository, str, str]],
|
||||||
|
no_verification: bool,
|
||||||
|
jobs: int,
|
||||||
|
) -> List[Tuple[str, str, bool, bool, List[str]]]:
|
||||||
|
"""
|
||||||
|
Verify all candidates (parallel if ``jobs > 1``), preserving input order.
|
||||||
|
|
||||||
|
Returns one tuple per candidate: ``(ident, repo_dir, has_verified_info,
|
||||||
|
verified_ok, errors)``.
|
||||||
|
"""
|
||||||
|
verify_jobs = max(1, min(jobs, len(candidates)))
|
||||||
|
if verify_jobs == 1:
|
||||||
|
return [
|
||||||
|
(ident, rd, *_verify_one(repo, rd, no_verification))
|
||||||
|
for repo, ident, rd in candidates
|
||||||
|
]
|
||||||
|
with ThreadPoolExecutor(max_workers=verify_jobs) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(_verify_one, repo, rd, no_verification)
|
||||||
|
for repo, _ident, rd in candidates
|
||||||
|
]
|
||||||
|
results = [f.result() for f in futures]
|
||||||
|
return [(ident, rd, *res) for (_repo, ident, rd), res in zip(candidates, results)]
|
||||||
|
|
||||||
|
|
||||||
def pull_with_verification(
|
def pull_with_verification(
|
||||||
selected_repos: List[Repository],
|
selected_repos: List[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
@@ -19,41 +69,50 @@ def pull_with_verification(
|
|||||||
extra_args: List[str],
|
extra_args: List[str],
|
||||||
no_verification: bool,
|
no_verification: bool,
|
||||||
preview: bool,
|
preview: bool,
|
||||||
|
jobs: int = 1,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Execute `git pull` for each repository with verification.
|
Execute `git pull` for each repository with verification.
|
||||||
|
|
||||||
- If verification fails and verification is enabled, prompt user to continue.
|
- Verification (I/O-bound) runs in parallel when ``jobs > 1``.
|
||||||
- Uses core.git.commands.pull_args() (no raw subprocess usage).
|
- Interactive prompts for failed verifications are handled serially on the
|
||||||
|
main thread after parallel verification completes.
|
||||||
|
- Approved repos are then pulled in parallel when ``jobs > 1``.
|
||||||
|
- On any pull failure, prints a summary and exits with status 1.
|
||||||
"""
|
"""
|
||||||
|
candidates: List[Tuple[Repository, str, str]] = []
|
||||||
for repo in selected_repos:
|
for repo in selected_repos:
|
||||||
repo_identifier = get_repo_identifier(repo, all_repos)
|
ident = get_repo_identifier(repo, all_repos)
|
||||||
repo_dir = get_repo_dir(repositories_base_dir, repo)
|
rd = get_repo_dir(repositories_base_dir, repo)
|
||||||
|
if not os.path.exists(rd):
|
||||||
if not os.path.exists(repo_dir):
|
print(f"Repository directory '{rd}' not found for {ident}.")
|
||||||
print(f"Repository directory '{repo_dir}' not found for {repo_identifier}.")
|
|
||||||
continue
|
continue
|
||||||
|
candidates.append((repo, ident, rd))
|
||||||
|
|
||||||
verified_info = repo.get("verified")
|
if not candidates:
|
||||||
verified_ok, errors, _commit_hash, _signing_key = verify_repository(
|
return
|
||||||
repo,
|
|
||||||
repo_dir,
|
|
||||||
mode="pull",
|
|
||||||
no_verification=no_verification,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not preview and not no_verification and verified_info and not verified_ok:
|
verify_results = _verify_all(candidates, no_verification, jobs)
|
||||||
print(f"Warning: Verification failed for {repo_identifier}:")
|
|
||||||
|
approved: List[RepoRef] = []
|
||||||
|
for ident, rd, has_verified_info, verified_ok, errors in verify_results:
|
||||||
|
if (
|
||||||
|
not preview
|
||||||
|
and not no_verification
|
||||||
|
and has_verified_info
|
||||||
|
and not verified_ok
|
||||||
|
):
|
||||||
|
print(f"Warning: Verification failed for {ident}:")
|
||||||
for err in errors:
|
for err in errors:
|
||||||
print(f" - {err}")
|
print(f" - {err}")
|
||||||
choice = input("Proceed with 'git pull'? (y/N): ").strip().lower()
|
choice = input("Proceed with 'git pull'? (y/N): ").strip().lower()
|
||||||
if choice != "y":
|
if choice != "y":
|
||||||
continue
|
continue
|
||||||
|
approved.append((ident, rd))
|
||||||
|
|
||||||
try:
|
run_on_repos(
|
||||||
pull_args(extra_args, cwd=repo_dir, preview=preview)
|
approved,
|
||||||
except GitPullArgsError as exc:
|
lambda rd: _pull_one(rd, extra_args, preview),
|
||||||
# Keep behavior consistent with previous implementation:
|
jobs=jobs,
|
||||||
# stop on first failure and propagate return code as generic failure.
|
op_name="pull",
|
||||||
print(str(exc))
|
)
|
||||||
sys.exit(1)
|
|
||||||
|
|||||||
39
src/pkgmgr/actions/repository/push.py
Normal file
39
src/pkgmgr/actions/repository/push.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
from pkgmgr.actions.repository._parallel import (
|
||||||
|
resolve_repos,
|
||||||
|
run_on_repos,
|
||||||
|
)
|
||||||
|
from pkgmgr.core.git.commands import push_args, GitPushArgsError
|
||||||
|
|
||||||
|
Repository = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _push_one(repo_dir: str, extra_args: List[str], preview: bool) -> Tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
push_args(extra_args, cwd=repo_dir, preview=preview)
|
||||||
|
return (True, "")
|
||||||
|
except GitPushArgsError as exc:
|
||||||
|
return (False, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def push_in_parallel(
|
||||||
|
selected_repos: List[Repository],
|
||||||
|
repositories_base_dir: str,
|
||||||
|
all_repos: List[Repository],
|
||||||
|
extra_args: List[str],
|
||||||
|
preview: bool,
|
||||||
|
jobs: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Execute `git push` for each repository, optionally in parallel.
|
||||||
|
"""
|
||||||
|
repos = resolve_repos(selected_repos, repositories_base_dir, all_repos)
|
||||||
|
run_on_repos(
|
||||||
|
repos,
|
||||||
|
lambda rd: _push_one(rd, extra_args, preview),
|
||||||
|
jobs=jobs,
|
||||||
|
op_name="push",
|
||||||
|
)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from .archive import handle_archive
|
||||||
from .repos import handle_repos_command
|
from .repos import handle_repos_command
|
||||||
from .config import handle_config
|
from .config import handle_config
|
||||||
from .tools import handle_tools_command
|
from .tools import handle_tools_command
|
||||||
@@ -10,6 +11,7 @@ from .branch import handle_branch
|
|||||||
from .mirror import handle_mirror_command
|
from .mirror import handle_mirror_command
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"handle_archive",
|
||||||
"handle_repos_command",
|
"handle_repos_command",
|
||||||
"handle_config",
|
"handle_config",
|
||||||
"handle_tools_command",
|
"handle_tools_command",
|
||||||
|
|||||||
76
src/pkgmgr/cli/commands/archive.py
Normal file
76
src/pkgmgr/cli/commands/archive.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pkgmgr.actions.archive import ArchivePlan, run_archive
|
||||||
|
from pkgmgr.cli.context import CLIContext
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(
|
||||||
|
directory: Path, readme: Path, plan: ArchivePlan, dry_run: bool
|
||||||
|
) -> None:
|
||||||
|
print(f"[archive] Directory: {directory}")
|
||||||
|
print(f"[archive] README: {readme}")
|
||||||
|
print(f"[archive] Files to process: {len(plan.archived)}")
|
||||||
|
print(f"[archive] Skipped (incomplete): {len(plan.skipped_incomplete)}")
|
||||||
|
print(f"[archive] New archive entries: {len(plan.new_entries)}")
|
||||||
|
print(f"[archive] Dry-run: {dry_run}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_skips(plan: ArchivePlan, cwd: Path) -> None:
|
||||||
|
if plan.skipped_incomplete:
|
||||||
|
print(
|
||||||
|
"[archive] SKIP: files with unchecked `- [ ]` items "
|
||||||
|
"(not archived, not deleted):"
|
||||||
|
)
|
||||||
|
for path, count in plan.skipped_incomplete:
|
||||||
|
suffix = "s" if count != 1 else ""
|
||||||
|
rel = _rel_or_abs(path, cwd)
|
||||||
|
print(f" - {rel} ({count} unchecked item{suffix})")
|
||||||
|
if plan.skipped_without_h1:
|
||||||
|
print("[archive] WARN: skipped files without an H1 heading:")
|
||||||
|
for path in plan.skipped_without_h1:
|
||||||
|
print(f" - {_rel_or_abs(path, cwd)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_actions(plan: ArchivePlan, cwd: Path, dry_run: bool) -> None:
|
||||||
|
verb = "would archive" if dry_run else "archived"
|
||||||
|
rm_verb = "would delete" if dry_run else "deleted"
|
||||||
|
for path, title in plan.archived:
|
||||||
|
rel = _rel_or_abs(path, cwd)
|
||||||
|
print(f"[archive] {verb}: {rel} -> '{title}'")
|
||||||
|
if not dry_run:
|
||||||
|
print(f"[archive] {rm_verb}: {rel}")
|
||||||
|
|
||||||
|
|
||||||
|
def _rel_or_abs(path: Path, cwd: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.resolve().relative_to(cwd).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_archive(args, _ctx: CLIContext) -> None:
|
||||||
|
directory = Path(args.directory).resolve()
|
||||||
|
readme = (
|
||||||
|
Path(args.readme).resolve()
|
||||||
|
if args.readme
|
||||||
|
else (directory / "README.md").resolve()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan = run_archive(
|
||||||
|
directory=directory,
|
||||||
|
readme_path=readme,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
include_template=args.include_template,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
print(f"[archive] ERROR: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cwd = Path.cwd().resolve()
|
||||||
|
_print_summary(directory, readme, plan, args.dry_run)
|
||||||
|
_print_skips(plan, cwd)
|
||||||
|
_print_actions(plan, cwd, args.dry_run)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# src/pkgmgr/cli/commands/config.py
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
@@ -38,29 +39,18 @@ def _load_user_config(user_config_path: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def _find_defaults_source_dir() -> Optional[str]:
|
def _find_defaults_source_dir() -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Find the directory inside the installed pkgmgr package OR the
|
Find the directory inside the installed pkgmgr package that contains
|
||||||
project root that contains default config files.
|
the default config files.
|
||||||
|
|
||||||
Preferred locations (in dieser Reihenfolge):
|
Preferred location:
|
||||||
- <pkg_root>/config_defaults
|
|
||||||
- <pkg_root>/config
|
- <pkg_root>/config
|
||||||
- <project_root>/config_defaults
|
|
||||||
- <project_root>/config
|
|
||||||
"""
|
"""
|
||||||
import pkgmgr # local import to avoid circular deps
|
import pkgmgr # local import to avoid circular deps
|
||||||
|
|
||||||
pkg_root = Path(pkgmgr.__file__).resolve().parent
|
pkg_root = Path(pkgmgr.__file__).resolve().parent
|
||||||
project_root = pkg_root.parent
|
cand = pkg_root / "config"
|
||||||
|
if cand.is_dir():
|
||||||
candidates = [
|
return str(cand)
|
||||||
pkg_root / "config_defaults",
|
|
||||||
pkg_root / "config",
|
|
||||||
project_root / "config_defaults",
|
|
||||||
project_root / "config",
|
|
||||||
]
|
|
||||||
for cand in candidates:
|
|
||||||
if cand.is_dir():
|
|
||||||
return str(cand)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +63,7 @@ def _update_default_configs(user_config_path: str) -> None:
|
|||||||
source_dir = _find_defaults_source_dir()
|
source_dir = _find_defaults_source_dir()
|
||||||
if not source_dir:
|
if not source_dir:
|
||||||
print(
|
print(
|
||||||
"[WARN] No config_defaults or config directory found in "
|
"[WARN] No config directory found in "
|
||||||
"pkgmgr installation. Nothing to update."
|
"pkgmgr installation. Nothing to update."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -88,7 +78,6 @@ def _update_default_configs(user_config_path: str) -> None:
|
|||||||
if not (lower.endswith(".yml") or lower.endswith(".yaml")):
|
if not (lower.endswith(".yml") or lower.endswith(".yaml")):
|
||||||
continue
|
continue
|
||||||
if name == "config.yaml":
|
if name == "config.yaml":
|
||||||
# Never overwrite the user config template / live config
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
src = os.path.join(source_dir, name)
|
src = os.path.join(source_dir, name)
|
||||||
@@ -102,48 +91,28 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
"""
|
"""
|
||||||
Handle 'pkgmgr config' subcommands.
|
Handle 'pkgmgr config' subcommands.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user_config_path = ctx.user_config_path
|
user_config_path = ctx.user_config_path
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config show
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "show":
|
if args.subcommand == "show":
|
||||||
if args.all or (not args.identifiers):
|
if args.all or (not args.identifiers):
|
||||||
# Full merged config view
|
|
||||||
show_config([], user_config_path, full_config=True)
|
show_config([], user_config_path, full_config=True)
|
||||||
else:
|
else:
|
||||||
# Show only matching entries from user config
|
|
||||||
user_config = _load_user_config(user_config_path)
|
user_config = _load_user_config(user_config_path)
|
||||||
selected = resolve_repos(
|
selected = resolve_repos(
|
||||||
args.identifiers,
|
args.identifiers, user_config.get("repositories", [])
|
||||||
user_config.get("repositories", []),
|
|
||||||
)
|
)
|
||||||
if selected:
|
if selected:
|
||||||
show_config(
|
show_config(selected, user_config_path, full_config=False)
|
||||||
selected,
|
|
||||||
user_config_path,
|
|
||||||
full_config=False,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config add
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "add":
|
if args.subcommand == "add":
|
||||||
interactive_add(ctx.config_merged, user_config_path)
|
interactive_add(ctx.config_merged, user_config_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config edit
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "edit":
|
if args.subcommand == "edit":
|
||||||
run_command(f"nano {user_config_path}")
|
run_command(f"nano {user_config_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config init
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "init":
|
if args.subcommand == "init":
|
||||||
user_config = _load_user_config(user_config_path)
|
user_config = _load_user_config(user_config_path)
|
||||||
config_init(
|
config_init(
|
||||||
@@ -154,9 +123,6 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config delete
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "delete":
|
if args.subcommand == "delete":
|
||||||
user_config = _load_user_config(user_config_path)
|
user_config = _load_user_config(user_config_path)
|
||||||
|
|
||||||
@@ -167,10 +133,7 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
to_delete = resolve_repos(
|
to_delete = resolve_repos(args.identifiers, user_config.get("repositories", []))
|
||||||
args.identifiers,
|
|
||||||
user_config.get("repositories", []),
|
|
||||||
)
|
|
||||||
new_repos = [
|
new_repos = [
|
||||||
entry
|
entry
|
||||||
for entry in user_config.get("repositories", [])
|
for entry in user_config.get("repositories", [])
|
||||||
@@ -181,9 +144,6 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
print(f"Deleted {len(to_delete)} entries from user config.")
|
print(f"Deleted {len(to_delete)} entries from user config.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config ignore
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "ignore":
|
if args.subcommand == "ignore":
|
||||||
user_config = _load_user_config(user_config_path)
|
user_config = _load_user_config(user_config_path)
|
||||||
|
|
||||||
@@ -194,17 +154,10 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
to_modify = resolve_repos(
|
to_modify = resolve_repos(args.identifiers, user_config.get("repositories", []))
|
||||||
args.identifiers,
|
|
||||||
user_config.get("repositories", []),
|
|
||||||
)
|
|
||||||
|
|
||||||
for entry in user_config["repositories"]:
|
for entry in user_config["repositories"]:
|
||||||
key = (
|
key = (entry.get("provider"), entry.get("account"), entry.get("repository"))
|
||||||
entry.get("provider"),
|
|
||||||
entry.get("account"),
|
|
||||||
entry.get("repository"),
|
|
||||||
)
|
|
||||||
for mod in to_modify:
|
for mod in to_modify:
|
||||||
mod_key = (
|
mod_key = (
|
||||||
mod.get("provider"),
|
mod.get("provider"),
|
||||||
@@ -218,21 +171,9 @@ def handle_config(args, ctx: CLIContext) -> None:
|
|||||||
save_user_config(user_config, user_config_path)
|
save_user_config(user_config, user_config_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# config update
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.subcommand == "update":
|
if args.subcommand == "update":
|
||||||
"""
|
|
||||||
Copy default YAML configs from the installed package into the
|
|
||||||
user's ~/.config/pkgmgr directory.
|
|
||||||
|
|
||||||
This will overwrite files with the same name (except config.yaml).
|
|
||||||
"""
|
|
||||||
_update_default_configs(user_config_path)
|
_update_default_configs(user_config_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Unknown subcommand
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
print(f"Unknown config subcommand: {args.subcommand}")
|
print(f"Unknown config subcommand: {args.subcommand}")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|||||||
@@ -49,7 +49,19 @@ def handle_release(
|
|||||||
print(f"[WARN] Skipping repository {identifier}: directory missing.")
|
print(f"[WARN] Skipping repository {identifier}: directory missing.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"[pkgmgr] Running release for repository {identifier}...")
|
retry = bool(getattr(args, "retry", False))
|
||||||
|
if retry and args.release_type:
|
||||||
|
print(
|
||||||
|
f"[WARN] Ignoring release_type '{args.release_type}' for {identifier} — --retry skips version bumps."
|
||||||
|
)
|
||||||
|
if not retry and not args.release_type:
|
||||||
|
print(
|
||||||
|
f"[WARN] Skipping {identifier}: release_type is required unless --retry is set."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
action_label = "retry-release" if retry else "release"
|
||||||
|
print(f"[pkgmgr] Running {action_label} for repository {identifier}...")
|
||||||
|
|
||||||
cwd_before = os.getcwd()
|
cwd_before = os.getcwd()
|
||||||
try:
|
try:
|
||||||
@@ -58,11 +70,12 @@ def handle_release(
|
|||||||
run_release(
|
run_release(
|
||||||
pyproject_path="pyproject.toml",
|
pyproject_path="pyproject.toml",
|
||||||
changelog_path="CHANGELOG.md",
|
changelog_path="CHANGELOG.md",
|
||||||
release_type=args.release_type,
|
release_type=args.release_type or "patch",
|
||||||
message=args.message or None,
|
message=args.message or None,
|
||||||
preview=getattr(args, "preview", False),
|
preview=getattr(args, "preview", False),
|
||||||
force=getattr(args, "force", False),
|
force=getattr(args, "force", False),
|
||||||
close=getattr(args, "close", False),
|
close=getattr(args, "close", False),
|
||||||
|
retry=retry,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not getattr(args, "no_publish", False):
|
if not getattr(args, "no_publish", False):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pkgmgr.core.repository.selected import get_selected_repos
|
|||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
|
||||||
from pkgmgr.cli.commands import (
|
from pkgmgr.cli.commands import (
|
||||||
|
handle_archive,
|
||||||
handle_repos_command,
|
handle_repos_command,
|
||||||
handle_tools_command,
|
handle_tools_command,
|
||||||
handle_release,
|
handle_release,
|
||||||
@@ -60,6 +61,10 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
|||||||
if maybe_handle_proxy(args, ctx):
|
if maybe_handle_proxy(args, ctx):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.command == "archive":
|
||||||
|
handle_archive(args, ctx)
|
||||||
|
return
|
||||||
|
|
||||||
commands_with_selection = {
|
commands_with_selection = {
|
||||||
"install",
|
"install",
|
||||||
"update",
|
"update",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import argparse
|
|||||||
|
|
||||||
from pkgmgr.cli.proxy import register_proxy_commands
|
from pkgmgr.cli.proxy import register_proxy_commands
|
||||||
|
|
||||||
|
from .archive_cmd import add_archive_subparser
|
||||||
from .branch_cmd import add_branch_subparsers
|
from .branch_cmd import add_branch_subparsers
|
||||||
from .changelog_cmd import add_changelog_subparser
|
from .changelog_cmd import add_changelog_subparser
|
||||||
from .common import SortedSubParsersAction
|
from .common import SortedSubParsersAction
|
||||||
@@ -65,6 +66,7 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
add_make_subparsers(subparsers)
|
add_make_subparsers(subparsers)
|
||||||
add_mirror_subparsers(subparsers)
|
add_mirror_subparsers(subparsers)
|
||||||
|
add_archive_subparser(subparsers)
|
||||||
|
|
||||||
register_proxy_commands(subparsers)
|
register_proxy_commands(subparsers)
|
||||||
return parser
|
return parser
|
||||||
|
|||||||
57
src/pkgmgr/cli/parser/archive_cmd.py
Normal file
57
src/pkgmgr/cli/parser/archive_cmd.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def add_archive_subparser(subparsers: argparse._SubParsersAction) -> None:
|
||||||
|
"""Register the archive subcommand.
|
||||||
|
|
||||||
|
Walks a directory of numbered ``NNN-topic.md`` files, promotes every
|
||||||
|
file whose ``- [ ]`` checklist is fully checked into the directorys
|
||||||
|
README ``## Archive`` section, then deletes the source file.
|
||||||
|
"""
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
"archive",
|
||||||
|
help=(
|
||||||
|
"Archive fully-checked Markdown spec files (NNN-topic.md) "
|
||||||
|
"into the directorys README and delete them."
|
||||||
|
),
|
||||||
|
description=(
|
||||||
|
"Walk DIR for files that match the numbered "
|
||||||
|
"NNN-topic.md naming, promote every file with zero "
|
||||||
|
"unchecked `- [ ]` items into READMEs ## Archive section, "
|
||||||
|
"then delete the source file. Files with unchecked items are "
|
||||||
|
"skipped. Use --dry-run to preview without writing."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"directory",
|
||||||
|
nargs="?",
|
||||||
|
default="docs/requirements",
|
||||||
|
help=(
|
||||||
|
"Directory to scan for archivable Markdown files. "
|
||||||
|
"Defaults to docs/requirements (relative to the current "
|
||||||
|
"working directory)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--readme",
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Path to the README that holds the ## Archive index. "
|
||||||
|
"Defaults to <directory>/README.md."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Print planned changes without modifying or deleting anything.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-template",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Also archive and delete 000-template.md. Off by default "
|
||||||
|
"because contributor guides typically reference it."
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -24,8 +24,12 @@ def add_release_subparser(
|
|||||||
|
|
||||||
release_parser.add_argument(
|
release_parser.add_argument(
|
||||||
"release_type",
|
"release_type",
|
||||||
|
nargs="?",
|
||||||
choices=["major", "minor", "patch"],
|
choices=["major", "minor", "patch"],
|
||||||
help="Type of version increment for the release (major, minor, patch).",
|
help=(
|
||||||
|
"Type of version increment for the release (major, minor, patch). "
|
||||||
|
"Omit when `--retry` is set."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
release_parser.add_argument(
|
release_parser.add_argument(
|
||||||
@@ -61,3 +65,15 @@ def add_release_subparser(
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Do not run publish automatically after a successful release.",
|
help="Do not run publish automatically after a successful release.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
release_parser.add_argument(
|
||||||
|
"--retry",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Re-deploy the existing HEAD release without re-tagging or "
|
||||||
|
"modifying any files: re-push the existing version tag, "
|
||||||
|
"re-align the floating `latest` tag, and (unless --no-publish) "
|
||||||
|
"re-run publish. Use this to recover from a release whose "
|
||||||
|
"post-tag push or PyPI upload failed mid-flight."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pkgmgr.cli.context import CLIContext
|
|||||||
from pkgmgr.actions.repository.clone import clone_repos
|
from pkgmgr.actions.repository.clone import clone_repos
|
||||||
from pkgmgr.actions.proxy import exec_proxy_command
|
from pkgmgr.actions.proxy import exec_proxy_command
|
||||||
from pkgmgr.actions.repository.pull import pull_with_verification
|
from pkgmgr.actions.repository.pull import pull_with_verification
|
||||||
|
from pkgmgr.actions.repository.push import push_in_parallel
|
||||||
from pkgmgr.core.repository.selected import get_selected_repos
|
from pkgmgr.core.repository.selected import get_selected_repos
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
|
||||||
@@ -177,6 +178,17 @@ def register_proxy_commands(
|
|||||||
default=False,
|
default=False,
|
||||||
help="Disable verification via commit/gpg",
|
help="Disable verification via commit/gpg",
|
||||||
)
|
)
|
||||||
|
if subcommand in ("pull", "push"):
|
||||||
|
parser.add_argument(
|
||||||
|
"-j",
|
||||||
|
"--jobs",
|
||||||
|
type=int,
|
||||||
|
default=min(os.cpu_count() or 4, 8),
|
||||||
|
help=(
|
||||||
|
f"Number of parallel {subcommand}s "
|
||||||
|
"(default: min(cpu_count, 8)). Use 1 for sequential."
|
||||||
|
),
|
||||||
|
)
|
||||||
if subcommand == "clone":
|
if subcommand == "clone":
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--clone-mode",
|
"--clone-mode",
|
||||||
@@ -234,6 +246,16 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool:
|
|||||||
args.extra_args,
|
args.extra_args,
|
||||||
args.no_verification,
|
args.no_verification,
|
||||||
args.preview,
|
args.preview,
|
||||||
|
jobs=args.jobs,
|
||||||
|
)
|
||||||
|
elif args.command == "push":
|
||||||
|
push_in_parallel(
|
||||||
|
selected,
|
||||||
|
ctx.repositories_base_dir,
|
||||||
|
ctx.all_repositories,
|
||||||
|
args.extra_args,
|
||||||
|
args.preview,
|
||||||
|
jobs=args.jobs,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
exec_proxy_command(
|
exec_proxy_command(
|
||||||
|
|||||||
@@ -5,16 +5,6 @@ directories:
|
|||||||
workspaces: ~/Workspaces/
|
workspaces: ~/Workspaces/
|
||||||
binaries: ~/.local/bin/
|
binaries: ~/.local/bin/
|
||||||
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
|
||||||
@@ -274,12 +264,12 @@ repositories:
|
|||||||
gpg_keys:
|
gpg_keys:
|
||||||
- 44D8F11FD62F878E
|
- 44D8F11FD62F878E
|
||||||
- B5690EEEBB952194
|
- B5690EEEBB952194
|
||||||
- account: kevinveenbirkenbach
|
- account: infinito-nexus
|
||||||
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: infinito-nexus
|
repository: core
|
||||||
verified:
|
verified:
|
||||||
gpg_keys:
|
gpg_keys:
|
||||||
- 44D8F11FD62F878E
|
- 44D8F11FD62F878E
|
||||||
@@ -369,17 +359,6 @@ 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
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# src/pkgmgr/core/config/load.py
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
@@ -7,31 +8,28 @@ Load and merge pkgmgr configuration.
|
|||||||
Layering rules:
|
Layering rules:
|
||||||
|
|
||||||
1. Defaults / category files:
|
1. Defaults / category files:
|
||||||
- Zuerst werden alle *.yml/*.yaml (außer config.yaml) im
|
- First load all *.yml/*.yaml (except config.yaml) from the user directory:
|
||||||
Benutzerverzeichnis geladen:
|
|
||||||
~/.config/pkgmgr/
|
~/.config/pkgmgr/
|
||||||
|
|
||||||
- Falls dort keine passenden Dateien existieren, wird auf die im
|
- If no matching files exist there, fall back to defaults shipped with pkgmgr:
|
||||||
Paket / Projekt mitgelieferten Config-Verzeichnisse zurückgegriffen:
|
|
||||||
|
|
||||||
<pkg_root>/config_defaults
|
|
||||||
<pkg_root>/config
|
<pkg_root>/config
|
||||||
<project_root>/config_defaults
|
|
||||||
<project_root>/config
|
|
||||||
|
|
||||||
Dabei werden ebenfalls alle *.yml/*.yaml als Layer geladen.
|
During development (src-layout), we optionally also check:
|
||||||
|
<repo_root>/config
|
||||||
|
|
||||||
- Der Dateiname ohne Endung (stem) wird als Kategorie-Name
|
All *.yml/*.yaml files are loaded as layers.
|
||||||
verwendet und in repo["category_files"] eingetragen.
|
|
||||||
|
- The filename stem is used as category name and stored in repo["category_files"].
|
||||||
|
|
||||||
2. User config:
|
2. User config:
|
||||||
- ~/.config/pkgmgr/config.yaml (oder der übergebene Pfad)
|
- ~/.config/pkgmgr/config.yaml (or the provided path)
|
||||||
wird geladen und PER LISTEN-MERGE über die Defaults gelegt:
|
is loaded and merged over defaults:
|
||||||
- directories: dict deep-merge
|
- directories: dict deep-merge
|
||||||
- repositories: per _merge_repo_lists (kein Löschen!)
|
- repositories: per _merge_repo_lists (no deletions!)
|
||||||
|
|
||||||
3. Ergebnis:
|
3. Result:
|
||||||
- Ein dict mit mindestens:
|
- A dict with at least:
|
||||||
config["directories"] (dict)
|
config["directories"] (dict)
|
||||||
config["repositories"] (list[dict])
|
config["repositories"] (list[dict])
|
||||||
"""
|
"""
|
||||||
@@ -40,7 +38,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Tuple, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -48,7 +46,7 @@ Repo = Dict[str, Any]
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Hilfsfunktionen
|
# Helper functions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -85,17 +83,16 @@ def _merge_repo_lists(
|
|||||||
"""
|
"""
|
||||||
Merge two repository lists, matching by (provider, account, repository).
|
Merge two repository lists, matching by (provider, account, repository).
|
||||||
|
|
||||||
- Wenn ein Repo aus new_list noch nicht existiert, wird es hinzugefügt.
|
- If a repo from new_list does not exist, it is added.
|
||||||
- Wenn es existiert, werden seine Felder per Deep-Merge überschrieben.
|
- If it exists, its fields are deep-merged (override wins).
|
||||||
- Wenn category_name gesetzt ist, wird dieser in
|
- If category_name is set, it is appended to repo["category_files"].
|
||||||
repo["category_files"] eingetragen.
|
|
||||||
"""
|
"""
|
||||||
index: Dict[Tuple[str, str, str], Repo] = {_repo_key(r): r for r in base_list}
|
index: Dict[Tuple[str, str, str], Repo] = {_repo_key(r): r for r in base_list}
|
||||||
|
|
||||||
for src in new_list:
|
for src in new_list:
|
||||||
key = _repo_key(src)
|
key = _repo_key(src)
|
||||||
if key == ("", "", ""):
|
if key == ("", "", ""):
|
||||||
# Unvollständiger Schlüssel -> einfach anhängen
|
# Incomplete key -> append as-is
|
||||||
dst = dict(src)
|
dst = dict(src)
|
||||||
if category_name:
|
if category_name:
|
||||||
dst.setdefault("category_files", [])
|
dst.setdefault("category_files", [])
|
||||||
@@ -143,10 +140,9 @@ def _load_layer_dir(
|
|||||||
"""
|
"""
|
||||||
Load all *.yml/*.yaml from a directory as layered defaults.
|
Load all *.yml/*.yaml from a directory as layered defaults.
|
||||||
|
|
||||||
- skip_filename: Dateiname (z.B. "config.yaml"), der ignoriert
|
- skip_filename: filename (e.g. "config.yaml") to ignore.
|
||||||
werden soll (z.B. User-Config).
|
|
||||||
|
|
||||||
Rückgabe:
|
Returns:
|
||||||
{
|
{
|
||||||
"directories": {...},
|
"directories": {...},
|
||||||
"repositories": [...],
|
"repositories": [...],
|
||||||
@@ -171,7 +167,7 @@ def _load_layer_dir(
|
|||||||
|
|
||||||
for path in yaml_files:
|
for path in yaml_files:
|
||||||
data = _load_yaml_file(path)
|
data = _load_yaml_file(path)
|
||||||
category_name = path.stem # Dateiname ohne .yml/.yaml
|
category_name = path.stem
|
||||||
|
|
||||||
dirs = data.get("directories")
|
dirs = data.get("directories")
|
||||||
if isinstance(dirs, dict):
|
if isinstance(dirs, dict):
|
||||||
@@ -192,8 +188,11 @@ def _load_layer_dir(
|
|||||||
|
|
||||||
def _load_defaults_from_package_or_project() -> Dict[str, Any]:
|
def _load_defaults_from_package_or_project() -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Fallback: load default configs from various possible install or development
|
Fallback: load default configs from possible install or dev layouts.
|
||||||
layouts (pip-installed, editable install, source repo with src/ layout).
|
|
||||||
|
Supported locations:
|
||||||
|
- <pkg_root>/config (installed wheel / editable)
|
||||||
|
- <repo_root>/config (optional dev fallback when pkg_root is src/pkgmgr)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import pkgmgr # type: ignore
|
import pkgmgr # type: ignore
|
||||||
@@ -201,25 +200,16 @@ def _load_defaults_from_package_or_project() -> Dict[str, Any]:
|
|||||||
return {"directories": {}, "repositories": []}
|
return {"directories": {}, "repositories": []}
|
||||||
|
|
||||||
pkg_root = Path(pkgmgr.__file__).resolve().parent
|
pkg_root = Path(pkgmgr.__file__).resolve().parent
|
||||||
roots = set()
|
candidates: List[Path] = []
|
||||||
|
|
||||||
# Case 1: installed package (site-packages/pkgmgr)
|
# Always prefer package-internal config dir
|
||||||
roots.add(pkg_root)
|
candidates.append(pkg_root / "config")
|
||||||
|
|
||||||
# Case 2: parent directory (site-packages/, src/)
|
# Dev fallback: repo_root/src/pkgmgr -> repo_root/config
|
||||||
roots.add(pkg_root.parent)
|
|
||||||
|
|
||||||
# Case 3: src-layout during development:
|
|
||||||
# repo_root/src/pkgmgr -> repo_root
|
|
||||||
parent = pkg_root.parent
|
parent = pkg_root.parent
|
||||||
if parent.name == "src":
|
if parent.name == "src":
|
||||||
roots.add(parent.parent)
|
repo_root = parent.parent
|
||||||
|
candidates.append(repo_root / "config")
|
||||||
# Candidate config dirs
|
|
||||||
candidates = []
|
|
||||||
for root in roots:
|
|
||||||
candidates.append(root / "config_defaults")
|
|
||||||
candidates.append(root / "config")
|
|
||||||
|
|
||||||
for cand in candidates:
|
for cand in candidates:
|
||||||
defaults = _load_layer_dir(cand, skip_filename=None)
|
defaults = _load_layer_dir(cand, skip_filename=None)
|
||||||
@@ -230,7 +220,7 @@ def _load_defaults_from_package_or_project() -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Hauptfunktion
|
# Public API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -238,53 +228,49 @@ def load_config(user_config_path: str) -> Dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
Load and merge configuration for pkgmgr.
|
Load and merge configuration for pkgmgr.
|
||||||
|
|
||||||
Schritte:
|
Steps:
|
||||||
1. Ermittle ~/.config/pkgmgr/ (oder das Verzeichnis von user_config_path).
|
1. Determine ~/.config/pkgmgr/ (or dir of user_config_path).
|
||||||
2. Lade alle *.yml/*.yaml dort (außer der User-Config selbst) als
|
2. Load all *.yml/*.yaml in that dir (except the user config file) as defaults.
|
||||||
Defaults / Kategorie-Layer.
|
3. If nothing found, fall back to package defaults.
|
||||||
3. Wenn dort nichts gefunden wurde, Fallback auf Paket/Projekt.
|
4. Load the user config file (if present).
|
||||||
4. Lade die User-Config-Datei selbst (falls vorhanden).
|
|
||||||
5. Merge:
|
5. Merge:
|
||||||
- directories: deep-merge (Defaults <- User)
|
- directories: deep-merge (defaults <- user)
|
||||||
- repositories: _merge_repo_lists (Defaults <- User)
|
- repositories: _merge_repo_lists (defaults <- user)
|
||||||
"""
|
"""
|
||||||
user_config_path_expanded = os.path.expanduser(user_config_path)
|
user_config_path_expanded = os.path.expanduser(user_config_path)
|
||||||
user_cfg_path = Path(user_config_path_expanded)
|
user_cfg_path = Path(user_config_path_expanded)
|
||||||
|
|
||||||
config_dir = user_cfg_path.parent
|
config_dir = user_cfg_path.parent
|
||||||
if not str(config_dir):
|
if not str(config_dir):
|
||||||
# Fallback, falls jemand nur "config.yaml" übergibt
|
|
||||||
config_dir = Path(os.path.expanduser("~/.config/pkgmgr"))
|
config_dir = Path(os.path.expanduser("~/.config/pkgmgr"))
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
user_cfg_name = user_cfg_path.name
|
user_cfg_name = user_cfg_path.name
|
||||||
|
|
||||||
# 1+2) Defaults / Kategorie-Layer aus dem User-Verzeichnis
|
# 1+2) Defaults from user directory
|
||||||
defaults = _load_layer_dir(config_dir, skip_filename=user_cfg_name)
|
defaults = _load_layer_dir(config_dir, skip_filename=user_cfg_name)
|
||||||
|
|
||||||
# 3) Falls dort nichts gefunden wurde, Fallback auf Paket/Projekt
|
# 3) Fallback to package defaults
|
||||||
if not defaults["directories"] and not defaults["repositories"]:
|
if not defaults["directories"] and not defaults["repositories"]:
|
||||||
defaults = _load_defaults_from_package_or_project()
|
defaults = _load_defaults_from_package_or_project()
|
||||||
|
|
||||||
defaults.setdefault("directories", {})
|
defaults.setdefault("directories", {})
|
||||||
defaults.setdefault("repositories", [])
|
defaults.setdefault("repositories", [])
|
||||||
|
|
||||||
# 4) User-Config
|
# 4) User config
|
||||||
user_cfg: Dict[str, Any] = {}
|
user_cfg: Dict[str, Any] = {}
|
||||||
if user_cfg_path.is_file():
|
if user_cfg_path.is_file():
|
||||||
user_cfg = _load_yaml_file(user_cfg_path)
|
user_cfg = _load_yaml_file(user_cfg_path)
|
||||||
user_cfg.setdefault("directories", {})
|
user_cfg.setdefault("directories", {})
|
||||||
user_cfg.setdefault("repositories", [])
|
user_cfg.setdefault("repositories", [])
|
||||||
|
|
||||||
# 5) Merge: directories deep-merge, repositories listen-merge
|
# 5) Merge
|
||||||
merged: Dict[str, Any] = {}
|
merged: Dict[str, Any] = {}
|
||||||
|
|
||||||
# directories
|
|
||||||
merged["directories"] = {}
|
merged["directories"] = {}
|
||||||
_deep_merge(merged["directories"], defaults["directories"])
|
_deep_merge(merged["directories"], defaults["directories"])
|
||||||
_deep_merge(merged["directories"], user_cfg["directories"])
|
_deep_merge(merged["directories"], user_cfg["directories"])
|
||||||
|
|
||||||
# repositories
|
|
||||||
merged["repositories"] = []
|
merged["repositories"] = []
|
||||||
_merge_repo_lists(
|
_merge_repo_lists(
|
||||||
merged["repositories"], defaults["repositories"], category_name=None
|
merged["repositories"], defaults["repositories"], category_name=None
|
||||||
@@ -293,7 +279,7 @@ def load_config(user_config_path: str) -> Dict[str, Any]:
|
|||||||
merged["repositories"], user_cfg["repositories"], category_name=None
|
merged["repositories"], user_cfg["repositories"], category_name=None
|
||||||
)
|
)
|
||||||
|
|
||||||
# andere Top-Level-Keys (falls vorhanden)
|
# Merge other top-level keys
|
||||||
other_keys = (set(defaults.keys()) | set(user_cfg.keys())) - {
|
other_keys = (set(defaults.keys()) | set(user_cfg.keys())) - {
|
||||||
"directories",
|
"directories",
|
||||||
"repositories",
|
"repositories",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .pull import GitPullError, pull
|
|||||||
from .pull_args import GitPullArgsError, pull_args
|
from .pull_args import GitPullArgsError, pull_args
|
||||||
from .pull_ff_only import GitPullFfOnlyError, pull_ff_only
|
from .pull_ff_only import GitPullFfOnlyError, pull_ff_only
|
||||||
from .push import GitPushError, push
|
from .push import GitPushError, push
|
||||||
|
from .push_args import GitPushArgsError, push_args
|
||||||
from .push_upstream import GitPushUpstreamError, push_upstream
|
from .push_upstream import GitPushUpstreamError, push_upstream
|
||||||
from .set_remote_url import GitSetRemoteUrlError, set_remote_url
|
from .set_remote_url import GitSetRemoteUrlError, set_remote_url
|
||||||
from .tag_annotated import GitTagAnnotatedError, tag_annotated
|
from .tag_annotated import GitTagAnnotatedError, tag_annotated
|
||||||
@@ -34,6 +35,7 @@ __all__ = [
|
|||||||
"pull_ff_only",
|
"pull_ff_only",
|
||||||
"merge_no_ff",
|
"merge_no_ff",
|
||||||
"push",
|
"push",
|
||||||
|
"push_args",
|
||||||
"commit",
|
"commit",
|
||||||
"delete_local_branch",
|
"delete_local_branch",
|
||||||
"delete_remote_branch",
|
"delete_remote_branch",
|
||||||
@@ -56,6 +58,7 @@ __all__ = [
|
|||||||
"GitPullFfOnlyError",
|
"GitPullFfOnlyError",
|
||||||
"GitMergeError",
|
"GitMergeError",
|
||||||
"GitPushError",
|
"GitPushError",
|
||||||
|
"GitPushArgsError",
|
||||||
"GitCommitError",
|
"GitCommitError",
|
||||||
"GitDeleteLocalBranchError",
|
"GitDeleteLocalBranchError",
|
||||||
"GitDeleteRemoteBranchError",
|
"GitDeleteRemoteBranchError",
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ def pull_args(
|
|||||||
try:
|
try:
|
||||||
run(["pull", *extra], cwd=cwd, preview=preview)
|
run(["pull", *extra], cwd=cwd, preview=preview)
|
||||||
except GitRunError as exc:
|
except GitRunError as exc:
|
||||||
|
details = getattr(exc, "output", None) or getattr(exc, "stderr", None) or ""
|
||||||
raise GitPullArgsError(
|
raise GitPullArgsError(
|
||||||
f"Failed to run `git pull` with args={extra!r}.",
|
(
|
||||||
|
f"Failed to run `git pull` with args={extra!r} "
|
||||||
|
f"in cwd={cwd!r}.\n{details}"
|
||||||
|
).rstrip(),
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
) from exc
|
) from exc
|
||||||
|
|||||||
39
src/pkgmgr/core/git/commands/push_args.py
Normal file
39
src/pkgmgr/core/git/commands/push_args.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ..errors import GitRunError, GitCommandError
|
||||||
|
from ..run import run
|
||||||
|
|
||||||
|
|
||||||
|
class GitPushArgsError(GitCommandError):
|
||||||
|
"""Raised when `git push` with arbitrary args fails."""
|
||||||
|
|
||||||
|
|
||||||
|
def push_args(
|
||||||
|
args: List[str] | None = None,
|
||||||
|
*,
|
||||||
|
cwd: str = ".",
|
||||||
|
preview: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Execute `git push` with caller-provided arguments.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
[] -> git push
|
||||||
|
["--force"] -> git push --force
|
||||||
|
["origin", "main"] -> git push origin main
|
||||||
|
["-u", "origin", "feature"] -> git push -u origin feature
|
||||||
|
"""
|
||||||
|
extra = args or []
|
||||||
|
try:
|
||||||
|
run(["push", *extra], cwd=cwd, preview=preview)
|
||||||
|
except GitRunError as exc:
|
||||||
|
details = getattr(exc, "output", None) or getattr(exc, "stderr", None) or ""
|
||||||
|
raise GitPushArgsError(
|
||||||
|
(
|
||||||
|
f"Failed to run `git push` with args={extra!r} "
|
||||||
|
f"in cwd={cwd!r}.\n{details}"
|
||||||
|
).rstrip(),
|
||||||
|
cwd=cwd,
|
||||||
|
) from exc
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ..errors import GitQueryError, GitRunError
|
import subprocess
|
||||||
from ..run import run
|
|
||||||
|
from ..errors import GitNotRepositoryError, GitQueryError
|
||||||
|
|
||||||
|
|
||||||
class GitLatestSigningKeyQueryError(GitQueryError):
|
class GitLatestSigningKeyQueryError(GitQueryError):
|
||||||
"""Raised when querying the latest commit signing key fails."""
|
"""Raised when querying the latest commit signing key fails."""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_not_repository(stderr: str) -> bool:
|
||||||
|
return "not a git repository" in (stderr or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_gpg_runtime_error(stderr: str) -> bool:
|
||||||
|
lowered = (stderr or "").lower()
|
||||||
|
markers = (
|
||||||
|
"cannot run gpg",
|
||||||
|
"can't check signature",
|
||||||
|
"no public key",
|
||||||
|
"failed to create temporary file",
|
||||||
|
"can't connect to the keyboxd",
|
||||||
|
"error opening key db",
|
||||||
|
"gpg failed",
|
||||||
|
"no such file or directory",
|
||||||
|
)
|
||||||
|
return any(marker in lowered for marker in markers)
|
||||||
|
|
||||||
|
|
||||||
def get_latest_signing_key(*, cwd: str = ".") -> str:
|
def get_latest_signing_key(*, cwd: str = ".") -> str:
|
||||||
"""
|
"""
|
||||||
Return the GPG signing key ID of the latest commit, via:
|
Return the GPG signing key ID of the latest commit, via:
|
||||||
@@ -17,9 +37,46 @@ def get_latest_signing_key(*, cwd: str = ".") -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
The key id string (may be empty if commit is not signed).
|
The key id string (may be empty if commit is not signed).
|
||||||
"""
|
"""
|
||||||
|
cmd = ["git", "log", "-1", "--format=%GK"]
|
||||||
try:
|
try:
|
||||||
return run(["log", "-1", "--format=%GK"], cwd=cwd).strip()
|
result = subprocess.run(
|
||||||
except GitRunError as exc:
|
cmd,
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
raise GitLatestSigningKeyQueryError(
|
raise GitLatestSigningKeyQueryError(
|
||||||
"Failed to query latest signing key.",
|
"Failed to query latest signing key.\n"
|
||||||
|
f"Command: {' '.join(cmd)}\n"
|
||||||
|
f"Reason: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
stdout = (result.stdout or "").strip()
|
||||||
|
stderr = (result.stderr or "").strip()
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
if _is_not_repository(stderr):
|
||||||
|
raise GitNotRepositoryError(
|
||||||
|
f"Not a git repository: {cwd!r}\n"
|
||||||
|
f"Command: {' '.join(cmd)}\n"
|
||||||
|
f"STDERR:\n{stderr}"
|
||||||
|
)
|
||||||
|
raise GitLatestSigningKeyQueryError(
|
||||||
|
"Failed to query latest signing key.\n"
|
||||||
|
f"Command: {' '.join(cmd)}\n"
|
||||||
|
f"Exit code: {result.returncode}\n"
|
||||||
|
f"STDOUT:\n{stdout}\n"
|
||||||
|
f"STDERR:\n{stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not stdout and stderr and _looks_like_gpg_runtime_error(stderr):
|
||||||
|
raise GitLatestSigningKeyQueryError(
|
||||||
|
"Failed to query latest signing key.\n"
|
||||||
|
f"Command: {' '.join(cmd)}\n"
|
||||||
|
f"STDERR:\n{stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class RepoPaths:
|
|||||||
# Packaging-related files
|
# Packaging-related files
|
||||||
arch_pkgbuild: Optional[str]
|
arch_pkgbuild: Optional[str]
|
||||||
debian_changelog: Optional[str]
|
debian_changelog: Optional[str]
|
||||||
|
debian_control: Optional[str]
|
||||||
rpm_spec: Optional[str]
|
rpm_spec: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +103,13 @@ def resolve_repo_paths(repo_dir: str) -> RepoPaths:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
debian_control = _first_existing(
|
||||||
|
[
|
||||||
|
os.path.join(repo_dir, "packaging", "debian", "control"),
|
||||||
|
os.path.join(repo_dir, "debian", "control"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# RPM spec: prefer the canonical file, else first spec in packaging/fedora, else first spec in repo root.
|
# RPM spec: prefer the canonical file, else first spec in packaging/fedora, else first spec in repo root.
|
||||||
rpm_spec = _first_existing(
|
rpm_spec = _first_existing(
|
||||||
[
|
[
|
||||||
@@ -122,5 +130,6 @@ def resolve_repo_paths(repo_dir: str) -> RepoPaths:
|
|||||||
changelog_md=changelog_md,
|
changelog_md=changelog_md,
|
||||||
arch_pkgbuild=arch_pkgbuild,
|
arch_pkgbuild=arch_pkgbuild,
|
||||||
debian_changelog=debian_changelog,
|
debian_changelog=debian_changelog,
|
||||||
|
debian_control=debian_control,
|
||||||
rpm_spec=rpm_spec,
|
rpm_spec=rpm_spec,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ def verify_repository(repo, repo_dir, mode="local", no_verification=False):
|
|||||||
|
|
||||||
commit_hash = ""
|
commit_hash = ""
|
||||||
signing_key = ""
|
signing_key = ""
|
||||||
|
signing_key_query_failed = False
|
||||||
|
|
||||||
# best-effort info collection
|
# best-effort info collection
|
||||||
try:
|
try:
|
||||||
@@ -59,6 +60,7 @@ def verify_repository(repo, repo_dir, mode="local", no_verification=False):
|
|||||||
except GitLatestSigningKeyQueryError as exc:
|
except GitLatestSigningKeyQueryError as exc:
|
||||||
error_details.append(str(exc))
|
error_details.append(str(exc))
|
||||||
signing_key = ""
|
signing_key = ""
|
||||||
|
signing_key_query_failed = True
|
||||||
|
|
||||||
commit_check_passed = True
|
commit_check_passed = True
|
||||||
gpg_check_passed = True
|
gpg_check_passed = True
|
||||||
@@ -78,9 +80,10 @@ def verify_repository(repo, repo_dir, mode="local", no_verification=False):
|
|||||||
if expected_gpg_keys:
|
if expected_gpg_keys:
|
||||||
if not signing_key:
|
if not signing_key:
|
||||||
gpg_check_passed = False
|
gpg_check_passed = False
|
||||||
error_details.append(
|
if not signing_key_query_failed:
|
||||||
f"Expected one of GPG keys: {expected_gpg_keys}, but no signing key was found."
|
error_details.append(
|
||||||
)
|
f"Expected one of GPG keys: {expected_gpg_keys}, but no signing key was found."
|
||||||
|
)
|
||||||
elif signing_key not in expected_gpg_keys:
|
elif signing_key not in expected_gpg_keys:
|
||||||
gpg_check_passed = False
|
gpg_check_passed = False
|
||||||
error_details.append(
|
error_details.append(
|
||||||
|
|||||||
1
src/pkgmgr/github/__init__.py
Normal file
1
src/pkgmgr/github/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""GitHub-related Python helpers for pkgmgr."""
|
||||||
28
src/pkgmgr/github/check_hadolint_sarif.py
Normal file
28
src/pkgmgr/github/check_hadolint_sarif.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail when a hadolint SARIF report contains warnings or errors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
sarif_path = Path(sys.argv[1] if len(sys.argv) > 1 else "hadolint-results.sarif")
|
||||||
|
|
||||||
|
with sarif_path.open("r", encoding="utf-8") as handle:
|
||||||
|
sarif = json.load(handle)
|
||||||
|
|
||||||
|
results = sarif.get("runs", [{}])[0].get("results", [])
|
||||||
|
levels = [result.get("level", "") for result in results]
|
||||||
|
warnings = sum(1 for level in levels if level == "warning")
|
||||||
|
errors = sum(1 for level in levels if level == "error")
|
||||||
|
|
||||||
|
print(f"SARIF results: total={len(results)} warnings={warnings} errors={errors}")
|
||||||
|
|
||||||
|
return 1 if warnings + errors > 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
118
tests/integration/test_config_defaults_integration.py
Normal file
118
tests/integration/test_config_defaults_integration.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# tests/integration/test_config_defaults_integration.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from pkgmgr.core.config.load import load_config
|
||||||
|
from pkgmgr.cli.commands import config as config_cmd
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigDefaultsIntegrationTest(unittest.TestCase):
|
||||||
|
def test_defaults_yaml_is_loaded_and_can_be_copied_to_user_config_dir(self):
|
||||||
|
"""
|
||||||
|
Integration test:
|
||||||
|
- Create a temp "site-packages/pkgmgr" fake install root
|
||||||
|
- Put defaults under "<pkg_root>/config/defaults.yaml"
|
||||||
|
- Verify:
|
||||||
|
A) load_config() picks up defaults from that config folder when user dir has no defaults
|
||||||
|
B) _update_default_configs() copies defaults.yaml into ~/.config/pkgmgr/
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
|
||||||
|
# Fake HOME for user config
|
||||||
|
home = root / "home"
|
||||||
|
user_cfg_dir = home / ".config" / "pkgmgr"
|
||||||
|
user_cfg_dir.mkdir(parents=True)
|
||||||
|
user_config_path = str(user_cfg_dir / "config.yaml")
|
||||||
|
|
||||||
|
# Create a user config file that should NOT be overwritten by update
|
||||||
|
(user_cfg_dir / "config.yaml").write_text(
|
||||||
|
yaml.safe_dump({"directories": {"user_only": "/home/user"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fake pkg install layout:
|
||||||
|
# pkg_root = <root>/site-packages/pkgmgr
|
||||||
|
site_packages = root / "site-packages"
|
||||||
|
pkg_root = site_packages / "pkgmgr"
|
||||||
|
pkg_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
# defaults live inside the package now: <pkg_root>/config/defaults.yaml
|
||||||
|
config_dir = pkg_root / "config"
|
||||||
|
config_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
defaults_payload = {
|
||||||
|
"directories": {
|
||||||
|
"repositories": "/opt/Repositories",
|
||||||
|
"binaries": "/usr/local/bin",
|
||||||
|
},
|
||||||
|
"repositories": [
|
||||||
|
{"provider": "github", "account": "acme", "repository": "demo"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(config_dir / "defaults.yaml").write_text(
|
||||||
|
yaml.safe_dump(defaults_payload),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provide fake pkgmgr module so your functions resolve pkg_root correctly
|
||||||
|
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||||
|
|
||||||
|
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||||
|
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||||
|
# A) load_config should fall back to <pkg_root>/config/defaults.yaml
|
||||||
|
merged = load_config(user_config_path)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
merged["directories"]["repositories"], "/opt/Repositories"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
merged["directories"]["binaries"], "/usr/local/bin"
|
||||||
|
)
|
||||||
|
|
||||||
|
# user-only key must still exist (user config merges over defaults)
|
||||||
|
self.assertEqual(merged["directories"]["user_only"], "/home/user")
|
||||||
|
|
||||||
|
self.assertIn("repositories", merged)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
r.get("provider") == "github"
|
||||||
|
and r.get("account") == "acme"
|
||||||
|
and r.get("repository") == "demo"
|
||||||
|
for r in merged["repositories"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# B) update_default_configs should copy defaults.yaml to ~/.config/pkgmgr/
|
||||||
|
before_config_yaml = (user_cfg_dir / "config.yaml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
config_cmd._update_default_configs(user_config_path)
|
||||||
|
|
||||||
|
self.assertTrue((user_cfg_dir / "defaults.yaml").is_file())
|
||||||
|
copied_defaults = yaml.safe_load(
|
||||||
|
(user_cfg_dir / "defaults.yaml").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
copied_defaults["directories"]["repositories"],
|
||||||
|
"/opt/Repositories",
|
||||||
|
)
|
||||||
|
|
||||||
|
after_config_yaml = (user_cfg_dir / "config.yaml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertEqual(after_config_yaml, before_config_yaml)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _find_repo_root() -> Path:
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
for parent in here.parents:
|
||||||
|
if (parent / "pyproject.toml").is_file() and (
|
||||||
|
parent / "src" / "pkgmgr"
|
||||||
|
).is_dir():
|
||||||
|
return parent
|
||||||
|
raise RuntimeError(
|
||||||
|
"Could not determine repository root for pkgmgr integration test"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitVerificationRuntimeDependencies(unittest.TestCase):
|
||||||
|
def test_flake_app_includes_git_and_gpg_runtime_tools(self) -> None:
|
||||||
|
repo_root = _find_repo_root()
|
||||||
|
flake_text = (repo_root / "flake.nix").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("pkgs.git", flake_text)
|
||||||
|
self.assertIn("pkgs.gnupg", flake_text)
|
||||||
|
|
||||||
|
def test_distro_dependency_scripts_install_gpg_tools(self) -> None:
|
||||||
|
repo_root = _find_repo_root()
|
||||||
|
expected_packages = {
|
||||||
|
"arch": "gnupg",
|
||||||
|
"debian": "gnupg",
|
||||||
|
"ubuntu": "gnupg",
|
||||||
|
"fedora": "gnupg2",
|
||||||
|
"centos": "gnupg2",
|
||||||
|
}
|
||||||
|
|
||||||
|
missing: list[str] = []
|
||||||
|
for distro, package_name in expected_packages.items():
|
||||||
|
script_path = (
|
||||||
|
repo_root / "scripts" / "installation" / distro / "dependencies.sh"
|
||||||
|
)
|
||||||
|
content = script_path.read_text(encoding="utf-8")
|
||||||
|
if not re.search(rf"\b{re.escape(package_name)}\b", content):
|
||||||
|
missing.append(
|
||||||
|
f"{distro}: expected package {package_name} in {script_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
self.fail(
|
||||||
|
"Git signature verification runtime dependencies are incomplete:\n"
|
||||||
|
+ "\n".join(f" - {item}" for item in missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
0
tests/unit/pkgmgr/actions/archive/__init__.py
Normal file
0
tests/unit/pkgmgr/actions/archive/__init__.py
Normal file
49
tests/unit/pkgmgr/actions/archive/test_discovery.py
Normal file
49
tests/unit/pkgmgr/actions/archive/test_discovery.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Unit tests for `pkgmgr.actions.archive.discovery`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pkgmgr.actions.archive.discovery import iter_archivable_files
|
||||||
|
|
||||||
|
|
||||||
|
class TestIterArchivableFiles(unittest.TestCase):
|
||||||
|
def test_only_numbered_markdown_files_are_returned_and_sorted(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
(d / "001-alpha.md").write_text("# 001 - A")
|
||||||
|
(d / "002-beta.md").write_text("# 002 - B")
|
||||||
|
(d / "README.md").write_text("# X")
|
||||||
|
(d / "notes.md").write_text("# X")
|
||||||
|
(d / "001-alpha.txt").write_text("x")
|
||||||
|
|
||||||
|
result = [p.name for p in iter_archivable_files(d, include_template=True)]
|
||||||
|
self.assertEqual(result, ["001-alpha.md", "002-beta.md"])
|
||||||
|
|
||||||
|
def test_template_is_skipped_unless_opted_in(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
(d / "000-template.md").write_text("# 000 - Template")
|
||||||
|
(d / "001-alpha.md").write_text("# 001 - A")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[p.name for p in iter_archivable_files(d, include_template=False)],
|
||||||
|
["001-alpha.md"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[p.name for p in iter_archivable_files(d, include_template=True)],
|
||||||
|
["000-template.md", "001-alpha.md"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_directory_returns_empty(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self.assertEqual(
|
||||||
|
iter_archivable_files(Path(tmp) / "missing", include_template=False),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
62
tests/unit/pkgmgr/actions/archive/test_inspect.py
Normal file
62
tests/unit/pkgmgr/actions/archive/test_inspect.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Unit tests for `pkgmgr.actions.archive.inspect`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pkgmgr.actions.archive.inspect import count_unchecked_items, extract_h1
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractH1(unittest.TestCase):
|
||||||
|
def test_returns_first_h1_title(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "001-x.md"
|
||||||
|
path.write_text("\n# 001 - Title\n\n## Subsection\n# Later H1\n")
|
||||||
|
self.assertEqual(extract_h1(path), "001 - Title")
|
||||||
|
|
||||||
|
def test_returns_none_when_no_h1(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "001-x.md"
|
||||||
|
path.write_text("no heading here\n## h2 only\n")
|
||||||
|
self.assertIsNone(extract_h1(path))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCountUncheckedItems(unittest.TestCase):
|
||||||
|
def test_zero_when_all_checked(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "001-x.md"
|
||||||
|
path.write_text(
|
||||||
|
"# 001 - Done\n\n## Acceptance Criteria\n\n- [x] A\n- [x] B\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(count_unchecked_items(path), 0)
|
||||||
|
|
||||||
|
def test_counts_unchecked_anywhere_in_file(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "001-x.md"
|
||||||
|
path.write_text(
|
||||||
|
"# 001 - In progress\n\n"
|
||||||
|
"## Acceptance Criteria\n\n"
|
||||||
|
"- [x] A\n"
|
||||||
|
"- [ ] B\n\n"
|
||||||
|
"## Notes\n\n"
|
||||||
|
"- [ ] still tracking this one too\n"
|
||||||
|
" - [ ] nested unchecked\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(count_unchecked_items(path), 3)
|
||||||
|
|
||||||
|
def test_ignores_non_task_dashes(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "001-x.md"
|
||||||
|
path.write_text(
|
||||||
|
"# 001 - X\n\n"
|
||||||
|
"- plain list item\n"
|
||||||
|
"- [x] checked\n"
|
||||||
|
"- not [ ] not a task marker\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(count_unchecked_items(path), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
73
tests/unit/pkgmgr/actions/archive/test_readme.py
Normal file
73
tests/unit/pkgmgr/actions/archive/test_readme.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Unit tests for `pkgmgr.actions.archive.readme`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pkgmgr.actions.archive.readme import (
|
||||||
|
existing_archive_entries,
|
||||||
|
merge_archive_section,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExistingArchiveEntries(unittest.TestCase):
|
||||||
|
def test_parses_only_archive_section_items(self) -> None:
|
||||||
|
readme = (
|
||||||
|
"# Requirements\n\n"
|
||||||
|
"Intro paragraph.\n\n"
|
||||||
|
"## Other\n\n"
|
||||||
|
"- not an archive entry\n\n"
|
||||||
|
"## Archive\n\n"
|
||||||
|
"- 001 - One\n"
|
||||||
|
"- 002 - Two\n\n"
|
||||||
|
"## Trailing\n\n"
|
||||||
|
"- ignored too\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
existing_archive_entries(readme),
|
||||||
|
{"001 - One", "002 - Two"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_empty_when_section_missing(self) -> None:
|
||||||
|
self.assertEqual(existing_archive_entries("# Title\n\nBody.\n"), set())
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeArchiveSection(unittest.TestCase):
|
||||||
|
def test_creates_section_when_missing(self) -> None:
|
||||||
|
readme = "# Requirements\n\nIntro.\n"
|
||||||
|
merged = merge_archive_section(readme, ["001 - One", "002 - Two"])
|
||||||
|
self.assertEqual(
|
||||||
|
merged,
|
||||||
|
"# Requirements\n\nIntro.\n\n## Archive\n\n- 001 - One\n- 002 - Two\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_appends_to_existing_section_preserving_entries(self) -> None:
|
||||||
|
readme = "# Requirements\n\nIntro.\n\n## Archive\n\n- 001 - One\n"
|
||||||
|
merged = merge_archive_section(readme, ["002 - Two", "003 - Three"])
|
||||||
|
self.assertEqual(
|
||||||
|
merged,
|
||||||
|
(
|
||||||
|
"# Requirements\n\n"
|
||||||
|
"Intro.\n\n"
|
||||||
|
"## Archive\n\n"
|
||||||
|
"- 001 - One\n"
|
||||||
|
"- 002 - Two\n"
|
||||||
|
"- 003 - Three\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_appends_before_trailing_section(self) -> None:
|
||||||
|
readme = "## Archive\n\n- 001 - One\n\n## Trailing\n\nfooter\n"
|
||||||
|
merged = merge_archive_section(readme, ["002 - Two"])
|
||||||
|
self.assertEqual(
|
||||||
|
merged,
|
||||||
|
"## Archive\n\n- 001 - One\n- 002 - Two\n\n## Trailing\n\nfooter\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_entries_returns_unchanged(self) -> None:
|
||||||
|
readme = "# X\n\nBody\n"
|
||||||
|
self.assertEqual(merge_archive_section(readme, []), readme)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
91
tests/unit/pkgmgr/actions/archive/test_workflow.py
Normal file
91
tests/unit/pkgmgr/actions/archive/test_workflow.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Unit tests for `pkgmgr.actions.archive.workflow.run_archive`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pkgmgr.actions.archive.workflow import run_archive
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunArchive(unittest.TestCase):
|
||||||
|
def test_dry_run_does_not_touch_files_or_readme(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
done = d / "001-done.md"
|
||||||
|
done.write_text("# 001 - Done\n\n- [x] ok\n")
|
||||||
|
readme = d / "README.md"
|
||||||
|
readme.write_text("# Specs\n\n## Archive\n")
|
||||||
|
|
||||||
|
plan = run_archive(d, readme, dry_run=True)
|
||||||
|
|
||||||
|
self.assertTrue(done.exists())
|
||||||
|
self.assertEqual(readme.read_text(), "# Specs\n\n## Archive\n")
|
||||||
|
self.assertEqual(plan.new_entries, ["001 - Done"])
|
||||||
|
self.assertEqual(plan.archived, [(done, "001 - Done")])
|
||||||
|
|
||||||
|
def test_real_run_archives_and_deletes_completed_file(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
done = d / "001-done.md"
|
||||||
|
done.write_text("# 001 - Done\n\n- [x] ok\n")
|
||||||
|
readme = d / "README.md"
|
||||||
|
readme.write_text("# Specs\n\n## Archive\n")
|
||||||
|
|
||||||
|
plan = run_archive(d, readme)
|
||||||
|
|
||||||
|
self.assertFalse(done.exists())
|
||||||
|
self.assertIn("- 001 - Done", readme.read_text())
|
||||||
|
self.assertEqual(plan.new_entries, ["001 - Done"])
|
||||||
|
|
||||||
|
def test_incomplete_files_are_skipped(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
wip = d / "002-wip.md"
|
||||||
|
wip.write_text("# 002 - WIP\n\n- [ ] open\n")
|
||||||
|
readme = d / "README.md"
|
||||||
|
readme.write_text("# Specs\n\n## Archive\n")
|
||||||
|
|
||||||
|
plan = run_archive(d, readme)
|
||||||
|
|
||||||
|
self.assertTrue(wip.exists())
|
||||||
|
self.assertEqual(plan.archived, [])
|
||||||
|
self.assertEqual(plan.skipped_incomplete, [(wip, 1)])
|
||||||
|
|
||||||
|
def test_already_archived_title_is_not_duplicated(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
done = d / "001-done.md"
|
||||||
|
done.write_text("# 001 - Done\n\n- [x] ok\n")
|
||||||
|
readme = d / "README.md"
|
||||||
|
readme.write_text("# Specs\n\n## Archive\n\n- 001 - Done\n")
|
||||||
|
|
||||||
|
plan = run_archive(d, readme)
|
||||||
|
|
||||||
|
self.assertEqual(plan.new_entries, [])
|
||||||
|
self.assertEqual(plan.archived, [(done, "001 - Done")])
|
||||||
|
# File still deleted; README unchanged.
|
||||||
|
self.assertFalse(done.exists())
|
||||||
|
self.assertEqual(
|
||||||
|
readme.read_text(),
|
||||||
|
"# Specs\n\n## Archive\n\n- 001 - Done\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_directory_raises_filenotfound(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
readme = Path(tmp) / "README.md"
|
||||||
|
readme.write_text("# X\n## Archive\n")
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
run_archive(Path(tmp) / "no-such-dir", readme)
|
||||||
|
|
||||||
|
def test_missing_readme_raises_filenotfound(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
(d / "001-done.md").write_text("# 001 - Done\n- [x] ok\n")
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
run_archive(d, d / "missing.md")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -256,7 +256,7 @@ class TestUpdateSpecVersion(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestUpdateChangelog(unittest.TestCase):
|
class TestUpdateChangelog(unittest.TestCase):
|
||||||
def test_update_changelog_creates_file_if_missing(self) -> None:
|
def test_update_changelog_creates_file_with_h1_if_missing(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
path = os.path.join(tmpdir, "CHANGELOG.md")
|
path = os.path.join(tmpdir, "CHANGELOG.md")
|
||||||
self.assertFalse(os.path.exists(path))
|
self.assertFalse(os.path.exists(path))
|
||||||
@@ -267,10 +267,35 @@ class TestUpdateChangelog(unittest.TestCase):
|
|||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
self.assertIn("## [1.2.3]", content)
|
# New file must lead with an H1 so markdownlint MD041 is happy.
|
||||||
|
self.assertTrue(content.startswith("# Changelog\n\n## [1.2.3]"))
|
||||||
self.assertIn("First release", content)
|
self.assertIn("First release", content)
|
||||||
|
|
||||||
def test_update_changelog_prepends_entry_to_existing_content(self) -> None:
|
def test_update_changelog_inserts_below_existing_h1(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "CHANGELOG.md")
|
||||||
|
existing = "# Changelog\n\n## [0.1.0] - 2024-01-01\n\n* Initial content\n"
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(existing)
|
||||||
|
|
||||||
|
update_changelog(path, "1.0.0", message="Second release", preview=False)
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# H1 still on top, new entry above the existing one.
|
||||||
|
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
|
||||||
|
# Exactly one H1.
|
||||||
|
self.assertEqual(
|
||||||
|
content.count("\n# Changelog\n") + content.startswith("# Changelog\n"), 1
|
||||||
|
)
|
||||||
|
# Old entry still present, after the new one.
|
||||||
|
self.assertLess(
|
||||||
|
content.index("## [1.0.0]"),
|
||||||
|
content.index("## [0.1.0]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_changelog_legacy_headerless_gets_h1_synthesized(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
path = os.path.join(tmpdir, "CHANGELOG.md")
|
path = os.path.join(tmpdir, "CHANGELOG.md")
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
@@ -281,7 +306,8 @@ class TestUpdateChangelog(unittest.TestCase):
|
|||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
self.assertTrue(content.startswith("## [1.0.0]"))
|
# An H1 is added so MD041 is satisfied even for legacy files.
|
||||||
|
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
|
||||||
self.assertIn("## [0.1.0] - 2024-01-01", content)
|
self.assertIn("## [0.1.0] - 2024-01-01", content)
|
||||||
|
|
||||||
def test_update_changelog_preview_does_not_write(self) -> None:
|
def test_update_changelog_preview_does_not_write(self) -> None:
|
||||||
|
|||||||
110
tests/unit/pkgmgr/actions/release/test_package_name.py
Normal file
110
tests/unit/pkgmgr/actions/release/test_package_name.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Unit tests for `pkgmgr.actions.release.package_name.resolve_package_name`.
|
||||||
|
|
||||||
|
The resolver must prefer the explicit name from existing packaging files
|
||||||
|
over the repository folder name so that renaming the folder does not
|
||||||
|
silently rename the distro package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pkgmgr.actions.release.package_name import resolve_package_name
|
||||||
|
from pkgmgr.core.repository.paths import RepoPaths
|
||||||
|
|
||||||
|
|
||||||
|
def _paths(
|
||||||
|
repo_dir: str,
|
||||||
|
*,
|
||||||
|
debian_control: Optional[str] = None,
|
||||||
|
arch_pkgbuild: Optional[str] = None,
|
||||||
|
rpm_spec: Optional[str] = None,
|
||||||
|
) -> RepoPaths:
|
||||||
|
return RepoPaths(
|
||||||
|
repo_dir=repo_dir,
|
||||||
|
pyproject_toml=os.path.join(repo_dir, "pyproject.toml"),
|
||||||
|
flake_nix=os.path.join(repo_dir, "flake.nix"),
|
||||||
|
changelog_md=None,
|
||||||
|
arch_pkgbuild=arch_pkgbuild,
|
||||||
|
debian_changelog=None,
|
||||||
|
debian_control=debian_control,
|
||||||
|
rpm_spec=rpm_spec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolvePackageName(unittest.TestCase):
|
||||||
|
def test_debian_control_wins_over_folder(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
|
||||||
|
control = Path(repo) / "control"
|
||||||
|
control.write_text(
|
||||||
|
"Source: infinito-nexus\nPackage: infinito-nexus\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_package_name(_paths(repo, debian_control=str(control))),
|
||||||
|
"infinito-nexus",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pkgbuild_used_when_no_control(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
|
||||||
|
pkgbuild = Path(repo) / "PKGBUILD"
|
||||||
|
pkgbuild.write_text(
|
||||||
|
"pkgname=infinito-nexus\npkgver=1.0\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_package_name(_paths(repo, arch_pkgbuild=str(pkgbuild))),
|
||||||
|
"infinito-nexus",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rpm_spec_used_when_no_control_no_pkgbuild(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
|
||||||
|
spec = Path(repo) / "pkg.spec"
|
||||||
|
spec.write_text("Name: infinito-nexus\n", encoding="utf-8")
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_package_name(_paths(repo, rpm_spec=str(spec))),
|
||||||
|
"infinito-nexus",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_folder_fallback_when_no_packaging_metadata(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="solo-tool_") as repo:
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_package_name(_paths(repo)),
|
||||||
|
os.path.basename(repo),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_priority_debian_over_pkgbuild_over_spec(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as repo:
|
||||||
|
control = Path(repo) / "control"
|
||||||
|
control.write_text("Package: deb-name\n", encoding="utf-8")
|
||||||
|
pkgbuild = Path(repo) / "PKGBUILD"
|
||||||
|
pkgbuild.write_text("pkgname=arch-name\n", encoding="utf-8")
|
||||||
|
spec = Path(repo) / "x.spec"
|
||||||
|
spec.write_text("Name: rpm-name\n", encoding="utf-8")
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_package_name(
|
||||||
|
_paths(
|
||||||
|
repo,
|
||||||
|
debian_control=str(control),
|
||||||
|
arch_pkgbuild=str(pkgbuild),
|
||||||
|
rpm_spec=str(spec),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"deb-name",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strips_quotes_in_pkgbuild(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as repo:
|
||||||
|
pkgbuild = Path(repo) / "PKGBUILD"
|
||||||
|
pkgbuild.write_text("pkgname='quoted-name'\n", encoding="utf-8")
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_package_name(_paths(repo, arch_pkgbuild=str(pkgbuild))),
|
||||||
|
"quoted-name",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
154
tests/unit/pkgmgr/actions/release/test_retry.py
Normal file
154
tests/unit/pkgmgr/actions/release/test_retry.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Unit tests for the `release(retry=True)` re-deploy entry point.
|
||||||
|
|
||||||
|
`TestReleaseRetry` exercises the routing decision in `release()` —
|
||||||
|
that `--retry` skips the full `_release_impl` flow and dispatches to
|
||||||
|
`retry_release` instead.
|
||||||
|
|
||||||
|
`TestRetryRelease` exercises the standalone module `pkgmgr.actions.release.retry`:
|
||||||
|
HEAD-tag discovery, idempotent push, latest-tag re-alignment, and
|
||||||
|
fallback behaviour when the current branch cannot be detected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pkgmgr.actions.release.retry import retry_release
|
||||||
|
from pkgmgr.actions.release.workflow import release
|
||||||
|
from pkgmgr.core.git import GitRunError
|
||||||
|
|
||||||
|
|
||||||
|
class TestReleaseRetry(unittest.TestCase):
|
||||||
|
@patch("pkgmgr.actions.release.workflow.retry_release")
|
||||||
|
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||||
|
def test_retry_routes_to_retry_release_only(
|
||||||
|
self, mock_release_impl, mock_retry_release
|
||||||
|
) -> None:
|
||||||
|
release(retry=True, preview=False)
|
||||||
|
|
||||||
|
mock_retry_release.assert_called_once()
|
||||||
|
mock_release_impl.assert_not_called()
|
||||||
|
self.assertFalse(mock_retry_release.call_args.kwargs["preview"])
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.workflow.retry_release")
|
||||||
|
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||||
|
def test_retry_preview_passthrough(
|
||||||
|
self, mock_release_impl, mock_retry_release
|
||||||
|
) -> None:
|
||||||
|
release(retry=True, preview=True)
|
||||||
|
|
||||||
|
mock_retry_release.assert_called_once()
|
||||||
|
mock_release_impl.assert_not_called()
|
||||||
|
self.assertTrue(mock_retry_release.call_args.kwargs["preview"])
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.workflow.retry_release")
|
||||||
|
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||||
|
def test_retry_ignores_release_args(
|
||||||
|
self, mock_release_impl, mock_retry_release
|
||||||
|
) -> None:
|
||||||
|
# release_type/message/close/force must NOT trigger the full release flow
|
||||||
|
# when retry=True.
|
||||||
|
release(
|
||||||
|
retry=True,
|
||||||
|
release_type="major",
|
||||||
|
message="ignored body",
|
||||||
|
preview=False,
|
||||||
|
force=True,
|
||||||
|
close=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_retry_release.assert_called_once()
|
||||||
|
mock_release_impl.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetryRelease(unittest.TestCase):
|
||||||
|
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||||
|
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
|
||||||
|
@patch("pkgmgr.actions.release.retry.run")
|
||||||
|
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||||
|
@patch(
|
||||||
|
"pkgmgr.actions.release.retry.get_current_branch",
|
||||||
|
return_value="release-fix",
|
||||||
|
)
|
||||||
|
def test_retry_pushes_existing_tag_and_updates_latest(
|
||||||
|
self,
|
||||||
|
_mock_branch,
|
||||||
|
mock_head_tags,
|
||||||
|
mock_run,
|
||||||
|
_mock_highest,
|
||||||
|
mock_update_latest,
|
||||||
|
) -> None:
|
||||||
|
mock_head_tags.return_value = ["v1.13.4"]
|
||||||
|
|
||||||
|
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||||
|
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["push", "origin", "release-fix", "v1.13.4"], preview=False
|
||||||
|
)
|
||||||
|
mock_update_latest.assert_called_once_with("v1.13.4", preview=False)
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||||
|
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=False)
|
||||||
|
@patch("pkgmgr.actions.release.retry.run")
|
||||||
|
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||||
|
@patch(
|
||||||
|
"pkgmgr.actions.release.retry.get_current_branch",
|
||||||
|
return_value="release-fix",
|
||||||
|
)
|
||||||
|
def test_retry_skips_latest_for_non_highest_tag(
|
||||||
|
self,
|
||||||
|
_mock_branch,
|
||||||
|
mock_head_tags,
|
||||||
|
_mock_run,
|
||||||
|
_mock_highest,
|
||||||
|
mock_update_latest,
|
||||||
|
) -> None:
|
||||||
|
mock_head_tags.return_value = ["v1.0.0"]
|
||||||
|
|
||||||
|
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||||
|
|
||||||
|
mock_update_latest.assert_not_called()
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"pkgmgr.actions.release.retry.head_semver_tags",
|
||||||
|
return_value=[],
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"pkgmgr.actions.release.retry.get_current_branch",
|
||||||
|
return_value="main",
|
||||||
|
)
|
||||||
|
def test_retry_raises_when_head_has_no_tag(
|
||||||
|
self, _mock_branch, _mock_head_tags
|
||||||
|
) -> None:
|
||||||
|
with self.assertRaises(RuntimeError) as cm:
|
||||||
|
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||||
|
self.assertIn("No version tag on HEAD", str(cm.exception))
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||||
|
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
|
||||||
|
@patch("pkgmgr.actions.release.retry.run")
|
||||||
|
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||||
|
@patch(
|
||||||
|
"pkgmgr.actions.release.retry.get_current_branch",
|
||||||
|
side_effect=GitRunError("detached"),
|
||||||
|
)
|
||||||
|
def test_retry_falls_back_to_main_branch_when_detection_fails(
|
||||||
|
self,
|
||||||
|
_mock_branch,
|
||||||
|
mock_head_tags,
|
||||||
|
mock_run,
|
||||||
|
_mock_highest,
|
||||||
|
_mock_update_latest,
|
||||||
|
) -> None:
|
||||||
|
mock_head_tags.return_value = ["v2.0.0"]
|
||||||
|
|
||||||
|
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||||
|
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["push", "origin", "main", "v2.0.0"], preview=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -112,6 +112,7 @@ class TestReleaseCommand(unittest.TestCase):
|
|||||||
preview=False,
|
preview=False,
|
||||||
force=True,
|
force=True,
|
||||||
close=True,
|
close=True,
|
||||||
|
retry=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("pkgmgr.cli.commands.release.os.path.isdir", return_value=True)
|
@patch("pkgmgr.cli.commands.release.os.path.isdir", return_value=True)
|
||||||
@@ -160,6 +161,7 @@ class TestReleaseCommand(unittest.TestCase):
|
|||||||
preview=True,
|
preview=True,
|
||||||
force=False,
|
force=False,
|
||||||
close=False,
|
close=False,
|
||||||
|
retry=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("pkgmgr.cli.commands.release.run_release")
|
@patch("pkgmgr.cli.commands.release.run_release")
|
||||||
|
|||||||
135
tests/unit/pkgmgr/core/config/test_cli_update.py
Normal file
135
tests/unit/pkgmgr/core/config/test_cli_update.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pkgmgr.cli.commands import config as config_cmd
|
||||||
|
|
||||||
|
|
||||||
|
class FindDefaultsSourceDirTests(unittest.TestCase):
|
||||||
|
def test_prefers_pkg_root_config_over_project_root_config(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
pkg_root = root / "site-packages" / "pkgmgr"
|
||||||
|
pkg_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
# both exist
|
||||||
|
(pkg_root / "config").mkdir(parents=True)
|
||||||
|
(pkg_root.parent / "config").mkdir(parents=True)
|
||||||
|
|
||||||
|
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||||
|
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||||
|
found = config_cmd._find_defaults_source_dir()
|
||||||
|
|
||||||
|
self.assertEqual(Path(found).resolve(), (pkg_root / "config").resolve())
|
||||||
|
|
||||||
|
def test_falls_back_to_project_root_config(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
pkg_root = root / "site-packages" / "pkgmgr"
|
||||||
|
pkg_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
# only project_root config exists
|
||||||
|
(pkg_root.parent / "config").mkdir(parents=True)
|
||||||
|
|
||||||
|
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||||
|
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||||
|
found = config_cmd._find_defaults_source_dir()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
Path(found).resolve(), (pkg_root.parent / "config").resolve()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_none_when_no_config_dirs_exist(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
pkg_root = root / "site-packages" / "pkgmgr"
|
||||||
|
pkg_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||||
|
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||||
|
found = config_cmd._find_defaults_source_dir()
|
||||||
|
|
||||||
|
self.assertIsNone(found)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateDefaultConfigsTests(unittest.TestCase):
|
||||||
|
def test_copies_yaml_files_skips_config_yaml(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
source_dir = root / "src"
|
||||||
|
source_dir.mkdir()
|
||||||
|
|
||||||
|
# Create files
|
||||||
|
(source_dir / "a.yaml").write_text("x: 1\n", encoding="utf-8")
|
||||||
|
(source_dir / "b.yml").write_text("y: 2\n", encoding="utf-8")
|
||||||
|
(source_dir / "config.yaml").write_text(
|
||||||
|
"should_not_copy: true\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(source_dir / "notes.txt").write_text("nope\n", encoding="utf-8")
|
||||||
|
|
||||||
|
home = root / "home"
|
||||||
|
dest_cfg_dir = home / ".config" / "pkgmgr"
|
||||||
|
dest_cfg_dir.mkdir(parents=True)
|
||||||
|
user_config_path = str(dest_cfg_dir / "config.yaml")
|
||||||
|
|
||||||
|
# Patch the source dir finder to our temp source_dir
|
||||||
|
with patch.object(
|
||||||
|
config_cmd, "_find_defaults_source_dir", return_value=str(source_dir)
|
||||||
|
):
|
||||||
|
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||||
|
config_cmd._update_default_configs(user_config_path)
|
||||||
|
|
||||||
|
self.assertTrue((dest_cfg_dir / "a.yaml").is_file())
|
||||||
|
self.assertTrue((dest_cfg_dir / "b.yml").is_file())
|
||||||
|
self.assertFalse(
|
||||||
|
(dest_cfg_dir / "config.yaml")
|
||||||
|
.read_text(encoding="utf-8")
|
||||||
|
.startswith("should_not_copy")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure config.yaml was not overwritten (it may exist, but should remain original if we create it)
|
||||||
|
# We'll strengthen: create an original config.yaml then re-run
|
||||||
|
(dest_cfg_dir / "config.yaml").write_text(
|
||||||
|
"original: true\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
config_cmd, "_find_defaults_source_dir", return_value=str(source_dir)
|
||||||
|
):
|
||||||
|
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||||
|
config_cmd._update_default_configs(user_config_path)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(dest_cfg_dir / "config.yaml").read_text(encoding="utf-8"),
|
||||||
|
"original: true\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prints_warning_and_returns_when_no_source_dir(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
home = root / "home"
|
||||||
|
dest_cfg_dir = home / ".config" / "pkgmgr"
|
||||||
|
dest_cfg_dir.mkdir(parents=True)
|
||||||
|
user_config_path = str(dest_cfg_dir / "config.yaml")
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
with patch.object(
|
||||||
|
config_cmd, "_find_defaults_source_dir", return_value=None
|
||||||
|
):
|
||||||
|
with patch("sys.stdout", buf):
|
||||||
|
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||||
|
config_cmd._update_default_configs(user_config_path)
|
||||||
|
|
||||||
|
out = buf.getvalue()
|
||||||
|
self.assertIn("[WARN] No config directory found", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
271
tests/unit/pkgmgr/core/config/test_load.py
Normal file
271
tests/unit/pkgmgr/core/config/test_load.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from pkgmgr.core.config.load import (
|
||||||
|
_deep_merge,
|
||||||
|
_merge_repo_lists,
|
||||||
|
_load_layer_dir,
|
||||||
|
_load_defaults_from_package_or_project,
|
||||||
|
load_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeepMergeTests(unittest.TestCase):
|
||||||
|
def test_deep_merge_overrides_scalars_and_merges_dicts(self):
|
||||||
|
base = {"a": 1, "b": {"x": 1, "y": 2}, "c": {"k": 1}}
|
||||||
|
override = {"a": 2, "b": {"y": 99, "z": 3}, "c": 7}
|
||||||
|
merged = _deep_merge(base, override)
|
||||||
|
|
||||||
|
self.assertEqual(merged["a"], 2)
|
||||||
|
self.assertEqual(merged["b"]["x"], 1)
|
||||||
|
self.assertEqual(merged["b"]["y"], 99)
|
||||||
|
self.assertEqual(merged["b"]["z"], 3)
|
||||||
|
self.assertEqual(merged["c"], 7)
|
||||||
|
|
||||||
|
|
||||||
|
class MergeRepoListsTests(unittest.TestCase):
|
||||||
|
def test_merge_repo_lists_adds_new_repo_and_tracks_category(self):
|
||||||
|
base = []
|
||||||
|
new = [{"provider": "github", "account": "a", "repository": "r", "x": 1}]
|
||||||
|
_merge_repo_lists(base, new, category_name="cat1")
|
||||||
|
|
||||||
|
self.assertEqual(len(base), 1)
|
||||||
|
self.assertEqual(base[0]["provider"], "github")
|
||||||
|
self.assertEqual(base[0]["x"], 1)
|
||||||
|
self.assertIn("category_files", base[0])
|
||||||
|
self.assertIn("cat1", base[0]["category_files"])
|
||||||
|
|
||||||
|
def test_merge_repo_lists_merges_existing_repo_fields(self):
|
||||||
|
base = [
|
||||||
|
{
|
||||||
|
"provider": "github",
|
||||||
|
"account": "a",
|
||||||
|
"repository": "r",
|
||||||
|
"x": 1,
|
||||||
|
"d": {"a": 1},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
new = [
|
||||||
|
{
|
||||||
|
"provider": "github",
|
||||||
|
"account": "a",
|
||||||
|
"repository": "r",
|
||||||
|
"x": 2,
|
||||||
|
"d": {"b": 2},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
_merge_repo_lists(base, new, category_name="cat2")
|
||||||
|
|
||||||
|
self.assertEqual(len(base), 1)
|
||||||
|
self.assertEqual(base[0]["x"], 2)
|
||||||
|
self.assertEqual(base[0]["d"]["a"], 1)
|
||||||
|
self.assertEqual(base[0]["d"]["b"], 2)
|
||||||
|
self.assertIn("cat2", base[0]["category_files"])
|
||||||
|
|
||||||
|
def test_merge_repo_lists_incomplete_key_appends(self):
|
||||||
|
base = []
|
||||||
|
new = [{"foo": "bar"}] # no provider/account/repository
|
||||||
|
_merge_repo_lists(base, new, category_name="cat")
|
||||||
|
|
||||||
|
self.assertEqual(len(base), 1)
|
||||||
|
self.assertEqual(base[0]["foo"], "bar")
|
||||||
|
self.assertIn("cat", base[0].get("category_files", []))
|
||||||
|
|
||||||
|
|
||||||
|
class LoadLayerDirTests(unittest.TestCase):
|
||||||
|
def test_load_layer_dir_merges_directories_and_repos_across_files_sorted(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg_dir = Path(td)
|
||||||
|
|
||||||
|
# 10_b.yaml should be applied after 01_a.yaml due to name sorting
|
||||||
|
(cfg_dir / "01_a.yaml").write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
{
|
||||||
|
"directories": {"repositories": "/opt/Repos"},
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"provider": "github",
|
||||||
|
"account": "a",
|
||||||
|
"repository": "r1",
|
||||||
|
"x": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(cfg_dir / "10_b.yaml").write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
{
|
||||||
|
"directories": {"binaries": "/usr/local/bin"},
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"provider": "github",
|
||||||
|
"account": "a",
|
||||||
|
"repository": "r1",
|
||||||
|
"x": 2,
|
||||||
|
},
|
||||||
|
{"provider": "github", "account": "a", "repository": "r2"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
defaults = _load_layer_dir(cfg_dir, skip_filename="config.yaml")
|
||||||
|
|
||||||
|
self.assertEqual(defaults["directories"]["repositories"], "/opt/Repos")
|
||||||
|
self.assertEqual(defaults["directories"]["binaries"], "/usr/local/bin")
|
||||||
|
|
||||||
|
# r1 merged: x becomes 2 and has category_files including both stems
|
||||||
|
repos = defaults["repositories"]
|
||||||
|
self.assertEqual(len(repos), 2)
|
||||||
|
r1 = next(r for r in repos if r["repository"] == "r1")
|
||||||
|
self.assertEqual(r1["x"], 2)
|
||||||
|
self.assertIn("01_a", r1.get("category_files", []))
|
||||||
|
self.assertIn("10_b", r1.get("category_files", []))
|
||||||
|
|
||||||
|
def test_load_layer_dir_skips_config_yaml(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg_dir = Path(td)
|
||||||
|
(cfg_dir / "config.yaml").write_text(
|
||||||
|
yaml.safe_dump({"directories": {"x": 1}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(cfg_dir / "defaults.yaml").write_text(
|
||||||
|
yaml.safe_dump({"directories": {"x": 2}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
defaults = _load_layer_dir(cfg_dir, skip_filename="config.yaml")
|
||||||
|
# only defaults.yaml should apply
|
||||||
|
self.assertEqual(defaults["directories"]["x"], 2)
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultsFromPackageOrProjectTests(unittest.TestCase):
|
||||||
|
def test_defaults_from_pkg_root_config_wins(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
pkg_root = root / "site-packages" / "pkgmgr"
|
||||||
|
cfg_dir = pkg_root / "config"
|
||||||
|
cfg_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
(cfg_dir / "defaults.yaml").write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
{"directories": {"repositories": "/opt/Repos"}, "repositories": []}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||||
|
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||||
|
defaults = _load_defaults_from_package_or_project()
|
||||||
|
|
||||||
|
self.assertEqual(defaults["directories"]["repositories"], "/opt/Repos")
|
||||||
|
|
||||||
|
def test_defaults_from_repo_root_src_layout(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
repo_root = Path(td) / "repo"
|
||||||
|
pkg_root = repo_root / "src" / "pkgmgr"
|
||||||
|
cfg_dir = repo_root / "config"
|
||||||
|
cfg_dir.mkdir(parents=True)
|
||||||
|
pkg_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
(cfg_dir / "defaults.yaml").write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
{"directories": {"binaries": "/usr/local/bin"}, "repositories": []}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||||
|
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||||
|
defaults = _load_defaults_from_package_or_project()
|
||||||
|
|
||||||
|
self.assertEqual(defaults["directories"]["binaries"], "/usr/local/bin")
|
||||||
|
|
||||||
|
def test_defaults_returns_empty_when_no_config_found(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
pkg_root = Path(td) / "site-packages" / "pkgmgr"
|
||||||
|
pkg_root.mkdir(parents=True)
|
||||||
|
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||||
|
|
||||||
|
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||||
|
defaults = _load_defaults_from_package_or_project()
|
||||||
|
|
||||||
|
self.assertEqual(defaults, {"directories": {}, "repositories": []})
|
||||||
|
|
||||||
|
|
||||||
|
class LoadConfigIntegrationUnitTests(unittest.TestCase):
|
||||||
|
def test_load_config_prefers_user_dir_defaults_over_package_defaults(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
home = Path(td) / "home"
|
||||||
|
user_cfg_dir = home / ".config" / "pkgmgr"
|
||||||
|
user_cfg_dir.mkdir(parents=True)
|
||||||
|
user_config_path = str(user_cfg_dir / "config.yaml")
|
||||||
|
|
||||||
|
# user dir defaults exist -> should be used, package fallback must not matter
|
||||||
|
(user_cfg_dir / "aa.yaml").write_text(
|
||||||
|
yaml.safe_dump({"directories": {"repositories": "/USER/Repos"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(user_cfg_dir / "config.yaml").write_text(
|
||||||
|
yaml.safe_dump({"directories": {"binaries": "/USER/bin"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||||
|
merged = load_config(user_config_path)
|
||||||
|
|
||||||
|
self.assertEqual(merged["directories"]["repositories"], "/USER/Repos")
|
||||||
|
self.assertEqual(merged["directories"]["binaries"], "/USER/bin")
|
||||||
|
|
||||||
|
def test_load_config_falls_back_to_package_when_user_dir_has_no_defaults(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
home = Path(td) / "home"
|
||||||
|
user_cfg_dir = home / ".config" / "pkgmgr"
|
||||||
|
user_cfg_dir.mkdir(parents=True)
|
||||||
|
user_config_path = str(user_cfg_dir / "config.yaml")
|
||||||
|
|
||||||
|
# Only user config exists, no other yaml defaults
|
||||||
|
(user_cfg_dir / "config.yaml").write_text(
|
||||||
|
yaml.safe_dump({"directories": {"x": 1}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provide package defaults via fake pkgmgr + pkg_root/config
|
||||||
|
root = Path(td) / "site-packages"
|
||||||
|
pkg_root = root / "pkgmgr"
|
||||||
|
cfg_dir = (
|
||||||
|
root / "config"
|
||||||
|
) # NOTE: load.py checks multiple roots, including pkg_root.parent (=site-packages)
|
||||||
|
pkg_root.mkdir(parents=True)
|
||||||
|
cfg_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
(cfg_dir / "defaults.yaml").write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
{"directories": {"repositories": "/PKG/Repos"}, "repositories": []}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||||
|
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||||
|
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||||
|
merged = load_config(user_config_path)
|
||||||
|
|
||||||
|
# directories are merged: defaults then user
|
||||||
|
self.assertEqual(merged["directories"]["repositories"], "/PKG/Repos")
|
||||||
|
self.assertEqual(merged["directories"]["x"], 1)
|
||||||
|
self.assertIn("repositories", merged)
|
||||||
|
self.assertIsInstance(merged["repositories"], list)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
import subprocess
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from pkgmgr.core.git.errors import GitNotRepositoryError, GitRunError
|
from pkgmgr.core.git.errors import GitNotRepositoryError
|
||||||
from pkgmgr.core.git.queries.get_latest_signing_key import (
|
from pkgmgr.core.git.queries.get_latest_signing_key import (
|
||||||
GitLatestSigningKeyQueryError,
|
GitLatestSigningKeyQueryError,
|
||||||
get_latest_signing_key,
|
get_latest_signing_key,
|
||||||
@@ -10,25 +11,53 @@ from pkgmgr.core.git.queries.get_latest_signing_key import (
|
|||||||
|
|
||||||
class TestGetLatestSigningKey(unittest.TestCase):
|
class TestGetLatestSigningKey(unittest.TestCase):
|
||||||
@patch(
|
@patch(
|
||||||
"pkgmgr.core.git.queries.get_latest_signing_key.run",
|
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
|
||||||
return_value="ABCDEF1234567890\n",
|
return_value=subprocess.CompletedProcess(
|
||||||
|
args=["git", "log", "-1", "--format=%GK"],
|
||||||
|
returncode=0,
|
||||||
|
stdout="ABCDEF1234567890\n",
|
||||||
|
stderr="",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
def test_strips_output(self, _mock_run) -> None:
|
def test_strips_output(self, _mock_run) -> None:
|
||||||
out = get_latest_signing_key(cwd="/tmp/repo")
|
out = get_latest_signing_key(cwd="/tmp/repo")
|
||||||
self.assertEqual(out, "ABCDEF1234567890")
|
self.assertEqual(out, "ABCDEF1234567890")
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"pkgmgr.core.git.queries.get_latest_signing_key.run",
|
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
|
||||||
side_effect=GitRunError("boom"),
|
return_value=subprocess.CompletedProcess(
|
||||||
|
args=["git", "log", "-1", "--format=%GK"],
|
||||||
|
returncode=1,
|
||||||
|
stdout="",
|
||||||
|
stderr="boom",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
def test_wraps_git_run_error(self, _mock_run) -> None:
|
def test_wraps_git_run_error(self, _mock_run) -> None:
|
||||||
with self.assertRaises(GitLatestSigningKeyQueryError):
|
with self.assertRaisesRegex(GitLatestSigningKeyQueryError, "boom"):
|
||||||
get_latest_signing_key(cwd="/tmp/repo")
|
get_latest_signing_key(cwd="/tmp/repo")
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"pkgmgr.core.git.queries.get_latest_signing_key.run",
|
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
|
||||||
side_effect=GitNotRepositoryError("no repo"),
|
return_value=subprocess.CompletedProcess(
|
||||||
|
args=["git", "log", "-1", "--format=%GK"],
|
||||||
|
returncode=128,
|
||||||
|
stdout="",
|
||||||
|
stderr="fatal: not a git repository",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
def test_does_not_catch_not_repository_error(self, _mock_run) -> None:
|
def test_does_not_catch_not_repository_error(self, _mock_run) -> None:
|
||||||
with self.assertRaises(GitNotRepositoryError):
|
with self.assertRaises(GitNotRepositoryError):
|
||||||
get_latest_signing_key(cwd="/tmp/no-repo")
|
get_latest_signing_key(cwd="/tmp/no-repo")
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
|
||||||
|
return_value=subprocess.CompletedProcess(
|
||||||
|
args=["git", "log", "-1", "--format=%GK"],
|
||||||
|
returncode=0,
|
||||||
|
stdout="",
|
||||||
|
stderr="error: cannot run gpg: No such file or directory",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_raises_when_git_reports_gpg_runtime_error(self, _mock_run) -> None:
|
||||||
|
with self.assertRaisesRegex(GitLatestSigningKeyQueryError, "cannot run gpg"):
|
||||||
|
get_latest_signing_key(cwd="/tmp/repo")
|
||||||
|
|||||||
@@ -77,6 +77,23 @@ class TestVerifyRepository(unittest.TestCase):
|
|||||||
self.assertEqual(commit, "")
|
self.assertEqual(commit, "")
|
||||||
self.assertEqual(key, "")
|
self.assertEqual(key, "")
|
||||||
|
|
||||||
|
def test_verified_gpg_query_error_does_not_add_missing_key_fallback(self) -> None:
|
||||||
|
repo = {"verified": {"commit": None, "gpg_keys": ["ABC"]}}
|
||||||
|
with (
|
||||||
|
patch("pkgmgr.core.repository.verify.get_head_commit", return_value=""),
|
||||||
|
patch(
|
||||||
|
"pkgmgr.core.repository.verify.get_latest_signing_key",
|
||||||
|
side_effect=GitLatestSigningKeyQueryError("cannot run gpg"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
ok, errors, commit, key = verify_repository(repo, "/tmp/repo", mode="local")
|
||||||
|
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("cannot run gpg", " ".join(errors))
|
||||||
|
self.assertFalse(any("no signing key was found" in e for e in errors))
|
||||||
|
self.assertEqual(commit, "")
|
||||||
|
self.assertEqual(key, "")
|
||||||
|
|
||||||
def test_strict_pull_collects_remote_error_message(self) -> None:
|
def test_strict_pull_collects_remote_error_message(self) -> None:
|
||||||
repo = {"verified": {"commit": "expected", "gpg_keys": None}}
|
repo = {"verified": {"commit": "expected", "gpg_keys": None}}
|
||||||
with (
|
with (
|
||||||
|
|||||||
Reference in New Issue
Block a user