Compare commits

..
6 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach 08738d812e Release version 2.3.0
CI / security-codeql (push) Blocked by required conditions
CI / test-unit (push) Blocked by required conditions
CI / test-integration (push) Blocked by required conditions
CI / test-env-virtual (push) Blocked by required conditions
CI / test-env-nix (push) Blocked by required conditions
CI / test-e2e (push) Blocked by required conditions
CI / test-virgin-user (push) Blocked by required conditions
CI / test-virgin-root (push) Blocked by required conditions
CI / lint-shell (push) Blocked by required conditions
CI / lint-python (push) Blocked by required conditions
CI / lint-docker (push) Blocked by required conditions
Mark stable commit / mark-stable (push) Waiting to run
CodeQL Advanced / Check security (none, actions) (push) Waiting to run
CodeQL Advanced / Check security (none, python) (push) Waiting to run
Test Units / test-unit (push) Waiting to run
Test Code Integration / test-integration (push) Waiting to run
Test OS Containers / test-env-virtual (arch) (push) Waiting to run
Test OS Containers / test-env-virtual (centos) (push) Waiting to run
Test OS Containers / test-env-virtual (debian) (push) Waiting to run
Test OS Containers / test-env-virtual (fedora) (push) Waiting to run
Test OS Containers / test-env-virtual (ubuntu) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (arch) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (centos) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (debian) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (fedora) (push) Waiting to run
Test Virgin Nix (flake only) / test-env-nix (ubuntu) (push) Waiting to run
Test End-To-End / test-e2e (arch) (push) Waiting to run
Test End-To-End / test-e2e (centos) (push) Waiting to run
Test End-To-End / test-e2e (debian) (push) Waiting to run
Test End-To-End / test-e2e (fedora) (push) Waiting to run
Test End-To-End / test-e2e (ubuntu) (push) Waiting to run
Test Virgin User / test-virgin-user (arch) (push) Waiting to run
Test Virgin User / test-virgin-user (centos) (push) Waiting to run
Test Virgin User / test-virgin-user (debian) (push) Waiting to run
Test Virgin User / test-virgin-user (fedora) (push) Waiting to run
Test Virgin User / test-virgin-user (ubuntu) (push) Waiting to run
Test Virgin Root / test-virgin-root (arch) (push) Waiting to run
Test Virgin Root / test-virgin-root (centos) (push) Waiting to run
Test Virgin Root / test-virgin-root (debian) (push) Waiting to run
Test Virgin Root / test-virgin-root (fedora) (push) Waiting to run
Test Virgin Root / test-virgin-root (ubuntu) (push) Waiting to run
ShellCheck / lint-shell (push) Waiting to run
Ruff (Python code sniffer) / lint-python (push) Waiting to run
Docker Linter / Lint Dockerfile (push) Waiting to run
2026-11-01 15:14:53 +01:00
Kevin Veen-Birkenbach 21cecf64fb feat(branch-gc): add --merged-into, --sizes and --min-age 2026-11-01 12:54:19 +01:00
Kevin Veen-Birkenbach d1a951ee94 Release version 2.2.0
Mark stable commit / mark-stable (push) Canceled after 0s
CI / security-codeql (push) Canceled after 0s
CI / test-unit (push) Canceled after 0s
CI / test-integration (push) Canceled after 0s
CI / test-env-virtual (push) Canceled after 0s
CI / test-env-nix (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-virgin-user (push) Canceled after 0s
CI / test-virgin-root (push) Canceled after 0s
CI / lint-shell (push) Canceled after 0s
CI / lint-python (push) Canceled after 0s
CI / lint-docker (push) Canceled after 0s
2026-11-01 01:55:31 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 f40cc5cfde feat(push): add --no-sign to push unsigned commits
pkgmgr push --no-sign skips the re-sign rebase and the unsigned-range
refusal, so a checkout without a usable signing key can still sync the
fetch URL and every mirror pushurl. Leases and the git cherry guard
still apply. push_branch takes sign as a required keyword; pkgmgr
release and release --retry pass sign=True, so releases stay signed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 01:55:31 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 944b4cb663 ci(publish): build container images on native arch runners
Each distro now builds amd64 on ubuntu-latest and arm64 on
ubuntu-24.04-arm instead of emulating arm64 under QEMU. image.sh gains
--platform, which pushes one <version>-<arch> tag, and --merge, which
joins those tags under the release, stable and alias tags with
buildx imagetools. The workflow splits into prepare, build and merge
jobs; scripts/build/publish.sh follows the same sequence locally.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 00:53:16 +01:00
Kevin Veen-BirkenbachandClaude Opus 5.5 919e6a6434 feat(push): re-sign and lease every mirror pushurl
pkgmgr push now signs unpushed commits like git-sign-push and pushes
each pushurl with its own --force-with-lease: the fetch URL against the
tracking ref, every mirror against its own fetched tip, each only after
git cherry shows no commit exists only there. --rewrite allows a
deliberate history rewrite. Without a repository selection it pushes
the current checkout, including worktrees outside the base dir.
pkgmgr release and release --retry sign before tagging and push the
branch and tag through the same path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 00:53:16 +01:00
24 changed files with 1184 additions and 110 deletions

No files matched your search

+75 -8
View File
@@ -6,13 +6,17 @@ on:
types: [completed] types: [completed]
jobs: jobs:
publish: prepare:
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write
outputs:
should_publish: ${{ steps.info.outputs.should_publish }}
version: ${{ steps.info.outputs.version }}
is_stable: ${{ steps.info.outputs.is_stable }}
steps: steps:
- name: Checkout repository (with tags) - name: Checkout repository (with tags)
@@ -36,24 +40,87 @@ jobs:
if: steps.branch-check.outputs.is_on_main == 'true' if: steps.branch-check.outputs.is_on_main == 'true'
run: bash scripts/github/publish-containers/compute-publish-container-info.sh run: bash scripts/github/publish-containers/compute-publish-container-info.sh
build:
needs: prepare
if: ${{ needs.prepare.outputs.should_publish == 'true' }}
runs-on: ${{ matrix.target.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
distro: &distros [arch, debian, ubuntu, fedora, centos]
target:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout workflow_run commit
uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
with: with:
use: true use: true
- name: Login to GHCR - name: Login to GHCR
if: ${{ steps.info.outputs.should_publish == 'true' }}
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish all images - name: Build and push ${{ matrix.distro }} for ${{ matrix.target.platform }}
if: ${{ steps.info.outputs.should_publish == 'true' }}
env: env:
OWNER: ${{ github.repository_owner }} OWNER: ${{ github.repository_owner }}
VERSION: ${{ steps.info.outputs.version }} VERSION: ${{ needs.prepare.outputs.version }}
IS_STABLE: ${{ steps.info.outputs.is_stable }} PKGMGR_DISTRO: ${{ matrix.distro }}
PLATFORM: ${{ matrix.target.platform }}
run: bash scripts/github/publish-containers/publish-container-images.sh run: bash scripts/github/publish-containers/publish-container-images.sh
merge:
needs: [prepare, build]
if: ${{ !cancelled() && needs.prepare.outputs.should_publish == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
distro: *distros
steps:
- name: Checkout workflow_run commit
uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
with:
use: true
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Join both platforms of ${{ matrix.distro }} under the release tags
env:
OWNER: ${{ github.repository_owner }}
VERSION: ${{ needs.prepare.outputs.version }}
IS_STABLE: ${{ needs.prepare.outputs.is_stable }}
PKGMGR_DISTRO: ${{ matrix.distro }}
run: bash scripts/github/publish-containers/merge-container-images.sh
+35
View File
@@ -1,5 +1,40 @@
# Changelog # Changelog
## [2.3.0] - 2026-11-01
Added:
- Branch: *gc --merged-into REF* treats items merged into REF as merged
- Branch: *--merged-into* is repeatable, protects REF and works for *purge*
- Branch: *--sizes* adds each worktree's disk usage to the plan
- Branch: *--min-age HOURS* skips items with a commit or index that recent
## [2.2.0] - 2026-11-01
Added:
- Push: *--no-sign* pushes unsigned commits as they are; leases still apply
- Push: *--rewrite* replaces commits a remote already has (amend, squash)
Changed:
- Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
- Push: each pushurl gets its own lease; mirrors no longer fail as stale
- Push: a commit that exists only on a mirror stops the push, nothing lost
- Push: without a selection it pushes the current checkout or worktree
- Release: signs before tagging and pushes branch and tag like *pkgmgr push*
Fixed:
- Container: images build on native amd64 and arm64 runners, not QEMU
Upgrade notes:
- Push: needs a working signing key; pass *--no-sign* to push unsigned
- Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
## [2.1.1] - 2026-10-31 ## [2.1.1] - 2026-10-31
Fixed: Fixed:
+1 -1
View File
@@ -32,7 +32,7 @@
rec { rec {
pkgmgr = pyPkgs.buildPythonApplication { pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager"; pname = "package-manager";
version = "2.1.1"; version = "2.3.0";
# Use the git repo as source # Use the git repo as source
src = ./.; src = ./.;
+1 -1
View File
@@ -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=2.1.1 pkgver=2.3.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')
+39
View File
@@ -1,3 +1,42 @@
package-manager (2.3.0-1) unstable; urgency=medium
Added:
- Branch: *gc --merged-into REF* treats items merged into REF as merged
- Branch: *--merged-into* is repeatable, protects REF and works for *purge*
- Branch: *--sizes* adds each worktree's disk usage to the plan
- Branch: *--min-age HOURS* skips items with a commit or index that recent
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 01 Nov 2026 15:14:53 +0100
package-manager (2.2.0-1) unstable; urgency=medium
Added:
- Push: *--no-sign* pushes unsigned commits as they are; leases still apply
- Push: *--rewrite* replaces commits a remote already has (amend, squash)
Changed:
- Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
- Push: each pushurl gets its own lease; mirrors no longer fail as stale
- Push: a commit that exists only on a mirror stops the push, nothing lost
- Push: without a selection it pushes the current checkout or worktree
- Release: signs before tagging and pushes branch and tag like *pkgmgr push*
Fixed:
- Container: images build on native amd64 and arm64 runners, not QEMU
Upgrade notes:
- Push: needs a working signing key; pass *--no-sign* to push unsigned
- Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
- Release: a version file ahead of the newest tag now raises the next version
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 01 Nov 2026 01:55:29 +0100
package-manager (2.1.1-1) unstable; urgency=medium package-manager (2.1.1-1) unstable; urgency=medium
Fixed: Fixed:
+34 -1
View File
@@ -1,5 +1,5 @@
Name: package-manager Name: package-manager
Version: 2.1.1 Version: 2.3.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
@@ -74,6 +74,39 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/ /usr/lib/package-manager/
%changelog %changelog
* Sun Nov 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.3.0-1
- Added:
- - Branch: *gc --merged-into REF* treats items merged into REF as merged
- - Branch: *--merged-into* is repeatable, protects REF and works for *purge*
- - Branch: *--sizes* adds each worktree's disk usage to the plan
- - Branch: *--min-age HOURS* skips items with a commit or index that recent
* Sun Nov 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.2.0-1
- Added:
- - Push: *--no-sign* pushes unsigned commits as they are; leases still apply
- - Push: *--rewrite* replaces commits a remote already has (amend, squash)
- Changed:
- - Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
- - Push: each pushurl gets its own lease; mirrors no longer fail as stale
- - Push: a commit that exists only on a mirror stops the push, nothing lost
- - Push: without a selection it pushes the current checkout or worktree
- - Release: signs before tagging and pushes branch and tag like *pkgmgr push*
- Fixed:
- - Container: images build on native amd64 and arm64 runners, not QEMU
- Upgrade notes:
- - Push: needs a working signing key; pass *--no-sign* to push unsigned
- - Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
- - Release: a version file ahead of the newest tag now raises the next version
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.1-1 * Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.1-1
- Fixed: - Fixed:
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "kpmx" name = "kpmx"
version = "2.1.1" version = "2.3.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"
+45 -5
View File
@@ -18,6 +18,8 @@ OWNER="" # e.g. github org/user
REPO_PREFIX="pkgmgr" # image base name (pkgmgr) REPO_PREFIX="pkgmgr" # image base name (pkgmgr)
VERSION="" # X.Y.Z (required for --publish) VERSION="" # X.Y.Z (required for --publish)
IS_STABLE="false" # "true" -> publish stable tags IS_STABLE="false" # "true" -> publish stable tags
PLATFORM="" # e.g. linux/arm64 -> build and push only this platform
MERGE=0 # if 1 -> join the per-platform tags of --version under the publish tags
DEFAULT_DISTRO="arch" DEFAULT_DISTRO="arch"
usage() { usage() {
@@ -39,9 +41,12 @@ Publish options:
--repo-prefix <name> Image base name (default: pkgmgr) --repo-prefix <name> Image base name (default: pkgmgr)
--version <X.Y.Z> Version for --publish --version <X.Y.Z> Version for --publish
--stable <true|false> Whether to publish :stable tags (default: false) --stable <true|false> Whether to publish :stable tags (default: false)
--platform <os/arch> Build and push only this platform; with --publish as <version>-<arch>
--merge Join the <version>-<arch> tags of all platforms under the publish tags
Notes: Notes:
- --publish implies --push and requires --registry, --owner, and --version. - --publish implies --push and requires --registry, --owner, and --version.
- --platform requires --push; --merge requires --publish and builds nothing.
- Local build (no --push) uses "docker build" and creates a local image like "pkgmgr-arch". - Local build (no --push) uses "docker build" and creates a local image like "pkgmgr-arch".
EOF EOF
} }
@@ -82,6 +87,12 @@ while [[ $# -gt 0 ]]; do
[[ -n "${IS_STABLE}" ]] || { echo "ERROR: --stable requires a value (true|false)"; exit 2; } [[ -n "${IS_STABLE}" ]] || { echo "ERROR: --stable requires a value (true|false)"; exit 2; }
shift 2 shift 2
;; ;;
--platform)
PLATFORM="${2:-}"
[[ -n "${PLATFORM}" ]] || { echo "ERROR: --platform requires a value"; exit 2; }
shift 2
;;
--merge) MERGE=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) *)
echo "ERROR: Unknown argument: $1" >&2 echo "ERROR: Unknown argument: $1" >&2
@@ -117,6 +128,19 @@ if [[ "${PUBLISH}" == "1" ]]; then
[[ -n "${VERSION}" ]] || { echo "ERROR: --publish requires --version"; exit 2; } [[ -n "${VERSION}" ]] || { echo "ERROR: --publish requires --version"; exit 2; }
fi fi
if [[ -n "${PLATFORM}" ]]; then
[[ "${PUSH}" == "1" ]] || { echo "ERROR: --platform requires --push or --publish"; exit 2; }
[[ ",$(resolve_platforms "$PKGMGR_DISTRO")," == *",${PLATFORM},"* ]] \
|| { echo "ERROR: ${PKGMGR_DISTRO} offers no platform ${PLATFORM}"; exit 2; }
fi
if [[ "${MERGE}" == "1" ]]; then
[[ "${PUBLISH}" == "1" ]] || { echo "ERROR: --merge requires --publish"; exit 2; }
[[ -z "${PLATFORM}" ]] || { echo "ERROR: --merge and --platform exclude each other"; exit 2; }
fi
DISTRO_TAG_BASE="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
# Guard: --push without --publish requires fully-qualified --tag # Guard: --push without --publish requires fully-qualified --tag
if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then if [[ "${PUSH}" == "1" && "${PUBLISH}" != "1" ]]; then
if [[ "${IMAGE_TAG}" != */* ]]; then if [[ "${IMAGE_TAG}" != */* ]]; then
@@ -130,7 +154,7 @@ echo "------------------------------------------------------------"
echo "[build] Building image" echo "[build] Building image"
echo "distro = ${PKGMGR_DISTRO}" echo "distro = ${PKGMGR_DISTRO}"
echo "BASE_IMAGE = ${BASE_IMAGE}" echo "BASE_IMAGE = ${BASE_IMAGE}"
echo "platforms = $(resolve_platforms "$PKGMGR_DISTRO")" echo "platforms = ${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}"
if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi
if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi
if [[ "${PUBLISH}" == "1" ]]; then if [[ "${PUBLISH}" == "1" ]]; then
@@ -150,7 +174,7 @@ if [[ "${NO_CACHE}" == "1" ]]; then
fi fi
compute_publish_tags() { compute_publish_tags() {
local distro_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}" local distro_tag_base="${DISTRO_TAG_BASE}"
local alias_tag_base="" local alias_tag_base=""
if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then
@@ -176,10 +200,26 @@ compute_publish_tags() {
printf '%s\n' "${tags[@]}" printf '%s\n' "${tags[@]}"
} }
if [[ "${PUSH}" == "1" ]]; then if [[ "${MERGE}" == "1" ]]; then
merge_args=(docker buildx imagetools create)
while IFS= read -r t; do
merge_args+=(-t "$t")
done < <(compute_publish_tags)
IFS=',' read -ra platforms <<< "$(resolve_platforms "$PKGMGR_DISTRO")"
for p in "${platforms[@]}"; do
merge_args+=("${DISTRO_TAG_BASE}:${VERSION}-${p#*/}")
done
echo "[build] Running: ${merge_args[*]}"
"${merge_args[@]}"
docker buildx imagetools inspect "${DISTRO_TAG_BASE}:${VERSION}"
elif [[ "${PUSH}" == "1" ]]; then
bx_args=(docker buildx build --push) bx_args=(docker buildx build --push)
if [[ "${PUBLISH}" == "1" ]]; then if [[ "${PUBLISH}" == "1" && -n "${PLATFORM}" ]]; then
bx_args+=(-t "${DISTRO_TAG_BASE}:${VERSION}-${PLATFORM#*/}")
elif [[ "${PUBLISH}" == "1" ]]; then
while IFS= read -r t; do while IFS= read -r t; do
bx_args+=(-t "$t") bx_args+=(-t "$t")
done < <(compute_publish_tags) done < <(compute_publish_tags)
@@ -187,7 +227,7 @@ if [[ "${PUSH}" == "1" ]]; then
bx_args+=(-t "${IMAGE_TAG}") bx_args+=(-t "${IMAGE_TAG}")
fi fi
bx_args+=(--platform "$(resolve_platforms "$PKGMGR_DISTRO")") bx_args+=(--platform "${PLATFORM:-$(resolve_platforms "$PKGMGR_DISTRO")}")
bx_args+=("${build_args[@]}") bx_args+=("${build_args[@]}")
bx_args+=(.) bx_args+=(.)
+15 -1
View File
@@ -14,6 +14,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=./scripts/build/base.sh
source "${SCRIPT_DIR}/base.sh"
REGISTRY="${REGISTRY:-ghcr.io}" REGISTRY="${REGISTRY:-ghcr.io}"
IS_STABLE="${IS_STABLE:-false}" IS_STABLE="${IS_STABLE:-false}"
DISTROS="${DISTROS:-arch debian ubuntu fedora centos}" DISTROS="${DISTROS:-arch debian ubuntu fedora centos}"
@@ -33,12 +36,23 @@ for d in ${DISTROS}; do
echo "[publish] PKGMGR_DISTRO=${d}" echo "[publish] PKGMGR_DISTRO=${d}"
echo "============================================================" echo "============================================================"
IFS=',' read -ra platforms <<< "$(resolve_platforms "${d}")"
for p in "${platforms[@]}"; do
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \
--registry "${REGISTRY}" \
--owner "${OWNER}" \
--version "${VERSION}" \
--platform "${p}"
done
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \ PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
--publish \ --publish \
--registry "${REGISTRY}" \ --registry "${REGISTRY}" \
--owner "${OWNER}" \ --owner "${OWNER}" \
--version "${VERSION}" \ --version "${VERSION}" \
--stable "${IS_STABLE}" --stable "${IS_STABLE}" \
--merge
done done
echo echo
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
: "${OWNER:?OWNER must be set}"
: "${VERSION:?VERSION must be set}"
: "${IS_STABLE:?IS_STABLE must be set}"
: "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
bash scripts/build/image.sh \
--publish \
--registry ghcr.io \
--owner "${OWNER}" \
--version "${VERSION}" \
--stable "${IS_STABLE}" \
--merge
@@ -3,6 +3,12 @@ set -euo pipefail
: "${OWNER:?OWNER must be set}" : "${OWNER:?OWNER must be set}"
: "${VERSION:?VERSION must be set}" : "${VERSION:?VERSION must be set}"
: "${IS_STABLE:?IS_STABLE must be set}" : "${PKGMGR_DISTRO:?PKGMGR_DISTRO must be set}"
: "${PLATFORM:?PLATFORM must be set}"
bash scripts/build/publish.sh bash scripts/build/image.sh \
--publish \
--registry ghcr.io \
--owner "${OWNER}" \
--version "${VERSION}" \
--platform "${PLATFORM}"
+140 -8
View File
@@ -6,6 +6,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import time
from collections.abc import Callable from collections.abc import Callable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
@@ -63,6 +64,8 @@ class Item:
images: list[str] = field(default_factory=list) images: list[str] = field(default_factory=list)
state: str = "" state: str = ""
merged_by: str | None = None merged_by: str | None = None
merged_into: str | None = None
size_bytes: int | None = None
skip: str | None = None skip: str | None = None
actions: list[str] = field(default_factory=list) actions: list[str] = field(default_factory=list)
reason: str = "" reason: str = ""
@@ -88,6 +91,9 @@ class Repo:
forge: ForgeFacts forge: ForgeFacts
containers: list[Container] | None containers: list[Container] | None
host_ns: bool host_ns: bool
lines: dict[str, tuple[str, set[str]]]
sizes: bool
min_age: float | None
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
items: list[Item] = field(default_factory=list) items: list[Item] = field(default_factory=list)
@@ -234,11 +240,51 @@ def _is_ancestor(sha: str, ref: str, repo_root: str) -> bool:
return True return True
def _in_line(repo: Repo, sha: str) -> str | None:
for line, (tip, first_parent) in repo.lines.items():
if sha == tip:
continue
if sha in first_parent or _is_ancestor(sha, line, repo.repo):
return line
return None
def _merged_by(repo: Repo, sha: str) -> str | None: def _merged_by(repo: Repo, sha: str) -> str | None:
if sha not in repo.first_parent and _is_ancestor(sha, repo.default, repo.repo): if sha not in repo.first_parent and _is_ancestor(sha, repo.default, repo.repo):
return "ancestor" return "ancestor"
number = repo.forge.merged_heads.get(sha) number = repo.forge.merged_heads.get(sha)
return f"pr#{number}" if number else None if number:
return f"pr#{number}"
line = _in_line(repo, sha)
return f"line:{line}" if line else None
def _du_bytes(path: str) -> int | None:
out = subprocess.run(
["du", "-sk", path], check=False, capture_output=True, text=True
).stdout.split()
return int(out[0]) * 1024 if out and out[0].isdigit() else None
def _age_hours(repo: Repo, shas: list[str], tree: Worktree | None) -> float:
stamps = [
int(run(["log", "-1", "--format=%ct", sha], cwd=repo.repo)) for sha in shas
]
if tree and os.path.isfile(os.path.join(tree.path, ".git")):
with open(os.path.join(tree.path, ".git"), encoding="utf-8") as fh:
index = os.path.join(fh.read().strip().removeprefix("gitdir: "), "index")
if os.path.isfile(index):
stamps.append(int(os.path.getmtime(index)))
return (time.time() - max(stamps)) / 3600
def _human(size: int) -> str:
value = float(size)
for unit in ("K", "M", "G"):
value /= 1024
if value < 1024:
return f"{value:.1f}{unit}"
return f"{value / 1024:.1f}T"
def _skip( def _skip(
@@ -331,9 +377,20 @@ def _build(
(verdict for verdict in verdicts.values() if verdict != "ancestor"), (verdict for verdict in verdicts.values() if verdict != "ancestor"),
"ancestor", "ancestor",
) )
item.merged_into = (
item.merged_by.removeprefix("line:")
if item.merged_by.startswith("line:")
else repo.default
)
item.reason = f"merged by {item.merged_by}" item.reason = f"merged by {item.merged_by}"
if repo.sizes and checkout and not tree.main:
item.size_bytes = _du_bytes(tree.path)
item.skip = _skip(repo, item, tree, checkout, cwds) item.skip = _skip(repo, item, tree, checkout, cwds)
if item.skip is None and repo.min_age is not None and verdicts:
age = _age_hours(repo, list(verdicts), tree if checkout else None)
if age < repo.min_age:
item.skip = f"younger than {repo.min_age:g} h"
for project in item.compose_projects: for project in item.compose_projects:
item.actions += [f"compose-down:{project}", f"images:{project}"] item.actions += [f"compose-down:{project}", f"images:{project}"]
if tree: if tree:
@@ -386,9 +443,51 @@ def _stale(repo_root: str) -> list[Item]:
return items return items
def _lines(
repo_root: str, refs: list[str], remotes: list[str], warnings: list[str]
) -> tuple[dict[str, tuple[str, set[str]]], set[str]]:
lines: dict[str, tuple[str, set[str]]] = {}
names: set[str] = set()
for ref in refs:
try:
tip = run(
["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], cwd=repo_root
)
except GitRunError:
warnings.append(f"merged-into ref {ref} not found")
continue
first_parent = set(
run(["rev-list", "--first-parent", ref], cwd=repo_root).splitlines()
)
lines[ref] = (tip, first_parent)
owner = next((r for r in remotes if ref.startswith(r + "/")), None)
names.add(ref[len(owner) + 1 :] if owner else ref)
return lines, names
def plan_repo( def plan_repo(
repo_root: str, containers: list[Container] | None, host_ns: bool repo_root: str,
containers: list[Container] | None,
host_ns: bool,
*,
merged_into: list[str],
sizes: bool,
min_age: float | None,
) -> Repo: ) -> Repo:
"""
Scan one repository and classify every branch and worktree.
Args:
repo_root: main worktree root of the repository.
containers: compose containers on the host; None when docker is unavailable.
host_ns: whether this process runs in the host PID namespace.
merged_into: integration lines (refs) that also count as merged; each is protected.
sizes: measure each worktree's disk usage into size_bytes.
min_age: skip items whose newest commit or worktree index is younger (hours).
Returns:
The repository with its classified items.
"""
warnings: list[str] = [] warnings: list[str] = []
try: try:
# Auto-maintenance runs worktree-prune, which deletes the metadata of # Auto-maintenance runs worktree-prune, which deletes the metadata of
@@ -422,18 +521,28 @@ def plan_repo(
) )
if forge.unavailable: if forge.unavailable:
warnings.append(f"forge unavailable: {forge.unavailable}") warnings.append(f"forge unavailable: {forge.unavailable}")
lines, line_names = _lines(repo_root, merged_into, remotes, warnings)
repo = Repo( repo = Repo(
repo=repo_root, repo=repo_root,
default=default, default=default,
remotes=remotes, remotes=remotes,
protected={default, *ALWAYS_PROTECTED, *head_names, *forge.protected}, protected={
default,
*ALWAYS_PROTECTED,
*head_names,
*forge.protected,
*line_names,
},
first_parent=set( first_parent=set(
run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines() run(["rev-list", "--first-parent", default], cwd=repo_root).splitlines()
), ),
forge=forge, forge=forge,
containers=containers, containers=containers,
host_ns=host_ns, host_ns=host_ns,
lines=lines,
sizes=sizes,
min_age=min_age,
warnings=warnings, warnings=warnings,
) )
repo.items = _items(repo) repo.items = _items(repo)
@@ -546,6 +655,8 @@ def _line(item: Item) -> str:
parts.append(item.skip) parts.append(item.skip)
if item.worktree and item.state != "stale": if item.worktree and item.state != "stale":
parts.append(f"worktree {item.worktree}") parts.append(f"worktree {item.worktree}")
if item.size_bytes is not None:
parts.append(f"size {_human(item.size_bytes)}")
if item.compose_projects: if item.compose_projects:
parts.append("stack " + ", ".join(item.compose_projects)) parts.append("stack " + ", ".join(item.compose_projects))
if item.remotes: if item.remotes:
@@ -566,6 +677,7 @@ def _render(
{ {
"repo": repo.repo, "repo": repo.repo,
"default": repo.default, "default": repo.default,
"merged_into": list(repo.lines),
"warnings": repo.warnings, "warnings": repo.warnings,
"items": [asdict(i) for i in repo.items if chosen(i)], "items": [asdict(i) for i in repo.items if chosen(i)],
} }
@@ -591,11 +703,14 @@ def _render(
def _prepare( def _prepare(
repo_roots: list[str], include: list[str], exclude: list[str] repo_roots: list[str],
include: list[str],
exclude: list[str],
options: dict,
) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None: ) -> tuple[list[Repo], bool, Callable[[Item], bool]] | None:
host_ns = host_pid_namespace() host_ns = host_pid_namespace()
containers = list_compose_containers() containers = list_compose_containers()
repos = [plan_repo(root, containers, host_ns) for root in repo_roots] repos = [plan_repo(root, containers, host_ns, **options) for root in repo_roots]
unknown = [ unknown = [
name name
for name in include for name in include
@@ -633,6 +748,9 @@ def branch_gc(
as_json: bool, as_json: bool,
include: list[str], include: list[str],
exclude: list[str], exclude: list[str],
merged_into: list[str],
sizes: bool,
min_age: float | None,
) -> int: ) -> int:
""" """
Plan, and with apply=True execute, the cleanup of merged and clean branches. Plan, and with apply=True execute, the cleanup of merged and clean branches.
@@ -643,6 +761,9 @@ def branch_gc(
as_json: print the plan as JSON instead of text lines. as_json: print the plan as JSON instead of text lines.
include: only items whose branch or worktree path matches one of these. include: only items whose branch or worktree path matches one of these.
exclude: drop items whose branch or worktree path matches one of these. exclude: drop items whose branch or worktree path matches one of these.
merged_into: integration lines (refs) that also count as merged.
sizes: measure each worktree's disk usage into the plan.
min_age: skip items younger than this many hours.
Returns: Returns:
0 on success, 1 when an applied item failed, 2 when refused. 0 on success, 1 when an applied item failed, 2 when refused.
@@ -650,7 +771,8 @@ def branch_gc(
if apply and not host_pid_namespace(): if apply and not host_pid_namespace():
print(REFUSAL, file=sys.stderr) print(REFUSAL, file=sys.stderr)
return 2 return 2
prepared = _prepare(repo_roots, include, exclude) options = {"merged_into": merged_into, "sizes": sizes, "min_age": min_age}
prepared = _prepare(repo_roots, include, exclude, options)
if prepared is None: if prepared is None:
return 2 return 2
repos, host_ns, chosen = prepared repos, host_ns, chosen = prepared
@@ -669,7 +791,13 @@ def branch_gc(
def branch_purge( def branch_purge(
repo_roots: list[str], *, include: list[str], exclude: list[str] repo_roots: list[str],
*,
include: list[str],
exclude: list[str],
merged_into: list[str],
sizes: bool,
min_age: float | None,
) -> int: ) -> int:
""" """
Delete unmerged or dirty items after a y/N confirmation per item. Delete unmerged or dirty items after a y/N confirmation per item.
@@ -678,6 +806,9 @@ def branch_purge(
repo_roots: main worktree roots of the repositories to scan. repo_roots: main worktree roots of the repositories to scan.
include: only items whose branch or worktree path matches one of these. include: only items whose branch or worktree path matches one of these.
exclude: drop items whose branch or worktree path matches one of these. exclude: drop items whose branch or worktree path matches one of these.
merged_into: integration lines (refs) that also count as merged.
sizes: measure each worktree's disk usage into the prompt line.
min_age: skip items younger than this many hours.
Returns: Returns:
0 on success, 1 when a confirmed item failed, 2 when refused. 0 on success, 1 when a confirmed item failed, 2 when refused.
@@ -685,7 +816,8 @@ def branch_purge(
if not host_pid_namespace(): if not host_pid_namespace():
print(REFUSAL, file=sys.stderr) print(REFUSAL, file=sys.stderr)
return 2 return 2
prepared = _prepare(repo_roots, include, exclude) options = {"merged_into": merged_into, "sizes": sizes, "min_age": min_age}
prepared = _prepare(repo_roots, include, exclude, options)
if prepared is None: if prepared is None:
return 2 return 2
repos, _, chosen = prepared repos, _, chosen = prepared
+4 -3
View File
@@ -10,7 +10,7 @@ the side effects downstream are incomplete.
`retry_release` re-runs the idempotent tail of that flow so a botched `retry_release` re-runs the idempotent tail of that flow so a botched
release can be re-pushed without touching code or recreating tags: release can be re-pushed without touching code or recreating tags:
* `git push origin <branch> <tag>` for the existing HEAD tag * push the branch and the existing HEAD tag through `pkgmgr push`'s leases
* re-align the floating `latest` tag if HEAD tag is the highest * re-align the floating `latest` tag if HEAD tag is the highest
Publishing (PyPI etc.) stays the caller's responsibility — the publish Publishing (PyPI etc.) stays the caller's responsibility — the publish
@@ -23,7 +23,8 @@ from __future__ import annotations
import os import os
from pkgmgr.actions.publish.git_tags import head_semver_tags from pkgmgr.actions.publish.git_tags import head_semver_tags
from pkgmgr.core.git import GitRunError, run from pkgmgr.actions.repository.push import push_branch
from pkgmgr.core.git import GitRunError
from pkgmgr.core.git.queries import get_current_branch from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.version.semver import SemVer from pkgmgr.core.version.semver import SemVer
@@ -50,7 +51,7 @@ def retry_release(
tag = max(tags, key=SemVer.parse) tag = max(tags, key=SemVer.parse)
print(f"Re-pushing existing tag: {tag}") print(f"Re-pushing existing tag: {tag}")
run(["push", "origin", branch, tag], preview=preview) push_branch(".", [], sign=True, refs=(tag,), preview=preview)
try: try:
if is_highest_version_tag(tag): if is_highest_version_tag(tag):
+6 -4
View File
@@ -4,7 +4,8 @@ import os
import sys import sys
from pkgmgr.actions.branch import close_branch from pkgmgr.actions.branch import close_branch
from pkgmgr.core.git import GitRunError, run from pkgmgr.actions.repository.push import push_branch, sign_unpushed
from pkgmgr.core.git import GitRunError
from pkgmgr.core.git.commands import add, commit, tag_annotated from pkgmgr.core.git.commands import add, commit, tag_annotated
from pkgmgr.core.git.queries import get_current_branch from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.repository.paths import resolve_repo_paths from pkgmgr.core.repository.paths import resolve_repo_paths
@@ -136,8 +137,9 @@ def _release_impl(
if preview: if preview:
add(existing_files, preview=True) add(existing_files, preview=True)
commit(commit_msg, all=True, preview=True) commit(commit_msg, all=True, preview=True)
sign_unpushed(".", preview=True)
tag_annotated(new_tag, tag_msg, preview=True) tag_annotated(new_tag, tag_msg, preview=True)
run(["push", "origin", branch, new_tag], preview=True) push_branch(".", [], sign=True, refs=(new_tag,), preview=True)
if is_highest_version_tag(new_tag): if is_highest_version_tag(new_tag):
update_latest_tag(new_tag, preview=True) update_latest_tag(new_tag, preview=True)
@@ -157,10 +159,10 @@ def _release_impl(
add(existing_files, preview=False) add(existing_files, preview=False)
commit(commit_msg, all=True, preview=False) commit(commit_msg, all=True, preview=False)
sign_unpushed(".", preview=False)
tag_annotated(new_tag, tag_msg, preview=False) tag_annotated(new_tag, tag_msg, preview=False)
# Push branch and ONLY the newly created version tag in one command (no --tags) push_branch(".", [], sign=True, refs=(new_tag,), preview=False)
run(["push", "origin", branch, new_tag], preview=False)
# Update 'latest' only if this is the highest version tag # Update 'latest' only if this is the highest version tag
try: try:
+243 -10
View File
@@ -1,39 +1,272 @@
from __future__ import annotations from __future__ import annotations
import os
import subprocess
import threading
from typing import Any from typing import Any
from pkgmgr.actions.repository._parallel import ( from pkgmgr.actions.repository._parallel import (
RepoRef,
resolve_repos, resolve_repos,
run_on_repos, run_on_repos,
) )
from pkgmgr.core.git.commands import GitPushArgsError, push_args from pkgmgr.core.git import GitRunError, run
Repository = dict[str, Any] Repository = dict[str, Any]
# Re-signing opens pinentry; parallel pushes must not ask for the passphrase at once.
_SIGN_LOCK = threading.Lock()
def _push_one(repo_dir: str, extra_args: list[str], preview: bool) -> tuple[bool, str]:
class PushRefusedError(RuntimeError):
"""Raised when a push would ship unsigned commits or overwrite a mirror."""
def _try_git(repo_dir: str, *args: str) -> str | None:
try: try:
push_args(extra_args, cwd=repo_dir, preview=preview) return run(list(args), cwd=repo_dir)
except GitRunError:
return None
def _push_target(repo_dir: str) -> tuple[str, str]:
branch = run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_dir)
if branch == "HEAD":
raise PushRefusedError(f"{repo_dir}: detached HEAD, nothing to push")
remote = (
_try_git(repo_dir, "config", "--get", f"branch.{branch}.pushRemote")
or _try_git(repo_dir, "config", "--get", "remote.pushDefault")
or _try_git(repo_dir, "config", "--get", f"branch.{branch}.remote")
or "origin"
)
return branch, remote
def _unpushed_signatures(repo_dir: str, remote: str) -> tuple[str, list[str]]:
for ref in ("@{u}", f"{remote}/main", f"{remote}/master"):
base = _try_git(repo_dir, "merge-base", "HEAD", ref)
if base:
return base, run(
["log", "--format=%G?", f"{base}..HEAD"], cwd=repo_dir
).split()
raise PushRefusedError(
f"{repo_dir}: no upstream and no {remote}/main or {remote}/master to sign against"
)
def sign_unpushed(repo_dir: str, *, preview: bool = False) -> None:
"""
GPG-sign every unpushed commit that is not signed yet, like git-sign-push.
Args:
repo_dir: checkout whose current branch is signed.
preview: print the fetch and rebase instead of running them.
"""
_branch, remote = _push_target(repo_dir)
run(["fetch", "--quiet", remote], cwd=repo_dir, preview=preview)
base, signatures = _unpushed_signatures(repo_dir, remote)
unsigned = sum(1 for s in signatures if s != "G")
if not unsigned:
return
if run(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir):
raise PushRefusedError(f"{repo_dir}: uncommitted changes, cannot re-sign")
cmd = ["git", "rebase", "--rebase-merges", "--force-rebase", "-S", base]
print(f"[push] Signing {unsigned} of {len(signatures)} commit(s) in {repo_dir}")
if preview:
print(f"[PREVIEW] Would run in {repo_dir!r}: {' '.join(cmd)}")
return
with _SIGN_LOCK:
result = subprocess.run(
cmd,
cwd=repo_dir,
env={**os.environ, "GIT_SEQUENCE_EDITOR": ":"},
check=False,
)
if result.returncode != 0:
raise PushRefusedError(
f"{repo_dir}: re-sign rebase failed; resolve or `git rebase --abort`"
)
def _lease(
repo_dir: str,
url: str,
branch_ref: str,
tracking: str | None,
keep: tuple[str, ...],
preview: bool,
) -> str:
if tracking is not None:
sha = _try_git(repo_dir, "rev-parse", "--verify", "--quiet", tracking) or ""
else:
listed = run(["ls-remote", url, branch_ref], cwd=repo_dir).splitlines()
sha = next(
(ln.split()[0] for ln in listed if ln.split()[1:] == [branch_ref]), ""
)
if sha and not preview:
run(["fetch", "--quiet", "--no-tags", url, branch_ref], cwd=repo_dir)
if not sha or preview:
return sha
lost: set[str] | None = None
for tip in keep:
plus = {
line[2:]
for line in run(["cherry", tip, sha], cwd=repo_dir).splitlines()
if line.startswith("+")
}
lost = plus if lost is None else lost & plus
if lost:
raise PushRefusedError(
f"{url}: {len(lost)} commit(s) exist only there, not overwritten: "
f"{' '.join(s[:10] for s in sorted(lost))}; "
"pass --rewrite for a deliberate history rewrite"
)
return sha
def push_branch(
repo_dir: str,
extra_args: list[str],
*,
sign: bool,
refs: tuple[str, ...] = (),
rewrite: bool = False,
preview: bool = False,
) -> None:
"""
Push the current branch to every pushurl of its push remote, each with its own lease.
The fetch URL is leased against the remote-tracking ref, every other
pushurl against its own branch tip; each only after `git cherry` shows it
holds no commit the new tip lacks. A mirror failure is reported after the
others were pushed; a canonical failure stops before any mirror.
Args:
repo_dir: checkout whose current branch is pushed.
extra_args: flags appended to every `git push`.
sign: refuse unsigned commits in the push range; False pushes them as they are.
refs: extra refs (e.g. a release tag) pushed to every URL.
rewrite: replace commits of the old canonical tip; the fetch URL skips
the cherry check, mirrors only refuse commits the old tip never had.
preview: print the pushes instead of running them.
"""
branch, remote = _push_target(repo_dir)
if sign:
_base, signatures = _unpushed_signatures(repo_dir, remote)
if not preview and any(s != "G" for s in signatures):
raise PushRefusedError(
f"{repo_dir}: {sum(s != 'G' for s in signatures)} unsigned commit(s) "
"in the push range; run `pkgmgr push` to sign them"
)
head = run(["rev-parse", "HEAD"], cwd=repo_dir)
fetch_url = run(["remote", "get-url", remote], cwd=repo_dir)
push_urls = run(
["remote", "get-url", "--push", "--all", remote], cwd=repo_dir
).splitlines()
branch_ref = f"refs/heads/{branch}"
tracking = f"refs/remotes/{remote}/{branch}"
old_tip = _try_git(repo_dir, "rev-parse", "--verify", "--quiet", tracking)
if fetch_url not in push_urls:
print(
f"[push] WARN {remote}: fetch URL is no pushurl; every URL is leased "
"as a mirror, tracking ref and upstream stay untouched"
)
failed: list[str] = []
for url in sorted(push_urls, key=lambda u: u != fetch_url):
if url == fetch_url:
keep: tuple[str, ...] = () if rewrite else (head,)
else:
keep = (head, old_tip) if rewrite and old_tip else (head,)
try:
expect = _lease(
repo_dir,
url,
branch_ref,
tracking if url == fetch_url else None,
keep,
preview,
)
run(
[
"push",
"--follow-tags",
f"--force-with-lease={branch_ref}:{expect}",
*extra_args,
url,
branch_ref,
*refs,
],
cwd=repo_dir,
preview=preview,
)
except (GitRunError, PushRefusedError) as exc:
if url == fetch_url:
raise
print(f"[push] FAILED {url}\n{exc}")
failed.append(url)
continue
print(f"[push] {url} <- {branch} {head[:10]}")
if url == fetch_url and not preview:
run(["update-ref", tracking, head], cwd=repo_dir)
if not _try_git(repo_dir, "rev-parse", "--abbrev-ref", "@{u}"):
run(["branch", f"--set-upstream-to={remote}/{branch}"], cwd=repo_dir)
if failed:
raise PushRefusedError(f"{repo_dir}: push failed for {', '.join(failed)}")
def _push_one(
repo_dir: str, extra_args: list[str], sign: bool, rewrite: bool, preview: bool
) -> tuple[bool, str]:
try:
if sign:
sign_unpushed(repo_dir, preview=preview)
push_branch(repo_dir, extra_args, sign=sign, rewrite=rewrite, preview=preview)
return (True, "") return (True, "")
except GitPushArgsError as exc: except (GitRunError, PushRefusedError) as exc:
return (False, str(exc)) return (False, str(exc))
def push_repo_dirs(
repos: list[RepoRef],
extra_args: list[str],
preview: bool,
sign: bool,
jobs: int = 1,
rewrite: bool = False,
) -> None:
"""
Sign and push each ``(identifier, repo_dir)``, optionally in parallel.
"""
run_on_repos(
repos,
lambda rd: _push_one(rd, extra_args, sign, rewrite, preview),
jobs=jobs,
op_name="push",
)
def push_in_parallel( def push_in_parallel(
selected_repos: list[Repository], selected_repos: list[Repository],
repositories_base_dir: str, repositories_base_dir: str,
all_repos: list[Repository], all_repos: list[Repository],
extra_args: list[str], extra_args: list[str],
preview: bool, preview: bool,
sign: bool,
jobs: int = 1, jobs: int = 1,
rewrite: bool = False,
) -> None: ) -> None:
""" """
Execute `git push` for each repository, optionally in parallel. Sign and push each selected repository, optionally in parallel.
""" """
repos = resolve_repos(selected_repos, repositories_base_dir, all_repos) push_repo_dirs(
run_on_repos( resolve_repos(selected_repos, repositories_base_dir, all_repos),
repos, extra_args,
lambda rd: _push_one(rd, extra_args, preview), preview,
sign,
jobs=jobs, jobs=jobs,
op_name="push", rewrite=rewrite,
) )
+11 -8
View File
@@ -32,7 +32,9 @@ def handle_branch(args, ctx: CLIContext) -> None:
- pkgmgr branch close [<name>] [--base <branch>] [--force|-f] - pkgmgr branch close [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch drop [<name>] [--base <branch>] [--force|-f] - pkgmgr branch drop [<name>] [--base <branch>] [--force|-f]
- pkgmgr branch gc [--apply] [--json] [--all] [--branch N]... [--exclude N]... - pkgmgr branch gc [--apply] [--json] [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS]
- pkgmgr branch purge [--all] [--branch N]... [--exclude N]... - pkgmgr branch purge [--all] [--branch N]... [--exclude N]...
[--merged-into REF]... [--sizes] [--min-age HOURS]
""" """
if args.subcommand in ("gc", "purge"): if args.subcommand in ("gc", "purge"):
try: try:
@@ -40,16 +42,17 @@ def handle_branch(args, ctx: CLIContext) -> None:
except GitBaseError as exc: except GitBaseError as exc:
print(f"Not inside a git repository: {exc}", file=sys.stderr) print(f"Not inside a git repository: {exc}", file=sys.stderr)
sys.exit(2) sys.exit(2)
options = {
"include": args.branch,
"exclude": args.exclude,
"merged_into": args.merged_into,
"sizes": args.sizes,
"min_age": args.min_age,
}
if args.subcommand == "gc": if args.subcommand == "gc":
code = branch_gc( code = branch_gc(roots, apply=args.apply, as_json=args.json, **options)
roots,
apply=args.apply,
as_json=args.json,
include=args.branch,
exclude=args.exclude,
)
else: else:
code = branch_purge(roots, include=args.branch, exclude=args.exclude) code = branch_purge(roots, **options)
sys.exit(code) sys.exit(code)
if args.subcommand == "open": if args.subcommand == "open":
+22
View File
@@ -140,3 +140,25 @@ def add_branch_subparsers(
metavar="NAME", metavar="NAME",
help="Skip this branch (or worktree path); repeatable", help="Skip this branch (or worktree path); repeatable",
) )
sub.add_argument(
"--merged-into",
action="append",
default=[],
metavar="REF",
help=(
"Also count an item as merged when its commits are in REF (an "
"integration line); REF is protected and a checkout at its tip "
"counts as fresh; repeatable"
),
)
sub.add_argument(
"--sizes",
action="store_true",
help="Measure each worktree's disk usage (du) into the plan",
)
sub.add_argument(
"--min-age",
type=float,
metavar="HOURS",
help="Skip items whose newest commit or worktree index is younger",
)
+39 -1
View File
@@ -8,8 +8,9 @@ from typing import Any
from pkgmgr.actions.proxy import exec_proxy_command from pkgmgr.actions.proxy import exec_proxy_command
from pkgmgr.actions.repository.clone import clone_repos from pkgmgr.actions.repository.clone import clone_repos
from pkgmgr.actions.repository.pull import pull_with_verification from pkgmgr.actions.repository.pull import pull_with_verification
from pkgmgr.actions.repository.push import push_in_parallel from pkgmgr.actions.repository.push import push_in_parallel, push_repo_dirs
from pkgmgr.cli.context import CLIContext from pkgmgr.cli.context import CLIContext
from pkgmgr.core.git import GitRunError, run
from pkgmgr.core.repository.dir import get_repo_dir from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.selected import get_selected_repos from pkgmgr.core.repository.selected import get_selected_repos
@@ -185,6 +186,25 @@ def register_proxy_commands(
"(default: min(cpu_count, 8)). Use 1 for sequential." "(default: min(cpu_count, 8)). Use 1 for sequential."
), ),
) )
if subcommand == "push":
parser.add_argument(
"--rewrite",
action="store_true",
default=False,
help=(
"Deliberate history rewrite: replace commits the remote "
"already has (amend, squash, drop); leases still apply."
),
)
parser.add_argument(
"--no-sign",
action="store_true",
default=False,
help=(
"Push unsigned commits as they are instead of re-signing "
"them; leases still apply."
),
)
if subcommand == "clone": if subcommand == "clone":
parser.add_argument( parser.add_argument(
"--clone-mode", "--clone-mode",
@@ -209,6 +229,22 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool:
if args.command not in all_proxy_subcommands: if args.command not in all_proxy_subcommands:
return False return False
if args.command == "push" and not _proxy_has_explicit_selection(args):
try:
toplevel = run(["rev-parse", "--show-toplevel"])
except GitRunError:
print("[ERROR] Current directory is not inside a git checkout.")
sys.exit(1)
push_repo_dirs(
[(toplevel, toplevel)],
args.extra_args,
args.preview,
not args.no_sign,
jobs=args.jobs,
rewrite=args.rewrite,
)
sys.exit(0)
# Default semantics: without explicit selection → repo of current folder. # Default semantics: without explicit selection → repo of current folder.
if _proxy_has_explicit_selection(args): if _proxy_has_explicit_selection(args):
selected = get_selected_repos(args, ctx.all_repositories) selected = get_selected_repos(args, ctx.all_repositories)
@@ -251,7 +287,9 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool:
ctx.all_repositories, ctx.all_repositories,
args.extra_args, args.extra_args,
args.preview, args.preview,
not args.no_sign,
jobs=args.jobs, jobs=args.jobs,
rewrite=args.rewrite,
) )
else: else:
exec_proxy_command( exec_proxy_command(
-3
View File
@@ -19,7 +19,6 @@ from .pull import GitPullError, pull
from .pull_args import GitPullArgsError, pull_args from .pull_args import GitPullArgsError, pull_args
from .pull_ff_only import GitPullFfOnlyError, pull_ff_only from .pull_ff_only import GitPullFfOnlyError, pull_ff_only
from .push import GitPushError, push from .push import GitPushError, push
from .push_args import GitPushArgsError, push_args
from .push_upstream import GitPushUpstreamError, push_upstream from .push_upstream import GitPushUpstreamError, push_upstream
from .set_remote_url import GitSetRemoteUrlError, set_remote_url from .set_remote_url import GitSetRemoteUrlError, set_remote_url
from .tag_annotated import GitTagAnnotatedError, tag_annotated from .tag_annotated import GitTagAnnotatedError, tag_annotated
@@ -43,7 +42,6 @@ __all__ = [
"GitPullArgsError", "GitPullArgsError",
"GitPullError", "GitPullError",
"GitPullFfOnlyError", "GitPullFfOnlyError",
"GitPushArgsError",
"GitPushError", "GitPushError",
"GitPushUpstreamError", "GitPushUpstreamError",
"GitSetRemoteUrlError", "GitSetRemoteUrlError",
@@ -67,7 +65,6 @@ __all__ = [
"pull_args", "pull_args",
"pull_ff_only", "pull_ff_only",
"push", "push",
"push_args",
"push_upstream", "push_upstream",
"set_remote_url", "set_remote_url",
"tag_annotated", "tag_annotated",
-37
View File
@@ -1,37 +0,0 @@
from __future__ import annotations
from ..errors import GitCommandError, GitRunError
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
+103 -6
View File
@@ -98,12 +98,40 @@ class TestBranchGc(unittest.TestCase):
def _lock(self, reason: str) -> None: def _lock(self, reason: str) -> None:
git(self.repo, "worktree", "lock", "--reason", reason, self.locked_wt) git(self.repo, "worktree", "lock", "--reason", reason, self.locked_wt)
def _plan(self, host_ns: bool, cwds: dict[int, str]) -> gc.Repo: def _plan(
self,
host_ns: bool,
cwds: dict[int, str],
merged_into: list[str] | None = None,
sizes: bool = False,
min_age: float | None = None,
) -> gc.Repo:
with ( with (
patch.object(gc, "fetch_forge_facts", return_value=self.forge), patch.object(gc, "fetch_forge_facts", return_value=self.forge),
patch.object(gc, "_process_cwds", return_value=cwds), patch.object(gc, "_process_cwds", return_value=cwds),
): ):
return gc.plan_repo(self.repo, [], host_ns) return gc.plan_repo(
self.repo,
[],
host_ns,
merged_into=merged_into or [],
sizes=sizes,
min_age=min_age,
)
def _integration_line(self) -> str:
git(self.repo, "checkout", "-b", "line", "main")
self._commit("line-base")
git(self.repo, "checkout", "-b", "feat", "line")
self._commit("feat")
git(self.repo, "checkout", "line")
git(self.repo, "merge", "--no-ff", "-m", "merge feat", "feat")
git(self.repo, "push", "origin", "line", "feat")
git(self.repo, "branch", "start", "line")
git(self.repo, "checkout", "main")
feat_wt = os.path.join(self.root, "wt-feat")
git(self.repo, "worktree", "add", feat_wt, "feat")
return feat_wt
def _items(self, plan: gc.Repo) -> dict[str, gc.Item]: def _items(self, plan: gc.Repo) -> dict[str, gc.Item]:
return {item.branch: item for item in plan.items if item.branch} return {item.branch: item for item in plan.items if item.branch}
@@ -127,6 +155,43 @@ class TestBranchGc(unittest.TestCase):
self.assertEqual(items["wip"].skip, "open-pr #3") self.assertEqual(items["wip"].skip, "open-pr #3")
self.assertEqual(items["dirty"].state, "dirty") self.assertEqual(items["dirty"].state, "dirty")
def test_merged_into_counts_commits_in_an_integration_line(self) -> None:
self._integration_line()
before = self._items(self._plan(host_ns=True, cwds={}))
self.assertEqual(before["feat"].state, "unmerged")
self.assertIsNone(before["feat"].merged_into)
for ref in ("line", "origin/line"):
with self.subTest(ref=ref):
items = self._items(
self._plan(host_ns=True, cwds={}, merged_into=[ref])
)
self.assertEqual(items["feat"].state, "merged-clean")
self.assertEqual(items["feat"].merged_by, f"line:{ref}")
self.assertEqual(items["feat"].merged_into, ref)
self.assertTrue(items["feat"].eligible)
self.assertEqual(items["line"].state, "protected")
self.assertEqual(items["start"].state, "unmerged")
self.assertEqual(items["merged"].merged_into, "origin/main")
def test_unknown_merged_into_ref_is_a_warning(self) -> None:
plan = self._plan(host_ns=True, cwds={}, merged_into=["no-such-line"])
self.assertIn("merged-into ref no-such-line not found", plan.warnings)
self.assertEqual(self._items(plan)["merged"].state, "merged-clean")
def test_sizes_and_min_age(self) -> None:
items = self._items(self._plan(host_ns=True, cwds={}, sizes=True))
self.assertGreater(items["merged"].size_bytes, 0)
self.assertIsNone(items["squashed"].size_bytes)
self.assertIn("size ", gc._line(items["merged"]))
young = self._items(self._plan(host_ns=True, cwds={}, min_age=1000))
self.assertEqual(young["merged"].skip, "younger than 1000 h")
self.assertFalse(young["merged"].eligible)
old = self._items(self._plan(host_ns=True, cwds={}, min_age=0))
self.assertIsNone(old["merged"].skip)
def test_busy_worktree_is_skipped(self) -> None: def test_busy_worktree_is_skipped(self) -> None:
cwds = {4242: os.path.join(self.merged_wt, "sub")} cwds = {4242: os.path.join(self.merged_wt, "sub")}
items = self._items(self._plan(host_ns=True, cwds=cwds)) items = self._items(self._plan(host_ns=True, cwds=cwds))
@@ -276,16 +341,34 @@ class TestBranchGc(unittest.TestCase):
redirect_stdout(out), redirect_stdout(out),
): ):
code = gc.branch_gc( code = gc.branch_gc(
[self.repo], apply=False, as_json=True, include=["merged"], exclude=[] [self.repo],
apply=False,
as_json=True,
include=["merged"],
exclude=[],
merged_into=["origin/main"],
sizes=True,
min_age=None,
) )
data = json.loads(out.getvalue()) data = json.loads(out.getvalue())
self.assertEqual(code, 0) self.assertEqual(code, 0)
self.assertEqual([i["branch"] for i in data["repos"][0]["items"]], ["merged"]) self.assertEqual(data["repos"][0]["merged_into"], ["origin/main"])
items = data["repos"][0]["items"]
self.assertEqual([i["branch"] for i in items], ["merged"])
self.assertEqual(items[0]["merged_into"], "origin/main")
self.assertGreater(items[0]["size_bytes"], 0)
with patch.object(gc, "host_pid_namespace", return_value=False): with patch.object(gc, "host_pid_namespace", return_value=False):
self.assertEqual( self.assertEqual(
gc.branch_gc( gc.branch_gc(
[self.repo], apply=True, as_json=False, include=[], exclude=[] [self.repo],
apply=True,
as_json=False,
include=[],
exclude=[],
merged_into=[],
sizes=False,
min_age=None,
), ),
2, 2,
) )
@@ -303,6 +386,13 @@ class TestBranchGc(unittest.TestCase):
"b", "b",
"--exclude", "--exclude",
"c", "c",
"--merged-into",
"origin/line",
"--merged-into",
"fork/line",
"--sizes",
"--min-age",
"24",
] ]
) )
with ( with (
@@ -314,7 +404,14 @@ class TestBranchGc(unittest.TestCase):
self.assertEqual(exit_ctx.exception.code, 1) self.assertEqual(exit_ctx.exception.code, 1)
run_gc.assert_called_once_with( run_gc.assert_called_once_with(
[self.repo], apply=True, as_json=True, include=["a", "b"], exclude=["c"] [self.repo],
apply=True,
as_json=True,
include=["a", "b"],
exclude=["c"],
merged_into=["origin/line", "fork/line"],
sizes=True,
min_age=24.0,
) )
@@ -0,0 +1,116 @@
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
BASE = "--build-arg BASE_IMAGE=ghcr.io/kevinveenbirkenbach/base-{distro}:latest ."
class TestBuildImagePublish(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
tmp = Path(self._tmp.name)
self.log = tmp / "docker.log"
fake = tmp / "docker"
fake.write_text('#!/usr/bin/env bash\necho "$*" >> "$FAKE_DOCKER_LOG"\n')
fake.chmod(0o755)
self.env = {
k: v for k, v in os.environ.items() if not k.startswith("BASE_IMAGES_")
}
self.env["PATH"] = f"{tmp}{os.pathsep}{os.environ['PATH']}"
self.env["FAKE_DOCKER_LOG"] = str(self.log)
def tearDown(self) -> None:
self._tmp.cleanup()
def _run(self, script: str, distro: str, *args: str, **env: str):
result = subprocess.run(
["bash", f"scripts/build/{script}", *args],
check=False,
cwd=REPO_ROOT,
env={**self.env, "PKGMGR_DISTRO": distro, **env},
capture_output=True,
text=True,
)
calls = self.log.read_text().splitlines() if self.log.exists() else []
return result, calls
def _publish(self, distro: str, *args: str):
return self._run(
"image.sh",
distro,
"--publish",
"--registry",
"ghcr.io",
"--owner",
"o",
"--version",
"2.1.2",
*args,
)
def test_platform_pushes_only_the_arch_tag(self) -> None:
result, calls = self._publish("debian", "--platform", "linux/arm64")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
calls,
[
(
"buildx build --push -t ghcr.io/o/pkgmgr-debian:2.1.2-arm64 "
"--platform linux/arm64 "
)
+ BASE.format(distro="debian")
],
)
def test_merge_joins_arch_tags_under_release_and_alias_tags(self) -> None:
result, calls = self._publish("arch", "--stable", "true", "--merge")
self.assertEqual(result.returncode, 0, result.stderr)
tags = " ".join(
f"-t ghcr.io/o/{image}:{tag}"
for image in ("pkgmgr-arch", "pkgmgr")
for tag in ("latest", "2.1.2", "stable")
)
self.assertEqual(
calls,
[
(
f"buildx imagetools create {tags} "
"ghcr.io/o/pkgmgr-arch:2.1.2-amd64 ghcr.io/o/pkgmgr-arch:2.1.2-arm64"
),
"buildx imagetools inspect ghcr.io/o/pkgmgr-arch:2.1.2",
],
)
def test_unknown_platform_and_merge_without_publish_are_refused(self) -> None:
unknown, _ = self._publish("debian", "--platform", "linux/s390x")
no_publish, calls = self._run("image.sh", "debian", "--merge")
self.assertEqual(unknown.returncode, 2)
self.assertEqual(no_publish.returncode, 2)
self.assertEqual(calls, [])
def test_publish_builds_each_platform_then_merges(self) -> None:
result, calls = self._run(
"publish.sh", "fedora", OWNER="o", VERSION="2.1.2", DISTROS="fedora"
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
[c.split(" -t ")[0] for c in calls],
["buildx build --push", "buildx build --push", "buildx imagetools create"]
+ ["buildx imagetools inspect ghcr.io/o/pkgmgr-fedora:2.1.2"],
)
self.assertIn("--platform linux/amd64", calls[0])
self.assertIn("--platform linux/arm64", calls[1])
self.assertNotIn(":stable", calls[2])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,221 @@
from __future__ import annotations
import io
import os
import shutil
import subprocess
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from pkgmgr.actions.release.retry import retry_release
from pkgmgr.actions.repository.push import (
PushRefusedError,
push_branch,
sign_unpushed,
)
from pkgmgr.cli.context import CLIContext
from pkgmgr.cli.parser import create_parser
from pkgmgr.cli.proxy import maybe_handle_proxy
def git(cwd: str, *args: str) -> str:
return subprocess.run(
["git", *args], cwd=cwd, check=True, capture_output=True, text=True
).stdout.strip()
@unittest.skipUnless(shutil.which("ssh-keygen"), "ssh-keygen signs the commits")
class TestPushMirrorLeases(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
root = Path(os.path.realpath(self._tmp.name))
self.root = str(root)
key = root / "key"
subprocess.run(
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)],
check=True,
)
(root / "allowed").write_text(f"t@example.org {(root / 'key.pub').read_text()}")
(root / "gitconfig").write_text(
"[user]\n\tname = t\n\temail = t@example.org\n"
f"\tsigningkey = {key}\n"
"[gpg]\n\tformat = ssh\n"
f'[gpg "ssh"]\n\tallowedSignersFile = {root / "allowed"}\n'
"[commit]\n\tgpgsign = false\n"
"[init]\n\tdefaultBranch = main\n"
)
env = patch.dict(
os.environ,
{"GIT_CONFIG_GLOBAL": str(root / "gitconfig"), "GIT_CONFIG_NOSYSTEM": "1"},
)
env.start()
self.addCleanup(env.stop)
self.canonical = str(root / "canonical.git")
self.mirror = str(root / "mirror.git")
self.repo = str(root / "repo")
git(self.root, "init", "-q", "--bare", self.canonical)
git(self.root, "init", "-q", "--bare", self.mirror)
git(self.root, "init", "-q", self.repo)
git(self.repo, "remote", "add", "origin", self.canonical)
git(self.repo, "remote", "set-url", "--add", "--push", "origin", self.canonical)
git(self.repo, "remote", "set-url", "--add", "--push", "origin", self.mirror)
self.init = self._commit("init")
git(self.repo, "push", "-q", self.canonical, "main")
git(self.repo, "push", "-q", self.mirror, "main")
git(self.repo, "fetch", "-q", "origin")
git(self.repo, "branch", "-q", "--set-upstream-to=origin/main")
def _commit(self, name: str, cwd: str | None = None, *flags: str) -> str:
cwd = cwd or self.repo
Path(cwd, f"{name}.txt").write_text(name)
git(cwd, "add", f"{name}.txt")
git(cwd, "commit", "-q", *flags, "-m", name)
return git(cwd, "rev-parse", "HEAD")
def _push(self, rewrite: bool = False) -> str:
with redirect_stdout(io.StringIO()) as out:
sign_unpushed(self.repo)
push_branch(self.repo, [], sign=True, rewrite=rewrite)
return out.getvalue()
def _cli_push(self, cwd: str, *argv: str) -> None:
ctx = CLIContext(
config_merged={},
repositories_base_dir=os.path.join(self.root, "base"),
all_repositories=[],
binaries_dir=os.path.join(self.root, "bin"),
user_config_path=os.path.join(self.root, "config.yaml"),
)
args = create_parser("pkgmgr").parse_args(["push", "-j", "1", *argv])
prev = os.getcwd()
os.chdir(cwd)
self.addCleanup(os.chdir, prev)
with redirect_stdout(io.StringIO()), self.assertRaises(SystemExit) as cm:
maybe_handle_proxy(args, ctx)
self.assertEqual(cm.exception.code, 0)
def _tip(self, bare: str, ref: str = "refs/heads/main") -> str:
return subprocess.run(
["git", "rev-parse", "--verify", "--quiet", ref],
cwd=bare,
check=False,
capture_output=True,
text=True,
).stdout.strip()
def _assert_shipped_signed(self) -> None:
head = git(self.repo, "rev-parse", "HEAD")
self.assertEqual(self._tip(self.canonical), head)
self.assertEqual(self._tip(self.mirror), head)
signatures = git(self.repo, "log", "--format=%G?", f"{self.init}..HEAD")
self.assertEqual(set(signatures.split()), {"G"})
def test_mirror_equal_gets_the_signed_commit(self) -> None:
self._commit("feature")
self._push()
self._assert_shipped_signed()
def test_mirror_behind_is_fast_forwarded(self) -> None:
self._commit("first", None, "-S")
git(self.repo, "push", "-q", self.canonical, "main")
git(self.repo, "fetch", "-q", "origin")
self._commit("second")
self._push()
self._assert_shipped_signed()
def test_mirror_on_older_rewrite_is_replaced_after_resign(self) -> None:
unsigned = self._commit("feature")
git(self.repo, "push", "-q", self.mirror, "main")
self._push()
self._assert_shipped_signed()
self.assertNotEqual(self._tip(self.mirror), unsigned)
def test_mirror_only_commit_is_refused_and_kept(self) -> None:
other = os.path.join(self.root, "other")
git(self.root, "clone", "-q", self.mirror, other)
foreign = self._commit("foreign", other)
git(other, "push", "-q", "origin", "main")
self._commit("feature")
with self.assertRaises(PushRefusedError) as cm:
self._push()
with self.assertRaises(PushRefusedError):
self._push(rewrite=True)
self.assertIn(self.mirror, str(cm.exception))
self.assertEqual(self._tip(self.mirror), foreign)
self.assertEqual(self._tip(self.canonical), git(self.repo, "rev-parse", "HEAD"))
def test_content_rewrite_needs_the_rewrite_flag(self) -> None:
self._commit("feature")
self._push()
shipped = git(self.repo, "rev-parse", "HEAD")
Path(self.repo, "feature.txt").write_text("changed")
git(self.repo, "commit", "-q", "-a", "--amend", "--no-edit")
with self.assertRaises(PushRefusedError) as cm:
self._push()
self.assertIn(self.canonical, str(cm.exception))
self.assertEqual(self._tip(self.canonical), shipped)
self.assertEqual(self._tip(self.mirror), shipped)
self._push(rewrite=True)
self._assert_shipped_signed()
self.assertNotEqual(self._tip(self.mirror), shipped)
def test_worktree_outside_base_dir_is_pushed(self) -> None:
worktree = os.path.join(self.root, "wt")
git(self.repo, "worktree", "add", "-q", "-b", "feature", worktree)
head_before = self._commit("feature", worktree)
self._cli_push(worktree)
head = git(worktree, "rev-parse", "HEAD")
self.assertNotEqual(head, head_before)
self.assertEqual(self._tip(self.canonical, "refs/heads/feature"), head)
self.assertEqual(self._tip(self.mirror, "refs/heads/feature"), head)
self.assertEqual(
git(worktree, "rev-parse", "--abbrev-ref", "@{u}"), "origin/feature"
)
def test_no_sign_pushes_unsigned_commits_to_every_url(self) -> None:
unsigned = self._commit("feature")
self._cli_push(self.repo, "--no-sign")
self.assertEqual(git(self.repo, "rev-parse", "HEAD"), unsigned)
self.assertEqual(self._tip(self.canonical), unsigned)
self.assertEqual(self._tip(self.mirror), unsigned)
self.assertEqual(git(self.repo, "log", "-1", "--format=%G?"), "N")
def test_release_retry_refuses_unsigned_range(self) -> None:
self._commit("release")
git(self.repo, "tag", "-a", "v1.0.0", "-m", "v1.0.0")
cwd = os.getcwd()
os.chdir(self.repo)
self.addCleanup(os.chdir, cwd)
with redirect_stdout(io.StringIO()), self.assertRaises(PushRefusedError):
retry_release(pyproject_path=os.path.join(self.repo, "pyproject.toml"))
self.assertEqual(self._tip(self.canonical), git(self.repo, "rev-parse", "@{u}"))
self.assertEqual(self._tip(self.canonical, "refs/tags/v1.0.0"), "")
if __name__ == "__main__":
unittest.main()
+10 -10
View File
@@ -65,7 +65,7 @@ class TestReleaseRetry(unittest.TestCase):
class TestRetryRelease(unittest.TestCase): class TestRetryRelease(unittest.TestCase):
@patch("pkgmgr.actions.release.retry.update_latest_tag") @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.is_highest_version_tag", return_value=True)
@patch("pkgmgr.actions.release.retry.run") @patch("pkgmgr.actions.release.retry.push_branch")
@patch("pkgmgr.actions.release.retry.head_semver_tags") @patch("pkgmgr.actions.release.retry.head_semver_tags")
@patch( @patch(
"pkgmgr.actions.release.retry.get_current_branch", "pkgmgr.actions.release.retry.get_current_branch",
@@ -75,7 +75,7 @@ class TestRetryRelease(unittest.TestCase):
self, self,
_mock_branch, _mock_branch,
mock_head_tags, mock_head_tags,
mock_run, mock_push,
_mock_highest, _mock_highest,
mock_update_latest, mock_update_latest,
) -> None: ) -> None:
@@ -83,14 +83,14 @@ class TestRetryRelease(unittest.TestCase):
retry_release(pyproject_path="pyproject.toml", preview=False) retry_release(pyproject_path="pyproject.toml", preview=False)
mock_run.assert_called_once_with( mock_push.assert_called_once_with(
["push", "origin", "release-fix", "v1.13.4"], preview=False ".", [], sign=True, refs=("v1.13.4",), preview=False
) )
mock_update_latest.assert_called_once_with("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.update_latest_tag")
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=False) @patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=False)
@patch("pkgmgr.actions.release.retry.run") @patch("pkgmgr.actions.release.retry.push_branch")
@patch("pkgmgr.actions.release.retry.head_semver_tags") @patch("pkgmgr.actions.release.retry.head_semver_tags")
@patch( @patch(
"pkgmgr.actions.release.retry.get_current_branch", "pkgmgr.actions.release.retry.get_current_branch",
@@ -100,7 +100,7 @@ class TestRetryRelease(unittest.TestCase):
self, self,
_mock_branch, _mock_branch,
mock_head_tags, mock_head_tags,
_mock_run, _mock_push,
_mock_highest, _mock_highest,
mock_update_latest, mock_update_latest,
) -> None: ) -> None:
@@ -127,7 +127,7 @@ class TestRetryRelease(unittest.TestCase):
@patch("pkgmgr.actions.release.retry.update_latest_tag") @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.is_highest_version_tag", return_value=True)
@patch("pkgmgr.actions.release.retry.run") @patch("pkgmgr.actions.release.retry.push_branch")
@patch("pkgmgr.actions.release.retry.head_semver_tags") @patch("pkgmgr.actions.release.retry.head_semver_tags")
@patch( @patch(
"pkgmgr.actions.release.retry.get_current_branch", "pkgmgr.actions.release.retry.get_current_branch",
@@ -137,7 +137,7 @@ class TestRetryRelease(unittest.TestCase):
self, self,
_mock_branch, _mock_branch,
mock_head_tags, mock_head_tags,
mock_run, mock_push,
_mock_highest, _mock_highest,
_mock_update_latest, _mock_update_latest,
) -> None: ) -> None:
@@ -145,8 +145,8 @@ class TestRetryRelease(unittest.TestCase):
retry_release(pyproject_path="pyproject.toml", preview=False) retry_release(pyproject_path="pyproject.toml", preview=False)
mock_run.assert_called_once_with( mock_push.assert_called_once_with(
["push", "origin", "main", "v2.0.0"], preview=False ".", [], sign=True, refs=("v2.0.0",), preview=False
) )