Files
voculinux/packaging/void/build-release.sh
T
xavierk 80fb4365bd test(void): guard the tag-built release path offline
- build-release.sh: POSIX-only checks (dash runs the release), a release form
  is exactly X.Y.Z or X.Y.Z-alpha|beta|rcN, and the install smoke refuses a
  package that has no smoke recipe.
- Tests: names and tag gate (exact match, committed template must agree), key
  refusals before any container starts (fake podman records calls), the
  render script pins only the build tree, podman wiring (only the sign phase
  sees the key, and it has no network), the xbps ordering of pre-release
  spellings, the workflow gates, tmpfs key staging, and the publish set
  (sdist, packages, signatures, repodata, client key).
2026-11-06 20:16:46 +05:30

366 lines
14 KiB
Bash
Executable File

#!/bin/sh
# The one Void release path (spec #16 "Release pipeline and upstream intake";
# tickets #28 and #29). The Gitea workflow (.gitea/workflows/xbps-release.yml),
# a local dry run and the smoke gate (packaging/void/smoke.sh) all call this
# script, so a dry run exercises exactly the code a tag runs.
#
# Usage:
# build-release.sh names TAG
# print what a tag maps to. Offline; builds nothing.
# build-release.sh check-tag TAG
# the tag gate. Offline: TAG must be exactly v + __version__ from
# src/vocalinux/version.py, and the committed Void template must already
# name that version and tag. Exits 1 otherwise.
# build-release.sh release TAG --key-file FILE --out-dir DIR
# the whole release: gate, reproducible sdist, signed repository,
# completeness check. Writes DIR/sdist and DIR/xbps. Publishes nothing;
# the workflow publishes DIR. DIR must be absent or empty.
# build-release.sh smoke
# the same pipeline for the current version, signed with a throwaway
# RSA4096 key in a temporary directory. Proves the path works; nothing is
# signed with the release key and nothing is published.
#
# Version mapping. xbps versions cannot contain '-', so the xbps spelling of a
# release is __version__ with the hyphen removed: 0.18.5 -> 0.18.5,
# 0.19.0-beta -> 0.19.0beta, 0.19.0-rc1 -> 0.19.0rc1. xbps sorts the pre-release
# forms below the final release (checked with xbps-uhelper cmpver in
# tests/test_void_release_build.py). Only X.Y.Z, X.Y.Z-alpha, X.Y.Z-beta and
# X.Y.Z-rcN are release forms; anything else is refused.
#
# The distfile and the release asset are both voculinux-<xbps version>.tar.gz.
# The sdist keeps its PEP 440 name inside (voculinux-0.19.0b0/); the xbps-src
# do-extract hook renames the sdist's single top-level directory to the
# package's wrksrc, so the two spellings need not match.
#
# Environment: PYTHON (default python3.11) and uv 0.12.x or 0.13.x on PATH, both
# for the sdist. The signing key is never echoed; it reaches the signing
# container only as a read-only mount.
set -eu
CDPATH=""
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
ROOT=$(cd -- "$HERE/../.." && pwd)
TEMPLATE="$HERE/srcpkgs/voculinux/template"
PLIST="$HERE/repository-key.plist"
PYTHON=${PYTHON:-python3.11}
SIGNEDBY="Voculinux <xbps@voculinux.com>"
MODE=""
WORK=""
fail() {
echo "build-release.sh: $*" >&2
exit 1
}
usage() {
cat >&2 <<'EOF'
usage: build-release.sh names TAG
build-release.sh check-tag TAG
build-release.sh release TAG --key-file FILE --out-dir DIR
build-release.sh smoke
EOF
exit 2
}
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || fail "pins missing from tool_checksums.txt"
# The package order is owned by smoke.sh and shared with every release path.
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh")
[ -n "$PKGS" ] || fail "could not read the package order from smoke.sh"
# source_version: __version__ from src/vocalinux/version.py, defined once.
source_version() {
count=$(sed -n '/^__version__ = /p' "$ROOT/src/vocalinux/version.py" | wc -l)
[ "$count" -eq 1 ] || fail "src/vocalinux/version.py must define __version__ exactly once"
value=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "$ROOT/src/vocalinux/version.py")
[ -n "$value" ] || fail "could not read __version__ from src/vocalinux/version.py"
printf '%s\n' "$value"
}
# xbps_version VERSION: the xbps spelling, or nothing when VERSION is not a
# release form.
xbps_version() {
printf '%s\n' "$1" |
sed -n -E 's/^([0-9]+\.[0-9]+\.[0-9]+)(-(alpha|beta|rc)[0-9]*)?$/\1\2/p' |
tr -d '-'
}
# derive TAG: sets TAG, VERSION, XBPS_VERSION and SDIST_NAME, or fails.
derive() {
TAG=${1:-}
VERSION=$(source_version)
[ "$TAG" = "v$VERSION" ] ||
fail "tag '$TAG' is not exactly v$VERSION (src/vocalinux/version.py); refusing to publish"
XBPS_VERSION=$(xbps_version "$VERSION")
[ -n "$XBPS_VERSION" ] ||
fail "version $VERSION is not a release form (X.Y.Z, or X.Y.Z-alpha, -beta, -rcN)"
SDIST_NAME="voculinux-$XBPS_VERSION.tar.gz"
}
# present PATTERN: true when at least one file matches the glob PATTERN. A plain
# POSIX loop: the release runs under /bin/sh, which is dash on Void, and bash-only
# builtins are not available there.
present() {
for match in $1; do
[ -e "$match" ] && return 0
done
return 1
}
# template_value KEY: the value of the single KEY= line of the committed template.
template_value() {
count=$(grep -c "^$1=" "$TEMPLATE" || true)
[ "$count" -eq 1 ] || fail "$TEMPLATE must hold exactly one $1= line (found $count)"
sed -n "s/^$1=//p" "$TEMPLATE"
}
# check_template: the committed template must already name this release. The
# build tree is pinned from the tag; the committed file is what a reader sees
# and what the next release starts from.
check_template() {
have=$(template_value version)
[ "$have" = "$XBPS_VERSION" ] ||
fail "committed template version is '$have', not '$XBPS_VERSION' for $TAG; update packaging/void/srcpkgs/voculinux/template in the release pull request"
have=$(template_value _tag)
[ "$have" = "$TAG" ] ||
fail "committed template _tag is '$have', not '$TAG'; update packaging/void/srcpkgs/voculinux/template"
}
# check_key KEY_FILE: RSA 4096, and for a release the public half must be the
# committed repository-key.plist key. Sets FINGERPRINT and writes the client
# key to $WORK/client.plist.
check_key() {
KEY_FILE=$1
[ -n "$KEY_FILE" ] ||
fail "no signing key: pass --key-file (the workflow writes the XBPS_SIGNING_KEY secret to a file first)"
[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] ||
fail "signing key missing or empty; refusing to publish an unsigned repository"
openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 ||
fail "the signing key is not a valid RSA private key; refusing to publish"
openssl rsa -in "$KEY_FILE" -noout -text 2>/dev/null | head -n1 | grep -q "4096 bit" ||
fail "the signing key is not RSA 4096; refusing to publish"
openssl rsa -in "$KEY_FILE" -pubout -out "$WORK/pub.pem" 2>/dev/null ||
fail "could not derive the public key from the signing key"
FINGERPRINT=$(python3 -I "$HERE/xbps-fingerprint.py" fingerprint "$WORK/pub.pem")
if [ "$MODE" = release ]; then
python3 -I -c 'import plistlib, sys; sys.stdout.buffer.write(plistlib.load(open(sys.argv[1], "rb"))["public-key"])' \
"$PLIST" >"$WORK/plist-pub.pem"
cmp -s "$WORK/pub.pem" "$WORK/plist-pub.pem" ||
fail "the signing key does not match packaging/void/repository-key.plist; refusing to publish"
cp "$PLIST" "$WORK/client.plist"
else
python3 -I "$HERE/xbps-fingerprint.py" plist "$WORK/pub.pem" "$SIGNEDBY" "$WORK/client.plist"
fi
echo "signing key: RSA 4096, fingerprint $FINGERPRINT ($MODE)"
}
# build_sdist: the canonical reproducible sdist, copied under its xbps name.
build_sdist() {
command -v "$PYTHON" >/dev/null 2>&1 ||
fail "$PYTHON not found: the sdist is built with Python 3.11 (set PYTHON)"
"$PYTHON" -c 'import sys; sys.exit(0 if sys.version_info[:2] == (3, 11) else 1)' ||
fail "$PYTHON is not Python 3.11"
command -v uv >/dev/null 2>&1 || fail "uv not found: the release pins uv 0.12.x or 0.13.x"
UV_VERSION=$(uv --version | awk '{print $2}')
case "$UV_VERSION" in
0.12.* | 0.13.*) ;;
*) fail "uv $UV_VERSION is outside the pinned 0.12.x / 0.13.x range" ;;
esac
(cd "$ROOT" && "$PYTHON" scripts/build_reproducible_wheel.py --python "$PYTHON" \
--output "$WORK/dist" --diagnostics "$WORK/diagnostics") ||
fail "the reproducible build failed; diagnostics are in $WORK/diagnostics"
set -- "$WORK"/dist/voculinux-*.tar.gz
[ "$#" -eq 1 ] && [ -f "$1" ] || fail "expected exactly one sdist in $WORK/dist"
mkdir -p "$WORK/inputs"
cp "$1" "$WORK/inputs/$SDIST_NAME"
SDIST_SHA256=$(sha256sum "$WORK/inputs/$SDIST_NAME" | cut -d' ' -f1)
echo "sdist: $SDIST_NAME sha256 $SDIST_SHA256"
}
# The podman phases. Only the build phase runs build scripts (with network);
# only the sign phase sees the key (and has no network); only the install phase
# runs the packages (in a clean image, without the key).
phase_build() {
mkdir -p "$WORK/packages"
podman pull -q "$BASE_IMAGE" >/dev/null
podman run --rm \
-v "$HERE/srcpkgs:/overlay:ro" \
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
-v "$HERE/render-voculinux.sh:/mnt/render-voculinux.sh:ro" \
-v "$WORK/inputs:/mnt/inputs:ro" \
-v "$WORK/packages:/mnt/packages" \
-e "VOID_COMMIT=$VOID_COMMIT" \
-e "PKGS=$PKGS" \
-e "TAG=$TAG" \
-e "XBPS_VERSION=$XBPS_VERSION" \
-e "SDIST_NAME=$SDIST_NAME" \
-e "SDIST_SHA256=$SDIST_SHA256" \
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh build
}
phase_sign() {
mkdir -p "$OUT/xbps"
podman run --rm --network none \
-v "$WORK/packages:/mnt/packages:ro" \
-v "$OUT/xbps:/mnt/xbps" \
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
-v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \
-e "SIGNEDBY=$SIGNEDBY" \
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh sign
mkdir -p "$OUT/xbps/keys"
cp "$WORK/client.plist" "$OUT/xbps/keys/$FINGERPRINT.plist"
printf 'signed-by: %s\nfingerprint: %s\n' "$SIGNEDBY" "$FINGERPRINT" >"$OUT/xbps/keys/FINGERPRINT"
}
phase_install() {
podman run --rm \
-v "$OUT/xbps:/mnt/xbps:ro" \
-v "$WORK/packages:/mnt/packages:ro" \
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
-e "PKGS=$PKGS" \
-e "XBPS_VERSION=$XBPS_VERSION" \
-e "VERSION=$VERSION" \
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh install
}
# verify_set: the staged set is complete and holds nothing that may not be
# published.
verify_set() {
cd "$OUT"
for pattern in 'sdist/*.tar.gz' 'xbps/repo/*.x86_64.xbps' 'xbps/repo/*.sig2' \
'xbps/repo/*-repodata*' 'xbps/keys/FINGERPRINT' 'xbps/keys/*.plist'; do
present "$pattern" || fail "missing from the signed set: $pattern"
done
[ "$(ls sdist | wc -l)" -eq 1 ] && [ -f "sdist/$SDIST_NAME" ] ||
fail "the sdist directory must hold exactly sdist/$SDIST_NAME"
[ "$(sha256sum "sdist/$SDIST_NAME" | cut -d' ' -f1)" = "$SDIST_SHA256" ] ||
fail "the staged sdist does not match its digest"
for f in xbps/repo/*; do
base=${f##*/}
case "$base" in
*musl*) fail "musl data in the repository: $base" ;;
*.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;;
*) fail "unexpected file (only glibc x86_64 data is published): xbps/repo/$base" ;;
esac
done
for f in xbps/keys/*; do
base=${f##*/}
case "$base" in
FINGERPRINT | *.plist) ;;
*) fail "unexpected file among the client keys: xbps/keys/$base" ;;
esac
done
for pkg in xbps/repo/*.x86_64.xbps; do
[ -s "$pkg.sig2" ] || fail "package has no signature: $pkg"
done
for sig in xbps/repo/*.sig2; do
[ -f "${sig%.sig2}" ] || fail "signature without its package: $sig"
done
for name in $PKGS; do
if [ "$name" = voculinux ]; then
[ -f "xbps/repo/voculinux-${XBPS_VERSION}_1.x86_64.xbps" ] ||
fail "the repository lacks voculinux-${XBPS_VERSION}_1"
else
present "xbps/repo/${name}-[0-9]*.x86_64.xbps" ||
fail "package missing from the repository: $name"
fi
done
FP=$(sed -n 's/^fingerprint: //p' xbps/keys/FINGERPRINT)
[ "$FP" = "$FINGERPRINT" ] || fail "FINGERPRINT says '$FP', the signing key is '$FINGERPRINT'"
[ -s "xbps/keys/$FINGERPRINT.plist" ] || fail "client key xbps/keys/$FINGERPRINT.plist is missing"
if [ "$MODE" = release ]; then
cmp -s "$PLIST" "xbps/keys/$FINGERPRINT.plist" ||
fail "the staged client key is not the committed repository-key.plist"
fi
cd "$ROOT"
echo "OK: complete signed glibc x86_64 set for $TAG, key $FINGERPRINT"
}
run_pipeline() {
derive "$1"
check_template
WORK=$(mktemp -d "${TMPDIR:-/tmp}/voculinux-release.XXXXXX")
cleanup() {
status=$?
rm -f "$WORK/throwaway.pem"
if [ "$status" -eq 0 ]; then
rm -rf "$WORK"
else
echo "build-release.sh: failed; diagnostics retained in $WORK" >&2
fi
}
trap cleanup EXIT
if [ "$MODE" = smoke ]; then
openssl genrsa -out "$WORK/throwaway.pem" 4096 2>/dev/null
KEY_FILE="$WORK/throwaway.pem"
OUT="$WORK/out"
fi
if [ -e "$OUT" ]; then
[ -d "$OUT" ] && [ -z "$(ls -A "$OUT")" ] ||
fail "$OUT exists and is not empty; choose a new output directory"
fi
check_key "$KEY_FILE"
build_sdist
phase_build
# The committed templates must be untouched: the build tree is the only
# place a release pins version, _tag and checksum.
if git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then
git -C "$ROOT" diff --quiet -- packaging/void/srcpkgs ||
fail "the build changed the committed templates"
fi
mkdir -p "$OUT"
OUT=$(cd "$OUT" && pwd)
phase_sign
phase_install
verify_set
printf '%s\n' "$SDIST_SHA256" >"$OUT/SDIST_SHA256"
echo "template bump for the post-release pull request: checksum=$SDIST_SHA256"
}
cmd=${1:-}
[ -n "$cmd" ] || usage
shift
case "$cmd" in
names)
[ "$#" -eq 1 ] || usage
derive "$1"
printf 'TAG=%s\nVERSION=%s\nXBPS_VERSION=%s\nSDIST_NAME=%s\n' \
"$TAG" "$VERSION" "$XBPS_VERSION" "$SDIST_NAME"
;;
check-tag)
[ "$#" -eq 1 ] || usage
derive "$1"
check_template
echo "tag gate passed: $TAG (xbps $XBPS_VERSION)"
;;
release)
[ "$#" -ge 1 ] || usage
TAG=$1
shift
KEY_FILE=""
OUT=""
while [ "$#" -gt 0 ]; do
case "$1" in
--key-file) KEY_FILE=${2:-}; shift 2 ;;
--out-dir) OUT=${2:-}; shift 2 ;;
*) usage ;;
esac
done
[ -n "$OUT" ] || usage
MODE=release
run_pipeline "$TAG"
echo "RELEASE BUILT: $TAG staged in $OUT (not published by this script)"
;;
smoke)
[ "$#" -eq 0 ] || usage
MODE=smoke
run_pipeline "v$(source_version)"
echo "SMOKE PASSED: $TAG built, signed with a throwaway key, installed and smoked; nothing published"
;;
*)
usage
;;
esac