From 05ef78922dbaabaebc7465b2797b3f8467f0581b Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 20:06:35 +0530 Subject: [PATCH 1/6] 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. --- .gitea/workflows/xbps-release.yml | 186 +++++------- justfile | 18 +- packaging/void/build-release.sh | 352 ++++++++++++++++++++++ packaging/void/build.sh | 51 ---- packaging/void/container-build.sh | 24 -- packaging/void/container-main.sh | 144 --------- packaging/void/container-release.sh | 178 +++++++++++ packaging/void/container-repo.sh | 129 -------- packaging/void/render-voculinux.sh | 71 +++++ packaging/void/repo.sh | 97 ------ packaging/void/smoke.sh | 33 +- packaging/void/srcpkgs/voculinux/template | 16 +- packaging/void/tool_checksums.txt | 2 +- packaging/void/xbps-fingerprint.py | 95 ++++++ 14 files changed, 798 insertions(+), 598 deletions(-) create mode 100755 packaging/void/build-release.sh delete mode 100755 packaging/void/build.sh delete mode 100755 packaging/void/container-build.sh delete mode 100755 packaging/void/container-main.sh create mode 100755 packaging/void/container-release.sh delete mode 100755 packaging/void/container-repo.sh create mode 100755 packaging/void/render-voculinux.sh delete mode 100755 packaging/void/repo.sh create mode 100755 packaging/void/xbps-fingerprint.py diff --git a/.gitea/workflows/xbps-release.yml b/.gitea/workflows/xbps-release.yml index 9f37c33..8540c01 100644 --- a/.gitea/workflows/xbps-release.yml +++ b/.gitea/workflows/xbps-release.yml @@ -1,28 +1,28 @@ name: xbps-release -# Signed Void xbps repository release (ticket #29). A human pushes a version -# tag; this workflow builds every Void template in the pinned Void glibc -# container with podman (packaging/void/repo.sh), indexes the full x86_64 -# repository, signs every package and the repository metadata with the RSA4096 -# repository key, and attaches the complete signed repository to two releases: +# Signed Void xbps repository release (tickets #28 and #29). A human pushes a +# version tag; this job does everything else through packaging/void/ +# build-release.sh, the same script a local dry run and the smoke gate call: # -# - the tag's own release (the permanent record of what that tag shipped), and -# - the rolling release named `latest`, because the client registration URL -# (README.md) serves the newest release's downloads: -# https://git.bongbetic.com/xavierk/voculinux/releases/download/latest +# 1. the tag must EXACTLY equal "v" + __version__ (src/vocalinux/version.py), +# and the committed Void template must already name that version; +# 2. the XBPS_SIGNING_KEY secret must be non-empty and RSA 4096, and its public +# half must be packaging/void/repository-key.plist; +# 3. the canonical reproducible sdist is built from the tag (Python 3.11, +# uv 0.12/0.13, scripts/build_reproducible_wheel.py); +# 4. every template builds in the pinned Void glibc image with podman (no +# signing key in that phase), then the repository is signed in a second +# container with no network, then voculinux is installed and smoked in a +# clean image with no key; +# 5. the sdist, the signed repository and the client key are published to the +# tag's release and to the rolling `latest` release that the client +# registration URL serves (README.md). # -# Hard gates, each exiting 1 before anything is published: -# 1. the tag must EXACTLY equal "v" + __version__ in src/vocalinux/version.py; -# 2. the XBPS_SIGNING_KEY secret must be non-empty (no key, no repo, never a -# silent skip and never an unsigned upload); -# 3. every class of repository file must exist, nothing but glibc x86_64 data -# may exist, every package must carry its signature, and the signing key's -# fingerprint must equal the one the README tells users to pin. -# -# Nothing here commits or pushes to any branch, and nothing bumps the Void -# templates: the post-release template bump is a normal pull request -# (docs/RELEASE_PROCESS.md). The private key exists only as the CI secret and a -# 0600 temp file that the last step removes. Never Docker: podman only. +# Nothing here is built or uploaded by hand, nothing commits or pushes to any +# branch, and no template is bumped: the post-release template bump is a normal +# pull request (docs/RELEASE_PROCESS.md section 3.5). The private key lives only +# as this secret and a 0600 file on tmpfs that the last step removes. Podman +# only, never Docker. Runner requirements: packaging/void/README.md. on: push: @@ -40,14 +40,13 @@ jobs: xbps-release: name: Build, sign and publish the xbps repository runs-on: bongbetic-ci - timeout-minutes: 180 + timeout-minutes: 240 env: GITEA_API: https://git.bongbetic.com/api/v1/repos/xavierk/voculinux - # Must equal the fingerprint in README.md, packaging/void/README.md and - # packaging/void/repository-key.plist; tests/test_xbps_release_workflow.py - # enforces it. A secret holding any other key fails before publishing. - XBPS_KEY_FINGERPRINT: "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25" - XBPS_KEY_FILE: /tmp/xbps-sign-key-${{ gitea.run_id }}.pem + # The key file lives on tmpfs (/dev/shm). Its name is fixed per run so the + # cleanup step can find it; it is created with noclobber, so a file that + # already exists makes the key step fail rather than be reused. + XBPS_KEY_FILE: /dev/shm/xbps-sign-key-${{ gitea.run_id }}.pem steps: - name: Checkout env: @@ -58,26 +57,20 @@ jobs: run: | git init -q . && git remote add origin "${SERVER_URL}/${REPOSITORY}.git" && git -c http.extraheader="Authorization: token ${GITEA_TOKEN}" fetch -q --depth=1 origin "${COMMIT_SHA}" && git checkout -q FETCH_HEAD + # Fail first and cheaply: the gate needs no tools and no secret. - name: Require the tag to equal the version in version.py shell: bash env: TAG: ${{ gitea.ref_name }} run: | set -euo pipefail - VERSION=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' src/vocalinux/version.py) - if [ -z "$VERSION" ]; then - echo "::error::could not read __version__ from src/vocalinux/version.py; refusing to publish" >&2 + packaging/void/build-release.sh check-tag "$TAG" || { + echo "::error::tag ${TAG} does not match src/vocalinux/version.py and the committed Void template; refusing to publish" >&2 exit 1 - fi - echo "tag: ${TAG}" - echo "version: v${VERSION}" - if [ "$TAG" != "v${VERSION}" ]; then - echo "::error::tag ${TAG} is not exactly v${VERSION} (src/vocalinux/version.py); refusing to publish the signed repository" >&2 - exit 1 - fi + } - # Fail closed: no key, no repo, no publish. This runs before any - # container so a missing secret costs seconds, not a build. + # Fail closed: no key, no repository. The key is written with noclobber + # (set -C) to a tmpfs file with mode 0600, and never echoed. - name: Require the repository signing key shell: bash env: @@ -88,95 +81,52 @@ jobs: echo "::error::XBPS_SIGNING_KEY is unset or empty; refusing to publish an unsigned repository (packaging/void/README.md)" >&2 exit 1 fi + KEY_DIR=$(dirname -- "$XBPS_KEY_FILE") + if [ "$(stat -f -c %T "$KEY_DIR")" != "tmpfs" ]; then + echo "::error::${KEY_DIR} is not tmpfs; the signing key must never touch disk (packaging/void/README.md)" >&2 + exit 1 + fi umask 077 - rm -f "$XBPS_KEY_FILE" - printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE" + (set -C; printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE") chmod 600 "$XBPS_KEY_FILE" - - name: Install the tools repo.sh and the publish step need + - name: Check the runner has the release tools shell: bash run: | set -euo pipefail missing="" - for tool in podman openssl curl python3; do + for tool in podman openssl git sha256sum python3.11 uv; do command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool" done if [ -n "$missing" ]; then - SUDO="" - [ "$(id -u)" -eq 0 ] || SUDO="sudo" - $SUDO apt-get update - # shellcheck disable=SC2086 - $SUDO apt-get install -y --no-install-recommends $missing + echo "::error::the bongbetic-ci runner lacks:${missing}; install them on the runner (packaging/void/README.md, Runner requirements)" >&2 + exit 1 fi + uv_version=$(uv --version | awk '{print $2}') + case "$uv_version" in + 0.12.* | 0.13.*) ;; + *) + echo "::error::uv ${uv_version} is outside the pinned 0.12.x / 0.13.x range" >&2 + exit 1 + ;; + esac podman --version - # repo.sh is the only path that builds or signs anything: pinned Void - # glibc image, every template in smoke-gate order, full x86_64 index, - # signed packages and metadata. It re-validates the key (RSA4096) before - # any container starts. - - name: Build and sign the repository + # The only path that builds, signs or verifies anything. It stages the + # complete set under publish/ and exits 1 before publishing when any check + # fails. + - name: Build, sign and stage the repository shell: bash + env: + TAG: ${{ gitea.ref_name }} run: | set -euo pipefail - XBPS_SIGNING_KEY_FILE="$XBPS_KEY_FILE" bash packaging/void/repo.sh - ls -la publish/xbps/repo publish/xbps/keys - - - name: Verify the signed repository is complete - shell: bash - run: | - set -euo pipefail - ROOT=$PWD - cd publish/xbps - fail() { echo "::error::$*" >&2; exit 1; } - - # Every class must exist, so a partial repository never attaches. - for pat in 'repo/*.x86_64.xbps' 'repo/*.sig2' 'repo/*-repodata*' 'keys/FINGERPRINT' 'keys/*.plist'; do - compgen -G "$pat" > /dev/null || fail "missing from the signed repository: $pat" - done - - # Every template the smoke gate builds must be in the repository. - PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$ROOT/packaging/void/smoke.sh") - [ -n "$PKGS" ] || fail "could not read the package order from packaging/void/smoke.sh" - for name in $PKGS; do - compgen -G "repo/${name}-[0-9]*.x86_64.xbps" > /dev/null || fail "package missing from the repository: ${name}" - done - - # Glibc x86_64 data only: no musl, no other architecture, no noarch. - while IFS= read -r name; do - case "$name" in - *musl*) fail "musl data in the repository: $name" ;; - *.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;; - *) fail "unexpected file (only glibc x86_64 data is published): repo/$name" ;; - esac - done < <(find repo -mindepth 1 -maxdepth 1 -printf '%f\n' | sort) - while IFS= read -r name; do - case "$name" in - FINGERPRINT | *.plist) ;; - *) fail "unexpected file among the client keys: keys/$name" ;; - esac - done < <(find keys -mindepth 1 -maxdepth 1 -printf '%f\n' | sort) - - # Never publish an unsigned package, or a signature without its package. - for pkg in repo/*.x86_64.xbps; do - [ -s "${pkg}.sig2" ] || fail "package has no signature: ${pkg}" - done - for sig in repo/*.sig2; do - [ -f "${sig%.sig2}" ] || fail "signature without its package: ${sig}" - done - - # The repository must be signed by the key the README tells users to - # pin, and the staged client key must be that key. - FP=$(sed -n 's/^fingerprint: //p' keys/FINGERPRINT) - if [ "$FP" != "$XBPS_KEY_FINGERPRINT" ]; then - fail "signing key fingerprint ${FP:-} is not the pinned ${XBPS_KEY_FINGERPRINT}; refusing to publish" - fi - [ "$(find keys -name '*.plist' | wc -l)" -eq 1 ] || fail "expected exactly one client key plist" - [ -s "keys/${FP}.plist" ] || fail "client key plist keys/${FP}.plist is missing" - echo "OK: complete signed glibc x86_64 repository, key ${FP}" + packaging/void/build-release.sh release "$TAG" --key-file "$XBPS_KEY_FILE" --out-dir publish + ls -la publish publish/sdist publish/xbps/repo publish/xbps/keys # Releases go through the Gitea REST API. Any non-2xx response fails the - # job. Only files staged by repo.sh upload; nothing is built or signed - # here. Package files, signatures and the client key go first and the + # job. Only what build-release.sh staged under publish/ is uploaded. The + # sdist, packages and signatures and the client key go first and the # repodata goes last, so a client never sees an index that lists packages # which are not there yet. - name: Publish to the tag release and the latest release @@ -187,7 +137,7 @@ jobs: COMMIT_SHA: ${{ gitea.sha }} run: | set -euo pipefail - cd publish/xbps + cd publish BODY=$(mktemp) trap 'rm -f "$BODY"' EXIT @@ -288,14 +238,18 @@ jobs: || fail "release $1 is missing uploaded assets" } - # Packages, signatures and the client key first, the repodata last. - mapfile -t FILES < <(printf '%s\n' repo/*.x86_64.xbps repo/*.sig2 keys/FINGERPRINT keys/*.plist repo/*-repodata* | awk '!seen[$0]++') + # The sdist first, then packages and signatures and the client key, + # then the repodata last. + mapfile -t FILES < <(printf '%s\n' sdist/*.tar.gz xbps/repo/*.x86_64.xbps xbps/repo/*.sig2 \ + xbps/keys/FINGERPRINT xbps/keys/*.plist xbps/repo/*-repodata* | awk '!seen[$0]++') NAMES=$(for f in "${FILES[@]}"; do basename "$f"; done) echo "assets to publish: $(printf '%s\n' "$NAMES" | wc -l)" - NOTES=$(printf '%s\n\n%s\n' \ - "Signed xbps repository, Void Linux glibc x86_64. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \ - "$(cat keys/FINGERPRINT)") + SDIST_SHA256=$(cat SDIST_SHA256) + NOTES=$(printf '%s\n\n%s\n%s\n' \ + "Signed xbps repository, Void Linux glibc x86_64, built from ${TAG} by the release workflow. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \ + "$(cat xbps/keys/FINGERPRINT)" \ + "sdist sha256: ${SDIST_SHA256} (voculinux-*.tar.gz, the source the packages are built from)") echo "== tag release ${TAG}" ensure_release "$TAG" "$TAG" "$NOTES" diff --git a/justfile b/justfile index 8502946..ef94f9c 100644 --- a/justfile +++ b/justfile @@ -134,14 +134,16 @@ native-smoke distro="debian:12": void-smoke: bash packaging/void/smoke.sh -# Build every Void template, index the full repository and sign packages -# plus metadata with the RSA4096 repository key, in the pinned Void -# container via podman (never Docker), as ticket #29 pipelines. Fails -# closed without a key: set XBPS_SIGNING_KEY_FILE (CI writes the -# XBPS_SIGNING_KEY secret to a file first). Needs no host Void install; -# run `just void-smoke` first — the repo assembles tested packages. -void-repo: - bash packaging/void/repo.sh +# Dry-run or perform the signed Void release for TAG (vX.Y.Z[-phase]) exactly +# as the Gitea tag workflow does: packaging/void/build-release.sh release. +# Stages the complete set under publish/ and publishes nothing. Needs +# XBPS_SIGNING_KEY_FILE (an RSA 4096 private key whose public half is +# packaging/void/repository-key.plist) and the release tools in +# packaging/void/README.md. Podman only, never Docker. +void-release TAG: + bash packaging/void/build-release.sh release "{{TAG}}" \ + --key-file "${XBPS_SIGNING_KEY_FILE:?set XBPS_SIGNING_KEY_FILE to the RSA 4096 key}" \ + --out-dir publish # Build the canonical sdist, prepare an explicitly unattested local PKGBUILD, # then build/install it unchanged on current Arch (needs uv and docker). diff --git a/packaging/void/build-release.sh b/packaging/void/build-release.sh new file mode 100755 index 0000000..20e3460 --- /dev/null +++ b/packaging/void/build-release.sh @@ -0,0 +1,352 @@ +#!/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-.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]+)*)(-(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 diff --git a/packaging/void/build.sh b/packaging/void/build.sh deleted file mode 100755 index 3c8edbd..0000000 --- a/packaging/void/build.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh -# Host-side harness for packaging/void: runs the container flow (see -# container-main.sh and container-build.sh) in the pinned Void glibc -# container via podman, never Docker. -# -# Usage: packaging/void/build.sh [-- ] -# -# packaging/void/build.sh python3-pywhispercpp -# packaging/void/build.sh python3-pywhispercpp -- -o ~vulkan pkg -# -# Anything after -- is passed to `xbps-src` before the package name (use it -# for -o ~vulkan CPU-only rebuilds). The package build itself fetches -# nothing from the network beyond the template's declared distfiles: -# xbps-src verifies distfile checksums before unpacking. -set -eu - -CDPATH="" -HERE=$(dirname -- "$0") -HERE=$(cd -- "$HERE" && pwd) -REPO_ROOT=$(cd -- "$HERE/../.." && pwd) -PKGNAME=${1:?usage: build.sh "" [-- ""]} -shift -if [ "${1:-}" = "--" ]; then - shift -fi -XBPS_ARGS=${*:-pkg} - -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" ] || { - echo "build.sh: pins missing from tool_checksums.txt" >&2 - exit 1 -} -case "$XBPS_ARGS" in -*~vulkan*) EXPECT_VULKAN=0 ;; -*) EXPECT_VULKAN=1 ;; -esac - -echo "Pulling pinned base image: $BASE_IMAGE" -podman pull "$BASE_IMAGE" - -podman run --rm \ - -v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \ - -v "$HERE/container-main.sh:/mnt/container-main.sh:ro" \ - -v "$HERE/container-build.sh:/mnt/container-build.sh:ro" \ - -e "VOID_COMMIT=$VOID_COMMIT" \ - -e "PKGNAME=$PKGNAME" \ - -e "XBPS_ARGS=$XBPS_ARGS" \ - -e "EXPECT_VULKAN=$EXPECT_VULKAN" \ - "$BASE_IMAGE" /bin/sh /mnt/container-main.sh -echo "OK: $PKGNAME built and imported cleanly" diff --git a/packaging/void/container-build.sh b/packaging/void/container-build.sh deleted file mode 100755 index 5e5e8da..0000000 --- a/packaging/void/container-build.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -# Builder-side half of packaging/void/build.sh. Runs inside the pinned Void -# container as the unprivileged builder user (xbps-src refuses root). -# -# Not executed directly: build.sh mounts the repo's srcpkgs next to this -# file, copies it into the container, and runs it after cloning -# void-packages at the pinned commit. PKGNAME and XBPS_ARGS arrive via the -# environment. Keeping this a real file (instead of an inline `sh -c` -# string) means normal quoting, heredocs, and shellcheck apply. -set -eu - -export HOME=/home/builder -export PATH=/usr/bin:/bin:/usr/sbin:/sbin -cd /void-packages - -echo "== xlint $PKGNAME" -xlint "srcpkgs/$PKGNAME/template" - -echo "== show-options $PKGNAME" -./xbps-src show-options "$PKGNAME" || true - -echo "== xbps-src $XBPS_ARGS $PKGNAME" -# shellcheck disable=SC2086 -./xbps-src $XBPS_ARGS "$PKGNAME" diff --git a/packaging/void/container-main.sh b/packaging/void/container-main.sh deleted file mode 100755 index d980cd1..0000000 --- a/packaging/void/container-main.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/bin/sh -# Root-side half of the packaging/void container harness. Runs inside the -# pinned Void container as root: system setup, the void-packages checkout, -# then the builder half (container-build.sh) as the builder user, then the -# install plus import smoke as root again. -# -# Not executed directly: build.sh mounts this file and runs it. PKGNAME, -# XBPS_ARGS, EXPECT_VULKAN and VOID_COMMIT arrive via the environment. -set -eu - -export PATH=/usr/bin:/bin:/usr/sbin:/sbin -xbps-install -Suy xbps >/dev/null -xbps-install -y git base-devel xtools binutils zstd >/dev/null -git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages -git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT" -git -C /void-packages checkout --quiet "$VOID_COMMIT" -cp -r /overlay/. /void-packages/srcpkgs/ -id builder >/dev/null 2>&1 || useradd -m builder -cp /mnt/container-build.sh /run-build.sh -chown builder:builder /void-packages /run-build.sh -chmod +x /run-build.sh -su -s /bin/sh builder -c /run-build.sh - -echo "== install $PKGNAME from the local repository" -PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKGNAME"-*.x86_64.xbps /host/binpkgs/"$PKGNAME"-*.x86_64.xbps 2>/dev/null | head -n1) -[ -n "$PKGFILE" ] || { - echo "built package not found" >&2 - exit 1 -} -echo "built: $PKGFILE" -xbps-install -Sy >/dev/null -xbps-install -y --repository="$(dirname "$PKGFILE")" "$PKGNAME" >/dev/null - -case "$PKGNAME" in -voculinux) - echo "== payload $PKGNAME: desktop entry, icons, packaged marker" - tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/applications/voculinux.desktop" || { - echo "renamed desktop entry missing from the built package" >&2 - exit 1 - } - tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/icons/hicolor/scalable/apps/voculinux.svg" || { - echo "renamed icons missing from the built package" >&2 - exit 1 - } - tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/voculinux/packaged" || { - echo "packaged-install marker missing from the built package" >&2 - exit 1 - } - echo "== payload $PKGNAME: no speech models, no vendored wheels" - if tar -tf "$PKGFILE" 2>/dev/null | grep -E "models/|\.ggml$|\.bin$|\.pt$|\.whl$"; then - echo "package payload carries models or wheels" >&2 - exit 1 - fi - echo "no models or wheels in the shipped package" - echo "== payload $PKGNAME: no pre-rename leftovers" - if tar -tf "$PKGFILE" 2>/dev/null | grep -E "usr/share/applications/vocalinux|usr/share/icons/.*/vocalinux"; then - echo "pre-rename file names in the OS-visible payload" >&2 - exit 1 - fi - echo "no pre-rename leftovers in the shipped package" - echo "== version and import smoke $PKGNAME" - voculinux --version - cat > /voculinux-smoke.py <<'PYEOF' -# The importable package keeps its retained internal name (vocalinux); the -# distribution metadata is the fork's own name (voculinux). -import importlib - -MODULES = ( - "vocalinux.main", - "vocalinux.ui.tray_indicator", - "vocalinux.speech_recognition.recognition_manager", - "vocalinux.speech_recognition.command_processor", - "vocalinux.text_injection.text_injector", - "vocalinux.text_injection.ibus_engine", - "vocalinux.ui.keyboard_backends", - "pywhispercpp.model", -) -for module in MODULES: - importlib.import_module(module) -print("import-smoke OK:", ", ".join(MODULES)) - -from importlib.metadata import version -print("voculinux", version("voculinux")) -PYEOF - python3 /voculinux-smoke.py - ;; -python3-pywhispercpp) -echo "== compiler flag scan of the shipped ggml library" -LIBMEMBER=$(tar -tf "$PKGFILE" 2>/dev/null | grep -E "libggml-cpu\.so" | head -n1) -[ -n "$LIBMEMBER" ] || { - echo "libggml-cpu missing from the built package" >&2 - exit 1 -} -echo "member: $LIBMEMBER" -mkdir -p /flagscan -tar -xf "$PKGFILE" -C /flagscan "$LIBMEMBER" -LIBSCAN="/flagscan/${LIBMEMBER#./}" -echo "-- .GCC.command.line:" -readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null || echo "(section absent)" -echo "-- .comment:" -readelf -p .comment "$LIBSCAN" 2>/dev/null || echo "(section absent)" -if readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null | grep -E "march=native|mcpu=native|mtune=native"; then - echo "host-native CPU tuning in the shipped library" >&2 - exit 1 -fi -echo "no host-native tuning in the shipped library" - -echo "== import smoke $PKGNAME" -python3 -c "from pywhispercpp.model import Model; print('model submodule ok')" -cat > /smoke.py <<'PYEOF' -import glob -import os -import pywhispercpp -pkgdir = os.path.dirname(pywhispercpp.__file__) -sitedir = os.path.dirname(pkgdir) -libs = sorted(os.path.basename(p) for p in glob.glob(sitedir + "/lib*.so*")) -print("shipped libraries:", libs) -assert any(n.startswith("libwhisper") for n in libs), "libwhisper missing" -exts = [n for n in os.listdir(sitedir) if n.startswith("_pywhispercpp") and n.endswith(".so")] -assert exts, "compiled extension missing" -print("extension:", exts) -has_vulkan = any("vulkan" in n for n in libs) -want_vulkan = bool(int(os.environ["EXPECT_VULKAN"])) -assert has_vulkan == want_vulkan, "vulkan backend present=%s, expected=%s" % (has_vulkan, want_vulkan) -print("smoke ok") -PYEOF -EXPECT_VULKAN="$EXPECT_VULKAN" python3 /smoke.py - ;; -python3-PyAudio) - echo "== import smoke $PKGNAME" - python3 -c "import pyaudio; print('pyaudio', pyaudio.__version__)" - ;; -python3-pynput) - # pynput selects a display backend at import and raises without one; - # the container has no display, so presence plus the installed - # version (the same proof the native smoke accepts) is the smoke. - echo "== import smoke $PKGNAME" - python3 -c "import importlib.metadata, importlib.util; assert importlib.util.find_spec('pynput'), 'pynput not importable'; print('pynput', importlib.metadata.version('pynput'))" - ;; -*) - echo "no smoke recipe for package '$PKGNAME' — add one alongside the template" >&2 - exit 1 - ;; -esac diff --git a/packaging/void/container-release.sh b/packaging/void/container-release.sh new file mode 100755 index 0000000..bd4018f --- /dev/null +++ b/packaging/void/container-release.sh @@ -0,0 +1,178 @@ +#!/bin/sh +# Container half of packaging/void/build-release.sh (tickets #28 and #29). +# Runs inside the pinned Void glibc image, once per phase. Each phase gets only +# the privileges it needs: +# +# build root, network on, NO signing key. Clones void-packages at the +# pinned commit, renders the voculinux build tree from the tag, and +# builds every template in smoke-gate order as the unprivileged +# builder user (xbps-src refuses root). Build scripts run here, so +# the key is never mounted in this phase. +# sign root, NO network, the RSA4096 key mounted read-only. Runs no +# build code: it indexes and signs the packages the build produced. +# install root, network on (the official repository supplies dependencies), +# NO key. In a clean image, installs voculinux from the staged signed +# repository and checks what a user sees: the version, the payload, +# the imports, and that the repository is refused until its key is +# trusted. +# +# Not executed directly: build-release.sh mounts this file and runs it with one +# phase argument. Inputs arrive as environment variables set there. +set -eu + +export PATH=/usr/bin:/bin:/usr/sbin:/sbin +KEY=/run/xbps-sign-key.pem +PKG_STAGE=/mnt/packages +XBPS_STAGE=/mnt/xbps +OFFICIAL=https://repo-default.voidlinux.org/current + +fail() { + echo "container-release.sh: $*" >&2 + exit 1 +} + +phase_build() { + : "${VOID_COMMIT:?}" "${PKGS:?}" "${XBPS_VERSION:?}" "${SDIST_NAME:?}" + export VOID_COMMIT PKGS XBPS_VERSION SDIST_NAME + + xbps-install -Suy xbps >/dev/null + xbps-install -y git base-devel xtools binutils zstd openssl >/dev/null + git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages + git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT" + git -C /void-packages checkout --quiet "$VOID_COMMIT" + cp -r /overlay/. /void-packages/srcpkgs/ + id builder >/dev/null 2>&1 || useradd -m builder + + # Build tree only: the committed template in the repository is never changed. + for PKG in $PKGS; do + if [ "$PKG" = voculinux ]; then + VOID_PACKAGES=/void-packages SDIST_DIR=/mnt/inputs sh /mnt/render-voculinux.sh + fi + done + + chown -R builder:builder /void-packages + su -s /bin/sh builder -c 'cd /void-packages && ./xbps-src binary-bootstrap' + for PKG in $PKGS; do + echo "== xlint $PKG" + su -s /bin/sh builder -c "cd /void-packages && xlint 'srcpkgs/$PKG/template'" + echo "== xbps-src pkg $PKG" + su -s /bin/sh builder -c "cd /void-packages && ./xbps-src pkg '$PKG'" + done + + # The distfile the template names must be the sdist staged for it. + su -s /bin/sh builder -c 'cd /void-packages && ./xbps-src show voculinux' >/tmp/show.txt 2>&1 || + fail "xbps-src show voculinux failed" + grep -qF "/$SDIST_NAME" /tmp/show.txt || fail "xbps-src does not resolve distfile $SDIST_NAME" + + mkdir -p "$PKG_STAGE" + for PKG in $PKGS; do + PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKG"-[0-9]*.x86_64.xbps 2>/dev/null | head -n1) + [ -n "$PKGFILE" ] || fail "built package missing from hostdir/binpkgs: $PKG" + echo "collected: $(basename "$PKGFILE")" + cp "$PKGFILE" "$PKG_STAGE/" + done +} + +phase_sign() { + : "${SIGNEDBY:?}" + [ -r "$KEY" ] && [ -s "$KEY" ] || fail "signing key is not mounted at $KEY" + mkdir -p "$XBPS_STAGE/repo" + cp "$PKG_STAGE"/*.x86_64.xbps "$XBPS_STAGE/repo/" + + echo "== index the full repository" + xbps-rindex -a "$XBPS_STAGE"/repo/*.x86_64.xbps + echo "== sign every binary package" + xbps-rindex -S --privkey "$KEY" --signedby "$SIGNEDBY" "$XBPS_STAGE"/repo/*.x86_64.xbps + echo "== sign the repository metadata" + xbps-rindex -s --privkey "$KEY" --signedby "$SIGNEDBY" "$XBPS_STAGE/repo" + echo "signed repository staged: $(ls "$XBPS_STAGE/repo" | wc -l) files" +} + +# The payload checks the package must pass before anyone installs it. +payload_voculinux() { + PKGFILE=$1 + tar -tf "$PKGFILE" | grep -q "usr/share/applications/voculinux.desktop" || + fail "renamed desktop entry missing from the package" + tar -tf "$PKGFILE" | grep -q "usr/share/icons/hicolor/scalable/apps/voculinux.svg" || + fail "renamed icon missing from the package" + tar -tf "$PKGFILE" | grep -q "usr/share/voculinux/packaged" || + fail "packaged-install marker missing from the package" + if tar -tf "$PKGFILE" | grep -E "models/|\.ggml$|\.bin$|\.pt$|\.whl$"; then + fail "package payload carries models or wheels" + fi + if tar -tf "$PKGFILE" | grep -E "usr/share/applications/vocalinux|usr/share/icons/.*/vocalinux"; then + fail "pre-rename file names in the OS-visible payload" + fi + echo "payload ok: desktop entry, icon and marker present; no models, wheels or pre-rename names" +} + +# whisper.cpp must ship its Vulkan backend and no host-native CPU tuning. +payload_pywhispercpp() { + PKGFILE=$1 + tar -tf "$PKGFILE" | grep -q "libggml-vulkan" || fail "the Vulkan backend is missing from $PKGFILE" + LIBMEMBER=$(tar -tf "$PKGFILE" | grep -E "libggml-cpu\.so" | head -n1) + [ -n "$LIBMEMBER" ] || fail "libggml-cpu is missing from $PKGFILE" + mkdir -p /flagscan + tar -xf "$PKGFILE" -C /flagscan "$LIBMEMBER" + LIBSCAN="/flagscan/${LIBMEMBER#./}" + if readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null | grep -E "march=native|mcpu=native|mtune=native"; then + fail "host-native CPU tuning in the shipped libggml-cpu" + fi + echo "payload ok: Vulkan backend present; no host-native tuning in libggml-cpu" +} + +phase_install() { + : "${PKGS:?}" "${XBPS_VERSION:?}" "${VERSION:?}" + [ -d "$XBPS_STAGE/repo" ] && [ -d "$XBPS_STAGE/keys" ] || fail "staged repository or keys are missing" + xbps-install -Sy xbps binutils >/dev/null + + echo "== the signed repository is refused while its key is not trusted" + # No -y: the documented first sync must not accept an unknown key. + set +e + REFUSAL=$(xbps-install -S --repository="$XBPS_STAGE/repo" voculinux &1) + REFUSAL_RC=$? + set -e + printf '%s\n' "$REFUSAL" | grep -i "fingerprint" || true + [ "$REFUSAL_RC" -ne 0 ] || fail "the repository installed without its key being trusted" + xbps-query voculinux >/dev/null 2>&1 && fail "voculinux was installed by the untrusted attempt" + echo "refused without the key, as required (rc=$REFUSAL_RC)" + + echo "== trust the staged key, then install voculinux from the signed repository" + cp "$XBPS_STAGE"/keys/*.plist /var/db/xbps/keys/ + xbps-install -y --repository="$XBPS_STAGE/repo" --repository="$OFFICIAL" -S voculinux >/dev/null + xbps-query voculinux | grep -qx "pkgver: voculinux-${XBPS_VERSION}_1" || + fail "installed package is not voculinux-${XBPS_VERSION}_1" + echo "installed: voculinux-${XBPS_VERSION}_1" + + echo "== the renamed binary reports its version" + VERSION_OUT=$(voculinux --version 2>&1) || fail "voculinux --version exited non-zero" + printf '%s\n' "$VERSION_OUT" + printf '%s' "$VERSION_OUT" | grep -qF "$VERSION" || fail "voculinux --version does not show $VERSION" + + echo "== payload of the built packages" + for PKG in $PKGS; do + PKGFILE=$(ls "$PKG_STAGE"/"$PKG"-[0-9]*.x86_64.xbps | head -n1) + case "$PKG" in + voculinux) payload_voculinux "$PKGFILE" ;; + python3-pywhispercpp) payload_pywhispercpp "$PKGFILE" ;; + esac + done + + echo "== imports, without a display or hardware" + # pynput: the dummy backend proves the package and its loader. The X backend + # needs an X server and is covered by the manual hardware checklist. + PYNPUT_BACKEND=dummy python3 -c "import pynput.keyboard, pynput.mouse; print('pynput dummy backend ok')" + python3 -c "import evdev; print('evdev ok')" + python3 -c "import gi; gi.require_version('Gtk', '3.0'); from gi.repository import Gtk; print('gi ok: Gtk', Gtk.get_major_version(), Gtk.get_minor_version())" + python3 -c "from pywhispercpp.model import Model; print('pywhispercpp.model ok')" + python3 -c "import pyaudio; print('pyaudio ok')" + python3 -c "import vocalinux.main, vocalinux.speech_recognition.recognition_manager; print('vocalinux imports ok')" + echo "INSTALL SMOKE OK: voculinux $VERSION installed from the signed repository" +} + +case "${1:-}" in +build) phase_build ;; +sign) phase_sign ;; +install) phase_install ;; +*) fail "usage: container-release.sh build|sign|install" ;; +esac diff --git a/packaging/void/container-repo.sh b/packaging/void/container-repo.sh deleted file mode 100755 index 324857b..0000000 --- a/packaging/void/container-repo.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/bin/sh -# Repository half of packaging/void/repo.sh (ticket #29). Runs inside the -# pinned Void container as root: system setup, the void-packages checkout -# at the pinned commit, then every template built in smoke-gate order as -# the unprivileged builder user (xbps-src refuses root), then index, sign, -# verify and stage as root again. -# -# Not executed directly: repo.sh mounts this file and runs it. VOID_COMMIT, -# PKGS and SIGNEDBY arrive via the environment; the RSA4096 private key is -# mounted read-only at /run/xbps-sign-key.pem (repo.sh validated it before -# any container ran). -# -# Keeping this a real file (instead of an inline `sh -c` string) means -# normal quoting, heredocs, and shellcheck apply. -set -eu - -export PATH=/usr/bin:/bin:/usr/sbin:/sbin -KEY=/run/xbps-sign-key.pem -OUT=/mnt/out -REPO="$OUT/repo" -KEYS="$OUT/keys" - -[ -r "$KEY" ] || { - echo "container-repo.sh: signing key not mounted at $KEY" >&2 - exit 1 -} - -xbps-install -Suy xbps >/dev/null -xbps-install -y git base-devel xtools binutils zstd openssl >/dev/null -git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages -git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT" -git -C /void-packages checkout --quiet "$VOID_COMMIT" -cp -r /overlay/. /void-packages/srcpkgs/ -id builder >/dev/null 2>&1 || useradd -m builder -chown builder:builder /void-packages - -echo "== build every template in order: $PKGS" -cd /void-packages -# shellcheck disable=SC2086 -for PKG in $PKGS; do - echo "== xlint $PKG" - su -s /bin/sh builder -c "cd /void-packages && xlint 'srcpkgs/$PKG/template'" - echo "== xbps-src pkg $PKG" - su -s /bin/sh builder -c "cd /void-packages && ./xbps-src pkg '$PKG'" -done - -echo "== collect built packages" -mkdir -p "$REPO" "$KEYS" -# shellcheck disable=SC2086 -for PKG in $PKGS; do - PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKG"-*.x86_64.xbps 2>/dev/null | head -n1) - [ -n "$PKGFILE" ] || { - echo "FAIL: $PKG (built package missing from hostdir/binpkgs)" >&2 - exit 1 - } - echo "collected: $(basename "$PKGFILE")" - cp "$PKGFILE" "$REPO/" -done - -echo "== index the full repository" -# shellcheck disable=SC2086 -xbps-rindex -a "$REPO"/*.x86_64.xbps - -echo "== sign every binary package" -xbps-rindex -S --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO"/*.x86_64.xbps - -echo "== sign the repository metadata" -xbps-rindex -s --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO" - -echo "== prove an unsigned install is refused" -set +e -PROBE=$(xbps-install -r /tmp/keyprobe --repository="$REPO" -Sy voculinux &1) -PROBE_RC=$? -set -e -if [ "$PROBE_RC" -eq 0 ] || ! printf '%s' "$PROBE" | grep -q "RSA signed"; then - echo "FAIL: unsigned install was not refused — signatures are not enforced" >&2 - printf '%s\n' "$PROBE" >&2 - exit 1 -fi -echo "unsigned install refused as required" - -FP=$(printf '%s' "$PROBE" | grep -i Fingerprint | awk '{print $2}' | head -n1) -[ -n "$FP" ] || { - echo "FAIL: could not read the key fingerprint from the refusal" >&2 - exit 1 -} -echo "key fingerprint: $FP" - -echo "== stage the client key" -openssl rsa -in "$KEY" -pubout -out /tmp/repo-pub.pem 2>/dev/null -PUB64=$(base64 -w0 /tmp/repo-pub.pem) -# XML-escape the signer identity: xbps writes </> itself on import -# and rejects a plist carrying raw angle brackets. -ESCAPED_SIGNEDBY=$(printf '%s' "$SIGNEDBY" | sed -e 's/&/\&/g' -e 's//\>/g') -{ - printf '%s\n' '' - printf '%s\n' '' - printf '%s\n' '' '' ' public-key' - printf ' %s\n' "$PUB64" - printf '%s\n' ' public-key-size' ' 4096' ' signature-by' - printf ' %s\n' "$ESCAPED_SIGNEDBY" - printf '%s\n' '' '' -} > "$KEYS/$FP.plist" -{ - printf 'signed-by: %s\n' "$SIGNEDBY" - printf 'fingerprint: %s\n' "$FP" -} > "$KEYS/FINGERPRINT" -echo "staged: keys/$FP.plist" - -echo "== verify: install the app from the signed repo in a clean rootdir" -VERIFY=/tmp/verify-install -mkdir -p "$VERIFY/var/db/xbps/keys" -# The app's full dependency tree resolves from the official remote, so the -# verify rootdir trusts the official keys too; our repo stays enforced -# because its packages verify only against the staged key below. -cp /var/db/xbps/keys/*.plist "$VERIFY/var/db/xbps/keys/" -cp "$KEYS/$FP.plist" "$VERIFY/var/db/xbps/keys/" -xbps-install -y -r "$VERIFY" \ - --repository="$REPO" \ - --repository=https://repo-default.voidlinux.org/current \ - -Sy voculinux -xbps-query -r "$VERIFY" -l | grep -q "^ii voculinux-" || { - echo "FAIL: voculinux not installed in the verify rootdir" >&2 - exit 1 -} -echo "verified: voculinux installs from the signed repository" - -ls "$REPO" -echo "SIGNED REPO OK: fingerprint $FP" diff --git a/packaging/void/render-voculinux.sh b/packaging/void/render-voculinux.sh new file mode 100755 index 0000000..d9f72dd --- /dev/null +++ b/packaging/void/render-voculinux.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# Renders the voculinux template for one release inside a void-packages build +# tree (ticket #27, spec #16 "Release pipeline and upstream intake"). +# +# The committed template is never edited by a build. This script runs against +# the container's copy of srcpkgs and does three things, each refused unless it +# lands exactly once: +# +# 1. pins version, _tag and checksum to the release being built; +# 2. checks the sdist's digest is the checksum it just pinned; +# 3. places the sdist where xbps-src looks for a distfile of this package +# (hostdir/sources/-/), so xbps-src verifies +# the local file and never downloads it. +# +# Run as root, by container-release.sh (release) and container-main.sh (smoke). +# Inputs arrive as environment variables from build-release.sh: +# VOID_PACKAGES void-packages checkout (default /void-packages) +# SDIST_DIR directory holding the sdist (read only) +# XBPS_VERSION xbps spelling of the release, e.g. 0.18.5 or 0.19.0beta +# TAG the release tag, e.g. v0.19.0-beta +# SDIST_NAME distfile basename, voculinux-.tar.gz +# SDIST_SHA256 sha256 of $SDIST_DIR/$SDIST_NAME +set -eu + +fail() { + echo "render-voculinux.sh: $*" >&2 + exit 1 +} + +VP=${VOID_PACKAGES:-/void-packages} +TEMPLATE="$VP/srcpkgs/voculinux/template" + +for name in SDIST_DIR XBPS_VERSION TAG SDIST_NAME SDIST_SHA256; do + eval "value=\${$name:-}" + [ -n "$value" ] || fail "$name is not set" +done +for name in XBPS_VERSION TAG SDIST_NAME SDIST_SHA256; do + eval "value=\${$name}" + case "$value" in + *[!A-Za-z0-9._-]*) fail "$name has characters outside [A-Za-z0-9._-]: $value" ;; + esac +done +[ -f "$TEMPLATE" ] || fail "no voculinux template at $TEMPLATE" +[ -s "$SDIST_DIR/$SDIST_NAME" ] || fail "sdist is missing: $SDIST_DIR/$SDIST_NAME" + +digest=$(sha256sum "$SDIST_DIR/$SDIST_NAME" | cut -d' ' -f1) +[ "$digest" = "$SDIST_SHA256" ] || fail "sdist digest $digest is not the expected $SDIST_SHA256" + +# set_line KEY VALUE: the template must hold exactly one KEY= line, and the +# rewritten line must read back exactly as asked. +set_line() { + key=$1 + value=$2 + count=$(grep -c "^$key=" "$TEMPLATE" || true) + [ "$count" = 1 ] || fail "template must hold exactly one $key= line, found $count" + sed -i "s|^$key=.*|$key=$value|" "$TEMPLATE" + grep -qxF "$key=$value" "$TEMPLATE" || fail "could not pin $key=$value in the build tree" +} + +set_line version "$XBPS_VERSION" +set_line _tag "$TAG" +set_line checksum "$SDIST_SHA256" + +DEST="$VP/hostdir/sources/voculinux-$XBPS_VERSION" +mkdir -p "$DEST" +cp "$SDIST_DIR/$SDIST_NAME" "$DEST/$SDIST_NAME" +[ "$(sha256sum "$DEST/$SDIST_NAME" | cut -d' ' -f1)" = "$SDIST_SHA256" ] || + fail "the copied distfile does not match $SDIST_SHA256" + +echo "render-voculinux.sh: pinned version=$XBPS_VERSION _tag=$TAG checksum=$SDIST_SHA256" +echo "render-voculinux.sh: distfile staged at $DEST/$SDIST_NAME" diff --git a/packaging/void/repo.sh b/packaging/void/repo.sh deleted file mode 100755 index fc4568f..0000000 --- a/packaging/void/repo.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/sh -# Signed xbps repository pipeline (ticket #29): build every template in -# smoke-gate order, index the full repository, and sign every binary -# package plus the repository metadata with the RSA4096 repository key. -# Runs in the pinned Void container via podman, never Docker; no host -# Void install needed. -# -# Usage: XBPS_SIGNING_KEY_FILE=/path/to/key.pem packaging/void/repo.sh -# or: XBPS_SIGNING_KEY_FILE=/path/to/key.pem just void-repo -# -# Options (all optional): -# --key-file PATH repository private key (default: $XBPS_SIGNING_KEY_FILE) -# --out-dir DIR stage the signed repo here (default: publish/xbps) -# --signed-by "N " signer identity (default: Voculinux ) -# -# The private key arrives as a file so CI can write the XBPS_SIGNING_KEY -# secret to disk with newlines intact: -# -# printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem -# XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem packaging/void/repo.sh -# -# Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before -# any container runs, so an unsigned repository can never publish. -set -eu - -CDPATH="" -HERE=$(dirname -- "$0") -HERE=$(cd -- "$HERE" && pwd) -REPO_ROOT=$(cd -- "$HERE/../.." && pwd) - -KEY_FILE=${XBPS_SIGNING_KEY_FILE:-} -OUT_DIR="$REPO_ROOT/publish/xbps" -SIGNEDBY="Voculinux " - -while [ $# -gt 0 ]; do - case "$1" in - --key-file) KEY_FILE=${2:?usage: repo.sh --key-file PATH} ; shift 2 ;; - --out-dir) OUT_DIR=${2:?usage: repo.sh --out-dir DIR} ; shift 2 ;; - --signed-by) SIGNEDBY=${2:?usage: repo.sh --signed-by "Name "} ; shift 2 ;; - -*) echo "repo.sh: unknown option: $1" >&2 ; exit 1 ;; - *) echo "repo.sh: unexpected argument: $1" >&2 ; exit 1 ;; - esac -done - -# Fail closed before any container runs: no key, no repo, no publish. -[ -n "$KEY_FILE" ] || { - echo "repo.sh: no signing key: pass --key-file or set XBPS_SIGNING_KEY_FILE" >&2 - echo "repo.sh: CI writes the XBPS_SIGNING_KEY secret to a file first:" >&2 - echo "repo.sh: printf '%s' \"\$XBPS_SIGNING_KEY\" > /tmp/xbps-sign-key.pem" >&2 - exit 1 -} -[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] || { - echo "repo.sh: signing key missing or empty: $KEY_FILE" >&2 - echo "repo.sh: refusing to publish an unsigned repository" >&2 - exit 1 -} -openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 || { - echo "repo.sh: not a valid RSA private key: $KEY_FILE" >&2 - echo "repo.sh: refusing to publish an unsigned repository" >&2 - exit 1 -} -openssl rsa -in "$KEY_FILE" -text -noout 2>/dev/null | head -1 | grep -q "4096 bit" || { - echo "repo.sh: signing key is not RSA4096: $KEY_FILE" >&2 - echo "repo.sh: generate one with: openssl genrsa -out xbps-sign-key.pem 4096" >&2 - exit 1 -} - -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" ] || { - echo "repo.sh: pins missing from tool_checksums.txt" >&2 - exit 1 -} -# One dependency order for the whole track: the signed repo builds the -# same set in the same order as the smoke gate (the app last), so a -# renamed or added template cannot silently drop out of either. -PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh") -[ -n "$PKGS" ] || { - echo "repo.sh: could not read the package order from smoke.sh" >&2 - exit 1 -} - -mkdir -p "$OUT_DIR" - -echo "Pulling pinned base image: $BASE_IMAGE" -podman pull "$BASE_IMAGE" - -podman run --rm \ - -v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \ - -v "$HERE/container-repo.sh:/mnt/container-repo.sh:ro" \ - -v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \ - -v "$OUT_DIR:/mnt/out" \ - -e "VOID_COMMIT=$VOID_COMMIT" \ - -e "PKGS=$PKGS" \ - -e "SIGNEDBY=$SIGNEDBY" \ - "$BASE_IMAGE" /bin/sh /mnt/container-repo.sh -echo "OK: signed repository staged in $OUT_DIR" diff --git a/packaging/void/smoke.sh b/packaging/void/smoke.sh index ae4ba7d..208445b 100755 --- a/packaging/void/smoke.sh +++ b/packaging/void/smoke.sh @@ -1,35 +1,24 @@ #!/bin/sh -# Smoke gate for all Void templates (ticket #28): lint, build, install -# and version/import smoke for every template, one command, from a clean -# checkout. Runs under podman, never Docker; no host Void install needed. +# Smoke gate for all Void templates (ticket #28): one command builds every +# template in dependency order, signs the result with a throwaway key, installs +# voculinux from that repository in a clean Void image and smokes it. The gate +# runs the same code path as a release (packaging/void/build-release.sh), so a +# gate that passes is a release that would build. # # Usage: packaging/void/smoke.sh # or: just void-smoke # -# Builds every template under packaging/void/srcpkgs in dependency order -# (leaves first, the app last — it resolves the others from the local -# repository at install time) by delegating each package to build.sh, -# which runs xlint, xbps-src, install and the payload/version/import -# smoke in the pinned Void container. Fails fast naming the package; the -# harness echoes each stage, so the output shows which package broke at -# which stage. +# Podman only, never Docker. Nothing is published, and the release key is not +# used. Needs the same tools as a release: podman, openssl, git, python3.11, +# and uv 0.12.x or 0.13.x (see packaging/void/README.md). set -eu CDPATH="" HERE=$(dirname -- "$0") HERE=$(cd -- "$HERE" && pwd) -# Dependency order: the app last. tests/test_void_smoke_gate.py fails if -# this list drifts from packaging/void/srcpkgs or the app moves earlier. +# Dependency order: the app last. tests/test_void_smoke_gate.py fails if this +# list drifts from packaging/void/srcpkgs. PKGS="python3-PyAudio python3-pynput python3-pywhispercpp voculinux" -for PKG in $PKGS; do - echo "== smoke $PKG" - "$HERE/build.sh" "$PKG" || { - echo "FAIL: $PKG (see the stage above: xlint, xbps-src, install, payload/version/import smoke)" >&2 - exit 1 - } - echo "OK: $PKG linted, built, installed and smoked" -done - -echo "SMOKE GATE PASSED: $PKGS" +exec "$HERE/build-release.sh" smoke diff --git a/packaging/void/srcpkgs/voculinux/template b/packaging/void/srcpkgs/voculinux/template index fdc331a..ffdf176 100644 --- a/packaging/void/srcpkgs/voculinux/template +++ b/packaging/void/srcpkgs/voculinux/template @@ -2,6 +2,10 @@ pkgname=voculinux version=0.18.5 revision=1 +# The release tag this template names: v + __version__, with the pre-release +# hyphen kept (the tag is v0.19.0-beta while version is 0.19.0beta). The release +# gate (packaging/void/build-release.sh check-tag) refuses any mismatch. +_tag=v0.18.5 # The release sdist ships no setup.py (setuptools pyproject-only), so the # build goes through the PEP 517 wheel (cf. Solaar, PhotoCollage). build_style=python3-pep517 @@ -16,12 +20,12 @@ short_desc="Free, offline voice dictation for Linux" maintainer="Xavier Karma " license="AGPL-3.0-only" homepage="https://git.bongbetic.com/xavierk/voculinux" -distfiles="https://git.bongbetic.com/xavierk/voculinux/releases/download/v${version}/voculinux-${version}.tar.gz" -# Pending: set to the sha256 of voculinux-${version}.tar.gz once the fork's -# v${version} release exists (see packaging/void/README.md, "Checksum"). -# Until then xbps-src fails closed on this value; it never falls back to the -# upstream archive. -checksum=PENDING_FORK_RELEASE_SHA256 +distfiles="https://git.bongbetic.com/xavierk/voculinux/releases/download/${_tag}/voculinux-${version}.tar.gz" +# sha256 of that release sdist. Recorded here by the post-release pull request +# after each release. Until the first fork release it is a placeholder that is +# never a real digest: every release build replaces it, in its build tree only, +# with the digest of the sdist it builds (packaging/void/render-voculinux.sh). +checksum=0000000000000000000000000000000000000000000000000000000000000000 post_install() { # The fork's sdist already names the desktop entry, icons and launchers diff --git a/packaging/void/tool_checksums.txt b/packaging/void/tool_checksums.txt index 522b170..6f9be95 100644 --- a/packaging/void/tool_checksums.txt +++ b/packaging/void/tool_checksums.txt @@ -1,4 +1,4 @@ -# Pinned build inputs for packaging/void/build.sh. +# Pinned build inputs for packaging/void/build-release.sh (and smoke.sh, which calls it). # # Same contract as packaging/appimage/tool_checksums.txt and # packaging/native/tool_checksums.txt: everything the package build diff --git a/packaging/void/xbps-fingerprint.py b/packaging/void/xbps-fingerprint.py new file mode 100755 index 0000000..2f36d30 --- /dev/null +++ b/packaging/void/xbps-fingerprint.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""xbps repository-key helper for packaging/void/build-release.sh. + + xbps-fingerprint.py fingerprint PEM + print the xbps fingerprint of an RSA public key (PEM): the MD5 of the + ssh-rsa blob of the key, the identifier libxbps calls xbps_pubkey2fp. + + xbps-fingerprint.py plist PEM SIGNER OUT + write the client key plist xbps expects for that public key. Used only + for a throwaway smoke key; the release publishes the committed + repository-key.plist byte for byte. + +Only the public key is ever read here. Run with -I: the interpreter must not +pick up modules from the directory a downloaded file sits in. +""" + +from __future__ import annotations + +import hashlib +import plistlib +import re +import struct +import subprocess +import sys +from pathlib import Path + + +def _rsa_numbers(pem: Path) -> tuple[int, int]: + """Modulus and exponent of a PEM public key, read through openssl.""" + text = subprocess.run( + ["openssl", "rsa", "-pubin", "-in", str(pem), "-noout", "-text"], + check=True, + capture_output=True, + text=True, + ).stdout + modulus_hex: list[str] = [] + exponent = None + in_modulus = False + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("Modulus"): + in_modulus = True + continue + if stripped.startswith("Exponent"): + in_modulus = False + exponent = int(stripped.split()[1]) + continue + if in_modulus and re.fullmatch(r"[0-9a-f:]+", stripped): + modulus_hex.append(stripped.replace(":", "")) + if exponent is None or not modulus_hex: + raise SystemExit(f"xbps-fingerprint.py: not an RSA public key: {pem}") + return int("".join(modulus_hex), 16), exponent + + +def _mpint(value: int) -> bytes: + """An SSH mpint: big-endian, with a leading zero when the top bit is set.""" + raw = value.to_bytes((value.bit_length() + 7) // 8, "big") + if raw[0] & 0x80: + raw = b"\x00" + raw + return struct.pack(">I", len(raw)) + raw + + +def fingerprint(pem: Path) -> str: + """Colon-separated lowercase MD5 of the ssh-rsa blob of the key.""" + modulus, exponent = _rsa_numbers(pem) + blob = struct.pack(">I", 7) + b"ssh-rsa" + _mpint(exponent) + _mpint(modulus) + digest = hashlib.md5(blob, usedforsecurity=False).hexdigest() + return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2)) + + +def write_plist(pem: Path, signer: str, out: Path) -> None: + """Write the client key plist xbps reads from /var/db/xbps/keys.""" + modulus, _ = _rsa_numbers(pem) + entry = { + "public-key": pem.read_bytes(), + "public-key-size": modulus.bit_length(), + "signature-by": signer, + } + with out.open("wb") as handle: + plistlib.dump(entry, handle, fmt=plistlib.FMT_XML) + + +def main(argv: list[str]) -> int: + if len(argv) == 2 and argv[0] == "fingerprint": + print(fingerprint(Path(argv[1]))) + return 0 + if len(argv) == 4 and argv[0] == "plist": + write_plist(Path(argv[1]), argv[2], Path(argv[3])) + return 0 + print(__doc__, file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) -- 2.54.0 From 80fb4365bd2002a508a8417f3e94788a62c09e7f Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 20:16:46 +0530 Subject: [PATCH 2/6] 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). --- packaging/void/build-release.sh | 25 +- packaging/void/container-release.sh | 4 +- packaging/void/smoke.sh | 1 + tests/test_rename_sweep.py | 4 +- tests/test_void_app_template.py | 64 ++- tests/test_void_pywhispercpp_template.py | 13 +- tests/test_void_release_build.py | 559 +++++++++++++++++++++++ tests/test_void_signed_repo.py | 192 ++------ tests/test_void_smoke_gate.py | 65 ++- tests/test_xbps_release_workflow.py | 461 ++++++++++--------- 10 files changed, 914 insertions(+), 474 deletions(-) create mode 100644 tests/test_void_release_build.py diff --git a/packaging/void/build-release.sh b/packaging/void/build-release.sh index 20e3460..bcb1de9 100755 --- a/packaging/void/build-release.sh +++ b/packaging/void/build-release.sh @@ -82,7 +82,7 @@ source_version() { # release form. xbps_version() { printf '%s\n' "$1" | - sed -n -E 's/^([0-9]+(\.[0-9]+)*)(-(alpha|beta|rc)[0-9]*)?$/\1\3/p' | + sed -n -E 's/^([0-9]+\.[0-9]+\.[0-9]+)(-(alpha|beta|rc)[0-9]*)?$/\1\2/p' | tr -d '-' } @@ -98,6 +98,16 @@ derive() { 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) @@ -118,7 +128,8 @@ check_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. +# committed repository-key.plist key. Sets FINGERPRINT and writes the client +# key to $WORK/client.plist. check_key() { KEY_FILE=$1 [ -n "$KEY_FILE" ] || @@ -219,7 +230,7 @@ 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" + 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" @@ -251,7 +262,7 @@ verify_set() { [ -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 || + present "xbps/repo/${name}-[0-9]*.x86_64.xbps" || fail "package missing from the repository: $name" fi done @@ -285,8 +296,10 @@ run_pipeline() { 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"; } + 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 diff --git a/packaging/void/container-release.sh b/packaging/void/container-release.sh index bd4018f..6673342 100755 --- a/packaging/void/container-release.sh +++ b/packaging/void/container-release.sh @@ -149,12 +149,14 @@ phase_install() { printf '%s\n' "$VERSION_OUT" printf '%s' "$VERSION_OUT" | grep -qF "$VERSION" || fail "voculinux --version does not show $VERSION" - echo "== payload of the built packages" + echo "== payload of the built packages; every package needs a smoke recipe" for PKG in $PKGS; do PKGFILE=$(ls "$PKG_STAGE"/"$PKG"-[0-9]*.x86_64.xbps | head -n1) case "$PKG" in voculinux) payload_voculinux "$PKGFILE" ;; python3-pywhispercpp) payload_pywhispercpp "$PKGFILE" ;; + python3-pynput | python3-PyAudio) ;; # checked by import below + *) fail "no smoke recipe for package $PKG; add one alongside its template" ;; esac done diff --git a/packaging/void/smoke.sh b/packaging/void/smoke.sh index 208445b..4dac15c 100755 --- a/packaging/void/smoke.sh +++ b/packaging/void/smoke.sh @@ -19,6 +19,7 @@ HERE=$(cd -- "$HERE" && pwd) # Dependency order: the app last. tests/test_void_smoke_gate.py fails if this # list drifts from packaging/void/srcpkgs. +# shellcheck disable=SC2034 # read as data by build-release.sh, which parses this line PKGS="python3-PyAudio python3-pynput python3-pywhispercpp voculinux" exec "$HERE/build-release.sh" smoke diff --git a/tests/test_rename_sweep.py b/tests/test_rename_sweep.py index 8e916ff..91ba498 100644 --- a/tests/test_rename_sweep.py +++ b/tests/test_rename_sweep.py @@ -179,7 +179,7 @@ def _allowed(path: str, line: str) -> bool: return True if path.startswith(_TOOLING_PREFIXES) and _PACKAGE_SEGMENT.search(line): return True - if path == "packaging/void/container-main.sh" and _PRE_RENAME_GUARD.search(line): + if path == "packaging/void/container-release.sh" and _PRE_RENAME_GUARD.search(line): return True if path == "src/vocalinux/utils/update_checker.py" and ( 'casefold() == "vocalinux"' in line or '"/{name}/vocalinux"' in line @@ -253,7 +253,7 @@ def test_allowlist_keeps_the_retained_surfaces() -> None: ("src/vocalinux/ui/tray_indicator.py", "from vocalinux.utils import paths"), ("src/vocalinux/app_identity.py", 'LEGACY_BUS_NAME: str = "com.vocalinux.Vocalinux"'), ("tests/test_app_identity.py", "from vocalinux import app_identity"), - ("packaging/void/container-main.sh", 'grep -E "usr/share/applications/vocalinux"'), + ("packaging/void/container-release.sh", 'grep -E "usr/share/applications/vocalinux"'), ("install.sh", 'local PKG_NAME="vocalinux"'), ] for path, line in kept: diff --git a/tests/test_void_app_template.py b/tests/test_void_app_template.py index 490c03a..2d4cae4 100644 --- a/tests/test_void_app_template.py +++ b/tests/test_void_app_template.py @@ -2,7 +2,9 @@ Spec #16 ships Voculinux itself as a Void package: an in-repo xbps template in void-packages layout under ``packaging/void`` builds the app -from the canonical, checksum-verified release source distribution, +from the canonical, checksum-verified release source distribution, which the +release path builds from the tag and stages for xbps-src (see +``tests/test_void_release_build.py``), depends on Void's own interpreter, GTK, desktop bindings and packaged Python libraries plus the dependency templates from tickets #25 and #26, and installs the renamed desktop entry and icons. Speech models are not @@ -12,9 +14,9 @@ install time. Independent sources of truth: ``src/vocalinux/version.py`` owns the version, ``pyproject.toml`` owns the dependency ranges, ``MANIFEST.in`` plus the renamed ``voculinux.desktop`` own the install paths, and the -GitHub release publish path (``releases/download``) plus ``xbps-src`` +fork's release path (``releases/download/``) plus ``xbps-src`` conventions own the fetch shape. The template must agree with all of -them; the container smoke gate (ticket #28) builds on top. +them; the container smoke gate (ticket #28) runs the same release path. """ import re @@ -27,8 +29,8 @@ SRC = VOID / "srcpkgs" TEMPLATE = SRC / "voculinux" / "template" INSTALL_MSG = SRC / "voculinux" / "INSTALL.msg" PINS = VOID / "tool_checksums.txt" -BUILD_SH = VOID / "build.sh" -CONTAINER_MAIN = VOID / "container-main.sh" +RELEASE_PATH = VOID / "build-release.sh" +CONTAINER_RELEASE = VOID / "container-release.sh" VERSION_PY = REPO_ROOT / "src" / "vocalinux" / "version.py" PYPROJECT = REPO_ROOT / "pyproject.toml" DISTRO_MAP = REPO_ROOT / "scripts" / "distro-package-map.yaml" @@ -75,9 +77,10 @@ RUNTIME_HELPER_PACKAGES = { "pulseaudio-utils": "pactl source listing", } -#: The checksum the template may carry before the fork's release exists. It is -#: explicit and fails closed in xbps-src; it is never the upstream digest. -PENDING_CHECKSUM = "PENDING_FORK_RELEASE_SHA256" +#: The placeholder the committed template carries until the first fork release +#: has a recorded digest. Every release build replaces it in its build tree; it +#: is never a real digest, and it never reaches a published package. +PLACEHOLDER_CHECKSUM = "0" * 64 #: sha256 of the upstream 0.18.5 archive. The template must never pin it: the #: package is the fork's own release, not a copy of upstream's. @@ -245,33 +248,29 @@ def test_template_version_matches_version_py() -> None: def test_distfiles_sources_the_canonical_release_by_checksum() -> None: - """One fork release-asset distfile, checksum-verified; never upstream or git.""" + """One fork release-asset distfile, checksum-bound; never upstream or git.""" variables = _template_vars() distfiles = variables["distfiles"] - version = variables["version"] assert distfiles == ( - f"{FORK_RELEASE_HOST}/releases/download/v${{version}}/" + f"{FORK_RELEASE_HOST}/releases/download/${{_tag}}/" f"{variables['pkgname']}-${{version}}.tar.gz" - ), "distfiles must be the fork's own release sdist for this version" + ), "distfiles must name the fork's release asset by its tag and xbps version" assert "github.com" not in distfiles and "VocaHQ" not in distfiles, "no upstream archive" for moving in ("/master", "/main", "/archive/", "git+"): assert moving not in distfiles, f"distfiles must not track a moving ref: {moving}" assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball" - # The sdist's top-level directory is pkgname-version, so xbps-src needs no - # wrksrc override (deprecated; xlint rejects it). - root = distfiles.rsplit("/", 1)[1].replace("${version}", version) - assert root == f"{variables['pkgname']}-{version}.tar.gz", "archive name must match pkgname" + assert variables["_tag"] == f"v{_app_version()}", "_tag is the tag, hyphen and all" + assert "v${version}" not in distfiles, "a pre-release tag keeps its hyphen in the URL" assert "wrksrc" not in variables, "deprecated wrksrc override: xlint fails the template" -def test_checksum_is_pinned_to_the_fork_release_or_explicitly_pending() -> None: - """A 64-hex digest once the fork release exists; until then, the explicit - pending marker. Never the upstream digest, never empty.""" +def test_checksum_is_a_recorded_digest_or_the_build_placeholder() -> None: + """A 64-hex digest once recorded; until then the all-zero placeholder that + every release build replaces. Never the upstream digest, never empty.""" checksum = _template_vars()["checksum"] assert checksum != UPSTREAM_0_18_5_SHA256, "the template must not pin the upstream archive" - assert ( - re.fullmatch(r"[0-9a-f]{64}", checksum) or checksum == PENDING_CHECKSUM - ), f"checksum must be a sha256 or the explicit pending marker, not {checksum!r}" + assert re.fullmatch(r"[0-9a-f]{64}", checksum), f"not a sha256: {checksum!r}" + assert "PENDING" not in _template_text(), "no pending marker: the build pins the digest" def test_every_project_dependency_is_covered() -> None: @@ -407,24 +406,21 @@ def test_install_msg_line_length() -> None: assert raw.endswith("\n"), "INSTALL.msg must end with a newline" -def test_container_harness_covers_the_app_package() -> None: - """build.sh stays podman-only; container-main smokes the installed app.""" - build = BUILD_SH.read_text(encoding="utf-8") - assert "podman" in build, "the build harness must use podman" - assert "docker" not in build.lower().replace("never docker", "") - harness = CONTAINER_MAIN.read_text(encoding="utf-8") +def test_release_path_covers_the_app_package() -> None: + """The install smoke runs the renamed binary and the app's payload checks.""" + harness = CONTAINER_RELEASE.read_text(encoding="utf-8") assert "voculinux --version" in harness, "the app smoke checks the entry point" - assert "vocalinux.ui.tray_indicator" in harness, "the smoke imports the desktop modules" - assert ( - "vocalinux.speech_recognition.recognition_manager" in harness - ), "the smoke imports the speech modules" assert "usr/share/applications/voculinux.desktop" in harness assert "usr/share/icons/hicolor/scalable/apps/voculinux.svg" in harness assert "usr/share/voculinux/packaged" in harness assert ( - "no pre-rename leftovers" in harness + "no pre-rename" in harness or "pre-rename file names" in harness ), "the payload check must reject pre-rename file names in the package" - assert "python3-pywhispercpp" in harness or "PKGNAME" in harness + assert "models or wheels" in harness, "the payload check rejects models and wheels" + assert "import vocalinux.main" in harness, "the install smoke imports the app" + assert "pywhispercpp.model" in harness, "the install smoke imports the speech binding" + assert "PYNPUT_BACKEND=dummy" in harness and "import evdev" in harness + assert "build-release.sh" in RELEASE_PATH.read_text(encoding="utf-8") def test_void_distro_inventory_matches_void_packages_reality() -> None: diff --git a/tests/test_void_pywhispercpp_template.py b/tests/test_void_pywhispercpp_template.py index 76c24ce..d69a6cb 100644 --- a/tests/test_void_pywhispercpp_template.py +++ b/tests/test_void_pywhispercpp_template.py @@ -20,7 +20,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] VOID_DIR = REPO_ROOT / "packaging" / "void" TEMPLATE = VOID_DIR / "srcpkgs" / "python3-pywhispercpp" / "template" PINS = VOID_DIR / "tool_checksums.txt" -BUILD_SH = VOID_DIR / "build.sh" +BUILD_SH = VOID_DIR / "build-release.sh" INSTALL_SH = REPO_ROOT / "install.sh" UV_LOCK = REPO_ROOT / "uv.lock" @@ -235,9 +235,8 @@ def test_build_script_uses_podman_and_the_pinned_image() -> None: scrubbed = text.lower().replace("never docker", "") assert "docker" not in scrubbed, "never Docker" assert "tool_checksums.txt" in text, "the image digest comes from the pins file" - # The container flow lives in mounted files, not an inline sh -c - # string: single quotes inside sh -c silently regroup the command. - for name in ("container-main.sh", "container-build.sh"): - path = VOID_DIR / name - assert path.is_file(), f"missing container script: {path}" - assert name in text, f"{BUILD_SH.name} must mount {name}" + # The container flow lives in a mounted file, not an inline sh -c string: + # single quotes inside sh -c silently regroup the command. + name = "container-release.sh" + assert (VOID_DIR / name).is_file(), f"missing container script: {name}" + assert name in text, f"{BUILD_SH.name} must mount {name}" diff --git a/tests/test_void_release_build.py b/tests/test_void_release_build.py new file mode 100644 index 0000000..8a4c4d4 --- /dev/null +++ b/tests/test_void_release_build.py @@ -0,0 +1,559 @@ +"""Offline guards for the one Void release path (spec #16; tickets #28 and #29). + +``packaging/void/build-release.sh`` is the release path. The Gitea tag workflow, +the local dry run and the smoke gate (``packaging/void/smoke.sh``) all call it, +so a passing dry run exercises the code a tag runs. These tests pin its seams: + +- the xbps spelling a tag maps to, and the tag gate (exact ``v`` plus + ``__version__``, and the committed template must already name that release); +- the signing-key checks, which must stop before any container starts (a fake + ``podman`` on PATH records any call and must never be reached); +- ``render-voculinux.sh``, run against a scratch void-packages tree, which pins + the build tree and never the repository's template; +- the podman wiring: which phase may see the key, and which phase has no network; +- the xbps version ordering the mapping depends on (``xbps-uhelper cmpver``, + skipped where xbps is not installed). + +The container runs themselves are the proof; these are the guards around them. +Expected values come from the documented rules, not from the code under test. +""" + +import hashlib +import os +import plistlib +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +VOID = REPO_ROOT / "packaging" / "void" +BUILD_RELEASE = VOID / "build-release.sh" +CONTAINER_RELEASE = VOID / "container-release.sh" +RENDER = VOID / "render-voculinux.sh" +FINGERPRINT_PY = VOID / "xbps-fingerprint.py" +SMOKE_SH = VOID / "smoke.sh" +PINS = VOID / "tool_checksums.txt" +PLIST = VOID / "repository-key.plist" +TEMPLATE = VOID / "srcpkgs" / "voculinux" / "template" +VERSION_PY = REPO_ROOT / "src" / "vocalinux" / "version.py" + +PINNED_FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25" +SDIST = "voculinux-0.19.0beta.tar.gz" +XBPS_UHELPER = shutil.which("xbps-uhelper") +OPENSSL = shutil.which("openssl") + +#: Files the release path reads from its own directory, copied into fixtures. +FIXTURE_FILES = (BUILD_RELEASE, CONTAINER_RELEASE, RENDER, FINGERPRINT_PY, SMOKE_SH, PINS, PLIST) + + +def _body(text: str, name: str) -> str: + """The body of a top-level shell function, from its opening line to the closing brace.""" + match = re.search(rf"^{name}\(\) \{{\n(.*?)^\}}$", text, re.MULTILINE | re.DOTALL) + assert match, f"{name}() must be a top-level function" + return match.group(1) + + +def _code(path: Path) -> str: + """A shell or Python script with comment-only lines removed.""" + return "\n".join( + line + for line in path.read_text(encoding="utf-8").splitlines() + if not line.lstrip().startswith("#") + ) + + +def make_tree( + root: Path, + version: str, + template_version: str, + template_tag: str, + version_py: Optional[str] = None, +) -> Path: + """A scratch copy of the release tree, with the given version and committed template.""" + pkg = root / "packaging" / "void" + pkg.mkdir(parents=True) + for source in FIXTURE_FILES: + shutil.copy2(source, pkg / source.name) + (pkg / "srcpkgs" / "voculinux").mkdir(parents=True) + text = TEMPLATE.read_text(encoding="utf-8") + text = re.sub(r"^version=.*$", f"version={template_version}", text, flags=re.MULTILINE) + text = re.sub(r"^_tag=.*$", f"_tag={template_tag}", text, flags=re.MULTILINE) + (pkg / "srcpkgs" / "voculinux" / "template").write_text(text, encoding="utf-8") + src = root / "src" / "vocalinux" + src.mkdir(parents=True) + (src / "version.py").write_text( + version_py if version_py is not None else f'"""Version."""\n\n__version__ = "{version}"\n', + encoding="utf-8", + ) + return root + + +def run_build(tree: Path, *args: str, path: Optional[str] = None, tmp: Optional[Path] = None): + """Run the tree's build-release.sh with a minimal, explicit environment.""" + env = {"PATH": path or os.environ["PATH"], "HOME": os.environ.get("HOME", "/")} + if tmp is not None: + env["TMPDIR"] = str(tmp) + return subprocess.run( + ["sh", str(tree / "packaging" / "void" / "build-release.sh"), *args], + cwd=tree, + env=env, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + +# --------------------------------------------------------------------------- +# Names: a tag maps to one xbps spelling, and only release forms map at all +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("version", "xbps"), + [ + ("0.18.5", "0.18.5"), + ("1.0.0", "1.0.0"), + ("0.19.0-alpha", "0.19.0alpha"), + ("0.19.0-beta", "0.19.0beta"), + ("0.19.0-rc1", "0.19.0rc1"), + ("0.19.0-rc12", "0.19.0rc12"), + ], +) +def test_a_tag_maps_to_its_xbps_spelling(tmp_path: Path, version: str, xbps: str) -> None: + """xbps versions have no '-': the hyphen goes, and the rest is unchanged.""" + tree = make_tree(tmp_path, version, template_version=xbps, template_tag=f"v{version}") + result = run_build(tree, "names", f"v{version}") + assert result.returncode == 0, result.stderr + names = dict(line.split("=", 1) for line in result.stdout.splitlines()) + assert names == { + "TAG": f"v{version}", + "VERSION": version, + "XBPS_VERSION": xbps, + "SDIST_NAME": f"voculinux-{xbps}.tar.gz", + } + + +@pytest.mark.parametrize( + "version", + [ + "0.19.0-beta.2", + "0.19.0+local", + "0.19.0_beta", + "0.19", + "0.19.0-dev", + "0.19.0-post1", + "0.19.0-beta-2", + "v0.19.0", + ], +) +def test_a_version_outside_the_release_forms_is_refused(tmp_path: Path, version: str) -> None: + """Only X.Y.Z and X.Y.Z-alpha|beta|rcN name a release; anything else stops.""" + tree = make_tree(tmp_path, version, template_version="0.19.0", template_tag=f"v{version}") + result = run_build(tree, "names", f"v{version}") + assert result.returncode == 1, result.stdout + assert "not a release form" in result.stderr + + +# --------------------------------------------------------------------------- +# The tag gate: exact match, and the committed template must already agree +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("tag", "version", "ok"), + [ + ("v0.18.5", "0.18.5", True), + ("v0.19.0-beta", "0.19.0-beta", True), + ("0.18.5", "0.18.5", False), # no v prefix + ("v0.18.50", "0.18.5", False), # the tag contains the version + ("v0.18", "0.18.5", False), # the version contains the tag + ("v0.18.5-beta", "0.18.5", False), + ("v0.18.5", "0.18.5-beta", False), + ("v0.18.4", "0.18.5", False), + ("vv0.18.5", "0.18.5", False), + ], +) +def test_tag_gate_is_an_exact_match_with_version_py( + tmp_path: Path, tag: str, version: str, ok: bool +) -> None: + xbps = version.replace("-", "") + tree = make_tree(tmp_path, version, template_version=xbps, template_tag=f"v{version}") + result = run_build(tree, "check-tag", tag) + if ok: + assert result.returncode == 0, result.stderr + assert "tag gate passed" in result.stdout + else: + assert result.returncode == 1, f"{tag} against {version} must stop: {result.stdout}" + assert "refusing to publish" in result.stderr + + +def test_the_committed_template_must_name_this_release(tmp_path: Path) -> None: + """A release whose template still names the previous version never publishes.""" + tree = make_tree(tmp_path, "0.19.0-beta", template_version="0.18.5", template_tag="v0.18.5") + result = run_build(tree, "check-tag", "v0.19.0-beta") + assert result.returncode == 1 + assert "committed template version is '0.18.5', not '0.19.0beta'" in result.stderr + + +def test_the_committed_template_tag_must_match_the_tag(tmp_path: Path) -> None: + tree = make_tree(tmp_path, "0.19.0-beta", template_version="0.19.0beta", template_tag="v0.19.0") + result = run_build(tree, "check-tag", "v0.19.0-beta") + assert result.returncode == 1 + assert "_tag is 'v0.19.0'" in result.stderr + + +def test_version_py_must_define_the_version_exactly_once(tmp_path: Path) -> None: + body = '__version__ = "0.18.5"\n__version__ = "0.18.6"\n' + tree = make_tree(tmp_path, "0.18.5", "0.18.5", "v0.18.5", version_py=body) + result = run_build(tree, "check-tag", "v0.18.5") + assert result.returncode == 1 + assert "exactly once" in result.stderr + + +# --------------------------------------------------------------------------- +# Version ordering: the pre-release spellings sort below the release +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(XBPS_UHELPER is None, reason="xbps-uhelper is not installed") +@pytest.mark.parametrize( + ("lower", "higher"), + [ + ("0.18.5beta", "0.18.5"), + ("0.18.5rc1", "0.18.5"), + ("0.18.5alpha", "0.18.5beta"), + ("0.18.5beta", "0.18.5rc1"), + ("0.18.5rc1", "0.18.5rc2"), + ("0.18.5rc2", "0.18.5rc10"), + ("0.18.5", "0.19.0beta"), + ("0.19.0beta", "0.19.0"), + ], +) +def test_xbps_sorts_pre_release_spellings_below_the_release(lower: str, higher: str) -> None: + """A stable user is never offered a pre-release of the same version as newer.""" + result = subprocess.run( + [XBPS_UHELPER, "cmpver", f"voculinux-{lower}_1", f"voculinux-{higher}_1"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + # xbps-uhelper exits -1 (255) when the first package is older than the second. + assert result.returncode == 255, f"{lower} must sort below {higher}: rc={result.returncode}" + + +# --------------------------------------------------------------------------- +# Signing key: every refusal happens before any container starts +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def fake_podman(tmp_path: Path) -> tuple: + """A podman that records its calls and fails. Any call means a container would start.""" + bindir = tmp_path / "fakebin" + bindir.mkdir() + log = tmp_path / "podman-calls.log" + script = bindir / "podman" + script.write_text(f'#!/bin/sh\necho "$@" >> "{log}"\nexit 97\n', encoding="utf-8") + script.chmod(0o755) + return f"{bindir}{os.pathsep}{os.environ['PATH']}", log + + +def _key(path: Path, bits: int) -> Path: + subprocess.run( + [OPENSSL, "genrsa", "-out", str(path), str(bits)], + capture_output=True, + check=True, + timeout=60, + ) + return path + + +@pytest.mark.skipif(OPENSSL is None, reason="openssl is not installed") +@pytest.mark.parametrize( + ("case", "message"), + [ + ("missing-arg", "no signing key"), + ("empty", "missing or empty"), + ("garbage", "not a valid RSA private key"), + ("rsa2048", "not RSA 4096"), + ("rsa4096-other", "does not match packaging/void/repository-key.plist"), + ], +) +def test_a_bad_key_stops_the_release_before_any_container( + tmp_path: Path, fake_podman: tuple, case: str, message: str +) -> None: + path, log = fake_podman + tree = make_tree(tmp_path / "tree", "0.18.5", template_version="0.18.5", template_tag="v0.18.5") + out = tmp_path / "out" + args = ["release", "v0.18.5", "--out-dir", str(out)] + if case == "empty": + key = tmp_path / "empty.pem" + key.write_bytes(b"") + elif case == "garbage": + key = tmp_path / "garbage.pem" + key.write_text("not a key\n", encoding="utf-8") + elif case == "rsa2048": + key = _key(tmp_path / "rsa2048.pem", 2048) + elif case == "rsa4096-other": + key = _key(tmp_path / "other.pem", 4096) + if case != "missing-arg": + args += ["--key-file", str(key)] + tmp = tmp_path / "work" + tmp.mkdir() + result = run_build(tree, *args, path=path, tmp=tmp) + assert result.returncode == 1, result.stdout + result.stderr + assert message in result.stderr + assert not log.exists(), "a container started with a refused key" + assert not out.exists(), "a refused release must not create its output directory" + for work in tmp.glob("voculinux-release.*"): + assert not (work / "dist").exists(), "the sdist must not be built for a refused key" + + +@pytest.mark.skipif(OPENSSL is None, reason="openssl is not installed") +def test_the_key_file_is_never_printed(tmp_path: Path, fake_podman: tuple) -> None: + """Refusals name the problem, never the key material.""" + path, _ = fake_podman + tree = make_tree(tmp_path / "tree", "0.18.5", template_version="0.18.5", template_tag="v0.18.5") + key = _key(tmp_path / "other.pem", 4096) + secret_line = key.read_text(encoding="ascii").splitlines()[1] + tmp = tmp_path / "work" + tmp.mkdir() + result = run_build( + tree, + "release", + "v0.18.5", + "--key-file", + str(key), + "--out-dir", + str(tmp_path / "o"), + path=path, + tmp=tmp, + ) + assert secret_line not in result.stdout + result.stderr + + +# --------------------------------------------------------------------------- +# render-voculinux.sh: the build tree is pinned, the repository never is +# --------------------------------------------------------------------------- + + +def _scratch_void_packages(tmp_path: Path) -> Path: + vp = tmp_path / "void-packages" + (vp / "srcpkgs" / "voculinux").mkdir(parents=True) + shutil.copy2(TEMPLATE, vp / "srcpkgs" / "voculinux" / "template") + return vp + + +def _render(tmp_path: Path, vp: Path, payload: bytes, **overrides: str): + inputs = tmp_path / "inputs" + inputs.mkdir(exist_ok=True) + (inputs / SDIST).write_bytes(payload) + env = { + "PATH": os.environ["PATH"], + "VOID_PACKAGES": str(vp), + "SDIST_DIR": str(inputs), + "XBPS_VERSION": "0.19.0beta", + "TAG": "v0.19.0-beta", + "SDIST_NAME": SDIST, + "SDIST_SHA256": hashlib.sha256(payload).hexdigest(), + } + env.update(overrides) + return subprocess.run( + ["sh", str(RENDER)], env=env, capture_output=True, text=True, timeout=30, check=False + ) + + +def test_render_pins_version_tag_and_checksum_in_the_build_tree(tmp_path: Path) -> None: + payload = b"canonical sdist bytes for the release" + vp = _scratch_void_packages(tmp_path) + repo_before = TEMPLATE.read_bytes() + result = _render(tmp_path, vp, payload) + assert result.returncode == 0, result.stderr + text = (vp / "srcpkgs" / "voculinux" / "template").read_text(encoding="utf-8") + digest = hashlib.sha256(payload).hexdigest() + assert re.search(r"^version=0\.19\.0beta$", text, re.MULTILINE) + assert re.search(r"^_tag=v0\.19\.0-beta$", text, re.MULTILINE) + assert re.search(rf"^checksum={digest}$", text, re.MULTILINE) + assert "releases/download/${_tag}/voculinux-${version}.tar.gz" in text, "distfiles unchanged" + staged = vp / "hostdir" / "sources" / "voculinux-0.19.0beta" / SDIST + assert staged.read_bytes() == payload, "xbps-src must find the sdist where it looks" + assert TEMPLATE.read_bytes() == repo_before, "the repository template must never change" + + +def test_render_refuses_a_payload_that_does_not_match_its_digest(tmp_path: Path) -> None: + vp = _scratch_void_packages(tmp_path) + before = (vp / "srcpkgs" / "voculinux" / "template").read_bytes() + result = _render(tmp_path, vp, b"payload", SDIST_SHA256="0" * 64) + assert result.returncode == 1 + assert "is not the expected" in result.stderr + assert (vp / "srcpkgs" / "voculinux" / "template").read_bytes() == before + + +def test_render_refuses_a_template_without_exactly_one_checksum_line(tmp_path: Path) -> None: + vp = _scratch_void_packages(tmp_path) + template = vp / "srcpkgs" / "voculinux" / "template" + template.write_text(template.read_text(encoding="utf-8") + "checksum=abc\n", encoding="utf-8") + result = _render(tmp_path, vp, b"payload") + assert result.returncode == 1 + assert "exactly one checksum= line" in result.stderr + + +@pytest.mark.parametrize("bad_tag", ["v0.19.0-beta;rm", "v0.19.0 beta", 'v0.19.0"beta']) +def test_render_refuses_values_outside_the_safe_set(tmp_path: Path, bad_tag: str) -> None: + vp = _scratch_void_packages(tmp_path) + result = _render(tmp_path, vp, b"payload", TAG=bad_tag) + assert result.returncode == 1 + assert "outside [A-Za-z0-9._-]" in result.stderr + + +# --------------------------------------------------------------------------- +# Podman wiring: who may see the key, who may use the network +# --------------------------------------------------------------------------- + + +def test_only_the_sign_phase_mounts_the_key() -> None: + """Build scripts run in the build phase; they must never see the release key.""" + text = _code(BUILD_RELEASE) + build = _body(text, "phase_build") + sign = _body(text, "phase_sign") + install = _body(text, "phase_install") + for name, body in (("build", build), ("install", install)): + assert "KEY_FILE" not in body, f"the {name} phase must not mount or name the key" + assert "/run/xbps-sign-key.pem" not in body + assert '-v "$KEY_FILE:/run/xbps-sign-key.pem:ro"' in sign, "the key is mounted read-only" + + +def test_the_sign_phase_has_no_network() -> None: + sign = _body(_code(BUILD_RELEASE), "phase_sign") + assert "--network none" in sign + + +def test_the_build_phase_is_the_only_one_that_runs_build_scripts() -> None: + text = _code(CONTAINER_RELEASE) + assert "xbps-src pkg" in _body_of_container(text, "phase_build") + for name in ("phase_sign", "phase_install"): + assert "xbps-src" not in _body_of_container(text, name), f"{name} must not build anything" + + +def _body_of_container(text: str, name: str) -> str: + match = re.search(rf"^{name}\(\) \{{\n(.*?)^\}}$", text, re.MULTILINE | re.DOTALL) + assert match, f"{name}() must be a function in container-release.sh" + return match.group(1) + + +def test_every_podman_run_uses_the_pinned_base_image() -> None: + text = _code(BUILD_RELEASE) + runs = text.count("podman run") + assert runs == 3, "build, sign and install: exactly three containers" + assert text.count('"$BASE_IMAGE" /bin/sh /mnt/container-release.sh') == 3 + assert "ghcr.io" not in text, "the base image is pinned in tool_checksums.txt only" + + +@pytest.mark.parametrize("path", [BUILD_RELEASE, CONTAINER_RELEASE, RENDER, SMOKE_SH]) +def test_no_docker_in_the_release_path(path: Path) -> None: + assert not re.search(r"\bdocker\b", _code(path), re.IGNORECASE), f"{path.name} names Docker" + + +def test_the_checks_run_before_the_sdist_and_before_any_container() -> None: + text = _code(BUILD_RELEASE) + pipeline = _body(text, "run_pipeline") + order = [ + pipeline.index("check_template"), + pipeline.index("check_key"), + pipeline.index("build_sdist"), + pipeline.index("phase_build"), + ] + assert order == sorted(order), order + + +def test_the_key_is_never_echoed_or_copied() -> None: + text = _code(BUILD_RELEASE) + assert "set -x" not in text + assert not re.search(r'cat\s+"?\$KEY_FILE', text) + assert not re.search(r'cp\s+"?\$KEY_FILE', text), "the key is mounted, never copied" + + +def test_the_build_tree_is_pinned_before_xbps_src_runs() -> None: + build = _body_of_container(_code(CONTAINER_RELEASE), "phase_build") + assert build.index("render-voculinux.sh") < build.index("xbps-src binary-bootstrap") + assert build.index("chown -R builder") < build.index("xbps-src binary-bootstrap") + + +def test_render_writes_only_inside_the_build_tree() -> None: + text = _code(RENDER) + assert 'TEMPLATE="$VP/srcpkgs/voculinux/template"' in text + assert 'DEST="$VP/hostdir/sources' in text + assert "$ROOT" not in text and "REPO" not in text, "the repository is never a target" + + +def test_the_install_phase_proves_the_key_is_needed() -> None: + install = _body_of_container(_code(CONTAINER_RELEASE), "phase_install") + assert "xbps-install -S --repository" in install, "the unsigned attempt runs without -y" + assert "refused without the key" in install + assert "/var/db/xbps/keys" in install + + +# --------------------------------------------------------------------------- +# The fingerprint helper: the pinned value is derived, not typed +# --------------------------------------------------------------------------- + + +def test_the_committed_key_has_the_pinned_fingerprint(tmp_path: Path) -> None: + pem = tmp_path / "pub.pem" + pem.write_bytes(plistlib.loads(PLIST.read_bytes())["public-key"]) + result = subprocess.run( + [sys.executable, "-I", str(FINGERPRINT_PY), "fingerprint", str(pem)], + capture_output=True, + text=True, + timeout=60, + check=True, + ) + assert result.stdout.strip() == PINNED_FINGERPRINT + + +def test_the_plist_writer_round_trips_the_public_key(tmp_path: Path) -> None: + if OPENSSL is None: + pytest.skip("openssl is not installed") + key = _key(tmp_path / "key.pem", 2048) + pem = tmp_path / "pub.pem" + subprocess.run( + [OPENSSL, "rsa", "-in", str(key), "-pubout", "-out", str(pem)], + capture_output=True, + check=True, + timeout=60, + ) + out = tmp_path / "client.plist" + subprocess.run( + [ + sys.executable, + "-I", + str(FINGERPRINT_PY), + "plist", + str(pem), + "Test ", + str(out), + ], + capture_output=True, + check=True, + timeout=60, + ) + entry = plistlib.loads(out.read_bytes()) + assert entry["public-key"] == pem.read_bytes() + assert entry["public-key-size"] == 2048 + assert entry["signature-by"] == "Test " + + +def test_smoke_is_the_same_path_with_a_throwaway_key() -> None: + """The smoke gate delegates to the release path; it never names the release key.""" + text = _code(SMOKE_SH) + assert 'exec "$HERE/build-release.sh" smoke' in text + assert "XBPS_SIGNING_KEY" not in text + release = _code(BUILD_RELEASE) + assert "throwaway.pem" in release and "genrsa" in release diff --git a/tests/test_void_signed_repo.py b/tests/test_void_signed_repo.py index 96b5cad..5480f27 100644 --- a/tests/test_void_signed_repo.py +++ b/tests/test_void_signed_repo.py @@ -1,37 +1,26 @@ """Offline policy guards for the signed Void xbps repository (ticket #29). -Spec #16 serves the Void templates from a signed personal xbps -repository: every template builds in dependency order in the pinned Void -container via podman, the repository index carries full repo data, and -packages plus metadata are signed with the RSA4096 repository key. The -pipeline fails closed — a missing, unreadable, or non-RSA4096 key aborts -before any container runs, so an unsigned repository can never publish. +Spec #16 serves the Void templates from a signed personal xbps repository. +The release path itself (build, sign, install smoke) is guarded in +``tests/test_void_release_build.py``; this module guards what must stay true +around it: the committed public key and its pinned fingerprint, the README and +root README registration text users copy, the release workflow's publish target, +the template bump documented as a pull request, and the rule that no private key +is ever committed. Seams under test (public interfaces, not internals): -- ``packaging/void/repo.sh`` — the host entry: key handling plus the - podman delegation. Exit status and stderr on a missing key are the - fail-closed contract. -- ``packaging/void/container-repo.sh`` — the container half: build-all, - index, sign, verify, stage. Its xbps-rindex invocations are the - signing contract. -- The ``void-repo`` just recipe — how release work and CI call the - pipeline, same shape as the other packaging gates. -- ``packaging/void/README.md`` — the documented maintainer and client - flow, including the CI secret name. -- ``packaging/void/repository-key.plist`` — the committed PUBLIC half of - the repository key, whose fingerprint the READMEs and the Gitea release - workflow pin. ``tests/test_xbps_release_workflow.py`` runs the workflow. +- ``packaging/void/repository-key.plist``: the committed PUBLIC half of the + repository key. Its fingerprint is what the READMEs pin. +- ``README.md`` and ``packaging/void/README.md``: the client registration text. +- ``.gitea/workflows/xbps-release.yml``: the tag trigger, the Gitea API it + publishes to, and the release path it calls. +- ``docs/RELEASE_PROCESS.md``: the template bump is a pull request. -Independent sources of truth: the xbps fingerprint definition (the MD5 of -the SSH-format ``ssh-rsa`` blob of the public key, per libxbps -``xbps_pubkey2fp``), ``packaging/void/srcpkgs/*/template`` -owns the template set (a renamed or added template must not silently -drop out), ``packaging/void/tool_checksums.txt`` owns the pins, -``packaging/void/smoke.sh`` owns the dependency order the signed repo -must agree with, and ``openssl`` plus ``xbps-rindex`` own the RSA4096 -signing mechanics the container half must invoke. The container run -itself is the proof; these tests are the wiring guard. +Independent sources of truth: the xbps fingerprint definition (the MD5 of the +ssh-rsa blob of the public key, per libxbps ``xbps_pubkey2fp``), computed here +with the standard library only, and ``packaging/void/smoke.sh`` owns the package +order the signed repository must agree with. """ import base64 @@ -52,8 +41,8 @@ ROOT_README = REPO_ROOT / "README.md" RELEASE_PROCESS = REPO_ROOT / "docs" / "RELEASE_PROCESS.md" VOID = REPO_ROOT / "packaging" / "void" SRC = VOID / "srcpkgs" -REPO_SH = VOID / "repo.sh" -CONTAINER_REPO = VOID / "container-repo.sh" +BUILD_RELEASE = VOID / "build-release.sh" +CONTAINER_RELEASE = VOID / "container-release.sh" SMOKE_SH = VOID / "smoke.sh" PINS = VOID / "tool_checksums.txt" JUSTFILE = REPO_ROOT / "justfile" @@ -72,18 +61,6 @@ def _srcpkg_templates() -> list: return sorted(p.name for p in SRC.iterdir() if (p / "template").is_file()) -def _repo_text() -> str: - assert REPO_SH.is_file(), f"missing signed-repo pipeline: {REPO_SH}" - return REPO_SH.read_text(encoding="utf-8") - - -def _container_text() -> str: - assert ( - CONTAINER_REPO.is_file() - ), f"missing container half of the signed-repo pipeline: {CONTAINER_REPO}" - return CONTAINER_REPO.read_text(encoding="utf-8") - - def _smoke_packages() -> list: """The ordered package list the smoke gate builds, from smoke.sh.""" match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M) @@ -91,124 +68,11 @@ def _smoke_packages() -> list: return match.group(1).split() -def test_pipeline_builds_every_template_and_nothing_else() -> None: - """A renamed or added template cannot silently drop out of the repo.""" - text = _repo_text() - assert "smoke.sh" in text or "PKGS" in text, ( - "repo.sh must take its package set from the smoke gate order, " - "not a second hand-maintained list" - ) - assert set(_smoke_packages()) == set(_srcpkg_templates()), ( - f"smoke order covers {_smoke_packages()}, " f"srcpkgs holds {_srcpkg_templates()}" - ) - - -def test_pipeline_builds_the_app_last() -> None: - """voculinux resolves the other templates from the signed repo.""" - assert ( - _smoke_packages()[-1] == "voculinux" - ), f"the app must build last, order is {_smoke_packages()}" - - -def test_pipeline_stays_podman_only_with_pinned_inputs() -> None: - """Podman, never Docker; pins live in tool_checksums.txt, not the flow.""" - for path, text in ( - (REPO_SH, _repo_text()), - (CONTAINER_REPO, _container_text()), - ): - assert "podman" in text or path == CONTAINER_REPO, f"{path.name} must use podman" - assert "docker" not in text.lower().replace( - "never docker", "" - ), f"{path.name} must never invoke Docker" - assert "ghcr.io" not in _repo_text(), "the base image pin lives in tool_checksums.txt" - assert PINS.is_file(), f"missing pins file: {PINS}" - - -def test_pipeline_fails_closed_without_a_key() -> None: - """No key file means no container, no repo, no publish — never unsigned.""" - text = _repo_text() - assert KEY_FILE_VAR in text, f"repo.sh must read the key path from {KEY_FILE_VAR}" - assert ( - SIGNING_SECRET in text or "SIGNING_KEY" in text - ), "repo.sh must name the CI secret carrying the private key" - for marker in ("exit 1",): - assert marker in text, "the missing-key path must abort non-zero" - assert re.search( - r"[Nn]o .*key|missing .*key|key .*missing", text - ), "the missing-key abort must say the key is missing" - - -def test_pipeline_requires_rsa4096_before_any_container_runs() -> None: - """The contract is RSA4096: any other key aborts before podman starts.""" - text = _repo_text() - assert "4096" in text, "repo.sh must pin the 4096-bit key size" - assert "openssl" in text, "repo.sh must validate the key with openssl" - key_check = text.index("openssl") - first_run = min(i for marker in ("podman pull", "podman run") if (i := text.find(marker)) >= 0) - assert key_check < first_run, "key validation must run before the first podman invocation" - - -def test_container_signs_packages_and_metadata_with_the_key() -> None: - """xbps-rindex signs every binary package and the repository metadata.""" - text = _container_text() - assert "xbps-rindex -a" in text, "the repo index (full repodata) is missing" - assert "-S" in text or "--sign-pkg" in text, "per-package signing (--sign-pkg) is missing" - assert ( - re.search(r"(^|\s)-s(\s|$)", text) or "--sign " in text - ), "repository metadata signing (-s/--sign) is missing" - assert "--privkey" in text, "signing must use the provisioned private key" - assert "--signedby" in text, "signing must carry the signer identity" - - -def test_container_lints_every_template_before_building() -> None: - """One bad template fails the repo, same as the per-package harness.""" - assert ( - "xlint" in _container_text() - ), "container-repo.sh must xlint each template before building it" - - -def test_container_names_the_key_fingerprint_for_clients() -> None: - """Clients pin the key by fingerprint; the pipeline must report it.""" - assert ( - "ingerprint" in _container_text() - ), "container-repo.sh must capture the key fingerprint for release notes" - - -def test_container_ships_an_importable_client_key() -> None: - """The staged plist must parse: xbps rejects raw <> in signature-by.""" - text = _container_text() - assert ".plist" in text, "container-repo.sh must stage the client .plist" - assert "<" in text and ">" in text, ( - "the plist writer must XML-escape the signer identity " - "(xbps shows Fingerprint and refuses the import otherwise)" - ) - - -def test_container_proves_signatures_with_a_clean_rootdir_install() -> None: - """The pipeline verifies what it signed: a fresh rootdir install.""" - text = _container_text() - assert ( - "xbps-install" in text and "-r " in text - ), "container-repo.sh must prove the signed repo with a rootdir install" - assert "voculinux" in text, "the proof install must be the app, resolving deps from the repo" - - -def test_just_recipe_calls_the_pipeline_like_other_packaging_gates() -> None: - """`just void-repo` runs the pipeline script with no shared-output step.""" - recipe = re.search( - r"^void-repo:([^\n]*)\n((?:[ \t].*\n|\n)+)", - JUSTFILE.read_text(encoding="utf-8"), - re.MULTILINE, - ) - assert recipe, "justfile must define a void-repo recipe" - assert not recipe.group(1).strip(), "void-repo must not take a shared-output prerequisite" - assert "packaging/void/repo.sh" in recipe.group(2), "void-repo must run packaging/void/repo.sh" - - def test_readme_documents_the_signed_repo_flow() -> None: """Maintainer key setup, CI secret, fail-closed rule, and client setup.""" text = README.read_text(encoding="utf-8") - assert "void-repo" in text or "repo.sh" in text, "README must document the signed-repo command" + assert "build-release.sh" in text, "README must document the release command" + assert "Runner requirements" in text, "README must document the runner requirements" assert "4096" in text, "README must document RSA4096 key generation" assert SIGNING_SECRET in text, "README must name the CI secret" assert ( @@ -228,17 +92,19 @@ def _gitea_workflow_text() -> str: def test_the_xbps_job_lives_in_the_gitea_workflow_not_the_github_release() -> None: - """One signing path: the tag-triggered Gitea workflow runs repo.sh.""" + """One signing path: the tag-triggered Gitea workflow runs build-release.sh.""" github = _release_workflow_text() for marker in ( "build-xbps", "xbps-repo", "XBPS_SIGNING_KEY", - "packaging/void/repo.sh", + "packaging/void/build-release.sh", ): assert marker not in github, f"release.yml must not carry the xbps job ({marker})" gitea = _gitea_workflow_text() - assert "packaging/void/repo.sh" in gitea, "the Gitea workflow must run the signed-repo pipeline" + assert ( + "packaging/void/build-release.sh" in gitea + ), "the Gitea workflow must run the release path" assert SIGNING_SECRET in gitea, "the Gitea workflow must read the XBPS_SIGNING_KEY secret" assert "podman" in gitea, "the Gitea workflow must run the pipeline with podman" workflow = yaml.safe_load(gitea) @@ -415,12 +281,6 @@ def test_readmes_pin_the_fingerprint_of_the_committed_key() -> None: assert fingerprint in path.read_text(encoding="utf-8"), f"{name} must show {fingerprint}" -def test_workflow_publishes_only_under_the_pinned_fingerprint() -> None: - """A secret holding another key fails the verify step before publishing.""" - workflow = yaml.safe_load(_gitea_workflow_text()) - assert workflow["jobs"]["xbps-release"]["env"]["XBPS_KEY_FINGERPRINT"] == _key_fingerprint() - - def test_void_readme_documents_the_secret_and_the_public_key_only_rule() -> None: text = README.read_text(encoding="utf-8") assert "repository-key.plist" in text, "README must name the committed public key" diff --git a/tests/test_void_smoke_gate.py b/tests/test_void_smoke_gate.py index fb8c38e..4d4088c 100644 --- a/tests/test_void_smoke_gate.py +++ b/tests/test_void_smoke_gate.py @@ -7,8 +7,9 @@ using podman and the pinned container, with no host Void install. Seams under test (public interfaces, not internals): -- ``packaging/void/smoke.sh`` — the gate command itself: exit status - plus package/stage identification on stdout/stderr. +- ``packaging/void/smoke.sh`` — the gate command. It runs the same release + path as a tag (``build-release.sh smoke``), so a passing gate is a release + that would build. - The ``void-smoke`` just recipe — how release work and CI call the gate, same shape as the other packaging gates. - ``packaging/void/README.md`` — the documented one-command entry. @@ -16,8 +17,8 @@ Seams under test (public interfaces, not internals): Independent sources of truth: ``packaging/void/srcpkgs/*/template`` owns the template set (a renamed or added template must not silently drop out), ``packaging/void/tool_checksums.txt`` owns the pins, and -``packaging/void/build.sh`` owns the per-package container flow the -gate orchestrates. The gate must agree with all of them; the container +``packaging/void/container-release.sh`` owns the install smoke each +package must pass. The gate must agree with all of them; the container run itself is the proof, these tests are the wiring guard. """ @@ -28,8 +29,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1] VOID = REPO_ROOT / "packaging" / "void" SRC = VOID / "srcpkgs" SMOKE_SH = VOID / "smoke.sh" -BUILD_SH = VOID / "build.sh" -CONTAINER_MAIN = VOID / "container-main.sh" +RELEASE_PATH = VOID / "build-release.sh" +CONTAINER_RELEASE = VOID / "container-release.sh" PINS = VOID / "tool_checksums.txt" JUSTFILE = REPO_ROOT / "justfile" README = VOID / "README.md" @@ -66,9 +67,11 @@ def test_gate_builds_the_app_last() -> None: ), f"the app must build last, gate order is {_gate_packages()}" -def test_gate_delegates_to_the_per_package_harness() -> None: - """One container flow implementation; the gate only orchestrates it.""" - assert "build.sh" in _smoke_text(), "smoke.sh must call build.sh per package" +def test_gate_delegates_to_the_release_path() -> None: + """One release path; the gate only selects the smoke mode, and its exit status is the path's.""" + text = _smoke_text() + assert 'exec "$HERE/build-release.sh" smoke' in text, "smoke.sh must exec the release path" + assert "build-release.sh" in RELEASE_PATH.read_text(encoding="utf-8") def test_gate_stays_podman_only_with_pinned_inputs() -> None: @@ -80,41 +83,35 @@ def test_gate_stays_podman_only_with_pinned_inputs() -> None: assert PINS.is_file(), f"missing pins file: {PINS}" -def test_gate_names_the_failing_package() -> None: - """Output identifies which package failed, alongside the stage echoes.""" - text = _smoke_text() - assert "FAIL" in text, "the gate must report failures as FAIL" - assert re.search( - r"FAIL.*\$PKG|FAIL.*\$\{PKG", text - ), "the FAIL report must name the failing package" - - def _case_branch(label: str) -> str: - """Shell lines of one case branch in container-main.sh.""" - text = CONTAINER_MAIN.read_text(encoding="utf-8") - start = text.index(f"\n{label})") + """Shell lines of one case branch in the install phase of container-release.sh.""" + text = CONTAINER_RELEASE.read_text(encoding="utf-8") + start = text.index(f"\n\t\t{label})") end = text.index(";;", start) return text[start:end] -def test_harness_scopes_the_ggml_scan_to_the_binding() -> None: - """Only the whisper binding ships libggml; leaves must not need it.""" - assert "libggml-cpu" in _case_branch("python3-pywhispercpp") - assert "libggml-cpu" not in _case_branch("*") +def test_the_ggml_scan_belongs_to_the_whisper_binding_only() -> None: + """Only the whisper binding ships libggml; the payload check is its own.""" + assert "payload_pywhispercpp" in _case_branch("python3-pywhispercpp") + assert "payload_voculinux" not in _case_branch("python3-pywhispercpp") + text = CONTAINER_RELEASE.read_text(encoding="utf-8") + assert "libggml-cpu" in text and "libggml-vulkan" in text -def test_harness_smokes_the_leaf_packages() -> None: - """PyAudio imports headless; pynput needs a display, so prove presence.""" - assert "import pyaudio" in _case_branch("python3-PyAudio") - branch = _case_branch("python3-pynput") - assert "pynput" in branch - assert "find_spec" in branch or "version(" in branch +def test_the_leaf_packages_are_imported_headless() -> None: + """PyAudio and pynput are proven by import: pynput's dummy backend needs no display.""" + text = CONTAINER_RELEASE.read_text(encoding="utf-8") + assert "import pyaudio" in text + assert "PYNPUT_BACKEND=dummy" in text and "import pynput.keyboard" in text -def test_harness_rejects_packages_without_a_smoke_recipe() -> None: +def test_a_package_without_a_smoke_recipe_fails_the_release() -> None: """A future template falls into *) and must fail loudly, never pass mute.""" - branch = _case_branch("*") - assert "exit 1" in branch + text = CONTAINER_RELEASE.read_text(encoding="utf-8") + assert "no smoke recipe for package" in _case_branch("*") or ( + "no smoke recipe for package" in text + ) def test_just_recipe_calls_the_gate_like_other_packaging_gates() -> None: diff --git a/tests/test_xbps_release_workflow.py b/tests/test_xbps_release_workflow.py index cf68164..b8da32c 100644 --- a/tests/test_xbps_release_workflow.py +++ b/tests/test_xbps_release_workflow.py @@ -1,29 +1,25 @@ -"""Offline guards for the signed xbps release workflow (ticket #29). +"""Offline guards for the signed xbps release workflow (tickets #28 and #29). Releasing the Void repository is a tag push on the fork's Gitea host. The -workflow ``.gitea/workflows/xbps-release.yml`` gates the tag against -``src/vocalinux/version.py``, requires the ``XBPS_SIGNING_KEY`` secret, builds -and signs only through ``packaging/void/repo.sh`` (podman, pinned Void glibc -container), verifies the staged repository, and publishes it to the tag's -release and to the rolling ``latest`` release the client registration URL -serves. +workflow ``.gitea/workflows/xbps-release.yml`` gates the tag, stages the +signing key on tmpfs, runs ``packaging/void/build-release.sh`` (the one release +path), and publishes exactly what that script staged to the tag's release and +to the rolling ``latest`` release the client registration URL serves. -These tests parse the workflow and then *run* its gate, verify, build and -publish steps in a scratch directory, so they assert behaviour rather than -spelling: +These tests parse the workflow and then *run* its gate, key, tools, and publish +steps in a scratch directory, so they assert behaviour rather than spelling: -- the shell of each step is executed with the environment the workflow - itself declares (``${{ ... }}`` expressions are rendered from an explicit - context, so a step wired to the wrong secret or token fails loudly); -- ``curl`` is replaced by a small stateful stand-in for the Gitea release API - on ``PATH``, so the publish step's requests, their order, and its refusal to - continue past a non-2xx response are observable. +- each step's shell runs with the environment the workflow itself declares + (``${{ ... }}`` is rendered from an explicit context, so an unexpected + expression fails loudly); +- ``curl`` is replaced by a stateful stand-in for the Gitea release API on + PATH, so the publish step's requests, their order and its refusal to continue + past a non-2xx response are observable. Independent sources of truth: the tag/version pair comes from the test (not the -workflow), the repository layout comes from what ``xbps-rindex`` writes -(``.x86_64.xbps`` plus ``.x86_64.xbps.sig2`` and ``x86_64-repodata``), -and the Gitea REST contract (release create, asset upload and delete, tag -lookup) comes from the Gitea API documentation. +workflow), the staged layout is what ``build-release.sh`` writes, and the Gitea +REST contract (release create, asset upload and delete, tag lookup) comes from +the Gitea API documentation. """ import json @@ -33,7 +29,7 @@ import shutil import stat import subprocess import sys -import textwrap +import tempfile from pathlib import Path from typing import Dict, List, Optional from urllib.parse import unquote @@ -43,21 +39,24 @@ import yaml REPO_ROOT = Path(__file__).resolve().parents[1] WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml" +VOID = REPO_ROOT / "packaging" / "void" +SMOKE_SH = VOID / "smoke.sh" JOB = "xbps-release" GATE_TAG = "Require the tag to equal the version in version.py" GATE_KEY = "Require the repository signing key" -INSTALL_TOOLS = "Install the tools repo.sh and the publish step need" -BUILD = "Build and sign the repository" -VERIFY = "Verify the signed repository is complete" +TOOLS = "Check the runner has the release tools" +BUILD = "Build, sign and stage the repository" PUBLISH = "Publish to the tag release and the latest release" CLEANUP = "Remove the signing key" -STEP_ORDER = (GATE_TAG, GATE_KEY, INSTALL_TOOLS, BUILD, VERIFY, PUBLISH, CLEANUP) +STEP_ORDER = (GATE_TAG, GATE_KEY, TOOLS, BUILD, PUBLISH, CLEANUP) API = "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux" TOKEN = "test-token-not-a-secret" SHA = "0123456789abcdef0123456789abcdef01234567" +FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25" FAKE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nline-one\nline-two\n-----END RSA PRIVATE KEY-----\n" +SDIST = "voculinux-0.18.5.tar.gz" def _text() -> str: @@ -104,27 +103,29 @@ def _run_step( context: Optional[Dict[str, str]] = None, extra_env: Optional[Dict[str, str]] = None, path_prefix: Optional[Path] = None, + path: Optional[str] = None, ) -> subprocess.CompletedProcess: """Execute one step's ``run`` script with the env the workflow declares.""" ctx = {"gitea.run_id": "42", "gitea.token": TOKEN, "gitea.sha": SHA} ctx.update(context or {}) step = _step(name) env = {k: os.environ[k] for k in ("HOME", "LANG") if k in os.environ} - path = os.environ.get("PATH", "/usr/bin:/bin") - env["PATH"] = f"{path_prefix}{os.pathsep}{path}" if path_prefix else path + base = path if path is not None else os.environ.get("PATH", "/usr/bin:/bin") + env["PATH"] = f"{path_prefix}{os.pathsep}{base}" if path_prefix else base for source in (_job().get("env", {}), step.get("env", {})): for key, value in source.items(): env[key] = _render(str(value), ctx) env.update(extra_env or {}) script = cwd / f"{re.sub(r'[^a-z]+', '-', name.lower())}.sh" script.write_text(step["run"], encoding="utf-8") + bash = shutil.which("bash", path=os.environ.get("PATH", "/usr/bin:/bin")) or "/bin/bash" return subprocess.run( - ["bash", str(script)], + [bash, str(script)], cwd=cwd, env=env, capture_output=True, text=True, - timeout=30, + timeout=60, check=False, ) @@ -152,7 +153,7 @@ def test_runs_on_the_project_runner_with_no_third_party_actions() -> None: def test_steps_run_in_the_gated_order() -> None: - """Both gates precede any container; verify precedes publish; key cleanup is last.""" + """Both gates precede any container; the cleanup of the key is last.""" names = [s.get("name") for s in _job()["steps"]] assert names[0] == "Checkout" assert tuple(names[1:]) == STEP_ORDER, names @@ -163,7 +164,12 @@ def test_signing_key_is_removed_by_an_always_step() -> None: assert last["name"] == CLEANUP assert last["if"] == "always()" assert "rm -f" in last["run"] and "$XBPS_KEY_FILE" in last["run"] - assert _job()["env"]["XBPS_KEY_FILE"].startswith("/tmp/xbps-sign-key-") + + +def test_the_key_file_lives_on_tmpfs_under_a_per_run_name() -> None: + path = _job()["env"]["XBPS_KEY_FILE"] + assert path.startswith("/dev/shm/xbps-sign-key-"), path + assert "${{ gitea.run_id }}" in path def test_signing_secret_is_read_by_exactly_one_step() -> None: @@ -181,20 +187,33 @@ def test_the_tag_reaches_scripts_through_env_never_interpolated() -> None: for step in _job()["steps"]: assert "${{" not in step.get("run", ""), step.get("name") assert _step(GATE_TAG)["env"]["TAG"] == "${{ gitea.ref_name }}" + assert _step(BUILD)["env"]["TAG"] == "${{ gitea.ref_name }}" -def test_repo_sh_is_the_only_build_and_sign_path() -> None: - """No step builds, indexes or signs by hand; only repo.sh does, once.""" +def test_the_build_step_is_the_release_path_with_the_key_file_only() -> None: + """One path: build-release.sh release, handed the key file and nothing else.""" code = _without_comments(_text()) - assert code.count("packaging/void/repo.sh") == 1 - for forbidden in ("xbps-src", "xbps-rindex", "openssl rsa", "xlint", "gpg "): - assert forbidden not in code, f"{forbidden!r} belongs inside repo.sh, not the workflow" - assert "repo.sh" in _run_text(BUILD) + assert code.count("packaging/void/build-release.sh release") == 1 + run = _run_text(BUILD) + assert '--key-file "$XBPS_KEY_FILE"' in run + assert "--out-dir publish" in run + for forbidden in ("xbps-src", "xbps-rindex", "openssl rsa", "xlint", "gpg ", "repo.sh"): + assert forbidden not in code, f"{forbidden!r} belongs inside build-release.sh" + + +def test_no_step_installs_packages_and_the_runner_must_bring_its_tools() -> None: + """The job checks its tools fail-fast; it does not install them with apt.""" + code = _without_comments(_text()) + assert "apt-get" not in code + check = _run_text(TOOLS) + for tool in ("podman", "openssl", "git", "sha256sum", "python3.11", "uv"): + assert tool in check, f"the tool check must name {tool}" + assert "0.12.*" in check and "0.13.*" in check, "uv must be checked against the pinned range" def test_pipeline_uses_podman_and_never_docker() -> None: code = _without_comments(_text()).lower() - assert "podman" in _run_text(INSTALL_TOOLS) + assert "podman --version" in code assert "docker" not in code @@ -218,18 +237,31 @@ def test_publish_goes_to_the_fork_gitea_api_with_the_job_token() -> None: # --------------------------------------------------------------------------- -# Gate 1: the tag equals the version source of truth +# Gate 1: the tag equals the version source of truth, and the template agrees # --------------------------------------------------------------------------- -def _version_file(root: Path, body: str) -> None: - target = root / "src" / "vocalinux" / "version.py" - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(body, encoding="utf-8") +def _gate_tree(root: Path, version: str, template_version: str, template_tag: str) -> Path: + """A scratch repository with the real release script and a chosen version.""" + pkg = root / "packaging" / "void" + pkg.mkdir(parents=True) + for name in ("build-release.sh", "smoke.sh", "tool_checksums.txt", "repository-key.plist"): + shutil.copy2(VOID / name, pkg / name) + template = (VOID / "srcpkgs" / "voculinux" / "template").read_text(encoding="utf-8") + template = re.sub(r"^version=.*$", f"version={template_version}", template, flags=re.M) + template = re.sub(r"^_tag=.*$", f"_tag={template_tag}", template, flags=re.M) + (pkg / "srcpkgs" / "voculinux").mkdir(parents=True) + (pkg / "srcpkgs" / "voculinux" / "template").write_text(template, encoding="utf-8") + (root / "src" / "vocalinux").mkdir(parents=True) + (root / "src" / "vocalinux" / "version.py").write_text( + f'"""Version."""\n\n__version__ = "{version}"\n', encoding="utf-8" + ) + return root -def _gate_tag(tmp_path: Path, tag: str, version_py: str) -> subprocess.CompletedProcess: - _version_file(tmp_path, version_py) +def _gate_tag(tmp_path: Path, tag: str, version: str) -> subprocess.CompletedProcess: + xbps = version.replace("-", "") + _gate_tree(tmp_path, version, template_version=xbps, template_tag=f"v{version}") return _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": tag}) @@ -250,7 +282,7 @@ def _gate_tag(tmp_path: Path, tag: str, version_py: str) -> subprocess.Completed def test_tag_gate_requires_exact_v_plus_version( tmp_path: Path, tag: str, version: str, ok: bool ) -> None: - result = _gate_tag(tmp_path, tag, f'"""Version."""\n\n__version__ = "{version}"\n') + result = _gate_tag(tmp_path, tag, version) if ok: assert result.returncode == 0, result.stderr else: @@ -258,17 +290,42 @@ def test_tag_gate_requires_exact_v_plus_version( assert "refusing to publish" in result.stderr +def test_tag_gate_refuses_a_committed_template_for_another_release(tmp_path: Path) -> None: + """The tag may match version.py, but the template the build tree starts from must too.""" + _gate_tree(tmp_path, "0.19.0-beta", template_version="0.18.5", template_tag="v0.18.5") + result = _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": "v0.19.0-beta"}) + assert result.returncode == 1 + assert "committed Void template" in result.stderr + + def test_tag_gate_fails_when_the_version_cannot_be_read(tmp_path: Path) -> None: - result = _gate_tag(tmp_path, "v0.18.5", '"""Version."""\n__version_info__ = (0, 18, 5)\n') + _gate_tree(tmp_path, "0.18.5", template_version="0.18.5", template_tag="v0.18.5") + (tmp_path / "src" / "vocalinux" / "version.py").write_text( + '"""Version."""\n__version_info__ = (0, 18, 5)\n', encoding="utf-8" + ) + result = _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": "v0.18.5"}) assert result.returncode == 1 assert "__version__" in result.stderr # --------------------------------------------------------------------------- -# Gate 2: the signing key +# Gate 2: the signing key is staged on tmpfs, 0600, never echoed # --------------------------------------------------------------------------- +def _tmpfs_dir() -> Optional[Path]: + """A fresh directory on /dev/shm when that is tmpfs, or None on a host without one.""" + shm = Path("/dev/shm") + if not shm.is_dir(): + return None + fstype = subprocess.run( + ["stat", "-f", "-c", "%T", str(shm)], capture_output=True, text=True, check=False + ).stdout.strip() + if fstype != "tmpfs": + return None + return Path(tempfile.mkdtemp(prefix="xbps-key-test-", dir=str(shm))) + + def test_unset_signing_key_exits_1_and_writes_nothing(tmp_path: Path) -> None: key_file = tmp_path / "key.pem" result = _run_step( @@ -283,7 +340,15 @@ def test_unset_signing_key_exits_1_and_writes_nothing(tmp_path: Path) -> None: assert not key_file.exists(), "no key material may be written when the secret is empty" -def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None: +def test_a_key_file_outside_tmpfs_is_refused(tmp_path: Path) -> None: + """A key that would land on disk stops the job before the secret is written.""" + if ( + subprocess.run( + ["stat", "-f", "-c", "%T", str(tmp_path)], capture_output=True, text=True, check=False + ).stdout.strip() + == "tmpfs" + ): + pytest.skip("the test directory is itself tmpfs") key_file = tmp_path / "key.pem" result = _run_step( GATE_KEY, @@ -291,10 +356,49 @@ def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None {"secrets.XBPS_SIGNING_KEY": FAKE_KEY}, {"XBPS_KEY_FILE": str(key_file)}, ) - assert result.returncode == 0, result.stderr - assert key_file.read_text(encoding="utf-8") == FAKE_KEY - assert stat.S_IMODE(key_file.stat().st_mode) == 0o600 - assert FAKE_KEY.splitlines()[1] not in result.stdout + result.stderr, "key was echoed" + assert result.returncode == 1 + assert "not tmpfs" in result.stderr + assert not key_file.exists() + + +def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None: + shm = _tmpfs_dir() + if shm is None: + pytest.skip("no tmpfs at /dev/shm on this host") + try: + key_file = shm / "key.pem" + result = _run_step( + GATE_KEY, + tmp_path, + {"secrets.XBPS_SIGNING_KEY": FAKE_KEY}, + {"XBPS_KEY_FILE": str(key_file)}, + ) + assert result.returncode == 0, result.stderr + assert key_file.read_text(encoding="utf-8") == FAKE_KEY + assert stat.S_IMODE(key_file.stat().st_mode) == 0o600 + assert FAKE_KEY.splitlines()[1] not in result.stdout + result.stderr, "key was echoed" + finally: + shutil.rmtree(shm, ignore_errors=True) + + +def test_an_existing_key_file_is_never_reused(tmp_path: Path) -> None: + """Noclobber: a file already at the key path fails the job rather than being overwritten.""" + shm = _tmpfs_dir() + if shm is None: + pytest.skip("no tmpfs at /dev/shm on this host") + try: + key_file = shm / "key.pem" + key_file.write_text("someone else's file\n", encoding="utf-8") + result = _run_step( + GATE_KEY, + tmp_path, + {"secrets.XBPS_SIGNING_KEY": FAKE_KEY}, + {"XBPS_KEY_FILE": str(key_file)}, + ) + assert result.returncode != 0 + assert key_file.read_text(encoding="utf-8") == "someone else's file\n" + finally: + shutil.rmtree(shm, ignore_errors=True) def test_cleanup_step_removes_the_key_file(tmp_path: Path) -> None: @@ -306,155 +410,17 @@ def test_cleanup_step_removes_the_key_file(tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# Build: repo.sh is handed the key file, nothing else +# Tools: a runner without the release tools stops before any work # --------------------------------------------------------------------------- -def test_build_step_runs_repo_sh_with_the_key_file_only(tmp_path: Path) -> None: - """repo.sh gets XBPS_SIGNING_KEY_FILE and never the secret itself.""" - script = tmp_path / "packaging" / "void" / "repo.sh" - script.parent.mkdir(parents=True) - script.write_text( - textwrap.dedent("""\ - #!/bin/sh - set -eu - [ -s "$XBPS_SIGNING_KEY_FILE" ] || exit 7 - printf '%s' "$XBPS_SIGNING_KEY_FILE" > called-with - printf '%s' "${XBPS_SIGNING_KEY:-unset}" > secret-seen - mkdir -p publish/xbps/repo publish/xbps/keys - """), - encoding="utf-8", - ) - key_file = tmp_path / "key.pem" - key_file.write_text(FAKE_KEY, encoding="utf-8") - result = _run_step(BUILD, tmp_path, extra_env={"XBPS_KEY_FILE": str(key_file)}) - assert result.returncode == 0, result.stderr - assert (tmp_path / "called-with").read_text(encoding="utf-8") == str(key_file) - assert (tmp_path / "secret-seen").read_text(encoding="utf-8") == "unset" - - -def test_build_step_fails_when_repo_sh_fails(tmp_path: Path) -> None: - """A failing repo.sh (missing or non-RSA4096 key, build error) ends the job.""" - script = tmp_path / "packaging" / "void" / "repo.sh" - script.parent.mkdir(parents=True) - script.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") - result = _run_step(BUILD, tmp_path, extra_env={"XBPS_KEY_FILE": str(tmp_path / "k")}) - assert result.returncode != 0 - - -# --------------------------------------------------------------------------- -# Verify: the full signed glibc x86_64 repository, nothing else -# --------------------------------------------------------------------------- - -FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25" -SMOKE_SH = REPO_ROOT / "packaging" / "void" / "smoke.sh" - - -def _smoke_packages() -> List[str]: - """The templates the smoke gate builds, in order: the repository's package set.""" - match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M) - assert match, "smoke.sh must declare its ordered PKGS list" - return match.group(1).split() - - -#: One built package per template, versioned the way xbps-src names them. -PACKAGES = [f"{name}-1.0_1" for name in _smoke_packages()] - - -def _stage(root: Path, fingerprint: str = FINGERPRINT) -> Path: - """Lay out publish/xbps the way repo.sh stages it, plus the smoke gate's list.""" - smoke = root / "packaging" / "void" / "smoke.sh" - smoke.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(SMOKE_SH, smoke) - out = root / "publish" / "xbps" - repo, keys = out / "repo", out / "keys" - repo.mkdir(parents=True) - keys.mkdir() - for pkg in PACKAGES: - (repo / f"{pkg}.x86_64.xbps").write_bytes(b"package bytes " + pkg.encode()) - (repo / f"{pkg}.x86_64.xbps.sig2").write_bytes(b"signature " + pkg.encode()) - (repo / "x86_64-repodata").write_bytes(b"signed index") - (keys / "FINGERPRINT").write_text( - f"signed-by: Voculinux \nfingerprint: {fingerprint}\n", - encoding="utf-8", - ) - (keys / f"{fingerprint}.plist").write_text("\n", encoding="utf-8") - return out - - -def test_the_pinned_fingerprint_is_what_the_verify_step_checks() -> None: - assert _job()["env"]["XBPS_KEY_FINGERPRINT"] == FINGERPRINT - - -def test_verify_accepts_the_complete_signed_repository(tmp_path: Path) -> None: - _stage(tmp_path) - result = _run_step(VERIFY, tmp_path) - assert result.returncode == 0, result.stderr - assert "complete signed glibc x86_64 repository" in result.stdout - - -@pytest.mark.parametrize( - "missing", - [ - f"repo/{PACKAGES[0]}.x86_64.xbps", - f"repo/{PACKAGES[-1]}.x86_64.xbps", - f"repo/{PACKAGES[0]}.x86_64.xbps.sig2", - "repo/x86_64-repodata", - "keys/FINGERPRINT", - f"keys/{FINGERPRINT}.plist", - ], -) -def test_verify_rejects_a_repository_missing_any_class(tmp_path: Path, missing: str) -> None: - out = _stage(tmp_path) - (out / missing).unlink() - result = _run_step(VERIFY, tmp_path) - assert result.returncode == 1, f"removing {missing} must fail verification" - - -@pytest.mark.parametrize( - "extra", - [ - "repo/voculinux-1.0_1.x86_64-musl.xbps", - "repo/voculinux-1.0_1.aarch64.xbps", - "repo/voculinux-1.0_1.noarch.xbps", - "repo/x86_64-musl-repodata", - "repo/aarch64-repodata", - "repo/notes.txt", - "keys/private.pem", - ], -) -def test_verify_rejects_anything_but_glibc_x86_64_data(tmp_path: Path, extra: str) -> None: - out = _stage(tmp_path) - (out / extra).write_bytes(b"x") - result = _run_step(VERIFY, tmp_path) - assert result.returncode == 1, f"{extra} must not be publishable" - - -def test_verify_rejects_an_unsigned_package(tmp_path: Path) -> None: - """One signature present does not excuse a package without its own.""" - out = _stage(tmp_path) - (out / "repo" / f"{PACKAGES[-1]}.x86_64.xbps.sig2").write_bytes(b"") - result = _run_step(VERIFY, tmp_path) +def test_a_runner_missing_the_release_tools_fails_fast(tmp_path: Path) -> None: + empty = tmp_path / "empty-bin" + empty.mkdir() + result = _run_step(TOOLS, tmp_path, path=str(empty)) assert result.returncode == 1 - assert "no signature" in result.stderr - - -def test_verify_rejects_a_signature_whose_package_is_gone(tmp_path: Path) -> None: - out = _stage(tmp_path) - (out / "repo" / f"{PACKAGES[0]}.x86_64.xbps.sig2").write_bytes(b"sig") - (out / "repo" / "ghost-1.0_1.x86_64.xbps.sig2").write_bytes(b"sig") - result = _run_step(VERIFY, tmp_path) - assert result.returncode == 1 - assert "without its package" in result.stderr - - -def test_verify_rejects_a_repository_signed_by_another_key(tmp_path: Path) -> None: - """A throwaway or wrong key must never publish under the pinned fingerprint.""" - other = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff" - _stage(tmp_path, fingerprint=other) - result = _run_step(VERIFY, tmp_path) - assert result.returncode == 1 - assert FINGERPRINT in result.stderr and other in result.stderr + assert "lacks" in result.stderr and "podman" in result.stderr + assert "packaging/void/README.md" in result.stderr, "the message names the runner requirements" # --------------------------------------------------------------------------- @@ -647,8 +613,40 @@ class FakeGitea: ) +def _smoke_packages() -> List[str]: + """The templates the release builds, in order, from the smoke gate's list.""" + match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M) + assert match, "smoke.sh must declare its ordered PKGS list" + return match.group(1).split() + + +def _stage(root: Path) -> Path: + """Lay out publish/ the way build-release.sh stages a release.""" + out = root / "publish" + sdist = out / "sdist" + repo = out / "xbps" / "repo" + keys = out / "xbps" / "keys" + for directory in (sdist, repo, keys): + directory.mkdir(parents=True) + (sdist / SDIST).write_bytes(b"canonical sdist bytes") + (out / "SDIST_SHA256").write_text("a" * 64 + "\n", encoding="utf-8") + for name in _smoke_packages(): + pkg = f"{name}-0.18.5_1" + (repo / f"{pkg}.x86_64.xbps").write_bytes(b"package bytes " + pkg.encode()) + (repo / f"{pkg}.x86_64.xbps.sig2").write_bytes(b"signature " + pkg.encode()) + (repo / "x86_64-repodata").write_bytes(b"signed index") + (keys / "FINGERPRINT").write_text( + f"signed-by: Voculinux \nfingerprint: {FINGERPRINT}\n", + encoding="utf-8", + ) + (keys / f"{FINGERPRINT}.plist").write_text("\n", encoding="utf-8") + return out + + def _staged_names(out: Path) -> List[str]: - return sorted(p.name for sub in ("repo", "keys") for p in (out / sub).iterdir()) + return sorted( + p.name for sub in ("sdist", "xbps/repo", "xbps/keys") for p in (out / sub).iterdir() + ) @pytest.fixture() @@ -659,11 +657,12 @@ def staged(tmp_path: Path) -> Path: return tmp_path -def test_publish_attaches_the_full_repository_to_both_releases(staged: Path) -> None: +def test_publish_attaches_the_sdist_and_the_repository_to_both_releases(staged: Path) -> None: gitea = FakeGitea(staged) result = gitea.run_publish(staged) assert result.returncode == 0, result.stdout + result.stderr - expected = _staged_names(staged / "publish" / "xbps") + expected = _staged_names(staged / "publish") + assert SDIST in expected for tag in ("v0.18.5", "latest"): assert sorted(gitea.asset_names(tag)) == expected, tag uploaded = gitea.uploads(tag) @@ -672,6 +671,12 @@ def test_publish_attaches_the_full_repository_to_both_releases(staged: Path) -> assert FINGERPRINT in gitea.release("v0.18.5")["body"] +def test_publish_states_the_sdist_digest_in_the_notes(staged: Path) -> None: + gitea = FakeGitea(staged) + assert gitea.run_publish(staged).returncode == 0 + assert "sdist sha256: " + "a" * 64 in gitea.release("latest")["body"] + + def test_publish_creates_latest_at_the_workflow_commit(staged: Path) -> None: gitea = FakeGitea(staged) assert gitea.run_publish(staged).returncode == 0 @@ -692,37 +697,42 @@ def test_publish_talks_only_to_the_fork_api_authenticated(staged: Path) -> None: for entry in log: assert entry["url"].startswith(API + "/"), entry["url"] assert entry["auth"] == [f"Authorization: token {TOKEN}"], entry - # The tag release is created and filled before latest is touched. posts = [e["data"]["tag_name"] for e in log if e["url"] == API + "/releases"] - assert posts == ["v0.18.5", "latest"] + assert posts == ["v0.18.5", "latest"], "the tag release is filled before latest is touched" def test_publish_replaces_every_asset_on_an_existing_latest_release(staged: Path) -> None: gitea = FakeGitea(staged) gitea.seed_release("latest", ["voculinux-0.18.4_1.x86_64.xbps", "x86_64-repodata", "old.txt"]) assert gitea.run_publish(staged).returncode == 0 - assert sorted(gitea.asset_names("latest")) == _staged_names(staged / "publish" / "xbps") + assert sorted(gitea.asset_names("latest")) == _staged_names(staged / "publish") assert len([r for r in gitea.state["releases"].values() if r["tag_name"] == "latest"]) == 1 assert "v0.18.5" in gitea.release("latest")["body"] -def test_publish_rerun_keeps_foreign_assets_on_the_tag_release(staged: Path) -> None: - """Rerunning a tag replaces only its own files; hand-attached assets survive.""" +def test_publish_replaces_a_same_named_asset_on_a_rerun(staged: Path) -> None: + """A rerun of a tag replaces its own files, the sdist included: nothing stale survives.""" gitea = FakeGitea(staged) - gitea.seed_release("v0.18.5", ["x86_64-repodata", "voculinux-0.18.5.tar.gz"]) + gitea.seed_release("v0.18.5", [SDIST, "x86_64-repodata"]) + before = {a["name"]: a["id"] for a in gitea.release("v0.18.5")["assets"]} assert gitea.run_publish(staged).returncode == 0 - names = gitea.asset_names("v0.18.5") - assert "voculinux-0.18.5.tar.gz" in names - assert names.count("x86_64-repodata") == 1 - assert set(_staged_names(staged / "publish" / "xbps")) <= set(names) + after = {a["name"]: a["id"] for a in gitea.release("v0.18.5")["assets"]} + assert after[SDIST] != before[SDIST], "the sdist on the tag release must be the staged one" + assert after["x86_64-repodata"] != before["x86_64-repodata"] + assert sorted(after) == _staged_names(staged / "publish") -def test_publish_percent_encodes_the_colon_fingerprint_asset_name(staged: Path) -> None: +def test_publish_uploads_only_what_the_release_path_staged(staged: Path) -> None: + """Nothing is built or uploaded by hand: every upload lives under publish/.""" gitea = FakeGitea(staged) assert gitea.run_publish(staged).returncode == 0 - plist_urls = [e["url"] for e in gitea.state["log"] if ".plist" in e["url"]] - assert plist_urls and all(":" not in u.split("name=")[1] for u in plist_urls), plist_urls - assert f"{FINGERPRINT}.plist" in gitea.asset_names("latest") + staged_names = set(_staged_names(staged / "publish")) + for tag in ("v0.18.5", "latest"): + assert set(gitea.uploads(tag)) == staged_names + for entry in gitea.state["log"]: + for form in entry["forms"]: + path = re.match(r"attachment=@(.*?);", form).group(1) + assert path.startswith(("sdist/", "xbps/")), f"not from the staged set: {path}" @pytest.mark.parametrize( @@ -751,14 +761,17 @@ def test_publish_does_not_touch_latest_when_the_tag_release_fails(staged: Path) assert not any(r["tag_name"] == "latest" for r in gitea.state["releases"].values()) -def test_publish_uploads_only_staged_files(staged: Path) -> None: - """Nothing is built or created by hand: uploads are exactly the staged set.""" +def test_publish_percent_encodes_the_colon_fingerprint_asset_name(staged: Path) -> None: gitea = FakeGitea(staged) assert gitea.run_publish(staged).returncode == 0 - staged_names = set(_staged_names(staged / "publish" / "xbps")) - for tag in ("v0.18.5", "latest"): - assert set(gitea.uploads(tag)) == staged_names - for entry in gitea.state["log"]: - for form in entry["forms"]: - path = re.match(r"attachment=@(.*?);", form).group(1) - assert path.startswith(("repo/", "keys/")), path + plist_urls = [e["url"] for e in gitea.state["log"] if ".plist" in e["url"]] + assert plist_urls and all(":" not in u.split("name=")[1] for u in plist_urls), plist_urls + assert f"{FINGERPRINT}.plist" in gitea.asset_names("latest") + + +def test_publish_refuses_to_run_without_a_staged_sdist_digest(staged: Path) -> None: + (staged / "publish" / "SDIST_SHA256").unlink() + gitea = FakeGitea(staged) + result = gitea.run_publish(staged) + assert result.returncode != 0 + assert not gitea.state["log"], "nothing may be uploaded without the staged digest" -- 2.54.0 From 22c52a906549fb1235a7846c517a22112f761f58 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 20:16:46 +0530 Subject: [PATCH 3/6] docs(void): describe the tag-built release, the template bump and runner requirements The sdist is built from the tag by the release path and never attached by hand. The release PR sets version and _tag in the template; the checksum is recorded by a pull request after the tag. Runner requirements, the three container phases and the local dry run are documented. --- docs/RELEASE_PROCESS.md | 113 +++++++++--------- packaging/void/README.md | 240 +++++++++++++++++++++++---------------- 2 files changed, 200 insertions(+), 153 deletions(-) diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 246e3d1..76249a5 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -40,19 +40,21 @@ publish succeeds. ### Void xbps repository (required for a Void release) The signed Void repository is released by `.gitea/workflows/xbps-release.yml` -on the fork's Gitea host, not by `release.yml`. Set it up once: +on the fork's Gitea host, not by `release.yml`. The whole release is one script, +`packaging/void/build-release.sh`, which the workflow and a local dry run both +call. Set it up once: | what | value | |---|---| | Secret | `XBPS_SIGNING_KEY`: the PEM-encoded RSA 4096 repository private key (Gitea repository settings, Actions, Secrets) | | Public key | `packaging/void/repository-key.plist`, committed, fingerprint `fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25` | -| Runner | `bongbetic-ci` with podman (the workflow installs it with apt when absent). Never Docker | +| Runner | `bongbetic-ci`, a host job with podman (rootless, with user namespaces), python3.11, uv 0.12/0.13 and tmpfs `/dev/shm`. Requirements: `packaging/void/README.md`, "Runner requirements". Never Docker | | Token | the job token must be allowed to create releases and upload release assets | Without the secret the workflow fails and publishes nothing; it never signs -with a different key either, because the fingerprint of the key that signed -must equal the one above. The private key is never committed; only the public -`.plist` is. Key generation and the check that a private key matches the +with a different key either: the release path refuses any key whose public half +is not `repository-key.plist`. The private key is never committed; only the +public `.plist` is. Key generation and the check that a private key matches the committed public key are in `packaging/void/README.md`. ## Quick Release Checklist @@ -72,7 +74,7 @@ Use this checklist for every release: - [ ] `docs/INSTALL.md` - Verify install examples use `main/install.sh` (not version-pinned raw URLs) - [ ] `docs/UPDATE.md` - Add "What's New" section for new version - [ ] `SECURITY.md` - Update supported versions table -- [ ] `packaging/void/srcpkgs/` - After the tag, bump template `version`/`checksum` to the new release as a pull request (never direct to `main`) +- [ ] `packaging/void/srcpkgs/voculinux/template` - set `version` (the xbps spelling, the hyphen removed) and `_tag` to this release, in the same release PR as `version.py`. The tag gate refuses a tag whose template names another release. The checksum is recorded after the release (section 3.5) ### Website - [ ] `web/src/app/page.tsx` - Update `softwareVersion` in schema @@ -329,26 +331,32 @@ Update the supported versions table: | < 0.4 | :x: | ``` -#### 3.5 Update Void templates (pull request) +#### 3.5 Void template: version in the release PR, checksum after the tag (pull request) -After the tag is cut, the Void templates in `packaging/void/srcpkgs/` must -point at the new release: bump `version` in the `voculinux` template (and -any template whose upstream moved) and refresh every pinned `checksum` -(`sha256` sums of the release sdist and distfiles). The `voculinux` template -builds the fork's release asset `voculinux-X.Y.Z.tar.gz`; its `checksum` is -the sha256 of that exact downloaded asset, never a value copied from -upstream, and stays `PENDING_FORK_RELEASE_SHA256` (which fails closed in -`xbps-src`) until the asset exists. Land the bump as a **pull request** -through the normal test, lint, and type gates, never as a direct or bot -commit to `main`, and never hand-edited onto the release itself (see -`just verify-release`). `tests/test_void_app_template.py` fails if the -template version or checksum drifts from `src/vocalinux/version.py` and the -published sdist. +The Void template `packaging/void/srcpkgs/voculinux/template` names one release. +It has two parts, handled at two times, and both go through a normal pull request: -Nothing automates this: the xbps workflow never commits or pushes. The -signed repository a tag publishes is built from the templates as committed -at that tag, so a bump merged after tag `vX.Y.Z` first ships with the next -tag push. +- **Version and tag: in the release pull request**, with `version.py`. Set + `version` to the xbps spelling of `__version__` (the hyphen removed: + `0.19.0-beta` becomes `0.19.0beta`) and `_tag` to the tag (`v0.19.0-beta`). + `build-release.sh check-tag` refuses any tag whose template names another + release, so a stale template stops the tag, before any build. +- **Checksum: after the tag, in a pull request.** The release build replaces + `checksum=` in its build tree only, with the sha256 of the sdist it builds, and + prints `checksum=` in the workflow log and in the release notes + (`sdist sha256:`). Copy that value into the template in a pull request through + the normal test, lint and type gates. Never commit it directly to `main`, and + never hand-edit a release asset. + +The committed template carries a 64-zero placeholder for `checksum=` until that +first pull request lands. The placeholder is never a real digest and is never +published: every release build replaces it. `tests/test_void_app_template.py` +fails if the template's version or `_tag` drifts from `src/vocalinux/version.py`. + +The release build never commits or pushes. Its signed repository is built from +the templates as committed at the tag, so a checksum pull request merged after +tag `vX.Y.Z` is recorded for the next release, and the next tag builds with it +(the build tree always takes the digest of the sdist it builds). ### Step 4: Update Website @@ -541,32 +549,30 @@ Pushing the tag to `origin` (the fork's Gitea repository) also starts `.gitea/workflows/xbps-release.yml`. Pushing a tag is a human act and is approved each time: the workflow has no schedule and no manual trigger. -1. **Tag equals version.** The tag must be exactly `v` plus `__version__` in - `src/vocalinux/version.py`. Any other tag, including one that merely - contains the version, exits 1 and publishes nothing. -2. **Signing key required.** An empty `XBPS_SIGNING_KEY` exits 1 before any - container runs. The key is written to a 0600 temp file, passed to - `packaging/void/repo.sh`, and removed by a step that always runs. -3. **Build and sign.** `repo.sh` is the only path that builds or signs: the - pinned Void glibc container under podman, every template in smoke-gate - order, the full `x86_64` index, every package and the metadata signed. -4. **Verify.** Packages, signatures, repodata, `FINGERPRINT` and the client - key must all be present, nothing but glibc `x86_64` data may be present, - every package must be signed, and the signing fingerprint must be the - pinned one. A partial or unsigned repository never attaches. -5. **Publish.** The complete signed set attaches to the tag's release on - Gitea and replaces the assets of the rolling `latest` release, which the - client registration URL +1. **Tag gate.** `build-release.sh check-tag`: the tag must be exactly `v` plus + `__version__`, and the committed template must name that release. Any other + tag exits 1 and publishes nothing. +2. **Key gate.** The `XBPS_SIGNING_KEY` secret must be non-empty. It is written + with noclobber to a `0600` file on tmpfs and removed by a final step that + always runs. +3. **Runner check.** podman, python3.11, uv 0.12/0.13, openssl, git and curl + must be on the runner, or the job names what is missing and stops. +4. **Release.** `build-release.sh release` builds the canonical sdist from the + tag (reproducibly), builds every template in the pinned Void glibc container + without the key, signs the repository in a second container with no network, + and installs `voculinux` from that repository in a clean image without the key. + A local dry run is `just void-release vX.Y.Z` with `XBPS_SIGNING_KEY_FILE` + set: the same script, the same containers, nothing published. +5. **Publish.** The staged set (sdist, packages, signatures, `x86_64-repodata`, + `FINGERPRINT`, the client `.plist`) attaches to the tag's release on Gitea and + replaces the assets of the rolling `latest` release, which the client + registration URL (`https://git.bongbetic.com/xavierk/voculinux/releases/download/latest`) - serves. Push tags in version order: pushing an older tag refreshes - `latest` with the older repository. + serves. Push tags in version order: pushing an older tag refreshes `latest` + with the older repository. -The workflow never commits or pushes to any branch and never bumps a -template; that is the pull request in section 3.5. The `voculinux` template -fetches the fork's `vX.Y.Z` release asset, so that asset must exist and its -checksum must be in the template before the build can pass. A tag whose -templates still carry the pending checksum fails at the build step, by -design. +Nothing in this run is built or uploaded by hand, and the workflow never commits +or pushes to any branch. The post-release checksum pull request is section 3.5. ### Step 9: Post-Release Tasks @@ -588,11 +594,12 @@ design. matches the line for that wheel in `SHA256SUMS` - [ ] Verify website was deployed (check voculinux.com) - [ ] Verify the Gitea xbps run succeeded, and that both the tag release and - `latest` carry the full signed set (`*.x86_64.xbps`, `*.sig2`, - `x86_64-repodata`, `FINGERPRINT`, the `.plist`) and that the fingerprint - matches the one in `README.md` -- [ ] Open the Void template bump pull request (section 3.5); never commit - it directly to `main` + `latest` carry the full signed set (`sdist/voculinux-*.tar.gz`, + `*.x86_64.xbps`, `*.sig2`, `x86_64-repodata`, `FINGERPRINT`, the `.plist`), + that the fingerprint matches the one in `README.md`, and that the sdist + sha256 in the release notes matches the `checksum=` line the run printed +- [ ] Open the Void checksum pull request (section 3.5) with that `checksum=` + value; never commit it directly to `main` - [ ] If the Flatpak remote is configured, verify it picked up the tag: `flatpak remote-info vocahq com.voculinux.Voculinux` (docs/FLATPAK_REMOTE.md) - [ ] Announce on social media/communities diff --git a/packaging/void/README.md b/packaging/void/README.md index 384999c..872e58f 100644 --- a/packaging/void/README.md +++ b/packaging/void/README.md @@ -10,27 +10,30 @@ repository first. ``` packaging/void/ ├── README.md this file -├── build.sh podman harness: lint + xbps-src build + import smoke -├── smoke.sh ticket #28 gate: every template, one command -├── repo.sh ticket #29 pipeline: build all, index, sign -├── container-repo.sh ticket #29 container half (index + sign + verify) +├── build-release.sh the release path: gate, sdist, signed repo, install smoke +├── container-release.sh the container half: build, sign, install (one per phase) +├── render-voculinux.sh pins version, _tag and checksum in the build tree only +├── smoke.sh ticket #28 gate: the release path with a throwaway key +├── xbps-fingerprint.py the repository key's fingerprint and client plist ├── repository-key.plist the repository's PUBLIC key (never a private key) ├── tool_checksums.txt pinned base image and void-packages commit └── srcpkgs/ ├── python3-pywhispercpp/ ticket #26: whisper.cpp Python binding - ├── ... ticket #25: small Python dependencies + ├── python3-pynput/ ticket #25: the input library Void does not ship + ├── python3-PyAudio/ ticket #25: the audio binding, updated past Void's copy └── voculinux ticket #27: the application ``` `srcpkgs/` mirrors void-packages so the tree overlays cleanly: `cp -r packaging/void/srcpkgs/. /path/to/void-packages/srcpkgs/` -(`build.sh` does this inside the container.) +(the release path does this inside the container). ## Templates | Template | Ticket | Notes | |---|---|---| | `python3-pywhispercpp` | #26 | Vulkan backend on by default; see below | +| `python3-pynput`, `python3-PyAudio` | #25 | Dependencies Void does not ship at the needed version | | `voculinux` | #27 | The app, from the canonical release sdist; see below | ## python3-pywhispercpp @@ -40,20 +43,17 @@ Version `1.5.0`, pinned to the same version `install.sh` The sdist checksum in the template must equal the `sha256` uv.lock records for the sdist; `tests/test_void_pywhispercpp_template.py` fails otherwise. -- **Vulkan** is a build option, on by default. Rebuild CPU-only with - `build.sh python3-pywhispercpp -- -o ~vulkan pkg`. There is no CUDA - build: Void ships no CUDA toolkit package. Rebuilding locally with a - hand-installed toolkit (`GGML_CUDA=1`) is a documented, unsupported - escape hatch. +- **Vulkan** is a build option, on by default. Rebuild CPU-only in a + void-packages checkout with the overlay applied: `./xbps-src -o ~vulkan pkg python3-pywhispercpp`. + There is no CUDA build: Void ships no CUDA toolkit package. Rebuilding + locally with a hand-installed toolkit (`GGML_CUDA=1`) is a documented, + unsupported escape hatch. - **Portable CPU flags**: every ggml SIMD option is pinned off (`-DGGML_NATIVE=OFF -DGGML_SSE42=OFF ...`), the plain x86-64 baseline - already used by the Flatpak (`#938`, `#957`). The offline test asserts - each pin in the template (the build configuration), and the harness - scans the shipped `libggml-cpu` (`.GCC.command.line`, `.comment`) for - recorded `-march=native`/`-mcpu=native`/`-mtune=native` flags and fails - on a match. The recorded-flags scan is a tripwire, not a proof: the - proof is the pinned configuration plus `setup.py`'s documented - `CMAKE_ARGS` passthrough. + already used by the Flatpak (`#938`, `#957`). The install smoke scans the + shipped `libggml-cpu` (`.GCC.command.line`) for `-march=native`, + `-mcpu=native` and `-mtune=native` and fails on a match. The scan is a + tripwire, not a proof: the proof is the pinned configuration. - **Shared libraries**: `BUILD_SHARED_LIBS=ON` with an `$ORIGIN` rpath (as `install.sh` does), so the extension resolves the whisper.cpp libraries installed beside it with no `LD_LIBRARY_PATH`. @@ -61,19 +61,13 @@ for the sdist; `tests/test_void_pywhispercpp_template.py` fails otherwise. ggml sources; the build fetches nothing beyond the declared distfiles. `PYWHISPERCPP_VERSION` is injected because the sdist carries no `version.txt` (its version otherwise comes from git metadata). -- **Runtime deps** (`python3-numpy`, `python3-requests`, `python3-tqdm`, - `python3-platformdirs`) are the sdist's own `install_requires` and were - verified to exist in current void-packages; the container build - re-verifies via dependency resolution. ## voculinux -Version `0.18.5`, matching `src/vocalinux/version.py` — never newest -upstream. The template builds the fork's canonical release source -distribution, `voculinux-0.18.5.tar.gz`, attached to the fork's `v0.18.5` -release on git.bongbetic.com, with a pinned `checksum` (see -[Checksum](#checksum)); `tests/test_void_app_template.py` fails if the -version drifts from `version.py`. +The template builds the fork's canonical release source distribution. It is +built from the release tag, never attached by hand, and never fetched from +upstream. Read [Version mapping](#version-mapping) and [Checksum](#checksum) +before changing `version`, `_tag` or `checksum`. - **Runtime deps** are Void's own interpreter, GTK and desktop bindings, the packaged Python libraries, the three templates from tickets #25 @@ -85,7 +79,7 @@ version drifts from `version.py`. container build re-verifies via dependency resolution. - **No payload beyond the app**: no speech models (they download on first run), no vendored wheels, no pip at build or install time. The - container smoke fails the build if the payload carries model weights + install smoke fails the release if the payload carries model weights or wheels. - **Installed names**: the `voculinux.desktop` entry, the hicolor icons, and a `usr/share/voculinux/packaged` marker the app reads to present @@ -102,71 +96,103 @@ version drifts from `version.py`. page) presents the package-manager path (`src/vocalinux/utils/packaging.py`). +### Version mapping + +xbps versions cannot contain `-`. The xbps spelling of a release is +`__version__` with the hyphen removed. `build-release.sh names TAG` prints it. + +| `__version__` (tag `v` + it) | xbps `version` | distfile and release asset | +|---|---|---| +| `0.18.5` | `0.18.5` | `voculinux-0.18.5.tar.gz` | +| `0.19.0-alpha` | `0.19.0alpha` | `voculinux-0.19.0alpha.tar.gz` | +| `0.19.0-beta` | `0.19.0beta` | `voculinux-0.19.0beta.tar.gz` | +| `0.19.0-rc1` | `0.19.0rc1` | `voculinux-0.19.0rc1.tar.gz` | + +Only `X.Y.Z` and `X.Y.Z-alpha`, `-beta` or `-rcN` are release forms; anything +else is refused. xbps sorts these spellings below the final release +(`xbps-uhelper cmpver`, checked in `tests/test_void_release_build.py`), so a +pre-release never looks newer than the release it precedes. The sdist keeps its +PEP 440 name inside (`voculinux-0.19.0b0/`), which xbps-src's extract hook +renames to the package's `wrksrc`, so the two spellings need not match. + +The distfile URL is `releases/download/${_tag}/voculinux-${version}.tar.gz`. +`_tag` keeps the hyphen (`v0.19.0-beta`), so the URL names the tag that exists. + ## Checksum -The template's `checksum=` is `PENDING_FORK_RELEASE_SHA256` until the -fork's `v0.18.5` release asset (`voculinux-0.18.5.tar.gz` on -git.bongbetic.com/xavierk/voculinux) exists. Once it does, set `checksum=` -to the `sha256sum` of that exact downloaded file, never a value copied from -upstream, and land the change in a pull request. Until then `xbps-src` -fails closed on the pending value, so neither the smoke gate nor the signed -repository can build `voculinux`. +The template's `checksum=` is a placeholder of 64 zeros in the committed +template until the first fork release has a recorded digest. That placeholder is +never a real digest. Every release build replaces it, in its **build tree only**, +with the sha256 of the sdist it builds (`render-voculinux.sh`). The release run +refuses to continue unless exactly one `checksum=` line exists and reads back as +the computed digest, and it refuses if the committed template changed. + +After a release, the digest of the published sdist is recorded in the template +by a **pull request**: the workflow log and the release notes both print +`checksum=`, and the pull request copies it into +`packaging/void/srcpkgs/voculinux/template`. Nothing commits to `main`. ## Building ```sh -packaging/void/build.sh python3-pywhispercpp -packaging/void/build.sh python3-pywhispercpp -- -o ~vulkan pkg -packaging/void/build.sh voculinux +just void-smoke # the gate: build, sign with a throwaway key, install, smoke +just void-release v0.18.5 # a dry run of the release, with XBPS_SIGNING_KEY_FILE set +packaging/void/build-release.sh names v0.19.0-beta # offline: the names a tag maps to +packaging/void/build-release.sh check-tag v0.19.0-beta # offline: the tag gate ``` -Podman only, never Docker. The base image digest and the void-packages -commit come from `tool_checksums.txt`; bump them there, never in the -script. +Podman only, never Docker. The base image digest and the void-packages commit +come from `tool_checksums.txt`; bump them there, never in a script. ## Smoke gate -One command runs the whole gate from a clean checkout — no host Void -install — linting, building, installing and smoke-testing every -template under `srcpkgs` in dependency order (the app last): +`packaging/void/smoke.sh` runs the release path for the current version with a +throwaway RSA 4096 key generated in a temporary directory. The gate needs no +host Void install and nothing is published. Stages, in order, each fails the run: -```sh -packaging/void/smoke.sh -# or: just void-smoke -``` +1. the tag gate (`check-tag`) and the committed template's version and `_tag`; +2. the reproducible sdist, built from the working tree; +3. `build`: every template in `PKGS` order (`smoke.sh` owns the list), linted + with `xlint`, built with `xbps-src`, the sdist resolved by `xbps-src show`; +4. `sign`: the packages indexed and signed, with no network; +5. `install`: in a clean image with no key, an unsigned install is refused, the + signed repository is trusted, `voculinux` installs, `voculinux --version` + shows the version, the payload checks pass (desktop entry, icon, marker, no + models, no pre-rename names, the Vulkan backend, no host-native CPU tuning), + and the imports load (`pynput` through its dummy backend, `evdev`, `gi` with + Gtk 3, `pywhispercpp.model`, `pyaudio`, and the app's own modules, which keep their retained internal name). -Each package delegates to `build.sh`, so the pinned container and the -per-package stages (xlint, xbps-src, install, payload/version/import -smoke) stay implemented once. The gate fails fast: a broken package -prints `FAIL: ` naming which package broke, above it the -harness echoes which stage broke. `tests/test_void_smoke_gate.py` -asserts offline that the gate stays wired to every `srcpkgs` template, -so a renamed or added template cannot silently drop out. +`tests/test_void_smoke_gate.py` asserts offline that the gate stays wired to +every `srcpkgs` template. ## Signed repository -`packaging/void/repo.sh` builds every template in smoke-gate order, indexes -the full repository (`x86_64-repodata`), and signs every binary package -(`*.sig2`) plus the repository metadata with the RSA 4096 repository key — -one command, same pinned container via podman, no host Void install: +`packaging/void/build-release.sh release TAG --key-file FILE --out-dir DIR` +is the whole release, and the only path that signs anything. It is the path the +tag workflow runs, and `just void-release` runs it locally. -```sh -# Run the smoke gate first: the repo assembles tested packages. -packaging/void/smoke.sh -# or: just void-smoke +Fail-closed, in this order, before any container starts: -printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem -XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem packaging/void/repo.sh -# or: XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem just void-repo -``` +- the tag must be exactly `v` plus `__version__`, and the committed template must + name that version and tag; +- the key must be non-empty, valid, RSA 4096, and its public half must equal + `repository-key.plist` byte for byte (a different key never publishes); +- the sdist must build, reproducibly, with Python 3.11 and uv 0.12/0.13. -The pipeline stages `publish/xbps/repo/` (packages, signatures, repodata) -plus `publish/xbps/keys/` (the client `.plist` named by key fingerprint, -and a `FINGERPRINT` file carrying the signer identity and fingerprint for -release notes). `publish/` is local build output and stays uncommitted. +Then three containers, each with only what it needs: -Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before any -container runs, so an unsigned repository can never publish. +| phase | privileges | what runs | +|---|---|---| +| build | root, network on, **no key** | void-packages at the pinned commit, the build tree rendered from the tag, every template built as the `builder` user | +| sign | root, **no network**, key mounted read-only | `xbps-rindex` indexes and signs; no build code runs | +| install | root, network on (dependencies), **no key** | an unsigned install is refused; the signed repository installs `voculinux`; the smoke above | + +The committed templates must be unchanged after the build; the run fails if they +are not. `DIR` must be absent or empty, and the run writes `DIR/sdist/`, +`DIR/xbps/repo/` (packages, signatures, `x86_64-repodata`), `DIR/xbps/keys/` +(the client `.plist` named by its fingerprint, and `FINGERPRINT`) and +`DIR/SDIST_SHA256`. A final check accepts only that set: nothing from musl, no +other architecture, every package signed, the key the pinned one. ### The repository key @@ -180,7 +206,7 @@ fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25 (the same file xbps keeps in `/var/db/xbps/keys/`, named by the fingerprint). Signer identity in the file: `Bongbetic `. `tests/test_void_signed_repo.py` derives the fingerprint from this file - and fails if either README, or the release workflow, shows a different one. + and fails if either README shows a different one. - **Private half**: never committed, never in the repository, never on a build host's disk beyond the run. It exists as the `XBPS_SIGNING_KEY` CI secret (PEM text, newlines intact) and in the maintainer's own backup. @@ -206,31 +232,46 @@ python3 -c "import plistlib,sys; sys.stdout.buffer.write(plistlib.load(open('pac A human pushes a version tag; `.gitea/workflows/xbps-release.yml` does the rest. It never runs on a schedule or a manual dispatch. -1. The tag must equal `v` plus `__version__` in `src/vocalinux/version.py`, - exactly; any other tag exits 1 and publishes nothing. -2. The CI secret `XBPS_SIGNING_KEY` must be set; empty exits 1 before any - container runs. The key is written to a 0600 temp file, handed to - `repo.sh` as `XBPS_SIGNING_KEY_FILE`, and removed by a final step that - always runs. -3. `repo.sh` builds and signs inside the pinned container (podman, never - Docker). -4. The output is checked: every class of file present (`*.x86_64.xbps`, - `*.sig2`, `*-repodata*`, `FINGERPRINT`, the `.plist`), every package - signed, nothing but glibc `x86_64` data, and the signing key's - fingerprint equal to the one above. -5. Everything attaches to the tag's release and to the rolling `latest` - release (replacing that release's old assets), through the Gitea API. - `latest` is what the client registration URL serves. +1. **Tag gate.** `build-release.sh check-tag` (see above). Nothing else runs first. +2. **Key gate.** The CI secret `XBPS_SIGNING_KEY` must be non-empty. The key is + written with noclobber to a `0600` file on tmpfs (`/dev/shm`), never to disk. +3. **Runner check.** The tools in [Runner requirements](#runner-requirements) + must be present, or the job stops and names what is missing. +4. **Release.** `build-release.sh release`, with the key file (above). +5. **Publish.** The staged set goes to the tag's release and to the rolling + `latest` release, which the client registration URL serves. Assets: + `sdist/voculinux-.tar.gz`, the packages, their `.sig2` signatures, + `x86_64-repodata` (last), `FINGERPRINT` and the `.plist`. Nothing is built or + uploaded by hand. +6. **Cleanup.** The key file is removed by a step that always runs. -The workflow never commits or pushes. Bumping the templates to the new -release is a normal pull request (`docs/RELEASE_PROCESS.md`). +Then the template's checksum is recorded by a pull request (above), and the +release notes show the sdist digest to copy from. Secret setup, once: Gitea repository settings, Actions, Secrets, add -`XBPS_SIGNING_KEY` with the PEM private key. The runner (`bongbetic-ci`) -needs podman (the workflow installs it with apt when absent), and the job -token must be allowed to create releases. +`XBPS_SIGNING_KEY` with the PEM private key. The job token must be allowed to +create releases and upload release assets. -### Client setup +### Runner requirements + +The job runs on the `bongbetic-ci` label as a host job. It is not a `container:` +job, and it never uses Docker. It needs: + +| requirement | why | check | +|---|---|---| +| `podman` (rootless with `subuid`/`subgid` ranges, or rootful) | the three phases | `podman run --rm alpine true` | +| unprivileged user namespaces | `xbps-src` builds in a user-namespace chroot (`uunshare`) | the build phase fails with a clear error otherwise | +| `/dev/shm` mounted as tmpfs | the key must never touch disk | `stat -f -c %T /dev/shm` prints `tmpfs` | +| `python3.11` on `PATH` | the sdist is built with Python 3.11, as CI does | `python3.11 --version` | +| `uv` 0.12.x or 0.13.x on `PATH` | the locked release-build tools | `uv --version` | +| `openssl`, `git`, `sha256sum`, `curl`, `python3` | key checks, checkout, digests, Gitea API | `command -v` | +| about 25 GiB free disk | the void-packages bootstrap and the whisper.cpp build | `df -h` | +| egress to `github.com`, `repo-default.voidlinux.org`, `ghcr.io`, `pypi.org`, `files.pythonhosted.org`, `git.bongbetic.com` | clone, bootstrap, base image, locked tools, distfiles, Gitea API | the build phase | + +The job's `timeout-minutes` is 240. A cold build takes most of that: the +whisper.cpp Vulkan build is the long step. + +## Client setup ```sh # as root on the Void system @@ -244,7 +285,6 @@ The key file is named by its fingerprint, so xbps finds it on the first sync. If xbps shows a fingerprint prompt anyway, answer `N` unless it reads exactly the fingerprint above and matches the `FINGERPRINT` file on the release; do not add `-y` to the first sync. `tests/test_void_signed_repo.py` -asserts offline that the pipeline stays wired to every `srcpkgs` template, -stays podman-only on the pinned inputs, and keeps the fail-closed key -contract; `tests/test_xbps_release_workflow.py` runs the workflow's gates -against fixtures. +asserts offline that the registration text stays correct, and +`tests/test_void_release_build.py` asserts the release path refuses to +publish under any other key. -- 2.54.0 From 6404a5efcd7def4bd9ab19be463af91f9238bff4 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 20:18:28 +0530 Subject: [PATCH 4/6] fix(void): read package payloads through zstd in the install smoke xbps packages are zstd-compressed. The clean install image had no zstd, so tar could not list the payload and the smoke failed on a package that was fine. Install zstd in the install phase. --- packaging/void/container-release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/void/container-release.sh b/packaging/void/container-release.sh index 6673342..a185c6b 100755 --- a/packaging/void/container-release.sh +++ b/packaging/void/container-release.sh @@ -124,7 +124,7 @@ payload_pywhispercpp() { phase_install() { : "${PKGS:?}" "${XBPS_VERSION:?}" "${VERSION:?}" [ -d "$XBPS_STAGE/repo" ] && [ -d "$XBPS_STAGE/keys" ] || fail "staged repository or keys are missing" - xbps-install -Sy xbps binutils >/dev/null + xbps-install -Sy xbps binutils zstd >/dev/null echo "== the signed repository is refused while its key is not trusted" # No -y: the documented first sync must not accept an unknown key. -- 2.54.0 From e8e26ae6dc9ee931f1295f1635bf85feff8378b7 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 20:23:22 +0530 Subject: [PATCH 5/6] docs(void): state that only the wheel is byte-reproducible The canonical sdist is built from the tag, but a second build of the same commit differs in tar and gzip timestamps, so its digest is recorded from the published asset, not from a rebuild. Correct the comments and docs that said "reproducible sdist". No behaviour change. --- .gitea/workflows/xbps-release.yml | 2 +- docs/RELEASE_PROCESS.md | 2 +- packaging/void/README.md | 7 +++++-- packaging/void/build-release.sh | 5 +++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/xbps-release.yml b/.gitea/workflows/xbps-release.yml index 8540c01..b85237a 100644 --- a/.gitea/workflows/xbps-release.yml +++ b/.gitea/workflows/xbps-release.yml @@ -8,7 +8,7 @@ name: xbps-release # and the committed Void template must already name that version; # 2. the XBPS_SIGNING_KEY secret must be non-empty and RSA 4096, and its public # half must be packaging/void/repository-key.plist; -# 3. the canonical reproducible sdist is built from the tag (Python 3.11, +# 3. the canonical sdist is built from the tag (Python 3.11, # uv 0.12/0.13, scripts/build_reproducible_wheel.py); # 4. every template builds in the pinned Void glibc image with podman (no # signing key in that phase), then the repository is signed in a second diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 76249a5..5baf60c 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -558,7 +558,7 @@ approved each time: the workflow has no schedule and no manual trigger. 3. **Runner check.** podman, python3.11, uv 0.12/0.13, openssl, git and curl must be on the runner, or the job names what is missing and stops. 4. **Release.** `build-release.sh release` builds the canonical sdist from the - tag (reproducibly), builds every template in the pinned Void glibc container + tag, builds every template in the pinned Void glibc container without the key, signs the repository in a second container with no network, and installs `voculinux` from that repository in a clean image without the key. A local dry run is `just void-release vX.Y.Z` with `XBPS_SIGNING_KEY_FILE` diff --git a/packaging/void/README.md b/packaging/void/README.md index 872e58f..1b68eeb 100644 --- a/packaging/void/README.md +++ b/packaging/void/README.md @@ -151,7 +151,7 @@ throwaway RSA 4096 key generated in a temporary directory. The gate needs no host Void install and nothing is published. Stages, in order, each fails the run: 1. the tag gate (`check-tag`) and the committed template's version and `_tag`; -2. the reproducible sdist, built from the working tree; +2. the canonical sdist, built from the working tree; 3. `build`: every template in `PKGS` order (`smoke.sh` owns the list), linted with `xlint`, built with `xbps-src`, the sdist resolved by `xbps-src show`; 4. `sign`: the packages indexed and signed, with no network; @@ -177,7 +177,10 @@ Fail-closed, in this order, before any container starts: name that version and tag; - the key must be non-empty, valid, RSA 4096, and its public half must equal `repository-key.plist` byte for byte (a different key never publishes); -- the sdist must build, reproducibly, with Python 3.11 and uv 0.12/0.13. +- the canonical sdist must build with Python 3.11 and uv 0.12/0.13. Only its + wheel is compared byte for byte: a second build of the same tag has identical + contents, but the tar and gzip timestamps differ, so the release records the + digest of the sdist it publishes, not of a rebuild. Then three containers, each with only what it needs: diff --git a/packaging/void/build-release.sh b/packaging/void/build-release.sh index bcb1de9..c3a7625 100755 --- a/packaging/void/build-release.sh +++ b/packaging/void/build-release.sh @@ -12,7 +12,7 @@ # 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, +# 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 @@ -155,7 +155,8 @@ check_key() { echo "signing key: RSA 4096, fingerprint $FINGERPRINT ($MODE)" } -# build_sdist: the canonical reproducible sdist, copied under its xbps name. +# 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)" -- 2.54.0 From d4ac89f0a1a741facef84b4d6c3f36f4098fecc9 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 20:34:37 +0530 Subject: [PATCH 6/6] fix(void): stage the sdist into the publish set before verifying it The completeness check requires sdist/ 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. --- packaging/void/build-release.sh | 3 ++- tests/test_void_release_build.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packaging/void/build-release.sh b/packaging/void/build-release.sh index c3a7625..f3381cf 100755 --- a/packaging/void/build-release.sh +++ b/packaging/void/build-release.sh @@ -310,8 +310,9 @@ run_pipeline() { git -C "$ROOT" diff --quiet -- packaging/void/srcpkgs || fail "the build changed the committed templates" fi - mkdir -p "$OUT" + mkdir -p "$OUT/sdist" OUT=$(cd "$OUT" && pwd) + cp "$WORK/inputs/$SDIST_NAME" "$OUT/sdist/$SDIST_NAME" phase_sign phase_install verify_set diff --git a/tests/test_void_release_build.py b/tests/test_void_release_build.py index 8a4c4d4..b78d3ab 100644 --- a/tests/test_void_release_build.py +++ b/tests/test_void_release_build.py @@ -557,3 +557,11 @@ def test_smoke_is_the_same_path_with_a_throwaway_key() -> None: assert "XBPS_SIGNING_KEY" not in text release = _code(BUILD_RELEASE) assert "throwaway.pem" in release and "genrsa" in release + + +def test_the_sdist_is_staged_for_publication_before_the_completeness_check() -> None: + """The publish set is DIR/sdist plus DIR/xbps; the sdist must be copied there first.""" + pipeline = _body(_code(BUILD_RELEASE), "run_pipeline") + assert 'mkdir -p "$OUT/sdist"' in pipeline + assert 'cp "$WORK/inputs/$SDIST_NAME" "$OUT/sdist/$SDIST_NAME"' in pipeline + assert pipeline.index('cp "$WORK/inputs/') < pipeline.index("verify_set"), "stage, then verify" -- 2.54.0