Compare commits
No files matched your search
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(*)"
|
||||
],
|
||||
"ask": [
|
||||
"Skill(update-config)",
|
||||
"Skill(update-config:*)"
|
||||
]
|
||||
},
|
||||
"sandbox": {
|
||||
"enabled": true,
|
||||
"failIfUnavailable": true,
|
||||
"autoAllowBashIfSandboxed": true
|
||||
}
|
||||
}
|
||||
@@ -6,34 +6,68 @@ on:
|
||||
- '**'
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: global-ci-${{ github.repository }}-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
security-codeql:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
security-events: write
|
||||
uses: ./.github/workflows/security-codeql.yml
|
||||
|
||||
test-unit:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/test-unit.yml
|
||||
|
||||
test-integration:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/test-integration.yml
|
||||
|
||||
test-env-virtual:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/test-env-virtual.yml
|
||||
|
||||
test-env-nix:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/test-env-nix.yml
|
||||
|
||||
test-e2e:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/test-e2e.yml
|
||||
|
||||
test-virgin-user:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/test-virgin-user.yml
|
||||
|
||||
test-virgin-root:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/test-virgin-root.yml
|
||||
|
||||
lint-shell:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/lint-shell.yml
|
||||
|
||||
lint-python:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/lint-python.yml
|
||||
|
||||
lint-docker:
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
uses: ./.github/workflows/lint-docker.yml
|
||||
@@ -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,6 +3,9 @@ name: Ruff (Python code sniffer)
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-python:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -3,6 +3,9 @@ name: ShellCheck
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-shell:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Mark stable commit
|
||||
|
||||
concurrency:
|
||||
group: mark-${{ github.repository }}-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
group: mark-stable-${{ github.repository }}-main
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,107 +11,29 @@ on:
|
||||
jobs:
|
||||
mark-stable:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 330
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Wait for CI success on main for this commit
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
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"
|
||||
|
||||
STATUS=""
|
||||
CONCLUSION=""
|
||||
|
||||
echo "Waiting for CI on main for ${SHA}..."
|
||||
for attempt in $(seq 1 30); 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}/30)"
|
||||
else
|
||||
echo "No CI run for main found yet (attempt ${attempt}/30)"
|
||||
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 20
|
||||
done
|
||||
|
||||
if [[ "${STATUS}" != "completed" || "${CONCLUSION}" != "success" ]]; then
|
||||
echo "Timed out waiting for successful CI on main for ${SHA}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true # We need all tags for version comparison
|
||||
fetch-tags: true # We need tags and main history for version comparison
|
||||
|
||||
- name: Check whether tagged commit is on main
|
||||
id: branch-check
|
||||
run: bash scripts/github/common/check-tagged-commit-on-main.sh
|
||||
|
||||
- name: Wait for CI success on main for this commit
|
||||
if: steps.branch-check.outputs.is_on_main == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/github/mark-stable/wait-for-main-ci-success.sh
|
||||
|
||||
- name: Move 'stable' tag only if this version is the highest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
echo "Ref: $GITHUB_REF"
|
||||
echo "SHA: $GITHUB_SHA"
|
||||
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "Current version tag: ${VERSION}"
|
||||
|
||||
echo "Collecting all version tags..."
|
||||
ALL_V_TAGS="$(git tag --list 'v*' || true)"
|
||||
|
||||
if [[ -z "${ALL_V_TAGS}" ]]; then
|
||||
echo "No version tags found. Skipping stable update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "All version tags:"
|
||||
echo "${ALL_V_TAGS}"
|
||||
|
||||
# Determine highest version using natural version sorting
|
||||
LATEST_TAG="$(printf '%s\n' ${ALL_V_TAGS} | sort -V | tail -n1)"
|
||||
|
||||
echo "Highest version tag: ${LATEST_TAG}"
|
||||
|
||||
if [[ "${VERSION}" != "${LATEST_TAG}" ]]; then
|
||||
echo "Current version ${VERSION} is NOT the highest version."
|
||||
echo "Stable tag will NOT be updated."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Current version ${VERSION} IS the highest version."
|
||||
echo "Updating 'stable' tag..."
|
||||
|
||||
# Delete existing stable tag (local + remote)
|
||||
git tag -d stable 2>/dev/null || true
|
||||
git push origin :refs/tags/stable || true
|
||||
|
||||
# Create new stable tag
|
||||
git tag stable "$GITHUB_SHA"
|
||||
git push origin stable
|
||||
|
||||
echo "✅ Stable tag updated to ${VERSION}."
|
||||
if: steps.branch-check.outputs.is_on_main == 'true'
|
||||
run: bash scripts/github/mark-stable/mark-stable-if-highest-version.sh
|
||||
@@ -21,44 +21,30 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout workflow_run commit and refresh tags
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git checkout -f "${{ github.event.workflow_run.head_sha }}"
|
||||
git fetch --tags --force
|
||||
git tag --list 'stable' 'v*' --sort=version:refname | tail -n 20
|
||||
env:
|
||||
WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: bash scripts/github/publish-containers/checkout-workflow-run-commit.sh
|
||||
|
||||
- name: Check whether tagged commit is on main
|
||||
id: branch-check
|
||||
env:
|
||||
TARGET_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: bash scripts/github/common/check-tagged-commit-on-main.sh
|
||||
|
||||
- name: Compute version and stable flag
|
||||
id: info
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
|
||||
V_TAG="$(git tag --points-at "${SHA}" --list 'v*' | sort -V | tail -n1)"
|
||||
if [[ -z "${V_TAG}" ]]; then
|
||||
echo "No version tag found for ${SHA}. Skipping publish."
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
VERSION="${V_TAG#v}"
|
||||
|
||||
STABLE_SHA="$(git rev-parse -q --verify refs/tags/stable^{commit} 2>/dev/null || true)"
|
||||
IS_STABLE=false
|
||||
[[ -n "${STABLE_SHA}" && "${STABLE_SHA}" == "${SHA}" ]] && IS_STABLE=true
|
||||
|
||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_stable=${IS_STABLE}" >> "$GITHUB_OUTPUT"
|
||||
if: steps.branch-check.outputs.is_on_main == 'true'
|
||||
run: bash scripts/github/publish-containers/compute-publish-container-info.sh
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
|
||||
with:
|
||||
use: true
|
||||
|
||||
- name: Login to GHCR
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -66,9 +52,8 @@ jobs:
|
||||
|
||||
- name: Publish all images
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OWNER="${{ github.repository_owner }}" \
|
||||
VERSION="${{ steps.info.outputs.version }}" \
|
||||
IS_STABLE="${{ steps.info.outputs.is_stable }}" \
|
||||
bash scripts/build/publish.sh
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
VERSION: ${{ steps.info.outputs.version }}
|
||||
IS_STABLE: ${{ steps.info.outputs.is_stable }}
|
||||
run: bash scripts/github/publish-containers/publish-container-images.sh
|
||||
@@ -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,6 +3,9 @@ name: Test End-To-End
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Test Virgin Nix (flake only)
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-env-nix:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Test OS Containers
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-env-virtual:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Test Code Integration
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Test Units
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-unit:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Test Virgin Root
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-virgin-root:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Test Virgin User
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-virgin-user:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+103
@@ -1,3 +1,106 @@
|
||||
## [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.
|
||||
|
||||
+3
-4
@@ -43,10 +43,10 @@ WORKDIR /build
|
||||
COPY . .
|
||||
|
||||
# Build and install distro-native package-manager package
|
||||
RUN set -euo pipefail; \
|
||||
RUN set -eu; \
|
||||
echo "Building and installing package-manager via make install..."; \
|
||||
make install; \
|
||||
cd /; rm -rf /build
|
||||
rm -rf /build
|
||||
|
||||
# Entry point
|
||||
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
||||
@@ -64,5 +64,4 @@ CMD ["pkgmgr", "--help"]
|
||||
FROM full AS slim
|
||||
|
||||
COPY scripts/docker/slim.sh /usr/local/bin/slim.sh
|
||||
RUN chmod +x /usr/local/bin/slim.sh
|
||||
RUN /usr/local/bin/slim.sh
|
||||
RUN chmod +x /usr/local/bin/slim.sh && /usr/local/bin/slim.sh
|
||||
@@ -32,7 +32,7 @@
|
||||
rec {
|
||||
pkgmgr = pyPkgs.buildPythonApplication {
|
||||
pname = "package-manager";
|
||||
version = "1.12.4";
|
||||
version = "1.14.0";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
@@ -40,6 +40,10 @@
|
||||
# Build using pyproject.toml
|
||||
format = "pyproject";
|
||||
|
||||
# Clear any stale wheels carried in from the source tree so
|
||||
# pypaInstallPhase doesn't collide on bin/pkgmgr.
|
||||
preBuild = "rm -rf dist";
|
||||
|
||||
# Build backend requirements from [build-system]
|
||||
nativeBuildInputs = [
|
||||
pyPkgs.setuptools
|
||||
@@ -51,6 +55,8 @@
|
||||
pyPkgs.pyyaml
|
||||
pyPkgs.jinja2
|
||||
pyPkgs.pip
|
||||
pkgs.git
|
||||
pkgs.gnupg
|
||||
];
|
||||
|
||||
doCheck = false;
|
||||
@@ -87,6 +93,7 @@
|
||||
buildInputs = [
|
||||
pythonWithDeps
|
||||
pkgs.git
|
||||
pkgs.gnupg
|
||||
ansiblePkg
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
|
||||
pkgname=package-manager
|
||||
pkgver=1.12.4
|
||||
pkgver=1.14.0
|
||||
pkgrel=1
|
||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||
arch=('any')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
post_install() {
|
||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
||||
/usr/lib/package-manager/nix/init.sh
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
||||
/usr/lib/package-manager/nix/init.sh
|
||||
}
|
||||
|
||||
post_remove() {
|
||||
|
||||
@@ -1,3 +1,113 @@
|
||||
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.
|
||||
|
||||
@@ -3,7 +3,7 @@ set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
||||
/usr/lib/package-manager/nix/init.sh
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: package-manager
|
||||
Version: 1.12.4
|
||||
Version: 1.14.0
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
@@ -62,7 +62,7 @@ rm -rf \
|
||||
%{buildroot}/usr/lib/package-manager/.gitkeep || true
|
||||
|
||||
%post
|
||||
/usr/lib/package-manager/nix/init.sh || echo ">>> ERROR: /usr/lib/package-manager/nix/init.sh not found or not executable."
|
||||
/usr/lib/package-manager/nix/init.sh
|
||||
|
||||
%postun
|
||||
echo ">>> package-manager removed. Nix itself was not removed."
|
||||
@@ -74,6 +74,95 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
||||
/usr/lib/package-manager/
|
||||
|
||||
%changelog
|
||||
* 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.
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kpmx"
|
||||
version = "1.12.4"
|
||||
version = "1.14.0"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
: "${BASE_IMAGE_DEBIAN:=debian:stable-slim}"
|
||||
: "${BASE_IMAGE_UBUNTU:=ubuntu:latest}"
|
||||
: "${BASE_IMAGE_FEDORA:=fedora:latest}"
|
||||
: "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:stream9}"
|
||||
: "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:latest}"
|
||||
|
||||
resolve_base_image() {
|
||||
local PKGMGR_DISTRO="$1"
|
||||
|
||||
@@ -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
|
||||
@@ -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}."
|
||||
@@ -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} 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..."
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ fi
|
||||
pacman -S --noconfirm --needed \
|
||||
base-devel \
|
||||
git \
|
||||
gnupg \
|
||||
rsync \
|
||||
curl \
|
||||
ca-certificates \
|
||||
|
||||
@@ -47,7 +47,7 @@ echo "[arch/package] Using 'aur_builder' user for makepkg..."
|
||||
chown -R aur_builder:aur_builder "${BUILD_ROOT}"
|
||||
|
||||
echo "[arch/package] Running makepkg in: ${PKG_BUILD_DIR}"
|
||||
su aur_builder -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
|
||||
runuser -u aur_builder -- bash -c "cd '${PKG_BUILD_DIR}' && rm -f package-manager-*.pkg.tar.* && makepkg --noconfirm --clean --nodeps"
|
||||
|
||||
echo "[arch/package] Installing generated Arch package..."
|
||||
pkg_path="$(find "${PKG_BUILD_DIR}" -maxdepth 1 -type f -name 'package-manager-*.pkg.tar.*' | head -n1)"
|
||||
|
||||
@@ -6,6 +6,7 @@ echo "[centos/dependencies] Installing CentOS build dependencies..."
|
||||
dnf -y update
|
||||
dnf -y install \
|
||||
git \
|
||||
gnupg2 \
|
||||
rsync \
|
||||
rpm-build \
|
||||
make \
|
||||
|
||||
@@ -9,6 +9,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
debhelper \
|
||||
dpkg-dev \
|
||||
git \
|
||||
gnupg \
|
||||
rsync \
|
||||
bash \
|
||||
curl \
|
||||
|
||||
@@ -6,6 +6,7 @@ echo "[fedora/dependencies] Installing Fedora build dependencies..."
|
||||
dnf -y update
|
||||
dnf -y install \
|
||||
git \
|
||||
gnupg2 \
|
||||
rsync \
|
||||
rpm-build \
|
||||
make \
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "[installation/install] Warning: Installation is just possible via root."
|
||||
exit 0
|
||||
echo "[installation/install] ERROR: Installation requires root. Re-run with sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[installation] Running as root (EUID=0)."
|
||||
|
||||
@@ -9,6 +9,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
debhelper \
|
||||
dpkg-dev \
|
||||
git \
|
||||
gnupg \
|
||||
tzdata \
|
||||
lsb-release \
|
||||
rsync \
|
||||
|
||||
+7
-5
@@ -37,10 +37,16 @@ fi
|
||||
# ---------------------------------------------------------------------------
|
||||
if ! command -v nix >/dev/null 2>&1; then
|
||||
if [[ -x "${FLAKE_DIR}/nix/init.sh" ]]; then
|
||||
"${FLAKE_DIR}/nix/init.sh" || true
|
||||
"${FLAKE_DIR}/nix/init.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v nix >/dev/null 2>&1; then
|
||||
echo "[launcher] ERROR: 'nix' binary not found on PATH after init." >&2
|
||||
echo "[launcher] Nix is required to run pkgmgr (no Python fallback)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primary path: use Nix flake if available (with GitHub 403 retry)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -51,7 +57,3 @@ if declare -F run_with_github_403_retry >/dev/null; then
|
||||
else
|
||||
exec nix run "${FLAKE_DIR}#pkgmgr" -- "$@"
|
||||
fi
|
||||
|
||||
echo "[launcher] ERROR: 'nix' binary not found on PATH after init."
|
||||
echo "[launcher] Nix is required to run pkgmgr (no Python fallback)."
|
||||
exit 1
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Resolve the distro-package name for a release.
|
||||
|
||||
The release flow writes the package identifier into `debian/changelog`,
|
||||
the RPM `%changelog` stanza, etc. Historically pkgmgr derived this
|
||||
identifier from the repository folder name (`os.path.basename(repo_root)`),
|
||||
which silently breaks when the repo is renamed but the existing packaging
|
||||
files still ship the legacy name. Renaming the folder must not change the
|
||||
distro-package identity — `apt`, `pacman`, `dnf`, and every downstream
|
||||
manifest pin the old name.
|
||||
|
||||
The resolver therefore walks the existing packaging files in priority
|
||||
order and only falls back to the folder name when none of them ship an
|
||||
explicit name.
|
||||
|
||||
Priority:
|
||||
1. `debian/control` `Package:` field (most authoritative — dpkg-source
|
||||
refuses to build if changelog and control disagree)
|
||||
2. `packaging/arch/PKGBUILD` `pkgname=` value
|
||||
3. RPM spec `Name:` field
|
||||
4. Repository folder basename (legacy fallback)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.core.repository.paths import RepoPaths
|
||||
|
||||
|
||||
_DEBIAN_PACKAGE_RE = re.compile(r"^Package:\s*(\S+)\s*$", re.MULTILINE)
|
||||
_PKGBUILD_NAME_RE = re.compile(r"^pkgname=([^\s#]+)\s*$", re.MULTILINE)
|
||||
_RPM_NAME_RE = re.compile(r"^Name:\s*(\S+)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _read(path: Optional[str]) -> str:
|
||||
if not path or not os.path.isfile(path):
|
||||
return ""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _extract(pattern: re.Pattern[str], text: str) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return None
|
||||
value = match.group(1).strip().strip('"').strip("'")
|
||||
return value or None
|
||||
|
||||
|
||||
def resolve_package_name(paths: RepoPaths) -> str:
|
||||
"""Return the distro-package name for the repo, with a folder fallback.
|
||||
|
||||
The fallback uses `os.path.basename(paths.repo_dir)` so behaviour is
|
||||
backwards-compatible for repos that ship no packaging metadata yet.
|
||||
"""
|
||||
debian_name = _extract(_DEBIAN_PACKAGE_RE, _read(paths.debian_control))
|
||||
if debian_name:
|
||||
return debian_name
|
||||
|
||||
pkgbuild_name = _extract(_PKGBUILD_NAME_RE, _read(paths.arch_pkgbuild))
|
||||
if pkgbuild_name:
|
||||
return pkgbuild_name
|
||||
|
||||
rpm_name = _extract(_RPM_NAME_RE, _read(paths.rpm_spec))
|
||||
if rpm_name:
|
||||
return rpm_name
|
||||
|
||||
return os.path.basename(paths.repo_dir) or "package"
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Re-deploy an existing release without modifying files or creating a new tag.
|
||||
|
||||
The release workflow normally bumps versions, rewrites packaging
|
||||
manifests, commits, tags, pushes, and uploads to PyPI in one shot.
|
||||
When a post-tag step fails mid-flight (typical examples: `git push`
|
||||
rejected, `twine upload` aborted by a broken venv, `update-latest`
|
||||
rejected by branch protection) the local tag still exists on HEAD but
|
||||
the side effects downstream are incomplete.
|
||||
|
||||
`retry_release` re-runs the idempotent tail of that flow so a botched
|
||||
release can be re-pushed without touching code or recreating tags:
|
||||
|
||||
* `git push origin <branch> <tag>` for the existing HEAD tag
|
||||
* re-align the floating `latest` tag if HEAD tag is the highest
|
||||
|
||||
Publishing (PyPI etc.) stays the caller's responsibility — the publish
|
||||
workflow is already idempotent (twine rejects duplicates per spec) and
|
||||
can be invoked independently via the `publish` subcommand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pkgmgr.actions.publish.git_tags import head_semver_tags
|
||||
from pkgmgr.core.git import GitRunError, run
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.version.semver import SemVer
|
||||
|
||||
from .git_ops import is_highest_version_tag, update_latest_tag
|
||||
|
||||
|
||||
def retry_release(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""Re-push the HEAD release without re-tagging or modifying any files."""
|
||||
try:
|
||||
branch = get_current_branch() or "main"
|
||||
except GitRunError:
|
||||
branch = "main"
|
||||
print(f"Retrying release push on branch: {branch}")
|
||||
|
||||
tags = head_semver_tags(cwd=os.path.dirname(os.path.abspath(pyproject_path)))
|
||||
if not tags:
|
||||
raise RuntimeError(
|
||||
"No version tag on HEAD. Nothing to retry — "
|
||||
"run `pkgmgr release <type>` first to create a release."
|
||||
)
|
||||
tag = max(tags, key=SemVer.parse)
|
||||
print(f"Re-pushing existing tag: {tag}")
|
||||
|
||||
run(["push", "origin", branch, tag], preview=preview)
|
||||
|
||||
try:
|
||||
if is_highest_version_tag(tag):
|
||||
update_latest_tag(tag, preview=preview)
|
||||
else:
|
||||
print(f"[INFO] Skipping 'latest' update (tag {tag} is not the highest).")
|
||||
except GitRunError as exc:
|
||||
print(f"[WARN] Failed to update floating 'latest' tag for {tag}: {exc}")
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Retry push for {tag} would now complete.")
|
||||
return
|
||||
|
||||
print(f"Retry push completed for {tag}.")
|
||||
@@ -24,7 +24,9 @@ from .git_ops import (
|
||||
is_highest_version_tag,
|
||||
update_latest_tag,
|
||||
)
|
||||
from .package_name import resolve_package_name
|
||||
from .prompts import confirm_proceed_release, should_delete_branch
|
||||
from .retry import retry_release
|
||||
from .versioning import bump_semver, determine_current_version
|
||||
|
||||
|
||||
@@ -90,7 +92,7 @@ def _release_impl(
|
||||
if changelog_message.strip():
|
||||
effective_message = changelog_message.strip()
|
||||
|
||||
package_name = os.path.basename(repo_root) or "package-manager"
|
||||
package_name = resolve_package_name(paths)
|
||||
|
||||
if paths.debian_changelog:
|
||||
update_debian_changelog(
|
||||
@@ -198,7 +200,12 @@ def release(
|
||||
preview: bool = False,
|
||||
force: bool = False,
|
||||
close: bool = False,
|
||||
retry: bool = False,
|
||||
) -> None:
|
||||
if retry:
|
||||
retry_release(pyproject_path=pyproject_path, preview=preview)
|
||||
return
|
||||
|
||||
if preview:
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Callable, Dict, List, Tuple
|
||||
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
RepoRef = Tuple[str, str]
|
||||
OpResult = Tuple[bool, str]
|
||||
RepoOp = Callable[[str], OpResult]
|
||||
|
||||
|
||||
def resolve_repos(
|
||||
selected_repos: List[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
) -> List[RepoRef]:
|
||||
"""
|
||||
Resolve ``(identifier, repo_dir)`` pairs for ``selected_repos``.
|
||||
|
||||
Repositories whose directory does not exist on disk are reported and
|
||||
skipped, matching the prior behavior of pull/push handlers.
|
||||
"""
|
||||
resolved: List[RepoRef] = []
|
||||
for repo in selected_repos:
|
||||
ident = get_repo_identifier(repo, all_repos)
|
||||
rd = get_repo_dir(repositories_base_dir, repo)
|
||||
if not os.path.exists(rd):
|
||||
print(f"Repository directory '{rd}' not found for {ident}.")
|
||||
continue
|
||||
resolved.append((ident, rd))
|
||||
return resolved
|
||||
|
||||
|
||||
def run_on_repos(
|
||||
repos: List[RepoRef],
|
||||
op: RepoOp,
|
||||
*,
|
||||
jobs: int,
|
||||
op_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Run ``op(repo_dir) -> (ok, msg)`` for each repo, optionally in parallel.
|
||||
|
||||
- ``jobs == 1``: serial, quiet on success, prints ``msg`` on failure.
|
||||
- ``jobs > 1``: parallel via ThreadPoolExecutor, prints a banner plus
|
||||
``[OK]``/``[FAIL]`` per repo and a final summary.
|
||||
- Exits with status 1 if any operation failed.
|
||||
"""
|
||||
if not repos:
|
||||
return
|
||||
|
||||
effective_jobs = max(1, min(jobs, len(repos)))
|
||||
failed: List[Tuple[str, str]] = []
|
||||
|
||||
if effective_jobs == 1:
|
||||
for ident, rd in repos:
|
||||
ok, msg = op(rd)
|
||||
if not ok:
|
||||
print(msg)
|
||||
failed.append((ident, msg))
|
||||
else:
|
||||
print(
|
||||
f"[{op_name.upper()}] Running {len(repos)} {op_name}(s) with up to "
|
||||
f"{effective_jobs} parallel jobs..."
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=effective_jobs) as executor:
|
||||
futures = {executor.submit(op, rd): ident for ident, rd in repos}
|
||||
for future in as_completed(futures):
|
||||
ident = futures[future]
|
||||
ok, msg = future.result()
|
||||
if ok:
|
||||
print(f"[OK] {ident}")
|
||||
else:
|
||||
print(f"[FAIL] {ident}")
|
||||
for line in msg.splitlines():
|
||||
print(f" {line}")
|
||||
failed.append((ident, msg))
|
||||
|
||||
if failed:
|
||||
if effective_jobs > 1:
|
||||
print(f"\n[SUMMARY] {len(failed)} of {len(repos)} {op_name}(s) failed:")
|
||||
for ident, _msg in failed:
|
||||
print(f" - {ident}")
|
||||
sys.exit(1)
|
||||
@@ -1,17 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Dict, Any
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from pkgmgr.actions.repository._parallel import RepoRef, run_on_repos
|
||||
from pkgmgr.core.git.commands import pull_args, GitPullArgsError
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.verify import verify_repository
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
|
||||
def _pull_one(repo_dir: str, extra_args: List[str], preview: bool) -> Tuple[bool, str]:
|
||||
try:
|
||||
pull_args(extra_args, cwd=repo_dir, preview=preview)
|
||||
return (True, "")
|
||||
except GitPullArgsError as exc:
|
||||
return (False, str(exc))
|
||||
|
||||
|
||||
def _verify_one(
|
||||
repo: Repository,
|
||||
repo_dir: str,
|
||||
no_verification: bool,
|
||||
) -> Tuple[bool, bool, List[str]]:
|
||||
"""Returns (has_verified_info, verified_ok, errors)."""
|
||||
verified_ok, errors, _commit, _key = verify_repository(
|
||||
repo,
|
||||
repo_dir,
|
||||
mode="pull",
|
||||
no_verification=no_verification,
|
||||
)
|
||||
return (bool(repo.get("verified")), verified_ok, errors)
|
||||
|
||||
|
||||
def _verify_all(
|
||||
candidates: List[Tuple[Repository, str, str]],
|
||||
no_verification: bool,
|
||||
jobs: int,
|
||||
) -> List[Tuple[str, str, bool, bool, List[str]]]:
|
||||
"""
|
||||
Verify all candidates (parallel if ``jobs > 1``), preserving input order.
|
||||
|
||||
Returns one tuple per candidate: ``(ident, repo_dir, has_verified_info,
|
||||
verified_ok, errors)``.
|
||||
"""
|
||||
verify_jobs = max(1, min(jobs, len(candidates)))
|
||||
if verify_jobs == 1:
|
||||
return [
|
||||
(ident, rd, *_verify_one(repo, rd, no_verification))
|
||||
for repo, ident, rd in candidates
|
||||
]
|
||||
with ThreadPoolExecutor(max_workers=verify_jobs) as executor:
|
||||
futures = [
|
||||
executor.submit(_verify_one, repo, rd, no_verification)
|
||||
for repo, _ident, rd in candidates
|
||||
]
|
||||
results = [f.result() for f in futures]
|
||||
return [(ident, rd, *res) for (_repo, ident, rd), res in zip(candidates, results)]
|
||||
|
||||
|
||||
def pull_with_verification(
|
||||
selected_repos: List[Repository],
|
||||
repositories_base_dir: str,
|
||||
@@ -19,41 +69,50 @@ def pull_with_verification(
|
||||
extra_args: List[str],
|
||||
no_verification: bool,
|
||||
preview: bool,
|
||||
jobs: int = 1,
|
||||
) -> None:
|
||||
"""
|
||||
Execute `git pull` for each repository with verification.
|
||||
|
||||
- If verification fails and verification is enabled, prompt user to continue.
|
||||
- Uses core.git.commands.pull_args() (no raw subprocess usage).
|
||||
- Verification (I/O-bound) runs in parallel when ``jobs > 1``.
|
||||
- Interactive prompts for failed verifications are handled serially on the
|
||||
main thread after parallel verification completes.
|
||||
- Approved repos are then pulled in parallel when ``jobs > 1``.
|
||||
- On any pull failure, prints a summary and exits with status 1.
|
||||
"""
|
||||
candidates: List[Tuple[Repository, str, str]] = []
|
||||
for repo in selected_repos:
|
||||
repo_identifier = get_repo_identifier(repo, all_repos)
|
||||
repo_dir = get_repo_dir(repositories_base_dir, repo)
|
||||
|
||||
if not os.path.exists(repo_dir):
|
||||
print(f"Repository directory '{repo_dir}' not found for {repo_identifier}.")
|
||||
ident = get_repo_identifier(repo, all_repos)
|
||||
rd = get_repo_dir(repositories_base_dir, repo)
|
||||
if not os.path.exists(rd):
|
||||
print(f"Repository directory '{rd}' not found for {ident}.")
|
||||
continue
|
||||
candidates.append((repo, ident, rd))
|
||||
|
||||
verified_info = repo.get("verified")
|
||||
verified_ok, errors, _commit_hash, _signing_key = verify_repository(
|
||||
repo,
|
||||
repo_dir,
|
||||
mode="pull",
|
||||
no_verification=no_verification,
|
||||
)
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
if not preview and not no_verification and verified_info and not verified_ok:
|
||||
print(f"Warning: Verification failed for {repo_identifier}:")
|
||||
verify_results = _verify_all(candidates, no_verification, jobs)
|
||||
|
||||
approved: List[RepoRef] = []
|
||||
for ident, rd, has_verified_info, verified_ok, errors in verify_results:
|
||||
if (
|
||||
not preview
|
||||
and not no_verification
|
||||
and has_verified_info
|
||||
and not verified_ok
|
||||
):
|
||||
print(f"Warning: Verification failed for {ident}:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
choice = input("Proceed with 'git pull'? (y/N): ").strip().lower()
|
||||
if choice != "y":
|
||||
continue
|
||||
approved.append((ident, rd))
|
||||
|
||||
try:
|
||||
pull_args(extra_args, cwd=repo_dir, preview=preview)
|
||||
except GitPullArgsError as exc:
|
||||
# Keep behavior consistent with previous implementation:
|
||||
# stop on first failure and propagate return code as generic failure.
|
||||
print(str(exc))
|
||||
sys.exit(1)
|
||||
run_on_repos(
|
||||
approved,
|
||||
lambda rd: _pull_one(rd, extra_args, preview),
|
||||
jobs=jobs,
|
||||
op_name="pull",
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from pkgmgr.actions.repository._parallel import (
|
||||
resolve_repos,
|
||||
run_on_repos,
|
||||
)
|
||||
from pkgmgr.core.git.commands import push_args, GitPushArgsError
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
|
||||
def _push_one(repo_dir: str, extra_args: List[str], preview: bool) -> Tuple[bool, str]:
|
||||
try:
|
||||
push_args(extra_args, cwd=repo_dir, preview=preview)
|
||||
return (True, "")
|
||||
except GitPushArgsError as exc:
|
||||
return (False, str(exc))
|
||||
|
||||
|
||||
def push_in_parallel(
|
||||
selected_repos: List[Repository],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
extra_args: List[str],
|
||||
preview: bool,
|
||||
jobs: int = 1,
|
||||
) -> None:
|
||||
"""
|
||||
Execute `git push` for each repository, optionally in parallel.
|
||||
"""
|
||||
repos = resolve_repos(selected_repos, repositories_base_dir, all_repos)
|
||||
run_on_repos(
|
||||
repos,
|
||||
lambda rd: _push_one(rd, extra_args, preview),
|
||||
jobs=jobs,
|
||||
op_name="push",
|
||||
)
|
||||
@@ -49,7 +49,19 @@ def handle_release(
|
||||
print(f"[WARN] Skipping repository {identifier}: directory missing.")
|
||||
continue
|
||||
|
||||
print(f"[pkgmgr] Running release for repository {identifier}...")
|
||||
retry = bool(getattr(args, "retry", False))
|
||||
if retry and args.release_type:
|
||||
print(
|
||||
f"[WARN] Ignoring release_type '{args.release_type}' for {identifier} — --retry skips version bumps."
|
||||
)
|
||||
if not retry and not args.release_type:
|
||||
print(
|
||||
f"[WARN] Skipping {identifier}: release_type is required unless --retry is set."
|
||||
)
|
||||
continue
|
||||
|
||||
action_label = "retry-release" if retry else "release"
|
||||
print(f"[pkgmgr] Running {action_label} for repository {identifier}...")
|
||||
|
||||
cwd_before = os.getcwd()
|
||||
try:
|
||||
@@ -58,11 +70,12 @@ def handle_release(
|
||||
run_release(
|
||||
pyproject_path="pyproject.toml",
|
||||
changelog_path="CHANGELOG.md",
|
||||
release_type=args.release_type,
|
||||
release_type=args.release_type or "patch",
|
||||
message=args.message or None,
|
||||
preview=getattr(args, "preview", False),
|
||||
force=getattr(args, "force", False),
|
||||
close=getattr(args, "close", False),
|
||||
retry=retry,
|
||||
)
|
||||
|
||||
if not getattr(args, "no_publish", False):
|
||||
|
||||
@@ -24,8 +24,12 @@ def add_release_subparser(
|
||||
|
||||
release_parser.add_argument(
|
||||
"release_type",
|
||||
nargs="?",
|
||||
choices=["major", "minor", "patch"],
|
||||
help="Type of version increment for the release (major, minor, patch).",
|
||||
help=(
|
||||
"Type of version increment for the release (major, minor, patch). "
|
||||
"Omit when `--retry` is set."
|
||||
),
|
||||
)
|
||||
|
||||
release_parser.add_argument(
|
||||
@@ -61,3 +65,15 @@ def add_release_subparser(
|
||||
action="store_true",
|
||||
help="Do not run publish automatically after a successful release.",
|
||||
)
|
||||
|
||||
release_parser.add_argument(
|
||||
"--retry",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Re-deploy the existing HEAD release without re-tagging or "
|
||||
"modifying any files: re-push the existing version tag, "
|
||||
"re-align the floating `latest` tag, and (unless --no-publish) "
|
||||
"re-run publish. Use this to recover from a release whose "
|
||||
"post-tag push or PyPI upload failed mid-flight."
|
||||
),
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.actions.repository.clone import clone_repos
|
||||
from pkgmgr.actions.proxy import exec_proxy_command
|
||||
from pkgmgr.actions.repository.pull import pull_with_verification
|
||||
from pkgmgr.actions.repository.push import push_in_parallel
|
||||
from pkgmgr.core.repository.selected import get_selected_repos
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
@@ -177,6 +178,17 @@ def register_proxy_commands(
|
||||
default=False,
|
||||
help="Disable verification via commit/gpg",
|
||||
)
|
||||
if subcommand in ("pull", "push"):
|
||||
parser.add_argument(
|
||||
"-j",
|
||||
"--jobs",
|
||||
type=int,
|
||||
default=min(os.cpu_count() or 4, 8),
|
||||
help=(
|
||||
f"Number of parallel {subcommand}s "
|
||||
"(default: min(cpu_count, 8)). Use 1 for sequential."
|
||||
),
|
||||
)
|
||||
if subcommand == "clone":
|
||||
parser.add_argument(
|
||||
"--clone-mode",
|
||||
@@ -234,6 +246,16 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool:
|
||||
args.extra_args,
|
||||
args.no_verification,
|
||||
args.preview,
|
||||
jobs=args.jobs,
|
||||
)
|
||||
elif args.command == "push":
|
||||
push_in_parallel(
|
||||
selected,
|
||||
ctx.repositories_base_dir,
|
||||
ctx.all_repositories,
|
||||
args.extra_args,
|
||||
args.preview,
|
||||
jobs=args.jobs,
|
||||
)
|
||||
else:
|
||||
exec_proxy_command(
|
||||
|
||||
@@ -5,16 +5,6 @@ directories:
|
||||
workspaces: ~/Workspaces/
|
||||
binaries: ~/.local/bin/
|
||||
repositories:
|
||||
- account: kevinveenbirkenbach
|
||||
alias: arc
|
||||
provider: github.com
|
||||
repository: analysis-ready-code
|
||||
description: Analysis-Ready Code (ARC) is a Python utility that recursively scans directories and transforms source code into a streamlined, analysis-ready format by removing comments, filtering files, and compressing content—perfect for AI and automated code analysis.
|
||||
homepage: https://github.com/kevinveenbirkenbach/analysis-ready-code
|
||||
verified:
|
||||
gpg_keys:
|
||||
- 44D8F11FD62F878E
|
||||
- B5690EEEBB952194
|
||||
- account: kevinveenbirkenbach
|
||||
description: A configurable Python package manager that automates repository tasks—including cloning, installation, updates, and status reporting—based on a YAML configuration file for streamlined software management which gives you access to the Kevin Veen-Birkenbach Code Universe.
|
||||
homepage: https://github.com/kevinveenbirkenbach/package-manager
|
||||
@@ -274,12 +264,11 @@ repositories:
|
||||
gpg_keys:
|
||||
- 44D8F11FD62F878E
|
||||
- B5690EEEBB952194
|
||||
- account: kevinveenbirkenbach
|
||||
alias: infinito
|
||||
- account: infinito-nexus
|
||||
provider: github.com
|
||||
description: Infinito.nexus streamlines Linux-based system setups and Docker image administration, perfect for servers and PCs. It offers extensive solutions for system initialization, admin tools, backups, monitoring, updates, driver management, security, and VPNs.
|
||||
homepage: https://infinito.nexus
|
||||
repository: infinito-nexus
|
||||
repository: core
|
||||
verified:
|
||||
gpg_keys:
|
||||
- 44D8F11FD62F878E
|
||||
|
||||
@@ -19,6 +19,7 @@ from .pull import GitPullError, pull
|
||||
from .pull_args import GitPullArgsError, pull_args
|
||||
from .pull_ff_only import GitPullFfOnlyError, pull_ff_only
|
||||
from .push import GitPushError, push
|
||||
from .push_args import GitPushArgsError, push_args
|
||||
from .push_upstream import GitPushUpstreamError, push_upstream
|
||||
from .set_remote_url import GitSetRemoteUrlError, set_remote_url
|
||||
from .tag_annotated import GitTagAnnotatedError, tag_annotated
|
||||
@@ -34,6 +35,7 @@ __all__ = [
|
||||
"pull_ff_only",
|
||||
"merge_no_ff",
|
||||
"push",
|
||||
"push_args",
|
||||
"commit",
|
||||
"delete_local_branch",
|
||||
"delete_remote_branch",
|
||||
@@ -56,6 +58,7 @@ __all__ = [
|
||||
"GitPullFfOnlyError",
|
||||
"GitMergeError",
|
||||
"GitPushError",
|
||||
"GitPushArgsError",
|
||||
"GitCommitError",
|
||||
"GitDeleteLocalBranchError",
|
||||
"GitDeleteRemoteBranchError",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from ..errors import GitRunError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitPushArgsError(GitCommandError):
|
||||
"""Raised when `git push` with arbitrary args fails."""
|
||||
|
||||
|
||||
def push_args(
|
||||
args: List[str] | None = None,
|
||||
*,
|
||||
cwd: str = ".",
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Execute `git push` with caller-provided arguments.
|
||||
|
||||
Examples:
|
||||
[] -> git push
|
||||
["--force"] -> git push --force
|
||||
["origin", "main"] -> git push origin main
|
||||
["-u", "origin", "feature"] -> git push -u origin feature
|
||||
"""
|
||||
extra = args or []
|
||||
try:
|
||||
run(["push", *extra], cwd=cwd, preview=preview)
|
||||
except GitRunError as exc:
|
||||
details = getattr(exc, "output", None) or getattr(exc, "stderr", None) or ""
|
||||
raise GitPushArgsError(
|
||||
(
|
||||
f"Failed to run `git push` with args={extra!r} "
|
||||
f"in cwd={cwd!r}.\n{details}"
|
||||
).rstrip(),
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
@@ -1,13 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitQueryError, GitRunError
|
||||
from ..run import run
|
||||
import subprocess
|
||||
|
||||
from ..errors import GitNotRepositoryError, GitQueryError
|
||||
|
||||
|
||||
class GitLatestSigningKeyQueryError(GitQueryError):
|
||||
"""Raised when querying the latest commit signing key fails."""
|
||||
|
||||
|
||||
def _is_not_repository(stderr: str) -> bool:
|
||||
return "not a git repository" in (stderr or "").lower()
|
||||
|
||||
|
||||
def _looks_like_gpg_runtime_error(stderr: str) -> bool:
|
||||
lowered = (stderr or "").lower()
|
||||
markers = (
|
||||
"cannot run gpg",
|
||||
"can't check signature",
|
||||
"no public key",
|
||||
"failed to create temporary file",
|
||||
"can't connect to the keyboxd",
|
||||
"error opening key db",
|
||||
"gpg failed",
|
||||
"no such file or directory",
|
||||
)
|
||||
return any(marker in lowered for marker in markers)
|
||||
|
||||
|
||||
def get_latest_signing_key(*, cwd: str = ".") -> str:
|
||||
"""
|
||||
Return the GPG signing key ID of the latest commit, via:
|
||||
@@ -17,9 +37,46 @@ def get_latest_signing_key(*, cwd: str = ".") -> str:
|
||||
Returns:
|
||||
The key id string (may be empty if commit is not signed).
|
||||
"""
|
||||
cmd = ["git", "log", "-1", "--format=%GK"]
|
||||
try:
|
||||
return run(["log", "-1", "--format=%GK"], cwd=cwd).strip()
|
||||
except GitRunError as exc:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise GitLatestSigningKeyQueryError(
|
||||
"Failed to query latest signing key.",
|
||||
"Failed to query latest signing key.\n"
|
||||
f"Command: {' '.join(cmd)}\n"
|
||||
f"Reason: {exc}"
|
||||
) from exc
|
||||
|
||||
stdout = (result.stdout or "").strip()
|
||||
stderr = (result.stderr or "").strip()
|
||||
|
||||
if result.returncode != 0:
|
||||
if _is_not_repository(stderr):
|
||||
raise GitNotRepositoryError(
|
||||
f"Not a git repository: {cwd!r}\n"
|
||||
f"Command: {' '.join(cmd)}\n"
|
||||
f"STDERR:\n{stderr}"
|
||||
)
|
||||
raise GitLatestSigningKeyQueryError(
|
||||
"Failed to query latest signing key.\n"
|
||||
f"Command: {' '.join(cmd)}\n"
|
||||
f"Exit code: {result.returncode}\n"
|
||||
f"STDOUT:\n{stdout}\n"
|
||||
f"STDERR:\n{stderr}"
|
||||
)
|
||||
|
||||
if not stdout and stderr and _looks_like_gpg_runtime_error(stderr):
|
||||
raise GitLatestSigningKeyQueryError(
|
||||
"Failed to query latest signing key.\n"
|
||||
f"Command: {' '.join(cmd)}\n"
|
||||
f"STDERR:\n{stderr}"
|
||||
)
|
||||
|
||||
return stdout
|
||||
@@ -36,6 +36,7 @@ class RepoPaths:
|
||||
# Packaging-related files
|
||||
arch_pkgbuild: Optional[str]
|
||||
debian_changelog: Optional[str]
|
||||
debian_control: Optional[str]
|
||||
rpm_spec: Optional[str]
|
||||
|
||||
|
||||
@@ -102,6 +103,13 @@ def resolve_repo_paths(repo_dir: str) -> RepoPaths:
|
||||
]
|
||||
)
|
||||
|
||||
debian_control = _first_existing(
|
||||
[
|
||||
os.path.join(repo_dir, "packaging", "debian", "control"),
|
||||
os.path.join(repo_dir, "debian", "control"),
|
||||
]
|
||||
)
|
||||
|
||||
# RPM spec: prefer the canonical file, else first spec in packaging/fedora, else first spec in repo root.
|
||||
rpm_spec = _first_existing(
|
||||
[
|
||||
@@ -122,5 +130,6 @@ def resolve_repo_paths(repo_dir: str) -> RepoPaths:
|
||||
changelog_md=changelog_md,
|
||||
arch_pkgbuild=arch_pkgbuild,
|
||||
debian_changelog=debian_changelog,
|
||||
debian_control=debian_control,
|
||||
rpm_spec=rpm_spec,
|
||||
)
|
||||
@@ -16,6 +16,7 @@ def verify_repository(repo, repo_dir, mode="local", no_verification=False):
|
||||
|
||||
commit_hash = ""
|
||||
signing_key = ""
|
||||
signing_key_query_failed = False
|
||||
|
||||
# best-effort info collection
|
||||
try:
|
||||
@@ -59,6 +60,7 @@ def verify_repository(repo, repo_dir, mode="local", no_verification=False):
|
||||
except GitLatestSigningKeyQueryError as exc:
|
||||
error_details.append(str(exc))
|
||||
signing_key = ""
|
||||
signing_key_query_failed = True
|
||||
|
||||
commit_check_passed = True
|
||||
gpg_check_passed = True
|
||||
@@ -78,6 +80,7 @@ def verify_repository(repo, repo_dir, mode="local", no_verification=False):
|
||||
if expected_gpg_keys:
|
||||
if not signing_key:
|
||||
gpg_check_passed = False
|
||||
if not signing_key_query_failed:
|
||||
error_details.append(
|
||||
f"Expected one of GPG keys: {expected_gpg_keys}, but no signing key was found."
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""GitHub-related Python helpers for pkgmgr."""
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a hadolint SARIF report contains warnings or errors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sarif_path = Path(sys.argv[1] if len(sys.argv) > 1 else "hadolint-results.sarif")
|
||||
|
||||
with sarif_path.open("r", encoding="utf-8") as handle:
|
||||
sarif = json.load(handle)
|
||||
|
||||
results = sarif.get("runs", [{}])[0].get("results", [])
|
||||
levels = [result.get("level", "") for result in results]
|
||||
warnings = sum(1 for level in levels if level == "warning")
|
||||
errors = sum(1 for level in levels if level == "error")
|
||||
|
||||
print(f"SARIF results: total={len(results)} warnings={warnings} errors={errors}")
|
||||
|
||||
return 1 if warnings + errors > 0 else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
if (parent / "pyproject.toml").is_file() and (
|
||||
parent / "src" / "pkgmgr"
|
||||
).is_dir():
|
||||
return parent
|
||||
raise RuntimeError(
|
||||
"Could not determine repository root for pkgmgr integration test"
|
||||
)
|
||||
|
||||
|
||||
class TestGitVerificationRuntimeDependencies(unittest.TestCase):
|
||||
def test_flake_app_includes_git_and_gpg_runtime_tools(self) -> None:
|
||||
repo_root = _find_repo_root()
|
||||
flake_text = (repo_root / "flake.nix").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("pkgs.git", flake_text)
|
||||
self.assertIn("pkgs.gnupg", flake_text)
|
||||
|
||||
def test_distro_dependency_scripts_install_gpg_tools(self) -> None:
|
||||
repo_root = _find_repo_root()
|
||||
expected_packages = {
|
||||
"arch": "gnupg",
|
||||
"debian": "gnupg",
|
||||
"ubuntu": "gnupg",
|
||||
"fedora": "gnupg2",
|
||||
"centos": "gnupg2",
|
||||
}
|
||||
|
||||
missing: list[str] = []
|
||||
for distro, package_name in expected_packages.items():
|
||||
script_path = (
|
||||
repo_root / "scripts" / "installation" / distro / "dependencies.sh"
|
||||
)
|
||||
content = script_path.read_text(encoding="utf-8")
|
||||
if not re.search(rf"\b{re.escape(package_name)}\b", content):
|
||||
missing.append(
|
||||
f"{distro}: expected package {package_name} in {script_path}"
|
||||
)
|
||||
|
||||
if missing:
|
||||
self.fail(
|
||||
"Git signature verification runtime dependencies are incomplete:\n"
|
||||
+ "\n".join(f" - {item}" for item in missing)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Unit tests for `pkgmgr.actions.release.package_name.resolve_package_name`.
|
||||
|
||||
The resolver must prefer the explicit name from existing packaging files
|
||||
over the repository folder name so that renaming the folder does not
|
||||
silently rename the distro package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.actions.release.package_name import resolve_package_name
|
||||
from pkgmgr.core.repository.paths import RepoPaths
|
||||
|
||||
|
||||
def _paths(
|
||||
repo_dir: str,
|
||||
*,
|
||||
debian_control: Optional[str] = None,
|
||||
arch_pkgbuild: Optional[str] = None,
|
||||
rpm_spec: Optional[str] = None,
|
||||
) -> RepoPaths:
|
||||
return RepoPaths(
|
||||
repo_dir=repo_dir,
|
||||
pyproject_toml=os.path.join(repo_dir, "pyproject.toml"),
|
||||
flake_nix=os.path.join(repo_dir, "flake.nix"),
|
||||
changelog_md=None,
|
||||
arch_pkgbuild=arch_pkgbuild,
|
||||
debian_changelog=None,
|
||||
debian_control=debian_control,
|
||||
rpm_spec=rpm_spec,
|
||||
)
|
||||
|
||||
|
||||
class TestResolvePackageName(unittest.TestCase):
|
||||
def test_debian_control_wins_over_folder(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
|
||||
control = Path(repo) / "control"
|
||||
control.write_text(
|
||||
"Source: infinito-nexus\nPackage: infinito-nexus\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_package_name(_paths(repo, debian_control=str(control))),
|
||||
"infinito-nexus",
|
||||
)
|
||||
|
||||
def test_pkgbuild_used_when_no_control(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
|
||||
pkgbuild = Path(repo) / "PKGBUILD"
|
||||
pkgbuild.write_text(
|
||||
"pkgname=infinito-nexus\npkgver=1.0\n", encoding="utf-8"
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_package_name(_paths(repo, arch_pkgbuild=str(pkgbuild))),
|
||||
"infinito-nexus",
|
||||
)
|
||||
|
||||
def test_rpm_spec_used_when_no_control_no_pkgbuild(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="infinito-nexus-core_") as repo:
|
||||
spec = Path(repo) / "pkg.spec"
|
||||
spec.write_text("Name: infinito-nexus\n", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
resolve_package_name(_paths(repo, rpm_spec=str(spec))),
|
||||
"infinito-nexus",
|
||||
)
|
||||
|
||||
def test_folder_fallback_when_no_packaging_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="solo-tool_") as repo:
|
||||
self.assertEqual(
|
||||
resolve_package_name(_paths(repo)),
|
||||
os.path.basename(repo),
|
||||
)
|
||||
|
||||
def test_priority_debian_over_pkgbuild_over_spec(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as repo:
|
||||
control = Path(repo) / "control"
|
||||
control.write_text("Package: deb-name\n", encoding="utf-8")
|
||||
pkgbuild = Path(repo) / "PKGBUILD"
|
||||
pkgbuild.write_text("pkgname=arch-name\n", encoding="utf-8")
|
||||
spec = Path(repo) / "x.spec"
|
||||
spec.write_text("Name: rpm-name\n", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
resolve_package_name(
|
||||
_paths(
|
||||
repo,
|
||||
debian_control=str(control),
|
||||
arch_pkgbuild=str(pkgbuild),
|
||||
rpm_spec=str(spec),
|
||||
)
|
||||
),
|
||||
"deb-name",
|
||||
)
|
||||
|
||||
def test_strips_quotes_in_pkgbuild(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as repo:
|
||||
pkgbuild = Path(repo) / "PKGBUILD"
|
||||
pkgbuild.write_text("pkgname='quoted-name'\n", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
resolve_package_name(_paths(repo, arch_pkgbuild=str(pkgbuild))),
|
||||
"quoted-name",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Unit tests for the `release(retry=True)` re-deploy entry point.
|
||||
|
||||
`TestReleaseRetry` exercises the routing decision in `release()` —
|
||||
that `--retry` skips the full `_release_impl` flow and dispatches to
|
||||
`retry_release` instead.
|
||||
|
||||
`TestRetryRelease` exercises the standalone module `pkgmgr.actions.release.retry`:
|
||||
HEAD-tag discovery, idempotent push, latest-tag re-alignment, and
|
||||
fallback behaviour when the current branch cannot be detected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.release.retry import retry_release
|
||||
from pkgmgr.actions.release.workflow import release
|
||||
from pkgmgr.core.git import GitRunError
|
||||
|
||||
|
||||
class TestReleaseRetry(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.release.workflow.retry_release")
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
def test_retry_routes_to_retry_release_only(
|
||||
self, mock_release_impl, mock_retry_release
|
||||
) -> None:
|
||||
release(retry=True, preview=False)
|
||||
|
||||
mock_retry_release.assert_called_once()
|
||||
mock_release_impl.assert_not_called()
|
||||
self.assertFalse(mock_retry_release.call_args.kwargs["preview"])
|
||||
|
||||
@patch("pkgmgr.actions.release.workflow.retry_release")
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
def test_retry_preview_passthrough(
|
||||
self, mock_release_impl, mock_retry_release
|
||||
) -> None:
|
||||
release(retry=True, preview=True)
|
||||
|
||||
mock_retry_release.assert_called_once()
|
||||
mock_release_impl.assert_not_called()
|
||||
self.assertTrue(mock_retry_release.call_args.kwargs["preview"])
|
||||
|
||||
@patch("pkgmgr.actions.release.workflow.retry_release")
|
||||
@patch("pkgmgr.actions.release.workflow._release_impl")
|
||||
def test_retry_ignores_release_args(
|
||||
self, mock_release_impl, mock_retry_release
|
||||
) -> None:
|
||||
# release_type/message/close/force must NOT trigger the full release flow
|
||||
# when retry=True.
|
||||
release(
|
||||
retry=True,
|
||||
release_type="major",
|
||||
message="ignored body",
|
||||
preview=False,
|
||||
force=True,
|
||||
close=True,
|
||||
)
|
||||
|
||||
mock_retry_release.assert_called_once()
|
||||
mock_release_impl.assert_not_called()
|
||||
|
||||
|
||||
class TestRetryRelease(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
|
||||
@patch("pkgmgr.actions.release.retry.run")
|
||||
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
return_value="release-fix",
|
||||
)
|
||||
def test_retry_pushes_existing_tag_and_updates_latest(
|
||||
self,
|
||||
_mock_branch,
|
||||
mock_head_tags,
|
||||
mock_run,
|
||||
_mock_highest,
|
||||
mock_update_latest,
|
||||
) -> None:
|
||||
mock_head_tags.return_value = ["v1.13.4"]
|
||||
|
||||
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["push", "origin", "release-fix", "v1.13.4"], preview=False
|
||||
)
|
||||
mock_update_latest.assert_called_once_with("v1.13.4", preview=False)
|
||||
|
||||
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=False)
|
||||
@patch("pkgmgr.actions.release.retry.run")
|
||||
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
return_value="release-fix",
|
||||
)
|
||||
def test_retry_skips_latest_for_non_highest_tag(
|
||||
self,
|
||||
_mock_branch,
|
||||
mock_head_tags,
|
||||
_mock_run,
|
||||
_mock_highest,
|
||||
mock_update_latest,
|
||||
) -> None:
|
||||
mock_head_tags.return_value = ["v1.0.0"]
|
||||
|
||||
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||
|
||||
mock_update_latest.assert_not_called()
|
||||
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.head_semver_tags",
|
||||
return_value=[],
|
||||
)
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
return_value="main",
|
||||
)
|
||||
def test_retry_raises_when_head_has_no_tag(
|
||||
self, _mock_branch, _mock_head_tags
|
||||
) -> None:
|
||||
with self.assertRaises(RuntimeError) as cm:
|
||||
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||
self.assertIn("No version tag on HEAD", str(cm.exception))
|
||||
|
||||
@patch("pkgmgr.actions.release.retry.update_latest_tag")
|
||||
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
|
||||
@patch("pkgmgr.actions.release.retry.run")
|
||||
@patch("pkgmgr.actions.release.retry.head_semver_tags")
|
||||
@patch(
|
||||
"pkgmgr.actions.release.retry.get_current_branch",
|
||||
side_effect=GitRunError("detached"),
|
||||
)
|
||||
def test_retry_falls_back_to_main_branch_when_detection_fails(
|
||||
self,
|
||||
_mock_branch,
|
||||
mock_head_tags,
|
||||
mock_run,
|
||||
_mock_highest,
|
||||
_mock_update_latest,
|
||||
) -> None:
|
||||
mock_head_tags.return_value = ["v2.0.0"]
|
||||
|
||||
retry_release(pyproject_path="pyproject.toml", preview=False)
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["push", "origin", "main", "v2.0.0"], preview=False
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,7 +1,8 @@
|
||||
import unittest
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.core.git.errors import GitNotRepositoryError, GitRunError
|
||||
from pkgmgr.core.git.errors import GitNotRepositoryError
|
||||
from pkgmgr.core.git.queries.get_latest_signing_key import (
|
||||
GitLatestSigningKeyQueryError,
|
||||
get_latest_signing_key,
|
||||
@@ -10,25 +11,53 @@ from pkgmgr.core.git.queries.get_latest_signing_key import (
|
||||
|
||||
class TestGetLatestSigningKey(unittest.TestCase):
|
||||
@patch(
|
||||
"pkgmgr.core.git.queries.get_latest_signing_key.run",
|
||||
return_value="ABCDEF1234567890\n",
|
||||
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=["git", "log", "-1", "--format=%GK"],
|
||||
returncode=0,
|
||||
stdout="ABCDEF1234567890\n",
|
||||
stderr="",
|
||||
),
|
||||
)
|
||||
def test_strips_output(self, _mock_run) -> None:
|
||||
out = get_latest_signing_key(cwd="/tmp/repo")
|
||||
self.assertEqual(out, "ABCDEF1234567890")
|
||||
|
||||
@patch(
|
||||
"pkgmgr.core.git.queries.get_latest_signing_key.run",
|
||||
side_effect=GitRunError("boom"),
|
||||
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=["git", "log", "-1", "--format=%GK"],
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="boom",
|
||||
),
|
||||
)
|
||||
def test_wraps_git_run_error(self, _mock_run) -> None:
|
||||
with self.assertRaises(GitLatestSigningKeyQueryError):
|
||||
with self.assertRaisesRegex(GitLatestSigningKeyQueryError, "boom"):
|
||||
get_latest_signing_key(cwd="/tmp/repo")
|
||||
|
||||
@patch(
|
||||
"pkgmgr.core.git.queries.get_latest_signing_key.run",
|
||||
side_effect=GitNotRepositoryError("no repo"),
|
||||
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=["git", "log", "-1", "--format=%GK"],
|
||||
returncode=128,
|
||||
stdout="",
|
||||
stderr="fatal: not a git repository",
|
||||
),
|
||||
)
|
||||
def test_does_not_catch_not_repository_error(self, _mock_run) -> None:
|
||||
with self.assertRaises(GitNotRepositoryError):
|
||||
get_latest_signing_key(cwd="/tmp/no-repo")
|
||||
|
||||
@patch(
|
||||
"pkgmgr.core.git.queries.get_latest_signing_key.subprocess.run",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=["git", "log", "-1", "--format=%GK"],
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="error: cannot run gpg: No such file or directory",
|
||||
),
|
||||
)
|
||||
def test_raises_when_git_reports_gpg_runtime_error(self, _mock_run) -> None:
|
||||
with self.assertRaisesRegex(GitLatestSigningKeyQueryError, "cannot run gpg"):
|
||||
get_latest_signing_key(cwd="/tmp/repo")
|
||||
@@ -77,6 +77,23 @@ class TestVerifyRepository(unittest.TestCase):
|
||||
self.assertEqual(commit, "")
|
||||
self.assertEqual(key, "")
|
||||
|
||||
def test_verified_gpg_query_error_does_not_add_missing_key_fallback(self) -> None:
|
||||
repo = {"verified": {"commit": None, "gpg_keys": ["ABC"]}}
|
||||
with (
|
||||
patch("pkgmgr.core.repository.verify.get_head_commit", return_value=""),
|
||||
patch(
|
||||
"pkgmgr.core.repository.verify.get_latest_signing_key",
|
||||
side_effect=GitLatestSigningKeyQueryError("cannot run gpg"),
|
||||
),
|
||||
):
|
||||
ok, errors, commit, key = verify_repository(repo, "/tmp/repo", mode="local")
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("cannot run gpg", " ".join(errors))
|
||||
self.assertFalse(any("no signing key was found" in e for e in errors))
|
||||
self.assertEqual(commit, "")
|
||||
self.assertEqual(key, "")
|
||||
|
||||
def test_strict_pull_collects_remote_error_message(self) -> None:
|
||||
repo = {"verified": {"commit": "expected", "gpg_keys": None}}
|
||||
with (
|
||||
|
||||
Reference in new issue
Block a user