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.
327 lines
14 KiB
YAML
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"
|