Files
voculinux/packaging/void/build-release.sh
T
xavierk 05ef78922d fix(void): build the xbps release from the tag, not an attached sdist
The signed Void release depended on a fork sdist attached to the release
before the tag existed, and the app template carried a PENDING checksum that
had to be filled by hand. Every release is now built from its tag alone:

- packaging/void/build-release.sh is the one release path. The Gitea workflow,
  the local dry run and the smoke gate all call it.
- The canonical reproducible sdist is built from the tag with
  scripts/build_reproducible_wheel.py (Python 3.11, pinned uv).
- render-voculinux.sh pins version, _tag and checksum in the container's build
  tree only, and places the sdist where xbps-src resolves its distfile.
  The committed template keeps a zero placeholder until the post-release
  checksum pull request.
- xbps version is __version__ with the hyphen removed (0.19.0-beta becomes
  0.19.0beta), which xbps sorts below the final release.
- Builds run without the key; signing runs with the key mounted read-only and
  no network; the install smoke runs in a clean image without the key.
- The workflow gates the tag and the key before any container starts, writes
  the key to a 0600 tmpfs file, and publishes only the staged set (sdist,
  packages, signatures, repodata, client key).
- smoke.sh runs the same pipeline with a throwaway key.
2026-11-06 20:06:35 +05:30

353 lines
13 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]+)*)(-(alpha|beta|rc)[0-9]*)?$/\1\3/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"
}
# 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 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
compgen -G "$pattern" >/dev/null || 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
compgen -G "xbps/repo/${name}-[0-9]*.x86_64.xbps" >/dev/null ||
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
[ -e "$OUT" ] && { [ -d "$OUT" ] && [ -z "$(ls -A "$OUT")" ]; } ||
{ [ ! -e "$OUT" ] || fail "$OUT exists and is not empty; choose a new output directory"; }
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