Files
voculinux/.gitea/workflows/xbps-release.yml
T
xavierk 180cd4d512
CI / security (push) Successful in 1m14s
CI / lint (push) Successful in 24s
CI / ai-review (push) Skipped
fix(spec16): close the review findings for the Void port (#34)
Model recommendation keyed to the installed build and free memory, fork-sourced Void templates, signed xbps release workflow on Gitea with the pinned repository key, niri injection guard and wtype-first order, tightened naming sweep, web copy fixes, and CI security and duplication fixes.
2026-11-06 14:13:49 +00:00

327 lines
14 KiB
YAML

name: xbps-release
# Signed Void xbps repository release (ticket #29). A human pushes a version
# tag; this workflow builds every Void template in the pinned Void glibc
# container with podman (packaging/void/repo.sh), indexes the full x86_64
# repository, signs every package and the repository metadata with the RSA4096
# repository key, and attaches the complete signed repository to two releases:
#
# - the tag's own release (the permanent record of what that tag shipped), and
# - the rolling release named `latest`, because the client registration URL
# (README.md) serves the newest release's downloads:
# https://git.bongbetic.com/xavierk/voculinux/releases/download/latest
#
# Hard gates, each exiting 1 before anything is published:
# 1. the tag must EXACTLY equal "v" + __version__ in src/vocalinux/version.py;
# 2. the XBPS_SIGNING_KEY secret must be non-empty (no key, no repo, never a
# silent skip and never an unsigned upload);
# 3. every class of repository file must exist, nothing but glibc x86_64 data
# may exist, every package must carry its signature, and the signing key's
# fingerprint must equal the one the README tells users to pin.
#
# Nothing here commits or pushes to any branch, and nothing bumps the Void
# templates: the post-release template bump is a normal pull request
# (docs/RELEASE_PROCESS.md). The private key exists only as the CI secret and a
# 0600 temp file that the last step removes. Never Docker: podman only.
on:
push:
tags:
- "v*"
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: 180
env:
GITEA_API: https://git.bongbetic.com/api/v1/repos/xavierk/voculinux
# Must equal the fingerprint in README.md, packaging/void/README.md and
# packaging/void/repository-key.plist; tests/test_xbps_release_workflow.py
# enforces it. A secret holding any other key fails before publishing.
XBPS_KEY_FINGERPRINT: "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25"
XBPS_KEY_FILE: /tmp/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
- name: Require the tag to equal the version in version.py
shell: bash
env:
TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
VERSION=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' src/vocalinux/version.py)
if [ -z "$VERSION" ]; then
echo "::error::could not read __version__ from src/vocalinux/version.py; refusing to publish" >&2
exit 1
fi
echo "tag: ${TAG}"
echo "version: v${VERSION}"
if [ "$TAG" != "v${VERSION}" ]; then
echo "::error::tag ${TAG} is not exactly v${VERSION} (src/vocalinux/version.py); refusing to publish the signed repository" >&2
exit 1
fi
# Fail closed: no key, no repo, no publish. This runs before any
# container so a missing secret costs seconds, not a build.
- 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
umask 077
rm -f "$XBPS_KEY_FILE"
printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE"
chmod 600 "$XBPS_KEY_FILE"
- name: Install the tools repo.sh and the publish step need
shell: bash
run: |
set -euo pipefail
missing=""
for tool in podman openssl curl python3; do
command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
done
if [ -n "$missing" ]; then
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
$SUDO apt-get update
# shellcheck disable=SC2086
$SUDO apt-get install -y --no-install-recommends $missing
fi
podman --version
# repo.sh is the only path that builds or signs anything: pinned Void
# glibc image, every template in smoke-gate order, full x86_64 index,
# signed packages and metadata. It re-validates the key (RSA4096) before
# any container starts.
- name: Build and sign the repository
shell: bash
run: |
set -euo pipefail
XBPS_SIGNING_KEY_FILE="$XBPS_KEY_FILE" bash packaging/void/repo.sh
ls -la publish/xbps/repo publish/xbps/keys
- name: Verify the signed repository is complete
shell: bash
run: |
set -euo pipefail
ROOT=$PWD
cd publish/xbps
fail() { echo "::error::$*" >&2; exit 1; }
# Every class must exist, so a partial repository never attaches.
for pat in 'repo/*.x86_64.xbps' 'repo/*.sig2' 'repo/*-repodata*' 'keys/FINGERPRINT' 'keys/*.plist'; do
compgen -G "$pat" > /dev/null || fail "missing from the signed repository: $pat"
done
# Every template the smoke gate builds must be in the repository.
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$ROOT/packaging/void/smoke.sh")
[ -n "$PKGS" ] || fail "could not read the package order from packaging/void/smoke.sh"
for name in $PKGS; do
compgen -G "repo/${name}-[0-9]*.x86_64.xbps" > /dev/null || fail "package missing from the repository: ${name}"
done
# Glibc x86_64 data only: no musl, no other architecture, no noarch.
while IFS= read -r name; do
case "$name" in
*musl*) fail "musl data in the repository: $name" ;;
*.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;;
*) fail "unexpected file (only glibc x86_64 data is published): repo/$name" ;;
esac
done < <(find repo -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
while IFS= read -r name; do
case "$name" in
FINGERPRINT | *.plist) ;;
*) fail "unexpected file among the client keys: keys/$name" ;;
esac
done < <(find keys -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
# Never publish an unsigned package, or a signature without its package.
for pkg in repo/*.x86_64.xbps; do
[ -s "${pkg}.sig2" ] || fail "package has no signature: ${pkg}"
done
for sig in repo/*.sig2; do
[ -f "${sig%.sig2}" ] || fail "signature without its package: ${sig}"
done
# The repository must be signed by the key the README tells users to
# pin, and the staged client key must be that key.
FP=$(sed -n 's/^fingerprint: //p' keys/FINGERPRINT)
if [ "$FP" != "$XBPS_KEY_FINGERPRINT" ]; then
fail "signing key fingerprint ${FP:-<none>} is not the pinned ${XBPS_KEY_FINGERPRINT}; refusing to publish"
fi
[ "$(find keys -name '*.plist' | wc -l)" -eq 1 ] || fail "expected exactly one client key plist"
[ -s "keys/${FP}.plist" ] || fail "client key plist keys/${FP}.plist is missing"
echo "OK: complete signed glibc x86_64 repository, key ${FP}"
# Releases go through the Gitea REST API. Any non-2xx response fails the
# job. Only files staged by repo.sh upload; nothing is built or signed
# here. Package files, 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/xbps
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"
}
# Packages, signatures and the client key first, the repodata last.
mapfile -t FILES < <(printf '%s\n' repo/*.x86_64.xbps repo/*.sig2 keys/FINGERPRINT keys/*.plist repo/*-repodata* | awk '!seen[$0]++')
NAMES=$(for f in "${FILES[@]}"; do basename "$f"; done)
echo "assets to publish: $(printf '%s\n' "$NAMES" | wc -l)"
NOTES=$(printf '%s\n\n%s\n' \
"Signed xbps repository, Void Linux glibc x86_64. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \
"$(cat keys/FINGERPRINT)")
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"