Compare commits
7
Commits
810d27badc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1a951ee94 | ||
|
|
f40cc5cfde | ||
|
|
944b4cb663 | ||
|
|
919e6a6434 | ||
|
|
4a4bf137ac | ||
|
|
ea82d91b75 | ||
|
|
1c0a4abcbb |
No files matched your search
@@ -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
|
||||||
@@ -1,5 +1,61 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.2.0] - 2026-11-01
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- Push: *--no-sign* pushes unsigned commits as they are; leases still apply
|
||||||
|
- Push: *--rewrite* replaces commits a remote already has (amend, squash)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
|
||||||
|
- Push: each pushurl gets its own lease; mirrors no longer fail as stale
|
||||||
|
- Push: a commit that exists only on a mirror stops the push, nothing lost
|
||||||
|
- Push: without a selection it pushes the current checkout or worktree
|
||||||
|
- Release: signs before tagging and pushes branch and tag like *pkgmgr push*
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Container: images build on native amd64 and arm64 runners, not QEMU
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Push: needs a working signing key; pass *--no-sign* to push unsigned
|
||||||
|
- Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
|
||||||
|
- Release: a version file ahead of the newest tag now raises the next version
|
||||||
|
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||||
|
|
||||||
|
## [2.1.1] - 2026-10-31
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Container: no 2.1.0 image reached GHCR; the two notes below carry over
|
||||||
|
- Release: a version file ahead of the newest tag now raises the next version
|
||||||
|
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-10-31
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
|
||||||
|
- Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
|
||||||
|
- Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
|
||||||
|
- Release: Cargo.toml version and its Cargo.lock entry follow the release
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- Release: starts from the highest version in the tag and all version files
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Release: a version file ahead of the newest tag now raises the next version
|
||||||
|
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||||
|
|
||||||
## [2.0.5] - 2026-10-29
|
## [2.0.5] - 2026-10-29
|
||||||
|
|
||||||
Fixed:
|
Fixed:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "2.0.5";
|
version = "2.2.0";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|||||||
@@ -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.0.5
|
pkgver=2.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
|||||||
@@ -1,3 +1,65 @@
|
|||||||
|
package-manager (2.2.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- Push: *--no-sign* pushes unsigned commits as they are; leases still apply
|
||||||
|
- Push: *--rewrite* replaces commits a remote already has (amend, squash)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
|
||||||
|
- Push: each pushurl gets its own lease; mirrors no longer fail as stale
|
||||||
|
- Push: a commit that exists only on a mirror stops the push, nothing lost
|
||||||
|
- Push: without a selection it pushes the current checkout or worktree
|
||||||
|
- Release: signs before tagging and pushes branch and tag like *pkgmgr push*
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Container: images build on native amd64 and arm64 runners, not QEMU
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Push: needs a working signing key; pass *--no-sign* to push unsigned
|
||||||
|
- Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
|
||||||
|
- Release: a version file ahead of the newest tag now raises the next version
|
||||||
|
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 01 Nov 2026 01:55:29 +0100
|
||||||
|
|
||||||
|
package-manager (2.1.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Container: no 2.1.0 image reached GHCR; the two notes below carry over
|
||||||
|
- Release: a version file ahead of the newest tag now raises the next version
|
||||||
|
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 31 Oct 2026 18:47:35 +0100
|
||||||
|
|
||||||
|
package-manager (2.1.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
|
||||||
|
- Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
|
||||||
|
- Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
|
||||||
|
- Release: Cargo.toml version and its Cargo.lock entry follow the release
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- Release: starts from the highest version in the tag and all version files
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Release: a version file ahead of the newest tag now raises the next version
|
||||||
|
- Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Sat, 31 Oct 2026 14:38:54 +0100
|
||||||
|
|
||||||
package-manager (2.0.5-1) unstable; urgency=medium
|
package-manager (2.0.5-1) unstable; urgency=medium
|
||||||
|
|
||||||
Fixed:
|
Fixed:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: package-manager
|
Name: package-manager
|
||||||
Version: 2.0.5
|
Version: 2.2.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,59 @@ 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.2.0-1
|
||||||
|
- Added:
|
||||||
|
|
||||||
|
- - Push: *--no-sign* pushes unsigned commits as they are; leases still apply
|
||||||
|
- - Push: *--rewrite* replaces commits a remote already has (amend, squash)
|
||||||
|
|
||||||
|
- Changed:
|
||||||
|
|
||||||
|
- - Push: *pkgmgr push* signs every unpushed unsigned commit before pushing
|
||||||
|
- - Push: each pushurl gets its own lease; mirrors no longer fail as stale
|
||||||
|
- - Push: a commit that exists only on a mirror stops the push, nothing lost
|
||||||
|
- - Push: without a selection it pushes the current checkout or worktree
|
||||||
|
- - Release: signs before tagging and pushes branch and tag like *pkgmgr push*
|
||||||
|
|
||||||
|
- Fixed:
|
||||||
|
|
||||||
|
- - Container: images build on native amd64 and arm64 runners, not QEMU
|
||||||
|
|
||||||
|
- Upgrade notes:
|
||||||
|
|
||||||
|
- - Push: needs a working signing key; pass *--no-sign* to push unsigned
|
||||||
|
- - Container: no 2.1.0 or 2.1.1 image reached GHCR; notes below carry over
|
||||||
|
- - Release: a version file ahead of the newest tag now raises the next version
|
||||||
|
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||||
|
|
||||||
|
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.1-1
|
||||||
|
- Fixed:
|
||||||
|
|
||||||
|
- - Release: tool-only or dynamic-version pyproject.toml is skipped, no crash
|
||||||
|
|
||||||
|
- Upgrade notes:
|
||||||
|
|
||||||
|
- - Container: no 2.1.0 image reached GHCR; the two notes below carry over
|
||||||
|
- - Release: a version file ahead of the newest tag now raises the next version
|
||||||
|
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||||
|
|
||||||
|
* Sat Oct 31 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.1.0-1
|
||||||
|
- Added:
|
||||||
|
|
||||||
|
- - Branch: *pkgmgr branch gc* removes merged clean branches, worktrees, stacks
|
||||||
|
- - Branch: *gc* only prints its plan; *--apply* acts, *--json* for scripts
|
||||||
|
- - Branch: *pkgmgr branch purge* deletes unmerged or dirty items after y/N
|
||||||
|
- - Release: Cargo.toml version and its Cargo.lock entry follow the release
|
||||||
|
|
||||||
|
- Changed:
|
||||||
|
|
||||||
|
- - Release: starts from the highest version in the tag and all version files
|
||||||
|
|
||||||
|
- Upgrade notes:
|
||||||
|
|
||||||
|
- - Release: a version file ahead of the newest tag now raises the next version
|
||||||
|
- - Branch: *gc --apply* and *purge* refuse to run inside a PID sandbox
|
||||||
|
|
||||||
* Thu Oct 29 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.5-1
|
* Thu Oct 29 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.5-1
|
||||||
- Fixed:
|
- Fixed:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kpmx"
|
name = "kpmx"
|
||||||
version = "2.0.5"
|
version = "2.2.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
@@ -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+=(.)
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -3,6 +3,11 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ImportError:
|
||||||
|
import tomli as tomllib # type: ignore
|
||||||
|
|
||||||
|
|
||||||
def update_pyproject_version(
|
def update_pyproject_version(
|
||||||
pyproject_path: str, new_version: str, preview: bool = False
|
pyproject_path: str, new_version: str, preview: bool = False
|
||||||
@@ -18,6 +23,11 @@ def update_pyproject_version(
|
|||||||
print(f"[WARN] Could not read pyproject.toml: {exc}")
|
print(f"[WARN] Could not read pyproject.toml: {exc}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
project = tomllib.loads(content).get("project")
|
||||||
|
if project is None or "version" in project.get("dynamic", []):
|
||||||
|
print("[INFO] pyproject.toml declares no static version, skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
m = re.search(r"(?ms)^\s*\[project\]\s*$.*?(?=^\s*\[|\Z)", content)
|
m = re.search(r"(?ms)^\s*\[project\]\s*$.*?(?=^\s*\[|\Z)", content)
|
||||||
if not m:
|
if not m:
|
||||||
raise RuntimeError("Missing [project] section in pyproject.toml")
|
raise RuntimeError("Missing [project] section in pyproject.toml")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
+39
-1
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
||||||
@@ -88,12 +88,15 @@ class TestUpdatePyprojectVersion(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("Missing version key", str(cm.exception))
|
self.assertIn("Missing version key", str(cm.exception))
|
||||||
|
|
||||||
def test_update_pyproject_version_raises_when_project_section_missing(self) -> None:
|
def test_update_pyproject_version_skips_tool_only_pyproject(self) -> None:
|
||||||
original = (
|
original = (
|
||||||
textwrap.dedent(
|
textwrap.dedent(
|
||||||
"""
|
"""
|
||||||
name = "example"
|
[dependency-groups]
|
||||||
version = "0.1.0"
|
lint = ["ruff==0.17.3"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
"""
|
"""
|
||||||
).strip()
|
).strip()
|
||||||
+ "\n"
|
+ "\n"
|
||||||
@@ -104,10 +107,38 @@ class TestUpdatePyprojectVersion(unittest.TestCase):
|
|||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
f.write(original)
|
f.write(original)
|
||||||
|
|
||||||
with self.assertRaises(RuntimeError) as cm:
|
update_pyproject_version(path, "1.2.3", preview=False)
|
||||||
update_pyproject_version(path, "1.2.3", preview=False)
|
|
||||||
|
|
||||||
self.assertIn("Missing [project] section", str(cm.exception))
|
with open(path, encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
self.assertEqual(content, original)
|
||||||
|
|
||||||
|
def test_update_pyproject_version_skips_dynamic_version(self) -> None:
|
||||||
|
original = (
|
||||||
|
textwrap.dedent(
|
||||||
|
"""
|
||||||
|
[project]
|
||||||
|
name = "example"
|
||||||
|
dynamic = [
|
||||||
|
"version",
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
).strip()
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "pyproject.toml")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(original)
|
||||||
|
|
||||||
|
update_pyproject_version(path, "1.2.3", preview=False)
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
self.assertEqual(content, original)
|
||||||
|
|
||||||
def test_update_pyproject_version_missing_file_is_skipped(self) -> None:
|
def test_update_pyproject_version_missing_file_is_skipped(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user