Files
voculinux/.gitea/workflows/xbps-release.yml
T
xavierk 731b919865
CI / security (push) Successful in 1m11s
CI / lint (push) Successful in 21s
CI / ai-review (push) Skipped
chore(release): prepare v0.19.0 (#53)
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.
2026-11-06 20:23:14 +00:00

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"