The completeness check requires sdist/<name> in the output directory, but nothing copied the built sdist there. Copy it from the build work directory before the signing phase, and pin that order in a test.
368 lines
14 KiB
Bash
Executable File
368 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, 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-<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 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
|