Compare commits
49 Commits
latest
...
ec7b959893
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec7b959893 | ||
|
|
6363709987 | ||
|
|
7764a2946c | ||
|
|
85d1abf61c | ||
|
|
b4e0594901 | ||
|
|
14b95d5639 | ||
|
|
8294bce436 | ||
|
|
f1517963a5 | ||
|
|
d0e99b6483 | ||
|
|
13fc8fe885 | ||
|
|
e8aed49a4c | ||
|
|
87ee6c66c8 | ||
|
|
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 |
17
.claude/settings.json
Normal file
17
.claude/settings.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"ask": [
|
||||||
|
"Skill(update-config)",
|
||||||
|
"Skill(update-config:*)",
|
||||||
|
"Bash(git commit)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(git push)",
|
||||||
|
"Bash(git push *)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"enabled": true,
|
||||||
|
"failIfUnavailable": true,
|
||||||
|
"autoAllowBashIfSandboxed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
@@ -2,38 +2,72 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches:
|
||||||
- main
|
- '**'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: global-ci-${{ github.repository }}-${{ github.ref_name }}
|
group: global-ci-${{ github.repository }}-${{ github.ref_name }}
|
||||||
cancel-in-progress: false
|
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
|
||||||
|
|||||||
112
.github/workflows/mark-stable.yml
vendored
112
.github/workflows/mark-stable.yml
vendored
@@ -1,111 +1,39 @@
|
|||||||
name: Mark stable commit
|
name: Mark stable commit
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: mark-${{ github.repository }}-${{ github.ref_name }}
|
group: mark-stable-${{ github.repository }}-main
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
- main # still run tests for main
|
- '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
|
||||||
|
|||||||
139
CHANGELOG.md
139
CHANGELOG.md
@@ -1,3 +1,142 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [1.16.0] - 2026-06-28
|
||||||
|
|
||||||
|
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||||
|
* The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
|
||||||
|
* The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
|
||||||
|
|
||||||
|
## [1.15.2] - 2026-05-28
|
||||||
|
|
||||||
|
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
|
||||||
|
|
||||||
|
## [1.15.1] - 2026-05-28
|
||||||
|
|
||||||
|
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
|
||||||
|
|
||||||
|
## [1.15.0] - 2026-05-28
|
||||||
|
|
||||||
|
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.14.0] - 2026-05-27
|
||||||
|
|
||||||
|
* Added
|
||||||
|
|
||||||
|
* New release --retry mode re-deploys the HEAD release without
|
||||||
|
re-tagging or modifying any files. It re-pushes the existing version
|
||||||
|
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||||
|
re-runs publish. Use this to recover from a release whose post-tag
|
||||||
|
push or PyPI upload failed mid-flight. The release_type argument
|
||||||
|
becomes optional under --retry.
|
||||||
|
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||||
|
helper so the workflow orchestrator stays focused on the forward
|
||||||
|
path.
|
||||||
|
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||||
|
debian_changelog under both packaging/debian and the legacy debian
|
||||||
|
layout.
|
||||||
|
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||||
|
the distro-name lookup chain and is unit-tested under
|
||||||
|
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||||
|
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||||
|
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||||
|
and branch-detection fallback.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
|
||||||
|
* pkgmgr release now derives the distro-package name from existing
|
||||||
|
packaging metadata instead of the repository folder name. The lookup
|
||||||
|
order is packaging/debian/control Package field, then
|
||||||
|
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||||
|
then folder basename as legacy fallback. Renaming a repository
|
||||||
|
folder no longer silently flips the debian/changelog top entry and
|
||||||
|
the RPM changelog stanza to a new identifier. Those keep matching
|
||||||
|
the authoritative value in the packaging files, which is what apt,
|
||||||
|
pacman, and dnf index against.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
|
||||||
|
* dpkg-source --before-build no longer fails with the message about
|
||||||
|
source package having two conflicting values after a repo-folder
|
||||||
|
rename, because the changelog and control file stay in agreement
|
||||||
|
on the next release.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.13.4] - 2026-05-27
|
||||||
|
|
||||||
|
* Changed
|
||||||
|
|
||||||
|
* pkgmgr release now derives the distro-package name from existing
|
||||||
|
packaging metadata instead of the repository folder name. The lookup
|
||||||
|
order is packaging/debian/control Package field, then
|
||||||
|
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||||
|
folder basename as legacy fallback. Renaming a repository folder (for
|
||||||
|
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||||
|
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||||
|
a new identifier. Those keep matching the authoritative Package,
|
||||||
|
pkgname, or Name value in the packaging files, which is what apt,
|
||||||
|
pacman, and dnf index against.
|
||||||
|
|
||||||
|
Added
|
||||||
|
|
||||||
|
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||||
|
debian_changelog under both packaging/debian (new layout) and debian
|
||||||
|
(legacy layout).
|
||||||
|
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||||
|
the priority chain and is unit-tested under
|
||||||
|
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
|
||||||
|
* dpkg-source --before-build no longer fails with the message about
|
||||||
|
source package having two conflicting values after a repo-folder
|
||||||
|
rename, because the changelog and control file stay in agreement.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.13.3] - 2026-03-26
|
||||||
|
|
||||||
|
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||||
|
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
|
||||||
|
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
|
||||||
|
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
|
||||||
|
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
|
||||||
|
|
||||||
|
|
||||||
|
## [1.13.2] - 2026-03-26
|
||||||
|
|
||||||
|
* Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.13.1] - 2026-03-20
|
||||||
|
|
||||||
|
* Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.13.0] - 2026-03-20
|
||||||
|
|
||||||
|
* Set CentOS docker image to latest
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.5] - 2026-02-24
|
||||||
|
|
||||||
|
* The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.4] - 2026-02-24
|
||||||
|
|
||||||
|
* The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.3] - 2026-02-24
|
||||||
|
|
||||||
|
* Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.12.2] - 2026-02-24
|
||||||
|
|
||||||
|
* Removed infinito-sphinx package
|
||||||
|
|
||||||
|
|
||||||
## [1.12.1] - 2026-02-14
|
## [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.
|
* pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
|
||||||
|
|||||||
@@ -43,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
|
||||||
@@ -64,5 +64,4 @@ CMD ["pkgmgr", "--help"]
|
|||||||
FROM full AS slim
|
FROM full AS slim
|
||||||
|
|
||||||
COPY scripts/docker/slim.sh /usr/local/bin/slim.sh
|
COPY scripts/docker/slim.sh /usr/local/bin/slim.sh
|
||||||
RUN chmod +x /usr/local/bin/slim.sh
|
RUN chmod +x /usr/local/bin/slim.sh && /usr/local/bin/slim.sh
|
||||||
RUN /usr/local/bin/slim.sh
|
|
||||||
|
|||||||
12
Makefile
12
Makefile
@@ -2,6 +2,7 @@
|
|||||||
build build-no-cache build-no-cache-all build-missing \
|
build build-no-cache build-no-cache-all build-missing \
|
||||||
delete-volumes purge \
|
delete-volumes purge \
|
||||||
test test-unit test-e2e test-integration test-env-virtual test-env-nix \
|
test test-unit test-e2e test-integration test-env-virtual test-env-nix \
|
||||||
|
lint \
|
||||||
setup setup-venv setup-nix
|
setup setup-venv setup-nix
|
||||||
|
|
||||||
# Distro
|
# Distro
|
||||||
@@ -82,6 +83,13 @@ build-no-cache-all:
|
|||||||
PKGMGR_DISTRO="$$d" $(MAKE) build-no-cache; \
|
PKGMGR_DISTRO="$$d" $(MAKE) build-no-cache; \
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Lint targets (run on the host, not in a container)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@bash scripts/lint/python.sh
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Test targets (delegated to scripts/test)
|
# Test targets (delegated to scripts/test)
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
@@ -101,8 +109,8 @@ test-env-virtual: build-missing
|
|||||||
test-env-nix: build-missing
|
test-env-nix: build-missing
|
||||||
@bash scripts/test/test-env-nix.sh
|
@bash scripts/test/test-env-nix.sh
|
||||||
|
|
||||||
# Combined test target for local + CI (unit + integration + e2e)
|
# Combined test target for local + CI (lint + unit + integration + e2e)
|
||||||
test: test-env-virtual test-unit test-integration test-e2e
|
test: lint test-env-virtual test-unit test-integration test-e2e
|
||||||
|
|
||||||
delete-volumes:
|
delete-volumes:
|
||||||
@docker volume rm "pkgmgr_nix_store_${PKGMGR_DISTRO}" "pkgmgr_nix_cache_${PKGMGR_DISTRO}" || echo "No volumes to delete."
|
@docker volume rm "pkgmgr_nix_store_${PKGMGR_DISTRO}" "pkgmgr_nix_cache_${PKGMGR_DISTRO}" || echo "No volumes to delete."
|
||||||
|
|||||||
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.12.1";
|
version = "1.16.0";
|
||||||
|
|
||||||
# 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,7 +1,7 @@
|
|||||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||||
|
|
||||||
pkgname=package-manager
|
pkgname=package-manager
|
||||||
pkgver=1.12.1
|
pkgver=1.16.0
|
||||||
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')
|
||||||
|
|||||||
@@ -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,157 @@
|
|||||||
|
package-manager (1.16.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||||
|
* The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
|
||||||
|
* The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 28 Jun 2026 16:54:16 +0200
|
||||||
|
|
||||||
|
package-manager (1.15.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 11:06:43 +0200
|
||||||
|
|
||||||
|
package-manager (1.15.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 08:18:23 +0200
|
||||||
|
|
||||||
|
package-manager (1.15.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 07:56:07 +0200
|
||||||
|
|
||||||
|
package-manager (1.14.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Added
|
||||||
|
|
||||||
|
* New release --retry mode re-deploys the HEAD release without
|
||||||
|
re-tagging or modifying any files. It re-pushes the existing version
|
||||||
|
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||||
|
re-runs publish. Use this to recover from a release whose post-tag
|
||||||
|
push or PyPI upload failed mid-flight. The release_type argument
|
||||||
|
becomes optional under --retry.
|
||||||
|
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||||
|
helper so the workflow orchestrator stays focused on the forward
|
||||||
|
path.
|
||||||
|
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||||
|
debian_changelog under both packaging/debian and the legacy debian
|
||||||
|
layout.
|
||||||
|
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||||
|
the distro-name lookup chain and is unit-tested under
|
||||||
|
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||||
|
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||||
|
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||||
|
and branch-detection fallback.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
|
||||||
|
* pkgmgr release now derives the distro-package name from existing
|
||||||
|
packaging metadata instead of the repository folder name. The lookup
|
||||||
|
order is packaging/debian/control Package field, then
|
||||||
|
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||||
|
then folder basename as legacy fallback. Renaming a repository
|
||||||
|
folder no longer silently flips the debian/changelog top entry and
|
||||||
|
the RPM changelog stanza to a new identifier. Those keep matching
|
||||||
|
the authoritative value in the packaging files, which is what apt,
|
||||||
|
pacman, and dnf index against.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
|
||||||
|
* dpkg-source --before-build no longer fails with the message about
|
||||||
|
source package having two conflicting values after a repo-folder
|
||||||
|
rename, because the changelog and control file stay in agreement
|
||||||
|
on the next release.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:53:14 +0200
|
||||||
|
|
||||||
|
package-manager (1.13.4-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Changed
|
||||||
|
|
||||||
|
* pkgmgr release now derives the distro-package name from existing
|
||||||
|
packaging metadata instead of the repository folder name. The lookup
|
||||||
|
order is packaging/debian/control Package field, then
|
||||||
|
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||||
|
folder basename as legacy fallback. Renaming a repository folder (for
|
||||||
|
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||||
|
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||||
|
a new identifier. Those keep matching the authoritative Package,
|
||||||
|
pkgname, or Name value in the packaging files, which is what apt,
|
||||||
|
pacman, and dnf index against.
|
||||||
|
|
||||||
|
Added
|
||||||
|
|
||||||
|
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||||
|
debian_changelog under both packaging/debian (new layout) and debian
|
||||||
|
(legacy layout).
|
||||||
|
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||||
|
the priority chain and is unit-tested under
|
||||||
|
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
|
||||||
|
* dpkg-source --before-build no longer fails with the message about
|
||||||
|
source package having two conflicting values after a repo-folder
|
||||||
|
rename, because the changelog and control file stay in agreement.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:32:39 +0200
|
||||||
|
|
||||||
|
package-manager (1.13.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||||
|
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
|
||||||
|
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
|
||||||
|
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
|
||||||
|
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 26 Mar 2026 17:10:21 +0100
|
||||||
|
|
||||||
|
package-manager (1.13.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 26 Mar 2026 12:26:55 +0100
|
||||||
|
|
||||||
|
package-manager (1.13.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 20 Mar 2026 02:57:25 +0100
|
||||||
|
|
||||||
|
package-manager (1.13.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Set CentOS docker image to latest
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 20 Mar 2026 01:29:38 +0100
|
||||||
|
|
||||||
|
package-manager (1.12.5-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 09:35:39 +0100
|
||||||
|
|
||||||
|
package-manager (1.12.4-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 09:32:01 +0100
|
||||||
|
|
||||||
|
package-manager (1.12.3-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 08:29:34 +0100
|
||||||
|
|
||||||
|
package-manager (1.12.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Removed infinito-sphinx package
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 24 Feb 2026 07:40:55 +0100
|
||||||
|
|
||||||
package-manager (1.12.1-1) unstable; urgency=medium
|
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.
|
* pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
|
||||||
|
|||||||
@@ -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.12.1
|
Version: 1.16.0
|
||||||
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,118 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
|||||||
/usr/lib/package-manager/
|
/usr/lib/package-manager/
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Sun Jun 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.0-1
|
||||||
|
- * New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||||
|
- * The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
|
||||||
|
- * The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
|
||||||
|
|
||||||
|
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.2-1
|
||||||
|
- Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
|
||||||
|
|
||||||
|
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.1-1
|
||||||
|
- Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
|
||||||
|
|
||||||
|
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.0-1
|
||||||
|
- Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
|
||||||
|
|
||||||
|
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.14.0-1
|
||||||
|
- Added
|
||||||
|
|
||||||
|
* New release --retry mode re-deploys the HEAD release without
|
||||||
|
re-tagging or modifying any files. It re-pushes the existing version
|
||||||
|
tag, re-aligns the floating latest tag, and (unless --no-publish)
|
||||||
|
re-runs publish. Use this to recover from a release whose post-tag
|
||||||
|
push or PyPI upload failed mid-flight. The release_type argument
|
||||||
|
becomes optional under --retry.
|
||||||
|
* New module pkgmgr.actions.release.retry hosts the retry_release
|
||||||
|
helper so the workflow orchestrator stays focused on the forward
|
||||||
|
path.
|
||||||
|
* RepoPaths now exposes a debian_control slot, discovered alongside
|
||||||
|
debian_changelog under both packaging/debian and the legacy debian
|
||||||
|
layout.
|
||||||
|
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||||
|
the distro-name lookup chain and is unit-tested under
|
||||||
|
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||||
|
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
|
||||||
|
idempotent push, latest-tag re-alignment, missing-tag error path,
|
||||||
|
and branch-detection fallback.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
|
||||||
|
* pkgmgr release now derives the distro-package name from existing
|
||||||
|
packaging metadata instead of the repository folder name. The lookup
|
||||||
|
order is packaging/debian/control Package field, then
|
||||||
|
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
|
||||||
|
then folder basename as legacy fallback. Renaming a repository
|
||||||
|
folder no longer silently flips the debian/changelog top entry and
|
||||||
|
the RPM changelog stanza to a new identifier. Those keep matching
|
||||||
|
the authoritative value in the packaging files, which is what apt,
|
||||||
|
pacman, and dnf index against.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
|
||||||
|
* dpkg-source --before-build no longer fails with the message about
|
||||||
|
source package having two conflicting values after a repo-folder
|
||||||
|
rename, because the changelog and control file stay in agreement
|
||||||
|
on the next release.
|
||||||
|
|
||||||
|
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.4-1
|
||||||
|
- Changed
|
||||||
|
|
||||||
|
* pkgmgr release now derives the distro-package name from existing
|
||||||
|
packaging metadata instead of the repository folder name. The lookup
|
||||||
|
order is packaging/debian/control Package field, then
|
||||||
|
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field, then
|
||||||
|
folder basename as legacy fallback. Renaming a repository folder (for
|
||||||
|
example infinito-nexus to infinito-nexus-core) no longer silently
|
||||||
|
flips the debian/changelog top entry and the RPM changelog stanza to
|
||||||
|
a new identifier. Those keep matching the authoritative Package,
|
||||||
|
pkgname, or Name value in the packaging files, which is what apt,
|
||||||
|
pacman, and dnf index against.
|
||||||
|
|
||||||
|
Added
|
||||||
|
|
||||||
|
* RepoPaths gains a debian_control slot that is discovered alongside
|
||||||
|
debian_changelog under both packaging/debian (new layout) and debian
|
||||||
|
(legacy layout).
|
||||||
|
* pkgmgr.actions.release.package_name.resolve_package_name centralises
|
||||||
|
the priority chain and is unit-tested under
|
||||||
|
tests/unit/pkgmgr/actions/release/test_package_name.py.
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
|
||||||
|
* dpkg-source --before-build no longer fails with the message about
|
||||||
|
source package having two conflicting values after a repo-folder
|
||||||
|
rename, because the changelog and control file stay in agreement.
|
||||||
|
|
||||||
|
* Thu Mar 26 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.3-1
|
||||||
|
- CI pipelines now include automated security scanning (CodeQL, Docker lint), increasing detection of vulnerabilities and misconfigurations
|
||||||
|
* Workflow permissions were tightened and fixed, ensuring secure and reliable execution of reusable workflows
|
||||||
|
* Publishing and “stable” tagging are now restricted to the `main` branch, preventing accidental releases from other branches
|
||||||
|
* Stale CI runs are automatically cancelled, reducing wasted resources and speeding up feedback cycles
|
||||||
|
* Overall CI reliability and security posture improved, with fewer false positives and more consistent pipeline results
|
||||||
|
|
||||||
|
* Thu Mar 26 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.2-1
|
||||||
|
- Fail fast with a clear error when the Nix bootstrap or nix binary is unavailable instead of continuing with a broken startup path.
|
||||||
|
|
||||||
|
* Fri Mar 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.1-1
|
||||||
|
- Fixed misleading GPG verification failures by adding explicit git and gnupg runtime dependencies and surfacing signing-key lookup errors accurately.
|
||||||
|
|
||||||
|
* Fri Mar 20 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.0-1
|
||||||
|
- Set CentOS docker image to latest
|
||||||
|
|
||||||
|
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.5-1
|
||||||
|
- The stable-tag workflow now waits up to two hours for a successful main-branch CI run on the same commit before updating stable.
|
||||||
|
|
||||||
|
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.4-1
|
||||||
|
- The release pipeline now updates the stable tag only for v* tags after a successful CI run on main for the same commit, while avoiding duplicate test executions.
|
||||||
|
|
||||||
|
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.3-1
|
||||||
|
- Stabilized Nix-based builds by switching to nixos-25.11 and committing flake.lock, ensuring reproducible pkgmgr test/runtime environments (with pip) and avoiding transient sphinx/Python 3.11 breakage.
|
||||||
|
|
||||||
|
* Tue Feb 24 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.2-1
|
||||||
|
- Removed infinito-sphinx package
|
||||||
|
|
||||||
* Sat Feb 14 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.12.1-1
|
* 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.
|
- pkgmgr now prefers distro-managed nix binaries on Arch before profile/PATH resolution, preventing libllhttp mismatch failures after pacman system upgrades.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kpmx"
|
name = "kpmx"
|
||||||
version = "1.12.1"
|
version = "1.16.0"
|
||||||
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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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,7 +38,7 @@ echo "[aur-builder-setup] Configuring sudoers for aur_builder..."
|
|||||||
${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder"
|
${ROOT_CMD} bash -c "echo '%aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/aur_builder"
|
||||||
${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder
|
${ROOT_CMD} chmod 0440 /etc/sudoers.d/aur_builder
|
||||||
|
|
||||||
RUN_AS_AUR=(su - aur_builder -s /bin/bash -c)
|
RUN_AS_AUR=(runuser -u aur_builder -- bash -c)
|
||||||
|
|
||||||
echo "[aur-builder-setup] Ensuring yay is installed for aur_builder..."
|
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
|
|
||||||
|
|||||||
15
scripts/lint/python.sh
Executable file
15
scripts/lint/python.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo ">>> Running RUFF lint on src and tests (local, no container)"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
if ! command -v ruff >/dev/null 2>&1; then
|
||||||
|
echo "ruff is not installed or not on PATH." >&2
|
||||||
|
echo "Install it with: pip install ruff" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
ruff --version
|
||||||
|
ruff check src tests
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Top-level pkgmgr package.
|
Top-level pkgmgr package.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# expose subpackages for patch() / resolve_name() friendliness
|
# expose subpackages for patch() / resolve_name() friendliness
|
||||||
from . import release as release # noqa: F401
|
from . import release as release
|
||||||
|
|
||||||
__all__ = ["release"]
|
__all__ = ["release"]
|
||||||
|
|||||||
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 collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_FILENAME_PATTERN = re.compile(r"^\d{3}-[^/]+\.md$")
|
||||||
|
TEMPLATE_FILENAME = "000-template.md"
|
||||||
|
|
||||||
|
|
||||||
|
def iter_archivable_files(
|
||||||
|
directory: Path,
|
||||||
|
*,
|
||||||
|
include_template: bool = False,
|
||||||
|
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
|
||||||
|
template_filename: str = TEMPLATE_FILENAME,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Return all files in *directory* whose name matches *pattern*, sorted.
|
||||||
|
|
||||||
|
``000-template.md`` (or whatever *template_filename* matches) is
|
||||||
|
excluded unless *include_template* is true. The check is filename
|
||||||
|
based; nested directories are not traversed.
|
||||||
|
"""
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
files: list[Path] = []
|
||||||
|
for path in sorted(directory.iterdir()):
|
||||||
|
if not path.is_file() or not pattern.match(path.name):
|
||||||
|
continue
|
||||||
|
if not include_template and path.name == template_filename:
|
||||||
|
continue
|
||||||
|
files.append(path)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def filter_archivable_files(
|
||||||
|
paths: Iterable[Path],
|
||||||
|
*,
|
||||||
|
include_template: bool = False,
|
||||||
|
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
|
||||||
|
template_filename: str = TEMPLATE_FILENAME,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Same predicate as :func:`iter_archivable_files`, applied to an iterable."""
|
||||||
|
result: list[Path] = []
|
||||||
|
for path in paths:
|
||||||
|
if not path.is_file() or not pattern.match(path.name):
|
||||||
|
continue
|
||||||
|
if not include_template and path.name == template_filename:
|
||||||
|
continue
|
||||||
|
result.append(path)
|
||||||
|
return result
|
||||||
35
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
|
||||||
114
src/pkgmgr/actions/archive/workflow.py
Normal file
114
src/pkgmgr/actions/archive/workflow.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Orchestrator for archiving fully-checked Markdown files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .discovery import iter_archivable_files
|
||||||
|
from .inspect import count_unchecked_items, extract_h1
|
||||||
|
from .readme import existing_archive_entries, merge_archive_section
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArchivePlan:
|
||||||
|
"""Outcome of an archive analysis run.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
archived: ``(source_path, title)`` for every file that was (or
|
||||||
|
would be) archived. Order matches the original directory
|
||||||
|
listing.
|
||||||
|
skipped_incomplete: ``(source_path, unchecked_count)`` for files
|
||||||
|
that still hold ``- [ ]`` markers.
|
||||||
|
skipped_without_h1: files that had no H1 heading to use as title.
|
||||||
|
new_entries: titles that will be appended to the README index.
|
||||||
|
existing_entries: titles already present in the README index.
|
||||||
|
"""
|
||||||
|
|
||||||
|
archived: list[tuple[Path, str]]
|
||||||
|
skipped_incomplete: list[tuple[Path, int]]
|
||||||
|
skipped_without_h1: list[Path]
|
||||||
|
new_entries: list[str]
|
||||||
|
existing_entries: set[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_files(
|
||||||
|
files: list[Path],
|
||||||
|
) -> tuple[
|
||||||
|
list[tuple[Path, str]],
|
||||||
|
list[tuple[Path, int]],
|
||||||
|
list[Path],
|
||||||
|
]:
|
||||||
|
plan: list[tuple[Path, str]] = []
|
||||||
|
skipped_incomplete: list[tuple[Path, int]] = []
|
||||||
|
skipped_without_h1: list[Path] = []
|
||||||
|
for path in files:
|
||||||
|
unchecked = count_unchecked_items(path)
|
||||||
|
if unchecked > 0:
|
||||||
|
skipped_incomplete.append((path, unchecked))
|
||||||
|
continue
|
||||||
|
title = extract_h1(path)
|
||||||
|
if title is None:
|
||||||
|
skipped_without_h1.append(path)
|
||||||
|
continue
|
||||||
|
plan.append((path, title))
|
||||||
|
return plan, skipped_incomplete, skipped_without_h1
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_titles(
|
||||||
|
plan: list[tuple[Path, str]], already_archived: set[str]
|
||||||
|
) -> list[str]:
|
||||||
|
new_entries: list[str] = []
|
||||||
|
for _path, title in plan:
|
||||||
|
if title in already_archived or title in new_entries:
|
||||||
|
continue
|
||||||
|
new_entries.append(title)
|
||||||
|
return new_entries
|
||||||
|
|
||||||
|
|
||||||
|
def run_archive(
|
||||||
|
directory: Path,
|
||||||
|
readme_path: Path,
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
include_template: bool = False,
|
||||||
|
) -> ArchivePlan:
|
||||||
|
"""Walk *directory* and archive every fully-checked file into *readme_path*.
|
||||||
|
|
||||||
|
Returns an :class:`ArchivePlan` describing the outcome. When
|
||||||
|
``dry_run`` is true no files are deleted and the README is not
|
||||||
|
rewritten — the plan still reflects what *would* happen.
|
||||||
|
|
||||||
|
Raises ``FileNotFoundError`` if *directory* or *readme_path* does
|
||||||
|
not exist.
|
||||||
|
"""
|
||||||
|
if not directory.is_dir():
|
||||||
|
raise FileNotFoundError(f"Archive directory not found: {directory}")
|
||||||
|
if not readme_path.is_file():
|
||||||
|
raise FileNotFoundError(f"README not found: {readme_path}")
|
||||||
|
|
||||||
|
files = iter_archivable_files(directory, include_template=include_template)
|
||||||
|
readme_text = readme_path.read_text(encoding="utf-8")
|
||||||
|
already_archived = existing_archive_entries(readme_text)
|
||||||
|
|
||||||
|
archived, skipped_incomplete, skipped_without_h1 = _bucket_files(files)
|
||||||
|
new_entries = _dedupe_titles(archived, already_archived)
|
||||||
|
|
||||||
|
if not dry_run and new_entries:
|
||||||
|
merged_text = merge_archive_section(readme_text, new_entries)
|
||||||
|
if merged_text != readme_text:
|
||||||
|
readme_path.write_text(merged_text, encoding="utf-8")
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
for path, _title in archived:
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
return ArchivePlan(
|
||||||
|
archived=archived,
|
||||||
|
skipped_incomplete=skipped_incomplete,
|
||||||
|
skipped_without_h1=skipped_without_h1,
|
||||||
|
new_entries=new_entries,
|
||||||
|
existing_entries=already_archived,
|
||||||
|
)
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
Public API for branch actions.
|
Public API for branch actions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .open_branch import open_branch
|
|
||||||
from .close_branch import close_branch
|
from .close_branch import close_branch
|
||||||
from .drop_branch import drop_branch
|
from .drop_branch import drop_branch
|
||||||
|
from .open_branch import open_branch
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"open_branch",
|
|
||||||
"close_branch",
|
"close_branch",
|
||||||
"drop_branch",
|
"drop_branch",
|
||||||
|
"open_branch",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pkgmgr.core.git.errors import GitRunError
|
|
||||||
from pkgmgr.core.git.queries import get_current_branch
|
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
GitDeleteRemoteBranchError,
|
GitDeleteRemoteBranchError,
|
||||||
checkout,
|
checkout,
|
||||||
@@ -14,12 +10,12 @@ from pkgmgr.core.git.commands import (
|
|||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
)
|
)
|
||||||
|
from pkgmgr.core.git.errors import GitRunError
|
||||||
from pkgmgr.core.git.queries import resolve_base_branch
|
from pkgmgr.core.git.queries import get_current_branch, resolve_base_branch
|
||||||
|
|
||||||
|
|
||||||
def close_branch(
|
def close_branch(
|
||||||
name: Optional[str],
|
name: str | None,
|
||||||
base_branch: str = "main",
|
base_branch: str = "main",
|
||||||
fallback_base: str = "master",
|
fallback_base: str = "master",
|
||||||
cwd: str = ".",
|
cwd: str = ".",
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pkgmgr.core.git.errors import GitRunError
|
|
||||||
from pkgmgr.core.git.queries import get_current_branch
|
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
GitDeleteRemoteBranchError,
|
GitDeleteRemoteBranchError,
|
||||||
delete_local_branch,
|
delete_local_branch,
|
||||||
delete_remote_branch,
|
delete_remote_branch,
|
||||||
)
|
)
|
||||||
|
from pkgmgr.core.git.errors import GitRunError
|
||||||
from pkgmgr.core.git.queries import resolve_base_branch
|
from pkgmgr.core.git.queries import get_current_branch, resolve_base_branch
|
||||||
|
|
||||||
|
|
||||||
def drop_branch(
|
def drop_branch(
|
||||||
name: Optional[str],
|
name: str | None,
|
||||||
base_branch: str = "main",
|
base_branch: str = "main",
|
||||||
fallback_base: str = "master",
|
fallback_base: str = "master",
|
||||||
cwd: str = ".",
|
cwd: str = ".",
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
checkout,
|
checkout,
|
||||||
create_branch,
|
create_branch,
|
||||||
@@ -13,7 +11,7 @@ from pkgmgr.core.git.queries import resolve_base_branch
|
|||||||
|
|
||||||
|
|
||||||
def open_branch(
|
def open_branch(
|
||||||
name: Optional[str],
|
name: str | None,
|
||||||
base_branch: str = "main",
|
base_branch: str = "main",
|
||||||
fallback_base: str = "master",
|
fallback_base: str = "master",
|
||||||
cwd: str = ".",
|
cwd: str = ".",
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Helpers to generate changelog information from Git history.
|
Helpers to generate changelog information from Git history.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pkgmgr.core.git.queries import (
|
from pkgmgr.core.git.queries import (
|
||||||
get_changelog,
|
|
||||||
GitChangelogQueryError,
|
GitChangelogQueryError,
|
||||||
|
get_changelog,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_changelog(
|
def generate_changelog(
|
||||||
cwd: str,
|
cwd: str,
|
||||||
from_ref: Optional[str] = None,
|
from_ref: str | None = None,
|
||||||
to_ref: Optional[str] = None,
|
to_ref: str | None = None,
|
||||||
include_merges: bool = False,
|
include_merges: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
161
src/pkgmgr/actions/code_scanning/__init__.py
Normal file
161
src/pkgmgr/actions/code_scanning/__init__.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Download GitHub code scanning results via the ``gh`` CLI.
|
||||||
|
|
||||||
|
Fetches all code scanning alerts (and the analysis metadata) for a
|
||||||
|
repository and writes them, plus a readable digest, into a timestamped
|
||||||
|
directory so they can be read and analysed offline. Authentication is
|
||||||
|
delegated to ``gh`` (credentials from its keyring/login).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class CodeScanningError(RuntimeError):
|
||||||
|
"""Raised when code scanning results cannot be downloaded."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CodeScanningResult:
|
||||||
|
repo: str
|
||||||
|
output_dir: str
|
||||||
|
alert_count: int
|
||||||
|
files: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _gh(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(["gh", *args], capture_output=True, text=True, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_repo(repo: str | None) -> str:
|
||||||
|
if repo:
|
||||||
|
return repo
|
||||||
|
proc = _gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
|
||||||
|
name = proc.stdout.strip()
|
||||||
|
if proc.returncode != 0 or not name:
|
||||||
|
raise CodeScanningError(
|
||||||
|
"could not resolve the current repository; run inside a GitHub "
|
||||||
|
"repo or pass --repo OWNER/REPO "
|
||||||
|
f"({proc.stderr.strip()})"
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_json(endpoint: str, params: list[str] | None = None) -> Any:
|
||||||
|
args = ["api", endpoint, "--paginate"]
|
||||||
|
for param in params or []:
|
||||||
|
args += ["-f", param]
|
||||||
|
proc = _gh(args)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise CodeScanningError(
|
||||||
|
f"gh api {endpoint} failed: {proc.stderr.strip() or 'unknown error'}"
|
||||||
|
)
|
||||||
|
body = proc.stdout.strip()
|
||||||
|
return json.loads(body) if body else []
|
||||||
|
|
||||||
|
|
||||||
|
def _alert_row(alert: dict) -> str:
|
||||||
|
rule = alert.get("rule") or {}
|
||||||
|
instance = alert.get("most_recent_instance") or {}
|
||||||
|
location = instance.get("location") or {}
|
||||||
|
message = (instance.get("message") or {}).get("text", "").strip().replace("\n", " ")
|
||||||
|
severity = rule.get("security_severity_level") or rule.get("severity") or "unknown"
|
||||||
|
path = location.get("path", "?")
|
||||||
|
line = location.get("start_line", "?")
|
||||||
|
state = alert.get("state", "?")
|
||||||
|
rule_id = rule.get("id", "?")
|
||||||
|
return f"- [{severity}] {rule_id} — {path}:{line} ({state})\n {message}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_summary(repo: str, generated_at: str, alerts: list[dict]) -> str:
|
||||||
|
by_severity: Counter = Counter()
|
||||||
|
by_state: Counter = Counter()
|
||||||
|
by_rule: Counter = Counter()
|
||||||
|
for alert in alerts:
|
||||||
|
rule = alert.get("rule") or {}
|
||||||
|
by_severity[
|
||||||
|
rule.get("security_severity_level") or rule.get("severity") or "unknown"
|
||||||
|
] += 1
|
||||||
|
by_state[alert.get("state", "unknown")] += 1
|
||||||
|
by_rule[rule.get("id", "unknown")] += 1
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# Code scanning summary — {repo}",
|
||||||
|
"",
|
||||||
|
f"- Generated: {generated_at}",
|
||||||
|
f"- Total alerts: {len(alerts)}",
|
||||||
|
"",
|
||||||
|
"## By severity",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
lines += [f"- {sev}: {count}" for sev, count in by_severity.most_common()] or [
|
||||||
|
"- none"
|
||||||
|
]
|
||||||
|
lines += ["", "## By state", ""]
|
||||||
|
lines += [f"- {state}: {count}" for state, count in by_state.most_common()] or [
|
||||||
|
"- none"
|
||||||
|
]
|
||||||
|
lines += ["", "## By rule", ""]
|
||||||
|
lines += [f"- {rule}: {count}" for rule, count in by_rule.most_common()] or [
|
||||||
|
"- none"
|
||||||
|
]
|
||||||
|
lines += ["", "## Alerts", ""]
|
||||||
|
lines += [_alert_row(alert) for alert in alerts] or ["- none"]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def download_code_scanning(
|
||||||
|
repo: str | None = None,
|
||||||
|
output_dir: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
) -> CodeScanningResult:
|
||||||
|
if not shutil.which("gh"):
|
||||||
|
raise CodeScanningError("the GitHub CLI 'gh' is not installed or not on PATH")
|
||||||
|
|
||||||
|
repo = _resolve_repo(repo)
|
||||||
|
repo_name = repo.rstrip("/").split("/")[-1]
|
||||||
|
|
||||||
|
if output_dir is None:
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||||
|
output_dir = os.path.join("/tmp", repo_name, "code-scanner", timestamp)
|
||||||
|
output_dir = os.path.abspath(os.path.expanduser(output_dir))
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
generated_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
alerts = _fetch_json(
|
||||||
|
f"repos/{repo}/code-scanning/alerts",
|
||||||
|
params=[f"state={state}"] if state else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
files: list[str] = []
|
||||||
|
|
||||||
|
def _write(name: str, content: str) -> None:
|
||||||
|
path = os.path.join(output_dir, name)
|
||||||
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(content)
|
||||||
|
files.append(path)
|
||||||
|
|
||||||
|
_write("alerts.json", json.dumps(alerts, indent=2, ensure_ascii=False) + "\n")
|
||||||
|
_write("summary.md", _build_summary(repo, generated_at, alerts))
|
||||||
|
|
||||||
|
try:
|
||||||
|
analyses = _fetch_json(f"repos/{repo}/code-scanning/analyses")
|
||||||
|
_write(
|
||||||
|
"analyses.json", json.dumps(analyses, indent=2, ensure_ascii=False) + "\n"
|
||||||
|
)
|
||||||
|
except CodeScanningError as exc:
|
||||||
|
_write("analyses.json", json.dumps({"error": str(exc)}, indent=2) + "\n")
|
||||||
|
|
||||||
|
return CodeScanningResult(
|
||||||
|
repo=repo,
|
||||||
|
output_dir=output_dir,
|
||||||
|
alert_count=len(alerts),
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import yaml
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from pkgmgr.core.config.save import save_user_config
|
from pkgmgr.core.config.save import save_user_config
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ def interactive_add(config, USER_CONFIG_PATH: str):
|
|||||||
confirm = input("Add this entry to user config? (y/N): ").strip().lower()
|
confirm = input("Add this entry to user config? (y/N): ").strip().lower()
|
||||||
if confirm == "y":
|
if confirm == "y":
|
||||||
if os.path.exists(USER_CONFIG_PATH):
|
if os.path.exists(USER_CONFIG_PATH):
|
||||||
with open(USER_CONFIG_PATH, "r") as f:
|
with open(USER_CONFIG_PATH) as f:
|
||||||
user_config = yaml.safe_load(f) or {}
|
user_config = yaml.safe_load(f) or {}
|
||||||
else:
|
else:
|
||||||
user_config = {"repositories": []}
|
user_config = {"repositories": []}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Initialize user configuration by scanning the repositories base directory.
|
Initialize user configuration by scanning the repositories base directory.
|
||||||
|
|
||||||
@@ -23,7 +20,7 @@ For each discovered repository, the function:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from pkgmgr.core.command.alias import generate_alias
|
from pkgmgr.core.command.alias import generate_alias
|
||||||
from pkgmgr.core.config.save import save_user_config
|
from pkgmgr.core.config.save import save_user_config
|
||||||
@@ -31,8 +28,8 @@ from pkgmgr.core.git.queries import get_latest_commit
|
|||||||
|
|
||||||
|
|
||||||
def config_init(
|
def config_init(
|
||||||
user_config: Dict[str, Any],
|
user_config: dict[str, Any],
|
||||||
defaults_config: Dict[str, Any],
|
defaults_config: dict[str, Any],
|
||||||
bin_dir: str,
|
bin_dir: str,
|
||||||
user_config_path: str,
|
user_config_path: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -55,7 +52,7 @@ def config_init(
|
|||||||
|
|
||||||
print("[INIT] Scanning repository base directory:")
|
print("[INIT] Scanning repository base directory:")
|
||||||
print(f" {repositories_base_dir}")
|
print(f" {repositories_base_dir}")
|
||||||
print("")
|
print()
|
||||||
|
|
||||||
if not os.path.isdir(repositories_base_dir):
|
if not os.path.isdir(repositories_base_dir):
|
||||||
print(f"[ERROR] Base directory does not exist: {repositories_base_dir}")
|
print(f"[ERROR] Base directory does not exist: {repositories_base_dir}")
|
||||||
@@ -129,7 +126,7 @@ def config_init(
|
|||||||
"[WARN] Could not read commit (not a git repo or no commits)."
|
"[WARN] Could not read commit (not a git repo or no commits)."
|
||||||
)
|
)
|
||||||
|
|
||||||
entry: Dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"account": account,
|
"account": account,
|
||||||
"repository": repo_name,
|
"repository": repo_name,
|
||||||
@@ -153,7 +150,7 @@ def config_init(
|
|||||||
|
|
||||||
new_entries.append(entry)
|
new_entries.append(entry)
|
||||||
|
|
||||||
print("") # blank line between accounts
|
print() # blank line between accounts
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Summary
|
# Summary
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from pkgmgr.core.config.load import load_config
|
from pkgmgr.core.config.load import load_config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# src/pkgmgr/actions/install/__init__.py
|
# src/pkgmgr/actions/install/__init__.py
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
High-level entry point for repository installation.
|
High-level entry point for repository installation.
|
||||||
@@ -16,28 +14,28 @@ Responsibilities:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any
|
||||||
|
|
||||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
|
||||||
from pkgmgr.core.repository.verify import verify_repository
|
|
||||||
from pkgmgr.actions.repository.clone import clone_repos
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
|
from pkgmgr.actions.install.installers.makefile import (
|
||||||
|
MakefileInstaller,
|
||||||
|
)
|
||||||
|
from pkgmgr.actions.install.installers.nix import (
|
||||||
|
NixFlakeInstaller,
|
||||||
|
)
|
||||||
from pkgmgr.actions.install.installers.os_packages import (
|
from pkgmgr.actions.install.installers.os_packages import (
|
||||||
ArchPkgbuildInstaller,
|
ArchPkgbuildInstaller,
|
||||||
DebianControlInstaller,
|
DebianControlInstaller,
|
||||||
RpmSpecInstaller,
|
RpmSpecInstaller,
|
||||||
)
|
)
|
||||||
from pkgmgr.actions.install.installers.nix import (
|
|
||||||
NixFlakeInstaller,
|
|
||||||
)
|
|
||||||
from pkgmgr.actions.install.installers.python import PythonInstaller
|
from pkgmgr.actions.install.installers.python import PythonInstaller
|
||||||
from pkgmgr.actions.install.installers.makefile import (
|
|
||||||
MakefileInstaller,
|
|
||||||
)
|
|
||||||
from pkgmgr.actions.install.pipeline import InstallationPipeline
|
from pkgmgr.actions.install.pipeline import InstallationPipeline
|
||||||
|
from pkgmgr.actions.repository.clone import clone_repos
|
||||||
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||||
|
from pkgmgr.core.repository.verify import verify_repository
|
||||||
|
|
||||||
Repository = Dict[str, Any]
|
Repository = dict[str, Any]
|
||||||
|
|
||||||
INSTALLERS = [
|
INSTALLERS = [
|
||||||
ArchPkgbuildInstaller(),
|
ArchPkgbuildInstaller(),
|
||||||
@@ -52,12 +50,12 @@ INSTALLERS = [
|
|||||||
def _ensure_repo_dir(
|
def _ensure_repo_dir(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
preview: bool,
|
preview: bool,
|
||||||
no_verification: bool,
|
no_verification: bool,
|
||||||
clone_mode: str,
|
clone_mode: str,
|
||||||
identifier: str,
|
identifier: str,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Compute and, if necessary, clone the repository directory.
|
Compute and, if necessary, clone the repository directory.
|
||||||
|
|
||||||
@@ -127,7 +125,7 @@ def _create_context(
|
|||||||
repo_dir: str,
|
repo_dir: str,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
bin_dir: str,
|
bin_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
no_verification: bool,
|
no_verification: bool,
|
||||||
preview: bool,
|
preview: bool,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
@@ -155,10 +153,10 @@ def _create_context(
|
|||||||
|
|
||||||
|
|
||||||
def install_repos(
|
def install_repos(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
bin_dir: str,
|
bin_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
no_verification: bool,
|
no_verification: bool,
|
||||||
preview: bool,
|
preview: bool,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
@@ -179,7 +177,7 @@ def install_repos(
|
|||||||
overall command never exits non-zero because of per-repository failures.
|
overall command never exits non-zero because of per-repository failures.
|
||||||
"""
|
"""
|
||||||
pipeline = InstallationPipeline(INSTALLERS)
|
pipeline = InstallationPipeline(INSTALLERS)
|
||||||
failures: List[Tuple[str, str]] = []
|
failures: list[tuple[str, str]] = []
|
||||||
|
|
||||||
for repo in selected_repos:
|
for repo in selected_repos:
|
||||||
identifier = get_repo_identifier(repo, all_repos)
|
identifier = get_repo_identifier(repo, all_repos)
|
||||||
@@ -232,7 +230,7 @@ def install_repos(
|
|||||||
f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..."
|
f"[Warning] install: repository {identifier} failed (exit={code}). Continuing..."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001 - batch boundary: one repository must never abort the run
|
||||||
failures.append((identifier, f"unexpected error: {exc}"))
|
failures.append((identifier, f"unexpected error: {exc}"))
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Capability detection for pkgmgr.
|
Capability detection for pkgmgr.
|
||||||
|
|
||||||
@@ -35,7 +32,8 @@ from __future__ import annotations
|
|||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Iterable, TYPE_CHECKING, Optional
|
from collections.abc import Iterable
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
@@ -46,12 +44,12 @@ if TYPE_CHECKING:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _read_text_if_exists(path: str) -> Optional[str]:
|
def _read_text_if_exists(path: str) -> str | None:
|
||||||
"""Read a file as UTF-8 text, returning None if it does not exist or fails."""
|
"""Read a file as UTF-8 text, returning None if it does not exist or fails."""
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
@@ -75,12 +73,12 @@ def _scan_files_for_patterns(files: Iterable[str], patterns: Iterable[str]) -> b
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _first_spec_file(repo_dir: str) -> Optional[str]:
|
def _first_spec_file(repo_dir: str) -> str | None:
|
||||||
"""Return the first *.spec file in repo_dir, if any."""
|
"""Return the first *.spec file in repo_dir, if any."""
|
||||||
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
|
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
|
||||||
if not matches:
|
if not matches:
|
||||||
return None
|
return None
|
||||||
return sorted(matches)[0]
|
return min(matches)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -100,7 +98,7 @@ class CapabilityMatcher(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True if this capability is actually provided by the given layer
|
Return True if this capability is actually provided by the given layer
|
||||||
for this repository.
|
for this repository.
|
||||||
@@ -133,7 +131,7 @@ class PythonRuntimeCapability(CapabilityMatcher):
|
|||||||
# OS packages may wrap Python builds, but must explicitly prove it
|
# OS packages may wrap Python builds, but must explicitly prove it
|
||||||
return layer in {"python", "nix", "os-packages"}
|
return layer in {"python", "nix", "os-packages"}
|
||||||
|
|
||||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||||
repo_dir = ctx.repo_dir
|
repo_dir = ctx.repo_dir
|
||||||
|
|
||||||
if layer == "python":
|
if layer == "python":
|
||||||
@@ -208,7 +206,7 @@ class MakeInstallCapability(CapabilityMatcher):
|
|||||||
def applies_to_layer(self, layer: str) -> bool:
|
def applies_to_layer(self, layer: str) -> bool:
|
||||||
return layer in {"makefile", "python", "nix", "os-packages"}
|
return layer in {"makefile", "python", "nix", "os-packages"}
|
||||||
|
|
||||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||||
repo_dir = ctx.repo_dir
|
repo_dir = ctx.repo_dir
|
||||||
|
|
||||||
if layer == "makefile":
|
if layer == "makefile":
|
||||||
@@ -216,7 +214,7 @@ class MakeInstallCapability(CapabilityMatcher):
|
|||||||
if not os.path.exists(makefile):
|
if not os.path.exists(makefile):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
with open(makefile, "r", encoding="utf-8") as f:
|
with open(makefile, encoding="utf-8") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
if line.strip().startswith("install:"):
|
if line.strip().startswith("install:"):
|
||||||
return True
|
return True
|
||||||
@@ -274,7 +272,7 @@ class NixFlakeCapability(CapabilityMatcher):
|
|||||||
# Only Nix itself and OS packages that explicitly wrap Nix
|
# Only Nix itself and OS packages that explicitly wrap Nix
|
||||||
return layer in {"nix", "os-packages"}
|
return layer in {"nix", "os-packages"}
|
||||||
|
|
||||||
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
|
def is_provided(self, ctx: RepoContext, layer: str) -> bool:
|
||||||
repo_dir = ctx.repo_dir
|
repo_dir = ctx.repo_dir
|
||||||
|
|
||||||
if layer == "nix":
|
if layer == "nix":
|
||||||
@@ -328,7 +326,7 @@ LAYER_ORDER: list[str] = [
|
|||||||
|
|
||||||
|
|
||||||
def detect_capabilities(
|
def detect_capabilities(
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
layers: Iterable[str],
|
layers: Iterable[str],
|
||||||
) -> dict[str, set[str]]:
|
) -> dict[str, set[str]]:
|
||||||
"""
|
"""
|
||||||
@@ -359,8 +357,8 @@ def detect_capabilities(
|
|||||||
|
|
||||||
|
|
||||||
def resolve_effective_capabilities(
|
def resolve_effective_capabilities(
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
layers: Optional[Iterable[str]] = None,
|
layers: Iterable[str] | None = None,
|
||||||
) -> dict[str, set[str]]:
|
) -> dict[str, set[str]]:
|
||||||
"""
|
"""
|
||||||
Resolve *effective* capabilities for each layer using a bottom-up strategy.
|
Resolve *effective* capabilities for each layer using a bottom-up strategy.
|
||||||
@@ -381,10 +379,7 @@ def resolve_effective_capabilities(
|
|||||||
This means *any* higher layer can overshadow a lower layer, not just
|
This means *any* higher layer can overshadow a lower layer, not just
|
||||||
a specific one like Nix. The resolver is completely generic.
|
a specific one like Nix. The resolver is completely generic.
|
||||||
"""
|
"""
|
||||||
if layers is None:
|
layers_list = list(LAYER_ORDER) if layers is None else list(layers)
|
||||||
layers_list = list(LAYER_ORDER)
|
|
||||||
else:
|
|
||||||
layers_list = list(layers)
|
|
||||||
|
|
||||||
raw_caps = detect_capabilities(ctx, layers_list)
|
raw_caps = detect_capabilities(ctx, layers_list)
|
||||||
effective: dict[str, set[str]] = {layer: set() for layer in layers_list}
|
effective: dict[str, set[str]] = {layer: set() for layer in layers_list}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# src/pkgmgr/actions/install/context.py
|
# src/pkgmgr/actions/install/context.py
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Shared context object for repository installation steps.
|
Shared context object for repository installation steps.
|
||||||
@@ -10,19 +8,19 @@ they do not depend on global state or long parameter lists.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RepoContext:
|
class RepoContext:
|
||||||
"""Container for all repository-related data used during installation."""
|
"""Container for all repository-related data used during installation."""
|
||||||
|
|
||||||
repo: Dict[str, Any]
|
repo: dict[str, Any]
|
||||||
identifier: str
|
identifier: str
|
||||||
repo_dir: str
|
repo_dir: str
|
||||||
repositories_base_dir: str
|
repositories_base_dir: str
|
||||||
bin_dir: str
|
bin_dir: str
|
||||||
all_repos: List[Dict[str, Any]]
|
all_repos: list[dict[str, Any]]
|
||||||
|
|
||||||
no_verification: bool
|
no_verification: bool
|
||||||
preview: bool
|
preview: bool
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Installer package for pkgmgr.
|
Installer package for pkgmgr.
|
||||||
|
|
||||||
@@ -9,15 +6,17 @@ pkgmgr.actions.install.installers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401
|
||||||
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401
|
|
||||||
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
|
|
||||||
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
|
||||||
|
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401
|
||||||
|
|
||||||
# OS-specific installers
|
# OS-specific installers
|
||||||
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
|
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
|
||||||
ArchPkgbuildInstaller as ArchPkgbuildInstaller,
|
ArchPkgbuildInstaller as ArchPkgbuildInstaller,
|
||||||
) # noqa: F401
|
)
|
||||||
from pkgmgr.actions.install.installers.os_packages.debian_control import (
|
from pkgmgr.actions.install.installers.os_packages.debian_control import (
|
||||||
DebianControlInstaller as DebianControlInstaller,
|
DebianControlInstaller as DebianControlInstaller,
|
||||||
) # noqa: F401
|
)
|
||||||
from pkgmgr.actions.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.os_packages.rpm_spec import (
|
||||||
|
RpmSpecInstaller, # noqa: F401
|
||||||
|
)
|
||||||
|
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Base interface for all installer components in the pkgmgr installation pipeline.
|
Base interface for all installer components in the pkgmgr installation pipeline.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Set, Optional
|
|
||||||
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
|
||||||
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
|
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
|
||||||
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
|
|
||||||
|
|
||||||
class BaseInstaller(ABC):
|
class BaseInstaller(ABC):
|
||||||
@@ -24,9 +21,9 @@ class BaseInstaller(ABC):
|
|||||||
# Examples: "nix", "python", "makefile".
|
# Examples: "nix", "python", "makefile".
|
||||||
# This is used by capability matchers to decide which patterns to
|
# This is used by capability matchers to decide which patterns to
|
||||||
# search for in the repository.
|
# search for in the repository.
|
||||||
layer: Optional[str] = None
|
layer: str | None = None
|
||||||
|
|
||||||
def discover_capabilities(self, ctx: RepoContext) -> Set[str]:
|
def discover_capabilities(self, ctx: RepoContext) -> set[str]:
|
||||||
"""
|
"""
|
||||||
Determine which logical capabilities this installer will provide
|
Determine which logical capabilities this installer will provide
|
||||||
for this specific repository instance.
|
for this specific repository instance.
|
||||||
@@ -36,7 +33,7 @@ class BaseInstaller(ABC):
|
|||||||
Makefile, etc.) and decide, via string matching, whether a given
|
Makefile, etc.) and decide, via string matching, whether a given
|
||||||
capability is actually provided by this layer.
|
capability is actually provided by this layer.
|
||||||
"""
|
"""
|
||||||
caps: Set[str] = set()
|
caps: set[str] = set()
|
||||||
if not self.layer:
|
if not self.layer:
|
||||||
return caps
|
return caps
|
||||||
|
|
||||||
|
|||||||
@@ -26,16 +26,14 @@ class MakefileInstaller(BaseInstaller):
|
|||||||
|
|
||||||
def _has_install_target(self, makefile_path: str) -> bool:
|
def _has_install_target(self, makefile_path: str) -> bool:
|
||||||
try:
|
try:
|
||||||
with open(makefile_path, "r", encoding="utf-8", errors="ignore") as f:
|
with open(makefile_path, encoding="utf-8", errors="ignore") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if re.search(r"^install\s*:", content, flags=re.MULTILINE):
|
if re.search(r"^install\s*:", content, flags=re.MULTILINE):
|
||||||
return True
|
return True
|
||||||
if re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE):
|
return bool(re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE))
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def run(self, ctx: RepoContext) -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .profile import NixProfileInspector
|
from .profile import NixProfileInspector
|
||||||
from .retry import GitHubRateLimitRetry
|
from .retry import GitHubRateLimitRetry
|
||||||
@@ -33,7 +33,7 @@ class NixConflictResolver:
|
|||||||
|
|
||||||
def resolve(
|
def resolve(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
install_cmd: str,
|
install_cmd: str,
|
||||||
stdout: str,
|
stdout: str,
|
||||||
stderr: str,
|
stderr: str,
|
||||||
@@ -49,7 +49,7 @@ class NixConflictResolver:
|
|||||||
store_prefixes = self._parser.existing_store_prefixes(combined)
|
store_prefixes = self._parser.existing_store_prefixes(combined)
|
||||||
|
|
||||||
# 2) Resolve them to concrete remove tokens
|
# 2) Resolve them to concrete remove tokens
|
||||||
tokens: List[str] = self._profile.find_remove_tokens_for_store_prefixes(
|
tokens: list[str] = self._profile.find_remove_tokens_for_store_prefixes(
|
||||||
ctx,
|
ctx,
|
||||||
self._runner,
|
self._runner,
|
||||||
store_prefixes,
|
store_prefixes,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from typing import TYPE_CHECKING, List, Tuple
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
# Newer nix rejects numeric indices; we learn this at runtime and cache the decision.
|
# Newer nix rejects numeric indices; we learn this at runtime and cache the decision.
|
||||||
self._indices_supported: bool | None = None
|
self._indices_supported: bool | None = None
|
||||||
|
|
||||||
def supports(self, ctx: "RepoContext") -> bool:
|
def supports(self, ctx: RepoContext) -> bool:
|
||||||
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
|
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print(
|
print(
|
||||||
@@ -42,13 +42,13 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
|
|
||||||
return os.path.exists(os.path.join(ctx.repo_dir, self.FLAKE_FILE))
|
return os.path.exists(os.path.join(ctx.repo_dir, self.FLAKE_FILE))
|
||||||
|
|
||||||
def _profile_outputs(self, ctx: "RepoContext") -> List[Tuple[str, bool]]:
|
def _profile_outputs(self, ctx: RepoContext) -> list[tuple[str, bool]]:
|
||||||
# (output_name, allow_failure)
|
# (output_name, allow_failure)
|
||||||
if ctx.identifier in {"pkgmgr", "package-manager"}:
|
if ctx.identifier in {"pkgmgr", "package-manager"}:
|
||||||
return [("pkgmgr", False), ("default", True)]
|
return [("pkgmgr", False), ("default", True)]
|
||||||
return [("default", False)]
|
return [("default", False)]
|
||||||
|
|
||||||
def run(self, ctx: "RepoContext") -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
if not self.supports(ctx):
|
if not self.supports(ctx):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
else:
|
else:
|
||||||
self._install_only(ctx, output, allow_failure)
|
self._install_only(ctx, output, allow_failure)
|
||||||
|
|
||||||
def _installable(self, ctx: "RepoContext", output: str) -> str:
|
def _installable(self, ctx: RepoContext, output: str) -> str:
|
||||||
return f"{ctx.repo_dir}#{output}"
|
return f"{ctx.repo_dir}#{output}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
@@ -76,7 +76,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
def _install_only(
|
def _install_only(
|
||||||
self, ctx: "RepoContext", output: str, allow_failure: bool
|
self, ctx: RepoContext, output: str, allow_failure: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
install_cmd = f"nix profile install {self._installable(ctx, output)}"
|
install_cmd = f"nix profile install {self._installable(ctx, output)}"
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
def _force_upgrade_output(
|
def _force_upgrade_output(
|
||||||
self, ctx: "RepoContext", output: str, allow_failure: bool
|
self, ctx: RepoContext, output: str, allow_failure: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
# Prefer token path if indices unsupported (new nix)
|
# Prefer token path if indices unsupported (new nix)
|
||||||
if self._indices_supported is False:
|
if self._indices_supported is False:
|
||||||
@@ -215,7 +215,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
s = (stderr or "").lower()
|
s = (stderr or "").lower()
|
||||||
return "no longer supports indices" in s or "does not support indices" in s
|
return "no longer supports indices" in s or "does not support indices" in s
|
||||||
|
|
||||||
def _upgrade_index(self, ctx: "RepoContext", idx: int) -> bool:
|
def _upgrade_index(self, ctx: RepoContext, idx: int) -> bool:
|
||||||
cmd = f"nix profile upgrade --refresh {idx}"
|
cmd = f"nix profile upgrade --refresh {idx}"
|
||||||
res = self._runner.run(ctx, cmd, allow_failure=True)
|
res = self._runner.run(ctx, cmd, allow_failure=True)
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
|
|
||||||
return res.returncode == 0
|
return res.returncode == 0
|
||||||
|
|
||||||
def _remove_index(self, ctx: "RepoContext", idx: int) -> None:
|
def _remove_index(self, ctx: RepoContext, idx: int) -> None:
|
||||||
res = self._runner.run(ctx, f"nix profile remove {idx}", allow_failure=True)
|
res = self._runner.run(ctx, f"nix profile remove {idx}", allow_failure=True)
|
||||||
|
|
||||||
if self._stderr_says_indices_unsupported(getattr(res, "stderr", "")):
|
if self._stderr_says_indices_unsupported(getattr(res, "stderr", "")):
|
||||||
@@ -237,7 +237,7 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
if self._indices_supported is None:
|
if self._indices_supported is None:
|
||||||
self._indices_supported = True
|
self._indices_supported = True
|
||||||
|
|
||||||
def _remove_tokens_for_output(self, ctx: "RepoContext", output: str) -> None:
|
def _remove_tokens_for_output(self, ctx: RepoContext, output: str) -> None:
|
||||||
tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output)
|
tokens = self._profile.find_remove_tokens_for_output(ctx, self._runner, output)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .inspector import NixProfileInspector
|
from .inspector import NixProfileInspector
|
||||||
from .models import NixProfileEntry
|
from .models import NixProfileEntry
|
||||||
|
|
||||||
__all__ = ["NixProfileInspector", "NixProfileEntry"]
|
__all__ = ["NixProfileEntry", "NixProfileInspector"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, List, TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from .matcher import (
|
from .matcher import (
|
||||||
entry_matches_output,
|
entry_matches_output,
|
||||||
@@ -29,7 +29,7 @@ class NixProfileInspector:
|
|||||||
- find_remove_tokens_for_store_prefixes()
|
- find_remove_tokens_for_store_prefixes()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def list_json(self, ctx: "RepoContext", runner: "CommandRunner") -> dict[str, Any]:
|
def list_json(self, ctx: RepoContext, runner: CommandRunner) -> dict[str, Any]:
|
||||||
res = runner.run(ctx, "nix profile list --json", allow_failure=False)
|
res = runner.run(ctx, "nix profile list --json", allow_failure=False)
|
||||||
raw = extract_stdout_text(res)
|
raw = extract_stdout_text(res)
|
||||||
return parse_profile_list_json(raw)
|
return parse_profile_list_json(raw)
|
||||||
@@ -40,14 +40,14 @@ class NixProfileInspector:
|
|||||||
|
|
||||||
def find_installed_indices_for_output(
|
def find_installed_indices_for_output(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
runner: "CommandRunner",
|
runner: CommandRunner,
|
||||||
output: str,
|
output: str,
|
||||||
) -> List[int]:
|
) -> list[int]:
|
||||||
data = self.list_json(ctx, runner)
|
data = self.list_json(ctx, runner)
|
||||||
entries = normalize_elements(data)
|
entries = normalize_elements(data)
|
||||||
|
|
||||||
hits: List[int] = []
|
hits: list[int] = []
|
||||||
for e in entries:
|
for e in entries:
|
||||||
if e.index is None:
|
if e.index is None:
|
||||||
continue
|
continue
|
||||||
@@ -58,10 +58,10 @@ class NixProfileInspector:
|
|||||||
|
|
||||||
def find_indices_by_store_path(
|
def find_indices_by_store_path(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
runner: "CommandRunner",
|
runner: CommandRunner,
|
||||||
store_path: str,
|
store_path: str,
|
||||||
) -> List[int]:
|
) -> list[int]:
|
||||||
needle = (store_path or "").strip()
|
needle = (store_path or "").strip()
|
||||||
if not needle:
|
if not needle:
|
||||||
return []
|
return []
|
||||||
@@ -69,7 +69,7 @@ class NixProfileInspector:
|
|||||||
data = self.list_json(ctx, runner)
|
data = self.list_json(ctx, runner)
|
||||||
entries = normalize_elements(data)
|
entries = normalize_elements(data)
|
||||||
|
|
||||||
hits: List[int] = []
|
hits: list[int] = []
|
||||||
for e in entries:
|
for e in entries:
|
||||||
if e.index is None:
|
if e.index is None:
|
||||||
continue
|
continue
|
||||||
@@ -84,10 +84,10 @@ class NixProfileInspector:
|
|||||||
|
|
||||||
def find_remove_tokens_for_output(
|
def find_remove_tokens_for_output(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
runner: "CommandRunner",
|
runner: CommandRunner,
|
||||||
output: str,
|
output: str,
|
||||||
) -> List[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Returns profile remove tokens to remove entries matching a given output.
|
Returns profile remove tokens to remove entries matching a given output.
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ class NixProfileInspector:
|
|||||||
data = self.list_json(ctx, runner)
|
data = self.list_json(ctx, runner)
|
||||||
entries = normalize_elements(data)
|
entries = normalize_elements(data)
|
||||||
|
|
||||||
tokens: List[str] = [
|
tokens: list[str] = [
|
||||||
out
|
out
|
||||||
] # critical: matches nix's own suggestion for conflicts
|
] # critical: matches nix's own suggestion for conflicts
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ class NixProfileInspector:
|
|||||||
|
|
||||||
# stable unique preserving order
|
# stable unique preserving order
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
uniq: List[str] = []
|
uniq: list[str] = []
|
||||||
for t in tokens:
|
for t in tokens:
|
||||||
if t and t not in seen:
|
if t and t not in seen:
|
||||||
uniq.append(t)
|
uniq.append(t)
|
||||||
@@ -128,10 +128,10 @@ class NixProfileInspector:
|
|||||||
|
|
||||||
def find_remove_tokens_for_store_prefixes(
|
def find_remove_tokens_for_store_prefixes(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
runner: "CommandRunner",
|
runner: CommandRunner,
|
||||||
prefixes: List[str],
|
prefixes: list[str],
|
||||||
) -> List[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Returns remove tokens for entries whose store path matches any prefix.
|
Returns remove tokens for entries whose store path matches any prefix.
|
||||||
"""
|
"""
|
||||||
@@ -143,7 +143,7 @@ class NixProfileInspector:
|
|||||||
data = self.list_json(ctx, runner)
|
data = self.list_json(ctx, runner)
|
||||||
entries = normalize_elements(data)
|
entries = normalize_elements(data)
|
||||||
|
|
||||||
tokens: List[str] = []
|
tokens: list[str] = []
|
||||||
for e in entries:
|
for e in entries:
|
||||||
if not e.store_paths:
|
if not e.store_paths:
|
||||||
continue
|
continue
|
||||||
@@ -156,7 +156,7 @@ class NixProfileInspector:
|
|||||||
tokens.append(n)
|
tokens.append(n)
|
||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
uniq: List[str] = []
|
uniq: list[str] = []
|
||||||
for t in tokens:
|
for t in tokens:
|
||||||
if t and t not in seen:
|
if t and t not in seen:
|
||||||
uniq.append(t)
|
uniq.append(t)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from .models import NixProfileEntry
|
from .models import NixProfileEntry
|
||||||
|
|
||||||
|
|
||||||
@@ -51,9 +49,9 @@ def entry_matches_store_path(entry: NixProfileEntry, store_path: str) -> bool:
|
|||||||
return any((p or "") == needle for p in entry.store_paths)
|
return any((p or "") == needle for p in entry.store_paths)
|
||||||
|
|
||||||
|
|
||||||
def stable_unique_ints(values: List[int]) -> List[int]:
|
def stable_unique_ints(values: list[int]) -> list[int]:
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
uniq: List[int] = []
|
uniq: list[int] = []
|
||||||
for v in values:
|
for v in values:
|
||||||
if v in seen:
|
if v in seen:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -11,7 +10,7 @@ class NixProfileEntry:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
index: Optional[int]
|
index: int | None
|
||||||
name: str
|
name: str
|
||||||
attr_path: str
|
attr_path: str
|
||||||
store_paths: List[str]
|
store_paths: list[str]
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, Iterable, List, Optional
|
from collections.abc import Iterable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .models import NixProfileEntry
|
from .models import NixProfileEntry
|
||||||
|
|
||||||
|
|
||||||
def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
def coerce_index(key: str, entry: dict[str, Any]) -> int | None:
|
||||||
"""
|
"""
|
||||||
Nix JSON schema varies:
|
Nix JSON schema varies:
|
||||||
- elements keys might be "0", "1", ...
|
- elements keys might be "0", "1", ...
|
||||||
@@ -20,7 +21,7 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
|||||||
if k.isdigit():
|
if k.isdigit():
|
||||||
try:
|
try:
|
||||||
return int(k)
|
return int(k)
|
||||||
except Exception:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 2) Explicit index fields (schema-dependent)
|
# 2) Explicit index fields (schema-dependent)
|
||||||
@@ -31,7 +32,7 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
|||||||
if isinstance(v, str) and v.strip().isdigit():
|
if isinstance(v, str) and v.strip().isdigit():
|
||||||
try:
|
try:
|
||||||
return int(v.strip())
|
return int(v.strip())
|
||||||
except Exception:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3) Last resort: extract trailing number from key if it looks like "<name>-<n>"
|
# 3) Last resort: extract trailing number from key if it looks like "<name>-<n>"
|
||||||
@@ -39,13 +40,13 @@ def coerce_index(key: str, entry: Dict[str, Any]) -> Optional[int]:
|
|||||||
if m:
|
if m:
|
||||||
try:
|
try:
|
||||||
return int(m.group(1))
|
return int(m.group(1))
|
||||||
except Exception:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def iter_store_paths(entry: Dict[str, Any]) -> Iterable[str]:
|
def iter_store_paths(entry: dict[str, Any]) -> Iterable[str]:
|
||||||
"""
|
"""
|
||||||
Yield all possible store paths from a nix profile JSON entry.
|
Yield all possible store paths from a nix profile JSON entry.
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ def iter_store_paths(entry: Dict[str, Any]) -> Iterable[str]:
|
|||||||
|
|
||||||
outs = entry.get("outputs")
|
outs = entry.get("outputs")
|
||||||
if isinstance(outs, dict):
|
if isinstance(outs, dict):
|
||||||
for _, ov in outs.items():
|
for ov in outs.values():
|
||||||
if isinstance(ov, dict):
|
if isinstance(ov, dict):
|
||||||
p = ov.get("storePath")
|
p = ov.get("storePath")
|
||||||
if isinstance(p, str):
|
if isinstance(p, str):
|
||||||
@@ -87,7 +88,7 @@ def normalize_store_path(store_path: str) -> str:
|
|||||||
return (store_path or "").strip()
|
return (store_path or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
|
def normalize_elements(data: dict[str, Any]) -> list[NixProfileEntry]:
|
||||||
"""
|
"""
|
||||||
Converts nix profile list JSON into a list of normalized entries.
|
Converts nix profile list JSON into a list of normalized entries.
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
|
|||||||
if not isinstance(elements, dict):
|
if not isinstance(elements, dict):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
normalized: List[NixProfileEntry] = []
|
normalized: list[NixProfileEntry] = []
|
||||||
|
|
||||||
for k, entry in elements.items():
|
for k, entry in elements.items():
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
@@ -109,7 +110,7 @@ def normalize_elements(data: Dict[str, Any]) -> List[NixProfileEntry]:
|
|||||||
name = str(entry.get("name", "") or "")
|
name = str(entry.get("name", "") or "")
|
||||||
attr = str(entry.get("attrPath", "") or "")
|
attr = str(entry.get("attrPath", "") or "")
|
||||||
|
|
||||||
store_paths: List[str] = []
|
store_paths: list[str] = []
|
||||||
for p in iter_store_paths(entry):
|
for p in iter_store_paths(entry):
|
||||||
sp = normalize_store_path(p)
|
sp = normalize_store_path(p)
|
||||||
if sp:
|
if sp:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
def parse_profile_list_json(raw: str) -> Dict[str, Any]:
|
def parse_profile_list_json(raw: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Parse JSON output from `nix profile list --json`.
|
Parse JSON output from `nix profile list --json`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, List, Tuple
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .runner import CommandRunner
|
from .runner import CommandRunner
|
||||||
|
|
||||||
@@ -19,12 +19,12 @@ class NixProfileListReader:
|
|||||||
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
|
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
|
||||||
return m.group(1) if m else raw
|
return m.group(1) if m else raw
|
||||||
|
|
||||||
def entries(self, ctx: "RepoContext") -> List[Tuple[int, str]]:
|
def entries(self, ctx: RepoContext) -> list[tuple[int, str]]:
|
||||||
res = self._runner.run(ctx, "nix profile list", allow_failure=True)
|
res = self._runner.run(ctx, "nix profile list", allow_failure=True)
|
||||||
if res.returncode != 0:
|
if res.returncode != 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
entries: List[Tuple[int, str]] = []
|
entries: list[tuple[int, str]] = []
|
||||||
pat = re.compile(
|
pat = re.compile(
|
||||||
r"^\s*(\d+)\s+.*?(/nix/store/[0-9a-z]{32}-[^/ \t]+)",
|
r"^\s*(\d+)\s+.*?(/nix/store/[0-9a-z]{32}-[^/ \t]+)",
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
@@ -35,12 +35,12 @@ class NixProfileListReader:
|
|||||||
sp = m.group(2)
|
sp = m.group(2)
|
||||||
try:
|
try:
|
||||||
idx = int(idx_s)
|
idx = int(idx_s)
|
||||||
except Exception:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
entries.append((idx, self._store_prefix(sp)))
|
entries.append((idx, self._store_prefix(sp)))
|
||||||
|
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
uniq: List[Tuple[int, str]] = []
|
uniq: list[tuple[int, str]] = []
|
||||||
for idx, sp in entries:
|
for idx, sp in entries:
|
||||||
if idx not in seen:
|
if idx not in seen:
|
||||||
seen.add(idx)
|
seen.add(idx)
|
||||||
@@ -49,20 +49,20 @@ class NixProfileListReader:
|
|||||||
return uniq
|
return uniq
|
||||||
|
|
||||||
def indices_matching_store_prefixes(
|
def indices_matching_store_prefixes(
|
||||||
self, ctx: "RepoContext", prefixes: List[str]
|
self, ctx: RepoContext, prefixes: list[str]
|
||||||
) -> List[int]:
|
) -> list[int]:
|
||||||
prefixes = [self._store_prefix(p) for p in prefixes if p]
|
prefixes = [self._store_prefix(p) for p in prefixes if p]
|
||||||
prefixes = [p for p in prefixes if p]
|
prefixes = [p for p in prefixes if p]
|
||||||
if not prefixes:
|
if not prefixes:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
hits: List[int] = []
|
hits: list[int] = []
|
||||||
for idx, sp in self.entries(ctx):
|
for idx, sp in self.entries(ctx):
|
||||||
if any(sp == p for p in prefixes):
|
if any(sp == p for p in prefixes):
|
||||||
hits.append(idx)
|
hits.append(idx)
|
||||||
|
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
uniq: List[int] = []
|
uniq: list[int] = []
|
||||||
for i in hits:
|
for i in hits:
|
||||||
if i not in seen:
|
if i not in seen:
|
||||||
seen.add(i)
|
seen.add(i)
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable, TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .types import RunResult
|
from .types import RunResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
|
|
||||||
from .runner import CommandRunner
|
from .runner import CommandRunner
|
||||||
|
|
||||||
|
|
||||||
@@ -31,8 +33,8 @@ class GitHubRateLimitRetry:
|
|||||||
|
|
||||||
def run_with_retry(
|
def run_with_retry(
|
||||||
self,
|
self,
|
||||||
ctx: "RepoContext",
|
ctx: RepoContext,
|
||||||
runner: "CommandRunner",
|
runner: CommandRunner,
|
||||||
install_cmd: str,
|
install_cmd: str,
|
||||||
) -> RunResult:
|
) -> RunResult:
|
||||||
quiet = bool(getattr(ctx, "quiet", False))
|
quiet = bool(getattr(ctx, "quiet", False))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .types import RunResult
|
from .types import RunResult
|
||||||
@@ -16,7 +15,7 @@ class CommandRunner:
|
|||||||
Supports preview mode and compact failure output logging.
|
Supports preview mode and compact failure output logging.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def run(self, ctx: "RepoContext", cmd: str, allow_failure: bool) -> RunResult:
|
def run(self, ctx: RepoContext, cmd: str, allow_failure: bool) -> RunResult:
|
||||||
repo_dir = getattr(ctx, "repo_dir", None) or getattr(ctx, "repo_path", None)
|
repo_dir = getattr(ctx, "repo_dir", None) or getattr(ctx, "repo_path", None)
|
||||||
preview = bool(getattr(ctx, "preview", False))
|
preview = bool(getattr(ctx, "preview", False))
|
||||||
quiet = bool(getattr(ctx, "quiet", False))
|
quiet = bool(getattr(ctx, "quiet", False))
|
||||||
@@ -32,8 +31,7 @@ class CommandRunner:
|
|||||||
shell=True,
|
shell=True,
|
||||||
cwd=repo_dir,
|
cwd=repo_dir,
|
||||||
check=False,
|
check=False,
|
||||||
stdout=subprocess.PIPE,
|
capture_output=True,
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import List
|
|
||||||
|
|
||||||
|
|
||||||
class NixConflictTextParser:
|
class NixConflictTextParser:
|
||||||
@@ -11,13 +10,13 @@ class NixConflictTextParser:
|
|||||||
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
|
m = re.match(r"^(/nix/store/[0-9a-z]{32}-[^/ \t]+)", raw)
|
||||||
return m.group(1) if m else raw
|
return m.group(1) if m else raw
|
||||||
|
|
||||||
def remove_tokens(self, text: str) -> List[str]:
|
def remove_tokens(self, text: str) -> list[str]:
|
||||||
pat = re.compile(
|
pat = re.compile(
|
||||||
r"^\s*nix profile remove\s+([^\s'\"`]+|'[^']+'|\"[^\"]+\")\s*$",
|
r"^\s*nix profile remove\s+([^\s'\"`]+|'[^']+'|\"[^\"]+\")\s*$",
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
tokens: List[str] = []
|
tokens: list[str] = []
|
||||||
for m in pat.finditer(text or ""):
|
for m in pat.finditer(text or ""):
|
||||||
t = (m.group(1) or "").strip()
|
t = (m.group(1) or "").strip()
|
||||||
if (t.startswith("'") and t.endswith("'")) or (
|
if (t.startswith("'") and t.endswith("'")) or (
|
||||||
@@ -28,7 +27,7 @@ class NixConflictTextParser:
|
|||||||
tokens.append(t)
|
tokens.append(t)
|
||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
uniq: List[str] = []
|
uniq: list[str] = []
|
||||||
for t in tokens:
|
for t in tokens:
|
||||||
if t not in seen:
|
if t not in seen:
|
||||||
seen.add(t)
|
seen.add(t)
|
||||||
@@ -36,9 +35,9 @@ class NixConflictTextParser:
|
|||||||
|
|
||||||
return uniq
|
return uniq
|
||||||
|
|
||||||
def existing_store_prefixes(self, text: str) -> List[str]:
|
def existing_store_prefixes(self, text: str) -> list[str]:
|
||||||
lines = (text or "").splitlines()
|
lines = (text or "").splitlines()
|
||||||
prefixes: List[str] = []
|
prefixes: list[str] = []
|
||||||
|
|
||||||
in_existing = False
|
in_existing = False
|
||||||
in_new = False
|
in_new = False
|
||||||
@@ -69,7 +68,7 @@ class NixConflictTextParser:
|
|||||||
norm = [self._store_prefix(p) for p in prefixes if p]
|
norm = [self._store_prefix(p) for p in prefixes if p]
|
||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
uniq: List[str] = []
|
uniq: list[str] = []
|
||||||
for p in norm:
|
for p in norm:
|
||||||
if p and p not in seen:
|
if p and p not in seen:
|
||||||
seen.add(p)
|
seen.add(p)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ArchPkgbuildInstaller(BaseInstaller):
|
|||||||
try:
|
try:
|
||||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except (AttributeError, OSError):
|
||||||
# On non-POSIX platforms just ignore this check.
|
# On non-POSIX platforms just ignore this check.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Installer for Debian/Ubuntu packages defined via debian/control.
|
Installer for Debian/Ubuntu packages defined via debian/control.
|
||||||
|
|
||||||
@@ -13,11 +10,11 @@ This installer:
|
|||||||
It is intended for Debian-based systems where dpkg-buildpackage and
|
It is intended for Debian-based systems where dpkg-buildpackage and
|
||||||
apt/dpkg tooling are available.
|
apt/dpkg tooling are available.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
@@ -56,7 +53,7 @@ class DebianControlInstaller(BaseInstaller):
|
|||||||
|
|
||||||
return os.path.exists(self._control_path(ctx))
|
return os.path.exists(self._control_path(ctx))
|
||||||
|
|
||||||
def _find_built_debs(self, repo_dir: str) -> List[str]:
|
def _find_built_debs(self, repo_dir: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Find .deb files built by dpkg-buildpackage.
|
Find .deb files built by dpkg-buildpackage.
|
||||||
|
|
||||||
@@ -67,7 +64,7 @@ class DebianControlInstaller(BaseInstaller):
|
|||||||
pattern = os.path.join(parent, "*.deb")
|
pattern = os.path.join(parent, "*.deb")
|
||||||
return sorted(glob.glob(pattern))
|
return sorted(glob.glob(pattern))
|
||||||
|
|
||||||
def _privileged_prefix(self) -> Optional[str]:
|
def _privileged_prefix(self) -> str | None:
|
||||||
"""
|
"""
|
||||||
Determine how to run privileged commands:
|
Determine how to run privileged commands:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Installer for RPM-based packages defined in *.spec files.
|
Installer for RPM-based packages defined in *.spec files.
|
||||||
|
|
||||||
@@ -14,12 +11,12 @@ This installer:
|
|||||||
|
|
||||||
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
|
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
from typing import List, Optional, Tuple
|
|
||||||
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
@@ -53,7 +50,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
|
|
||||||
return has_dnf or has_yum or has_yum_builddep
|
return has_dnf or has_yum or has_yum_builddep
|
||||||
|
|
||||||
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
|
def _spec_path(self, ctx: RepoContext) -> str | None:
|
||||||
"""Return the first *.spec file in the repository root, if any."""
|
"""Return the first *.spec file in the repository root, if any."""
|
||||||
pattern = os.path.join(ctx.repo_dir, "*.spec")
|
pattern = os.path.join(ctx.repo_dir, "*.spec")
|
||||||
matches = sorted(glob.glob(pattern))
|
matches = sorted(glob.glob(pattern))
|
||||||
@@ -92,7 +89,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
for sub in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
|
for sub in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
|
||||||
os.makedirs(os.path.join(topdir, sub), exist_ok=True)
|
os.makedirs(os.path.join(topdir, sub), exist_ok=True)
|
||||||
|
|
||||||
def _parse_name_version(self, spec_path: str) -> Optional[Tuple[str, str]]:
|
def _parse_name_version(self, spec_path: str) -> tuple[str, str] | None:
|
||||||
"""
|
"""
|
||||||
Parse Name and Version from the given .spec file.
|
Parse Name and Version from the given .spec file.
|
||||||
|
|
||||||
@@ -101,7 +98,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
name = None
|
name = None
|
||||||
version = None
|
version = None
|
||||||
|
|
||||||
with open(spec_path, "r", encoding="utf-8") as f:
|
with open(spec_path, encoding="utf-8") as f:
|
||||||
for raw_line in f:
|
for raw_line in f:
|
||||||
line = raw_line.strip()
|
line = raw_line.strip()
|
||||||
# Ignore comments
|
# Ignore comments
|
||||||
@@ -183,7 +180,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
|
|
||||||
return self._spec_path(ctx) is not None
|
return self._spec_path(ctx) is not None
|
||||||
|
|
||||||
def _find_built_rpms(self) -> List[str]:
|
def _find_built_rpms(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Find RPMs built by rpmbuild.
|
Find RPMs built by rpmbuild.
|
||||||
|
|
||||||
@@ -202,9 +199,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
|
|
||||||
if shutil.which("dnf") is not None:
|
if shutil.which("dnf") is not None:
|
||||||
cmd = f"sudo dnf builddep -y {spec_basename}"
|
cmd = f"sudo dnf builddep -y {spec_basename}"
|
||||||
elif shutil.which("yum-builddep") is not None:
|
elif shutil.which("yum-builddep") is not None or shutil.which("yum") is not None:
|
||||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
|
||||||
elif shutil.which("yum") is not None:
|
|
||||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
cmd = f"sudo yum-builddep -y {spec_basename}"
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
@@ -215,7 +210,7 @@ class RpmSpecInstaller(BaseInstaller):
|
|||||||
|
|
||||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||||
|
|
||||||
def _install_built_rpms(self, ctx: RepoContext, rpms: List[str]) -> None:
|
def _install_built_rpms(self, ctx: RepoContext, rpms: list[str]) -> None:
|
||||||
"""
|
"""
|
||||||
Install or upgrade the built RPMs.
|
Install or upgrade the built RPMs.
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
from pkgmgr.core.command.run import run_command
|
from pkgmgr.core.command.run import run_command
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
CLI layer model for the pkgmgr installation pipeline.
|
CLI layer model for the pkgmgr installation pipeline.
|
||||||
|
|
||||||
@@ -19,7 +16,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class CliLayer(str, Enum):
|
class CliLayer(str, Enum):
|
||||||
@@ -38,7 +34,7 @@ CLI_LAYERS: list[CliLayer] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def layer_priority(layer: Optional[CliLayer]) -> int:
|
def layer_priority(layer: CliLayer | None) -> int:
|
||||||
"""
|
"""
|
||||||
Return a numeric priority index for a given layer.
|
Return a numeric priority index for a given layer.
|
||||||
|
|
||||||
@@ -70,13 +66,11 @@ def classify_command_layer(command: str, repo_dir: str) -> CliLayer:
|
|||||||
home = os.path.expanduser("~")
|
home = os.path.expanduser("~")
|
||||||
|
|
||||||
# OS package managers
|
# OS package managers
|
||||||
if command_abs.startswith("/usr/") or command_abs.startswith("/bin/"):
|
if command_abs.startswith(("/usr/", "/bin/")):
|
||||||
return CliLayer.OS_PACKAGES
|
return CliLayer.OS_PACKAGES
|
||||||
|
|
||||||
# Nix store / profile
|
# Nix store / profile
|
||||||
if command_abs.startswith("/nix/store/") or command_abs.startswith(
|
if command_abs.startswith(("/nix/store/", os.path.join(home, ".nix-profile"))):
|
||||||
os.path.join(home, ".nix-profile")
|
|
||||||
):
|
|
||||||
return CliLayer.NIX
|
return CliLayer.NIX
|
||||||
|
|
||||||
# User-local bin
|
# User-local bin
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# src/pkgmgr/actions/install/pipeline.py
|
# src/pkgmgr/actions/install/pipeline.py
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Installation pipeline orchestration for repositories.
|
Installation pipeline orchestration for repositories.
|
||||||
@@ -8,8 +6,8 @@ Installation pipeline orchestration for repositories.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Sequence, Set
|
|
||||||
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
@@ -24,8 +22,8 @@ from pkgmgr.core.command.resolve import resolve_command_for_repo
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandState:
|
class CommandState:
|
||||||
command: Optional[str]
|
command: str | None
|
||||||
layer: Optional[CliLayer]
|
layer: CliLayer | None
|
||||||
|
|
||||||
|
|
||||||
class CommandResolver:
|
class CommandResolver:
|
||||||
@@ -83,7 +81,7 @@ class InstallationPipeline:
|
|||||||
else:
|
else:
|
||||||
repo.pop("command", None)
|
repo.pop("command", None)
|
||||||
|
|
||||||
provided_capabilities: Set[str] = set()
|
provided_capabilities: set[str] = set()
|
||||||
|
|
||||||
for installer in self._installers:
|
for installer in self._installers:
|
||||||
layer_name = getattr(installer, "layer", None)
|
layer_name = getattr(installer, "layer", None)
|
||||||
|
|||||||
@@ -9,19 +9,20 @@ Public API:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from .types import Repository, MirrorMap
|
|
||||||
from .list_cmd import list_mirrors
|
|
||||||
from .diff_cmd import diff_mirrors
|
from .diff_cmd import diff_mirrors
|
||||||
|
from .list_cmd import list_mirrors
|
||||||
from .merge_cmd import merge_mirrors
|
from .merge_cmd import merge_mirrors
|
||||||
from .setup_cmd import setup_mirrors
|
from .setup_cmd import setup_mirrors
|
||||||
|
from .types import MirrorMap, Repository
|
||||||
from .visibility_cmd import set_mirror_visibility
|
from .visibility_cmd import set_mirror_visibility
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Repository",
|
|
||||||
"MirrorMap",
|
"MirrorMap",
|
||||||
"list_mirrors",
|
"Repository",
|
||||||
"diff_mirrors",
|
"diff_mirrors",
|
||||||
|
"list_mirrors",
|
||||||
"merge_mirrors",
|
"merge_mirrors",
|
||||||
"setup_mirrors",
|
|
||||||
"set_mirror_visibility",
|
"set_mirror_visibility",
|
||||||
|
"setup_mirrors",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
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
|
||||||
|
|
||||||
@@ -12,7 +10,7 @@ from .types import MirrorMap, RepoMirrorContext, Repository
|
|||||||
def build_context(
|
def build_context(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
) -> RepoMirrorContext:
|
) -> RepoMirrorContext:
|
||||||
"""
|
"""
|
||||||
Build a RepoMirrorContext for a single repository.
|
Build a RepoMirrorContext for a single repository.
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from .context import build_context
|
from .context import build_context
|
||||||
from .printing import print_header
|
from .printing import print_header
|
||||||
from .types import Repository
|
from .types import Repository
|
||||||
|
|
||||||
|
|
||||||
def diff_mirrors(
|
def diff_mirrors(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Show differences between config mirrors and MIRRORS file.
|
Show differences between config mirrors and MIRRORS file.
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Optional, Set
|
|
||||||
|
|
||||||
from pkgmgr.core.git.errors import GitRunError
|
|
||||||
from pkgmgr.core.git.commands import (
|
from pkgmgr.core.git.commands import (
|
||||||
GitAddRemoteError,
|
GitAddRemoteError,
|
||||||
GitAddRemotePushUrlError,
|
GitAddRemotePushUrlError,
|
||||||
@@ -12,6 +10,7 @@ from pkgmgr.core.git.commands import (
|
|||||||
add_remote_push_url,
|
add_remote_push_url,
|
||||||
set_remote_url,
|
set_remote_url,
|
||||||
)
|
)
|
||||||
|
from pkgmgr.core.git.errors import GitRunError
|
||||||
from pkgmgr.core.git.queries import get_remote_push_urls, list_remotes
|
from pkgmgr.core.git.queries import get_remote_push_urls, list_remotes
|
||||||
|
|
||||||
from .types import MirrorMap, RepoMirrorContext, Repository
|
from .types import MirrorMap, RepoMirrorContext, Repository
|
||||||
@@ -38,13 +37,10 @@ def _is_git_remote_url(url: str) -> bool:
|
|||||||
if u.startswith("ssh://"):
|
if u.startswith("ssh://"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
|
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def build_default_ssh_url(repo: Repository) -> Optional[str]:
|
def build_default_ssh_url(repo: Repository) -> str | None:
|
||||||
provider = repo.get("provider")
|
provider = repo.get("provider")
|
||||||
account = repo.get("account")
|
account = repo.get("account")
|
||||||
name = repo.get("repository")
|
name = repo.get("repository")
|
||||||
@@ -66,7 +62,7 @@ def _git_mirrors_only(m: MirrorMap) -> MirrorMap:
|
|||||||
def determine_primary_remote_url(
|
def determine_primary_remote_url(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
ctx: RepoMirrorContext,
|
ctx: RepoMirrorContext,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Priority order (GIT URLS ONLY):
|
Priority order (GIT URLS ONLY):
|
||||||
1. origin from resolved mirrors (if it is a git URL)
|
1. origin from resolved mirrors (if it is a git URL)
|
||||||
@@ -80,7 +76,7 @@ def determine_primary_remote_url(
|
|||||||
return origin
|
return origin
|
||||||
|
|
||||||
for mirrors in (ctx.file_mirrors, ctx.config_mirrors):
|
for mirrors in (ctx.file_mirrors, ctx.config_mirrors):
|
||||||
for _, url in mirrors.items():
|
for url in mirrors.values():
|
||||||
if url and _is_git_remote_url(url):
|
if url and _is_git_remote_url(url):
|
||||||
return url
|
return url
|
||||||
|
|
||||||
@@ -116,7 +112,7 @@ def _ensure_additional_push_urls(
|
|||||||
Non-git URLs (like PyPI) are ignored and will never land in git config.
|
Non-git URLs (like PyPI) are ignored and will never land in git config.
|
||||||
"""
|
"""
|
||||||
git_only = _git_mirrors_only(mirrors)
|
git_only = _git_mirrors_only(mirrors)
|
||||||
desired: Set[str] = {u for u in git_only.values() if u and u != primary}
|
desired: set[str] = {u for u in git_only.values() if u and u != primary}
|
||||||
if not desired:
|
if not desired:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def read_mirrors_file(repo_dir: str, filename: str = "MIRRORS") -> MirrorMap:
|
|||||||
return mirrors
|
return mirrors
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as fh:
|
with open(path, encoding="utf-8") as fh:
|
||||||
for line in fh:
|
for line in fh:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if not stripped or stripped.startswith("#"):
|
if not stripped or stripped.startswith("#"):
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from .context import build_context
|
from .context import build_context
|
||||||
from .printing import print_header, print_named_mirrors
|
from .printing import print_header, print_named_mirrors
|
||||||
from .types import Repository
|
from .types import Repository
|
||||||
|
|
||||||
|
|
||||||
def list_mirrors(
|
def list_mirrors(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
source: str = "all",
|
source: str = "all",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Dict, List, Tuple, Optional
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -11,13 +10,12 @@ from .context import build_context
|
|||||||
from .io import write_mirrors_file
|
from .io import write_mirrors_file
|
||||||
from .types import MirrorMap, Repository
|
from .types import MirrorMap, Repository
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _repo_key(repo: Repository) -> Tuple[str, str, str]:
|
def _repo_key(repo: Repository) -> tuple[str, str, str]:
|
||||||
"""
|
"""
|
||||||
Normalised key for identifying a repository in config files.
|
Normalised key for identifying a repository in config files.
|
||||||
"""
|
"""
|
||||||
@@ -28,7 +26,7 @@ def _repo_key(repo: Repository) -> Tuple[str, str, str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_user_config(path: str) -> Dict[str, object]:
|
def _load_user_config(path: str) -> dict[str, object]:
|
||||||
"""
|
"""
|
||||||
Load a user config YAML file as dict.
|
Load a user config YAML file as dict.
|
||||||
Non-dicts yield {}.
|
Non-dicts yield {}.
|
||||||
@@ -37,10 +35,10 @@ def _load_user_config(path: str) -> Dict[str, object]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
data = yaml.safe_load(f) or {}
|
data = yaml.safe_load(f) or {}
|
||||||
return data if isinstance(data, dict) else {}
|
return data if isinstance(data, dict) else {}
|
||||||
except Exception:
|
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@@ -50,13 +48,13 @@ def _load_user_config(path: str) -> Dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
def merge_mirrors(
|
def merge_mirrors(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
source: str,
|
source: str,
|
||||||
target: str,
|
target: str,
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
user_config_path: Optional[str] = None,
|
user_config_path: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Merge mirrors between config and MIRRORS file.
|
Merge mirrors between config and MIRRORS file.
|
||||||
@@ -74,8 +72,8 @@ def merge_mirrors(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Load user config once if we intend to write to it.
|
# Load user config once if we intend to write to it.
|
||||||
user_cfg: Optional[Dict[str, object]] = None
|
user_cfg: dict[str, object] | None = None
|
||||||
user_cfg_path_expanded: Optional[str] = None
|
user_cfg_path_expanded: str | None = None
|
||||||
|
|
||||||
if target == "config" and user_config_path and not preview:
|
if target == "config" and user_config_path and not preview:
|
||||||
user_cfg_path_expanded = os.path.expanduser(user_config_path)
|
user_cfg_path_expanded = os.path.expanduser(user_config_path)
|
||||||
@@ -130,7 +128,7 @@ def merge_mirrors(
|
|||||||
|
|
||||||
repos = user_cfg.get("repositories")
|
repos = user_cfg.get("repositories")
|
||||||
target_key = _repo_key(repo)
|
target_key = _repo_key(repo)
|
||||||
existing_repo: Optional[Repository] = None
|
existing_repo: Repository | None = None
|
||||||
|
|
||||||
# Find existing repo entry
|
# Find existing repo entry
|
||||||
for entry in repos:
|
for entry in repos:
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, ensure_remote_repo
|
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, ensure_remote_repo
|
||||||
from pkgmgr.core.remote_provisioning.ensure import EnsureOptions
|
from pkgmgr.core.remote_provisioning.ensure import EnsureOptions
|
||||||
|
|
||||||
@@ -64,7 +62,7 @@ def ensure_remote_repository_for_url(
|
|||||||
def ensure_remote_repository(
|
def ensure_remote_repository(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
preview: bool,
|
preview: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pkgmgr.core.git.queries import probe_remote_reachable_detail
|
from pkgmgr.core.git.queries import probe_remote_reachable_detail
|
||||||
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
||||||
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
||||||
@@ -23,9 +21,7 @@ def _is_git_remote_url(url: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
if u.startswith("ssh://"):
|
if u.startswith("ssh://"):
|
||||||
return True
|
return True
|
||||||
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
|
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_hint_from_host(host: str) -> str | None:
|
def _provider_hint_from_host(host: str) -> str | None:
|
||||||
@@ -89,7 +85,7 @@ def _print_probe_result(name: str | None, url: str, *, cwd: str) -> None:
|
|||||||
def _setup_local_mirrors_for_repo(
|
def _setup_local_mirrors_for_repo(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
preview: bool,
|
preview: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = build_context(repo, repositories_base_dir, all_repos)
|
ctx = build_context(repo, repositories_base_dir, all_repos)
|
||||||
@@ -106,7 +102,7 @@ def _setup_local_mirrors_for_repo(
|
|||||||
def _setup_remote_mirrors_for_repo(
|
def _setup_remote_mirrors_for_repo(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
preview: bool,
|
preview: bool,
|
||||||
ensure_remote: bool,
|
ensure_remote: bool,
|
||||||
ensure_visibility: str | None,
|
ensure_visibility: str | None,
|
||||||
@@ -195,9 +191,9 @@ def _setup_remote_mirrors_for_repo(
|
|||||||
|
|
||||||
|
|
||||||
def setup_mirrors(
|
def setup_mirrors(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
local: bool = True,
|
local: bool = True,
|
||||||
remote: bool = True,
|
remote: bool = True,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
Repository = Dict[str, Any]
|
Repository = dict[str, Any]
|
||||||
MirrorMap = Dict[str, str]
|
MirrorMap = dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
def hostport_from_git_url(url: str) -> Tuple[str, Optional[str]]:
|
def hostport_from_git_url(url: str) -> tuple[str, str | None]:
|
||||||
url = (url or "").strip()
|
url = (url or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
return "", None
|
return "", None
|
||||||
@@ -58,7 +57,7 @@ def _strip_dot_git(name: str) -> str:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
def parse_repo_from_git_url(url: str) -> Tuple[str, Optional[str], Optional[str]]:
|
def parse_repo_from_git_url(url: str) -> tuple[str, str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
Parse (host, owner, repo_name) from common Git remote URLs.
|
Parse (host, owner, repo_name) from common Git remote URLs.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, set_repo_visibility
|
||||||
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
from pkgmgr.core.remote_provisioning.visibility import VisibilityOptions
|
||||||
|
|
||||||
@@ -20,9 +18,7 @@ def _is_git_remote_url(url: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
if u.startswith("ssh://"):
|
if u.startswith("ssh://"):
|
||||||
return True
|
return True
|
||||||
if (u.startswith("https://") or u.startswith("http://")) and u.endswith(".git"):
|
return bool((u.startswith(("https://", "http://"))) and u.endswith(".git"))
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_hint_from_host(host: str) -> str | None:
|
def _provider_hint_from_host(host: str) -> str | None:
|
||||||
@@ -66,9 +62,9 @@ def _apply_visibility_for_url(
|
|||||||
|
|
||||||
|
|
||||||
def set_mirror_visibility(
|
def set_mirror_visibility(
|
||||||
selected_repos: List[Repository],
|
selected_repos: list[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: list[Repository],
|
||||||
*,
|
*,
|
||||||
visibility: str,
|
visibility: str,
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
|
||||||
from pkgmgr.core.command.run import run_command
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from pkgmgr.core.command.run import run_command
|
||||||
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||||
|
|
||||||
|
|
||||||
def exec_proxy_command(
|
def exec_proxy_command(
|
||||||
proxy_prefix: str,
|
proxy_prefix: str,
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Backwards-compatible facade for the release file update helpers.
|
Backwards-compatible facade for the release file update helpers.
|
||||||
|
|
||||||
@@ -13,23 +10,23 @@ Keep this package stable so existing imports continue to work, e.g.:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .editor import _open_editor_for_changelog
|
|
||||||
from .pyproject import update_pyproject_version
|
|
||||||
from .flake import update_flake_version
|
|
||||||
from .pkgbuild import update_pkgbuild_version
|
|
||||||
from .rpm_spec import update_spec_version
|
|
||||||
from .changelog_md import update_changelog
|
from .changelog_md import update_changelog
|
||||||
from .debian import _get_debian_author, update_debian_changelog
|
from .debian import _get_debian_author, update_debian_changelog
|
||||||
|
from .editor import _open_editor_for_changelog
|
||||||
|
from .flake import update_flake_version
|
||||||
|
from .pkgbuild import update_pkgbuild_version
|
||||||
|
from .pyproject import update_pyproject_version
|
||||||
from .rpm_changelog import update_spec_changelog
|
from .rpm_changelog import update_spec_changelog
|
||||||
|
from .rpm_spec import update_spec_version
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"_get_debian_author",
|
||||||
"_open_editor_for_changelog",
|
"_open_editor_for_changelog",
|
||||||
"update_pyproject_version",
|
"update_changelog",
|
||||||
|
"update_debian_changelog",
|
||||||
"update_flake_version",
|
"update_flake_version",
|
||||||
"update_pkgbuild_version",
|
"update_pkgbuild_version",
|
||||||
"update_spec_version",
|
"update_pyproject_version",
|
||||||
"update_changelog",
|
|
||||||
"_get_debian_author",
|
|
||||||
"update_debian_changelog",
|
|
||||||
"update_spec_changelog",
|
"update_spec_changelog",
|
||||||
|
"update_spec_version",
|
||||||
]
|
]
|
||||||
|
|||||||
99
src/pkgmgr/actions/release/files/changelog_lint.py
Normal file
99
src/pkgmgr/actions/release/files/changelog_lint.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_HEADING = re.compile(r"^\s*#{1,6}\s+(.*?)\s*#*\s*$")
|
||||||
|
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
|
||||||
|
_FINDING = re.compile(r"\bMD\d{3}\b")
|
||||||
|
_MULTI_BLANK = re.compile(r"\n{3,}")
|
||||||
|
|
||||||
|
|
||||||
|
class ChangelogLintError(RuntimeError):
|
||||||
|
"""Raised when a changelog entry cannot be made markdown-lint clean."""
|
||||||
|
|
||||||
|
|
||||||
|
def transform_changelog_message(text: str) -> str:
|
||||||
|
"""Normalise a free-form release message into the changelog house style.
|
||||||
|
|
||||||
|
A leading ``#`` heading becomes a bold line of its own (markdown
|
||||||
|
headings inside an entry body would collide with the ``## [version]``
|
||||||
|
structure), and inline ``code`` spans become ``*italic*`` so the entry
|
||||||
|
stays free of backticks.
|
||||||
|
"""
|
||||||
|
text = _INLINE_CODE.sub(r"*\1*", text)
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
for line in text.split("\n"):
|
||||||
|
heading = _HEADING.match(line)
|
||||||
|
if heading:
|
||||||
|
if out and out[-1].strip():
|
||||||
|
out.append("")
|
||||||
|
out.append(f"**{heading.group(1).strip()}**")
|
||||||
|
out.append("")
|
||||||
|
else:
|
||||||
|
out.append(line)
|
||||||
|
|
||||||
|
joined = _MULTI_BLANK.sub("\n\n", "\n".join(out))
|
||||||
|
return joined.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def lint_changelog_entry(reference_path: str, entry: str) -> list[str]:
|
||||||
|
"""Return markdown-lint findings for *entry* (empty list when clean).
|
||||||
|
|
||||||
|
The entry is checked inside a minimal ``# Changelog`` document so the
|
||||||
|
result reflects only the entry, not pre-existing issues in the target
|
||||||
|
file. ``markdownlint-cli2`` is used when available (picking up the
|
||||||
|
target repository's own config, i.e. the same rules the repo enforces);
|
||||||
|
otherwise a small built-in check runs.
|
||||||
|
"""
|
||||||
|
document = f"# Changelog\n\n{entry.strip()}\n"
|
||||||
|
if shutil.which("markdownlint-cli2"):
|
||||||
|
return _markdownlint(reference_path, document)
|
||||||
|
return _builtin_lint(document)
|
||||||
|
|
||||||
|
|
||||||
|
def _markdownlint(reference_path: str, document: str) -> list[str]:
|
||||||
|
directory = os.path.dirname(os.path.abspath(reference_path)) or "."
|
||||||
|
fd, tmp_path = tempfile.mkstemp(
|
||||||
|
suffix=".md", prefix=".pkgmgr-changelog-", dir=directory
|
||||||
|
)
|
||||||
|
name = os.path.basename(tmp_path)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(document)
|
||||||
|
proc = subprocess.run(
|
||||||
|
["markdownlint-cli2", name],
|
||||||
|
cwd=directory,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return []
|
||||||
|
output = f"{proc.stdout or ''}{proc.stderr or ''}"
|
||||||
|
findings = [
|
||||||
|
line.strip().replace(name, "changelog entry")
|
||||||
|
for line in output.splitlines()
|
||||||
|
if _FINDING.search(line)
|
||||||
|
]
|
||||||
|
return findings or [output.strip() or "markdown-lint reported an error"]
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _builtin_lint(document: str) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
for number, line in enumerate(document.split("\n"), start=1):
|
||||||
|
if line != line.rstrip():
|
||||||
|
errors.append(f"line {number}: trailing whitespace (MD009)")
|
||||||
|
if "`" in line:
|
||||||
|
errors.append(f"line {number}: backtick is not allowed (use *italic*)")
|
||||||
|
if "\n\n\n" in document:
|
||||||
|
errors.append("multiple consecutive blank lines (MD012)")
|
||||||
|
return errors
|
||||||
@@ -1,62 +1,126 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
from .changelog_lint import (
|
||||||
|
ChangelogLintError,
|
||||||
|
lint_changelog_entry,
|
||||||
|
transform_changelog_message,
|
||||||
|
)
|
||||||
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,
|
||||||
new_version: str,
|
new_version: str,
|
||||||
message: Optional[str] = None,
|
message: str | None = 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.
|
|
||||||
"""
|
|
||||||
today = date.today().isoformat()
|
|
||||||
|
|
||||||
if message is None:
|
The entry is placed after the documents H1 heading (creating one if
|
||||||
if preview:
|
missing) and above any existing release entries, so the result stays
|
||||||
message = "Automated release."
|
markdown-lint-clean (MD041 first-line-h1, MD012 no-multiple-blanks).
|
||||||
else:
|
"""
|
||||||
print(
|
today = date.today().isoformat() # noqa: DTZ011 - changelog dates are local calendar dates
|
||||||
"\n[INFO] No release message provided, opening editor for changelog entry...\n"
|
|
||||||
|
def _entry_for(raw: str) -> tuple[str, str]:
|
||||||
|
body = transform_changelog_message(raw).strip() or f"Release {new_version}"
|
||||||
|
return body, f"## [{new_version}] - {today}\n\n{body}\n\n"
|
||||||
|
|
||||||
|
def _print_findings(findings: list[str]) -> None:
|
||||||
|
print("\n[ERROR] Changelog entry is not markdown-lint clean:")
|
||||||
|
for finding in findings:
|
||||||
|
print(f" - {finding}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if message is not None:
|
||||||
|
body, entry = _entry_for(message)
|
||||||
|
findings = lint_changelog_entry(changelog_path, entry)
|
||||||
|
if findings:
|
||||||
|
_print_findings(findings)
|
||||||
|
raise ChangelogLintError(
|
||||||
|
"Provided changelog message is not markdown-lint clean."
|
||||||
)
|
)
|
||||||
editor_message = _open_editor_for_changelog()
|
elif preview or not sys.stdin.isatty():
|
||||||
if not editor_message:
|
body, entry = _entry_for(message or f"Release {new_version}")
|
||||||
message = "Automated release."
|
else:
|
||||||
else:
|
attempt: str | None = None
|
||||||
message = editor_message
|
while True:
|
||||||
|
print(
|
||||||
header = f"## [{new_version}] - {today}\n"
|
"\n[INFO] Provide the changelog entry — a leading '#' becomes "
|
||||||
header += f"\n* {message}\n\n"
|
"bold, `code` becomes italic.\n"
|
||||||
|
)
|
||||||
|
raw = _open_editor_for_changelog(attempt)
|
||||||
|
body, entry = _entry_for(raw or f"Release {new_version}")
|
||||||
|
findings = lint_changelog_entry(changelog_path, entry)
|
||||||
|
if not findings:
|
||||||
|
break
|
||||||
|
_print_findings(findings)
|
||||||
|
attempt = body
|
||||||
|
print("[INFO] Re-opening the editor so you can fix the entry...")
|
||||||
|
|
||||||
|
changelog = ""
|
||||||
if os.path.exists(changelog_path):
|
if os.path.exists(changelog_path):
|
||||||
try:
|
try:
|
||||||
with open(changelog_path, "r", encoding="utf-8") as f:
|
with open(changelog_path, encoding="utf-8") as f:
|
||||||
changelog = f.read()
|
changelog = f.read()
|
||||||
except Exception as exc:
|
except (OSError, UnicodeDecodeError) as exc:
|
||||||
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
||||||
changelog = ""
|
|
||||||
else:
|
|
||||||
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 body
|
||||||
|
|
||||||
with open(changelog_path, "w", encoding="utf-8") as f:
|
with open(changelog_path, "w", encoding="utf-8") as f:
|
||||||
f.write(new_changelog)
|
f.write(new_changelog)
|
||||||
|
|
||||||
print(f"Updated CHANGELOG.md with version {new_version}")
|
print(f"Updated CHANGELOG.md with version {new_version}")
|
||||||
return message
|
return body
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user