Version 0.19.0 across version.py, the Void template and the synced docs; changelog entry; Gitea project URLs in pyproject.toml; per-backend ladder thresholds measured in #19; the xbps release workflow is manual-only, and packaging/void/publish-release.sh publishes a locally built, verified repository to the Gitea releases.
289 lines
13 KiB
YAML
289 lines
13 KiB
YAML
name: xbps-release
|
|
|
|
# Signed Void xbps repository release (tickets #28 and #29). Manual dispatch only.
|
|
#
|
|
# A tag push does NOT start this workflow. Releases are currently built and
|
|
# published from the maintainer's own x86_64 Void host, not from CI: the only
|
|
# runner with the bongbetic-ci label is ARM64 Docker on another server, and it
|
|
# cannot build the glibc x86_64 release. The local sequence is in
|
|
# docs/RELEASE_PROCESS.md (Step 7 and the Void section of Step 8) and
|
|
# packaging/void/README.md ("Releasing"):
|
|
# 1. create and push the annotated tag on the verified origin/main commit;
|
|
# 2. packaging/void/build-release.sh release TAG --key-file FILE --out-dir DIR,
|
|
# in a clean checkout of the tag;
|
|
# 3. packaging/void/publish-release.sh TAG --out-dir DIR, after --dry-run.
|
|
#
|
|
# This job is kept for a future x86_64 host-mode runner with rootless podman. It
|
|
# runs the same release path as the local run, and its publish step makes the
|
|
# same API calls as publish-release.sh. A dispatch's ref is a branch, so the tag
|
|
# gate below refuses it before anything is built or signed.
|
|
#
|
|
# The job's steps: the tag must EXACTLY equal "v" + __version__
|
|
# (src/vocalinux/version.py), and the committed Void template must already name
|
|
# that version; the XBPS_SIGNING_KEY secret must be non-empty and RSA 4096, and
|
|
# its public half must be packaging/void/repository-key.plist; the canonical sdist
|
|
# is built from the tag; every template builds in the pinned Void glibc image with
|
|
# podman, then the repository is signed in a second container with no network,
|
|
# then voculinux is installed and smoked in a clean image with no key; and the
|
|
# staged set is published to the tag's release and to the rolling `latest`
|
|
# release that the client registration URL serves (README.md).
|
|
#
|
|
# Nothing here is built or uploaded by hand, nothing commits or pushes to any
|
|
# branch, and no template is bumped: the post-release template bump is a normal
|
|
# pull request (docs/RELEASE_PROCESS.md section 3.5). The private key lives only
|
|
# as this secret and a 0600 file on tmpfs that the last step removes. Podman
|
|
# only, never Docker. Runner requirements: packaging/void/README.md.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
concurrency:
|
|
group: xbps-release
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
xbps-release:
|
|
name: Build, sign and publish the xbps repository
|
|
runs-on: bongbetic-ci
|
|
timeout-minutes: 240
|
|
env:
|
|
GITEA_API: https://git.bongbetic.com/api/v1/repos/xavierk/voculinux
|
|
# The key file lives on tmpfs (/dev/shm). Its name is fixed per run so the
|
|
# cleanup step can find it; it is created with noclobber, so a file that
|
|
# already exists makes the key step fail rather than be reused.
|
|
XBPS_KEY_FILE: /dev/shm/xbps-sign-key-${{ gitea.run_id }}.pem
|
|
steps:
|
|
- name: Checkout
|
|
env:
|
|
GITEA_TOKEN: ${{ gitea.token }}
|
|
SERVER_URL: ${{ gitea.server_url }}
|
|
REPOSITORY: ${{ gitea.repository }}
|
|
COMMIT_SHA: ${{ gitea.sha }}
|
|
run: |
|
|
git init -q . && git remote add origin "${SERVER_URL}/${REPOSITORY}.git" && git -c http.extraheader="Authorization: token ${GITEA_TOKEN}" fetch -q --depth=1 origin "${COMMIT_SHA}" && git checkout -q FETCH_HEAD
|
|
|
|
# Fail first and cheaply: the gate needs no tools and no secret.
|
|
- name: Require the tag to equal the version in version.py
|
|
shell: bash
|
|
env:
|
|
TAG: ${{ gitea.ref_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
packaging/void/build-release.sh check-tag "$TAG" || {
|
|
echo "::error::tag ${TAG} does not match src/vocalinux/version.py and the committed Void template; refusing to publish" >&2
|
|
exit 1
|
|
}
|
|
|
|
# Fail closed: no key, no repository. The key is written with noclobber
|
|
# (set -C) to a tmpfs file with mode 0600, and never echoed.
|
|
- name: Require the repository signing key
|
|
shell: bash
|
|
env:
|
|
XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "${XBPS_SIGNING_KEY:-}" ]; then
|
|
echo "::error::XBPS_SIGNING_KEY is unset or empty; refusing to publish an unsigned repository (packaging/void/README.md)" >&2
|
|
exit 1
|
|
fi
|
|
KEY_DIR=$(dirname -- "$XBPS_KEY_FILE")
|
|
if [ "$(stat -f -c %T "$KEY_DIR")" != "tmpfs" ]; then
|
|
echo "::error::${KEY_DIR} is not tmpfs; the signing key must never touch disk (packaging/void/README.md)" >&2
|
|
exit 1
|
|
fi
|
|
umask 077
|
|
(set -C; printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE")
|
|
chmod 600 "$XBPS_KEY_FILE"
|
|
|
|
- name: Check the runner has the release tools
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
missing=""
|
|
for tool in podman openssl git sha256sum python3.11 uv; do
|
|
command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
|
|
done
|
|
if [ -n "$missing" ]; then
|
|
echo "::error::the bongbetic-ci runner lacks:${missing}; install them on the runner (packaging/void/README.md, Runner requirements)" >&2
|
|
exit 1
|
|
fi
|
|
uv_version=$(uv --version | awk '{print $2}')
|
|
case "$uv_version" in
|
|
0.12.* | 0.13.*) ;;
|
|
*)
|
|
echo "::error::uv ${uv_version} is outside the pinned 0.12.x / 0.13.x range" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
podman --version
|
|
|
|
# The only path that builds, signs or verifies anything. It stages the
|
|
# complete set under publish/ and exits 1 before publishing when any check
|
|
# fails.
|
|
- name: Build, sign and stage the repository
|
|
shell: bash
|
|
env:
|
|
TAG: ${{ gitea.ref_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
packaging/void/build-release.sh release "$TAG" --key-file "$XBPS_KEY_FILE" --out-dir publish
|
|
ls -la publish publish/sdist publish/xbps/repo publish/xbps/keys
|
|
|
|
# Releases go through the Gitea REST API. Any non-2xx response fails the
|
|
# job. Only what build-release.sh staged under publish/ is uploaded. The
|
|
# sdist, packages and signatures and the client key go first and the
|
|
# repodata goes last, so a client never sees an index that lists packages
|
|
# which are not there yet.
|
|
- name: Publish to the tag release and the latest release
|
|
shell: bash
|
|
env:
|
|
GITEA_TOKEN: ${{ gitea.token }}
|
|
TAG: ${{ gitea.ref_name }}
|
|
COMMIT_SHA: ${{ gitea.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
cd publish
|
|
BODY=$(mktemp)
|
|
trap 'rm -f "$BODY"' EXIT
|
|
|
|
fail() { echo "::error::$*" >&2; exit 1; }
|
|
|
|
urlencode() {
|
|
local s="$1" out="" c i
|
|
for ((i = 0; i < ${#s}; i++)); do
|
|
c="${s:i:1}"
|
|
case "$c" in
|
|
[a-zA-Z0-9._~-]) out+="$c" ;;
|
|
*) out+=$(printf '%%%02X' "'$c") ;;
|
|
esac
|
|
done
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
# call METHOD URL [curl args...]: response body lands in $BODY.
|
|
call() {
|
|
local method="$1" url="$2" code
|
|
shift 2
|
|
code=$(curl -sS -o "$BODY" -w '%{http_code}' -X "$method" \
|
|
-H "Authorization: token ${GITEA_TOKEN}" "$@" "$url") \
|
|
|| fail "request failed: ${method} ${url}"
|
|
case "$code" in
|
|
2[0-9][0-9]) ;;
|
|
*) cat "$BODY" >&2; echo >&2; fail "${method} ${url} returned HTTP ${code}" ;;
|
|
esac
|
|
}
|
|
|
|
json_field() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "$BODY" "$1"; }
|
|
|
|
release_payload() {
|
|
python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target_commitish": sys.argv[2], "name": sys.argv[3], "body": sys.argv[4], "draft": False, "prerelease": False}))' "$@"
|
|
}
|
|
|
|
# release_id TAG: sets REL_ID to the release id, or to "" when the
|
|
# tag has no release. These helpers set a variable instead of
|
|
# printing, because bash clears `set -e` inside $(...): a failure in
|
|
# a nested substitution would otherwise be swallowed.
|
|
release_id() {
|
|
local code
|
|
REL_ID=""
|
|
code=$(curl -sS -o "$BODY" -w '%{http_code}' \
|
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
"${GITEA_API}/releases/tags/$(urlencode "$1")") \
|
|
|| fail "request failed: GET releases/tags/$1"
|
|
case "$code" in
|
|
200) REL_ID=$(json_field id) ;;
|
|
404) ;;
|
|
*) cat "$BODY" >&2; echo >&2; fail "GET releases/tags/$1 returned HTTP ${code}" ;;
|
|
esac
|
|
}
|
|
|
|
# ensure_release TAG NAME NOTES: sets REL_ID, creating the release
|
|
# when the tag has none.
|
|
ensure_release() {
|
|
local payload
|
|
release_id "$1"
|
|
if [ -z "$REL_ID" ]; then
|
|
payload=$(release_payload "$1" "$COMMIT_SHA" "$2" "$3")
|
|
call POST "${GITEA_API}/releases" -H 'Content-Type: application/json' -d "$payload"
|
|
REL_ID=$(json_field id)
|
|
fi
|
|
}
|
|
|
|
# delete_assets ID MODE: MODE is "all", or "same" to remove only the
|
|
# assets whose names this run uploads again (a rerun on a tag release).
|
|
delete_assets() {
|
|
local listing aid aname
|
|
call GET "${GITEA_API}/releases/$1/assets"
|
|
listing=$(python3 -c 'import json,sys; [print(a["id"], a["name"]) for a in json.load(open(sys.argv[1]))]' "$BODY")
|
|
[ -n "$listing" ] || return 0
|
|
while read -r aid aname; do
|
|
if [ "$2" = "all" ] || printf '%s\n' "$NAMES" | grep -qxF -- "$aname"; then
|
|
call DELETE "${GITEA_API}/releases/$1/assets/${aid}"
|
|
echo "deleted: ${aname}"
|
|
fi
|
|
done <<< "$listing"
|
|
}
|
|
|
|
upload_assets() {
|
|
local f name
|
|
for f in "${FILES[@]}"; do
|
|
name=$(basename "$f")
|
|
call POST "${GITEA_API}/releases/$1/assets?name=$(urlencode "$name")" \
|
|
-F "attachment=@${f};filename=${name}"
|
|
echo "uploaded: ${name}"
|
|
done
|
|
}
|
|
|
|
# verify_assets ID: every file we uploaded is on the release.
|
|
verify_assets() {
|
|
local have
|
|
call GET "${GITEA_API}/releases/$1/assets"
|
|
have=$(python3 -c 'import json,sys; [print(a["name"]) for a in json.load(open(sys.argv[1]))]' "$BODY" | sort)
|
|
[ "$(printf '%s\n' "$NAMES" | sort | comm -23 - <(printf '%s\n' "$have") | wc -l)" -eq 0 ] \
|
|
|| fail "release $1 is missing uploaded assets"
|
|
}
|
|
|
|
# The sdist first, then packages and signatures and the client key,
|
|
# then the repodata last.
|
|
mapfile -t FILES < <(printf '%s\n' sdist/*.tar.gz xbps/repo/*.x86_64.xbps xbps/repo/*.sig2 \
|
|
xbps/keys/FINGERPRINT xbps/keys/*.plist xbps/repo/*-repodata* | awk '!seen[$0]++')
|
|
NAMES=$(for f in "${FILES[@]}"; do basename "$f"; done)
|
|
echo "assets to publish: $(printf '%s\n' "$NAMES" | wc -l)"
|
|
|
|
SDIST_SHA256=$(cat SDIST_SHA256)
|
|
NOTES=$(printf '%s\n\n%s\n%s\n' \
|
|
"Signed xbps repository, Void Linux glibc x86_64, built from ${TAG} by the release workflow. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \
|
|
"$(cat xbps/keys/FINGERPRINT)" \
|
|
"sdist sha256: ${SDIST_SHA256} (voculinux-*.tar.gz, the source the packages are built from)")
|
|
|
|
echo "== tag release ${TAG}"
|
|
ensure_release "$TAG" "$TAG" "$NOTES"
|
|
TAG_ID=$REL_ID
|
|
delete_assets "$TAG_ID" same
|
|
upload_assets "$TAG_ID"
|
|
verify_assets "$TAG_ID"
|
|
|
|
# The rolling release the registration URL points at. The tag
|
|
# created with it stays where it was first made; the assets are the
|
|
# source of truth for what clients download.
|
|
echo "== latest release"
|
|
LATEST_NOTES=$(printf '%s\n\n%s\n' \
|
|
"Rolling signed xbps repository, currently built from ${TAG} (${COMMIT_SHA})." \
|
|
"$NOTES")
|
|
ensure_release latest latest "$LATEST_NOTES"
|
|
LATEST_ID=$REL_ID
|
|
PATCH_PAYLOAD=$(python3 -c 'import json,sys; print(json.dumps({"name": sys.argv[1], "body": sys.argv[2]}))' latest "$LATEST_NOTES")
|
|
call PATCH "${GITEA_API}/releases/${LATEST_ID}" -H 'Content-Type: application/json' -d "$PATCH_PAYLOAD"
|
|
delete_assets "$LATEST_ID" all
|
|
upload_assets "$LATEST_ID"
|
|
verify_assets "$LATEST_ID"
|
|
echo "OK: signed repository published to ${TAG} and latest"
|
|
|
|
- name: Remove the signing key
|
|
if: always()
|
|
shell: bash
|
|
run: rm -f "$XBPS_KEY_FILE"
|