#!/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, canonical 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-.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 " 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 sdist (the reproducible build path; only its wheel is # compared byte for byte), 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/sdist" OUT=$(cd "$OUT" && pwd) cp "$WORK/inputs/$SDIST_NAME" "$OUT/sdist/$SDIST_NAME" 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