- 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).
366 lines
14 KiB
Bash
Executable File
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
|