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.
This commit is contained in:
2026-11-06 20:06:35 +05:30
parent 180cd4d512
commit 05ef78922d
14 changed files with 798 additions and 598 deletions
+70 -116
View File
@@ -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:-<none>} 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"
+10 -8
View File
@@ -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).
+352
View File
@@ -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-<xbps version>.tar.gz.
# The sdist keeps its PEP 440 name inside (voculinux-0.19.0b0/); the xbps-src
# do-extract hook renames the sdist's single top-level directory to the
# package's wrksrc, so the two spellings need not match.
#
# Environment: PYTHON (default python3.11) and uv 0.12.x or 0.13.x on PATH, both
# for the sdist. The signing key is never echoed; it reaches the signing
# container only as a read-only mount.
set -eu
CDPATH=""
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
ROOT=$(cd -- "$HERE/../.." && pwd)
TEMPLATE="$HERE/srcpkgs/voculinux/template"
PLIST="$HERE/repository-key.plist"
PYTHON=${PYTHON:-python3.11}
SIGNEDBY="Voculinux <xbps@voculinux.com>"
MODE=""
WORK=""
fail() {
echo "build-release.sh: $*" >&2
exit 1
}
usage() {
cat >&2 <<'EOF'
usage: build-release.sh names TAG
build-release.sh check-tag TAG
build-release.sh release TAG --key-file FILE --out-dir DIR
build-release.sh smoke
EOF
exit 2
}
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || fail "pins missing from tool_checksums.txt"
# The package order is owned by smoke.sh and shared with every release path.
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh")
[ -n "$PKGS" ] || fail "could not read the package order from smoke.sh"
# source_version: __version__ from src/vocalinux/version.py, defined once.
source_version() {
count=$(sed -n '/^__version__ = /p' "$ROOT/src/vocalinux/version.py" | wc -l)
[ "$count" -eq 1 ] || fail "src/vocalinux/version.py must define __version__ exactly once"
value=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "$ROOT/src/vocalinux/version.py")
[ -n "$value" ] || fail "could not read __version__ from src/vocalinux/version.py"
printf '%s\n' "$value"
}
# xbps_version VERSION: the xbps spelling, or nothing when VERSION is not a
# release form.
xbps_version() {
printf '%s\n' "$1" |
sed -n -E 's/^([0-9]+(\.[0-9]+)*)(-(alpha|beta|rc)[0-9]*)?$/\1\3/p' |
tr -d '-'
}
# derive TAG: sets TAG, VERSION, XBPS_VERSION and SDIST_NAME, or fails.
derive() {
TAG=${1:-}
VERSION=$(source_version)
[ "$TAG" = "v$VERSION" ] ||
fail "tag '$TAG' is not exactly v$VERSION (src/vocalinux/version.py); refusing to publish"
XBPS_VERSION=$(xbps_version "$VERSION")
[ -n "$XBPS_VERSION" ] ||
fail "version $VERSION is not a release form (X.Y.Z, or X.Y.Z-alpha, -beta, -rcN)"
SDIST_NAME="voculinux-$XBPS_VERSION.tar.gz"
}
# template_value KEY: the value of the single KEY= line of the committed template.
template_value() {
count=$(grep -c "^$1=" "$TEMPLATE" || true)
[ "$count" -eq 1 ] || fail "$TEMPLATE must hold exactly one $1= line (found $count)"
sed -n "s/^$1=//p" "$TEMPLATE"
}
# check_template: the committed template must already name this release. The
# build tree is pinned from the tag; the committed file is what a reader sees
# and what the next release starts from.
check_template() {
have=$(template_value version)
[ "$have" = "$XBPS_VERSION" ] ||
fail "committed template version is '$have', not '$XBPS_VERSION' for $TAG; update packaging/void/srcpkgs/voculinux/template in the release pull request"
have=$(template_value _tag)
[ "$have" = "$TAG" ] ||
fail "committed template _tag is '$have', not '$TAG'; update packaging/void/srcpkgs/voculinux/template"
}
# check_key KEY_FILE: RSA 4096, and for a release the public half must be the
# committed repository-key.plist key. Sets FINGERPRINT and CLIENT_PLIST.
check_key() {
KEY_FILE=$1
[ -n "$KEY_FILE" ] ||
fail "no signing key: pass --key-file (the workflow writes the XBPS_SIGNING_KEY secret to a file first)"
[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] ||
fail "signing key missing or empty; refusing to publish an unsigned repository"
openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 ||
fail "the signing key is not a valid RSA private key; refusing to publish"
openssl rsa -in "$KEY_FILE" -noout -text 2>/dev/null | head -n1 | grep -q "4096 bit" ||
fail "the signing key is not RSA 4096; refusing to publish"
openssl rsa -in "$KEY_FILE" -pubout -out "$WORK/pub.pem" 2>/dev/null ||
fail "could not derive the public key from the signing key"
FINGERPRINT=$(python3 -I "$HERE/xbps-fingerprint.py" fingerprint "$WORK/pub.pem")
if [ "$MODE" = release ]; then
python3 -I -c 'import plistlib, sys; sys.stdout.buffer.write(plistlib.load(open(sys.argv[1], "rb"))["public-key"])' \
"$PLIST" >"$WORK/plist-pub.pem"
cmp -s "$WORK/pub.pem" "$WORK/plist-pub.pem" ||
fail "the signing key does not match packaging/void/repository-key.plist; refusing to publish"
cp "$PLIST" "$WORK/client.plist"
else
python3 -I "$HERE/xbps-fingerprint.py" plist "$WORK/pub.pem" "$SIGNEDBY" "$WORK/client.plist"
fi
echo "signing key: RSA 4096, fingerprint $FINGERPRINT ($MODE)"
}
# build_sdist: the canonical reproducible sdist, copied under its xbps name.
build_sdist() {
command -v "$PYTHON" >/dev/null 2>&1 ||
fail "$PYTHON not found: the sdist is built with Python 3.11 (set PYTHON)"
"$PYTHON" -c 'import sys; sys.exit(0 if sys.version_info[:2] == (3, 11) else 1)' ||
fail "$PYTHON is not Python 3.11"
command -v uv >/dev/null 2>&1 || fail "uv not found: the release pins uv 0.12.x or 0.13.x"
UV_VERSION=$(uv --version | awk '{print $2}')
case "$UV_VERSION" in
0.12.* | 0.13.*) ;;
*) fail "uv $UV_VERSION is outside the pinned 0.12.x / 0.13.x range" ;;
esac
(cd "$ROOT" && "$PYTHON" scripts/build_reproducible_wheel.py --python "$PYTHON" \
--output "$WORK/dist" --diagnostics "$WORK/diagnostics") ||
fail "the reproducible build failed; diagnostics are in $WORK/diagnostics"
set -- "$WORK"/dist/voculinux-*.tar.gz
[ "$#" -eq 1 ] && [ -f "$1" ] || fail "expected exactly one sdist in $WORK/dist"
mkdir -p "$WORK/inputs"
cp "$1" "$WORK/inputs/$SDIST_NAME"
SDIST_SHA256=$(sha256sum "$WORK/inputs/$SDIST_NAME" | cut -d' ' -f1)
echo "sdist: $SDIST_NAME sha256 $SDIST_SHA256"
}
# The podman phases. Only the build phase runs build scripts (with network);
# only the sign phase sees the key (and has no network); only the install phase
# runs the packages (in a clean image, without the key).
phase_build() {
mkdir -p "$WORK/packages"
podman pull -q "$BASE_IMAGE" >/dev/null
podman run --rm \
-v "$HERE/srcpkgs:/overlay:ro" \
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
-v "$HERE/render-voculinux.sh:/mnt/render-voculinux.sh:ro" \
-v "$WORK/inputs:/mnt/inputs:ro" \
-v "$WORK/packages:/mnt/packages" \
-e "VOID_COMMIT=$VOID_COMMIT" \
-e "PKGS=$PKGS" \
-e "TAG=$TAG" \
-e "XBPS_VERSION=$XBPS_VERSION" \
-e "SDIST_NAME=$SDIST_NAME" \
-e "SDIST_SHA256=$SDIST_SHA256" \
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh build
}
phase_sign() {
mkdir -p "$OUT/xbps"
podman run --rm --network none \
-v "$WORK/packages:/mnt/packages:ro" \
-v "$OUT/xbps:/mnt/xbps" \
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
-v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \
-e "SIGNEDBY=$SIGNEDBY" \
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh sign
mkdir -p "$OUT/xbps/keys"
cp "$WORK/client.plist" "$OUT/xbps/keys/$FINGERPRINT.plist"
printf 'signed-by: %s\nfingerprint: %s\n' "$SIGNEDBY" "$FINGERPRINT" >"$OUT/xbps/keys/FINGERPRINT"
}
phase_install() {
podman run --rm \
-v "$OUT/xbps:/mnt/xbps:ro" \
-v "$WORK/packages:/mnt/packages:ro" \
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
-e "PKGS=$PKGS" \
-e "XBPS_VERSION=$XBPS_VERSION" \
-e "VERSION=$VERSION" \
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh install
}
# verify_set: the staged set is complete and holds nothing that may not be
# published.
verify_set() {
cd "$OUT"
for pattern in 'sdist/*.tar.gz' 'xbps/repo/*.x86_64.xbps' 'xbps/repo/*.sig2' \
'xbps/repo/*-repodata*' 'xbps/keys/FINGERPRINT' 'xbps/keys/*.plist'; do
compgen -G "$pattern" >/dev/null || fail "missing from the signed set: $pattern"
done
[ "$(ls sdist | wc -l)" -eq 1 ] && [ -f "sdist/$SDIST_NAME" ] ||
fail "the sdist directory must hold exactly sdist/$SDIST_NAME"
[ "$(sha256sum "sdist/$SDIST_NAME" | cut -d' ' -f1)" = "$SDIST_SHA256" ] ||
fail "the staged sdist does not match its digest"
for f in xbps/repo/*; do
base=${f##*/}
case "$base" in
*musl*) fail "musl data in the repository: $base" ;;
*.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;;
*) fail "unexpected file (only glibc x86_64 data is published): xbps/repo/$base" ;;
esac
done
for f in xbps/keys/*; do
base=${f##*/}
case "$base" in
FINGERPRINT | *.plist) ;;
*) fail "unexpected file among the client keys: xbps/keys/$base" ;;
esac
done
for pkg in xbps/repo/*.x86_64.xbps; do
[ -s "$pkg.sig2" ] || fail "package has no signature: $pkg"
done
for sig in xbps/repo/*.sig2; do
[ -f "${sig%.sig2}" ] || fail "signature without its package: $sig"
done
for name in $PKGS; do
if [ "$name" = voculinux ]; then
[ -f "xbps/repo/voculinux-${XBPS_VERSION}_1.x86_64.xbps" ] ||
fail "the repository lacks voculinux-${XBPS_VERSION}_1"
else
compgen -G "xbps/repo/${name}-[0-9]*.x86_64.xbps" >/dev/null ||
fail "package missing from the repository: $name"
fi
done
FP=$(sed -n 's/^fingerprint: //p' xbps/keys/FINGERPRINT)
[ "$FP" = "$FINGERPRINT" ] || fail "FINGERPRINT says '$FP', the signing key is '$FINGERPRINT'"
[ -s "xbps/keys/$FINGERPRINT.plist" ] || fail "client key xbps/keys/$FINGERPRINT.plist is missing"
if [ "$MODE" = release ]; then
cmp -s "$PLIST" "xbps/keys/$FINGERPRINT.plist" ||
fail "the staged client key is not the committed repository-key.plist"
fi
cd "$ROOT"
echo "OK: complete signed glibc x86_64 set for $TAG, key $FINGERPRINT"
}
run_pipeline() {
derive "$1"
check_template
WORK=$(mktemp -d "${TMPDIR:-/tmp}/voculinux-release.XXXXXX")
cleanup() {
status=$?
rm -f "$WORK/throwaway.pem"
if [ "$status" -eq 0 ]; then
rm -rf "$WORK"
else
echo "build-release.sh: failed; diagnostics retained in $WORK" >&2
fi
}
trap cleanup EXIT
if [ "$MODE" = smoke ]; then
openssl genrsa -out "$WORK/throwaway.pem" 4096 2>/dev/null
KEY_FILE="$WORK/throwaway.pem"
OUT="$WORK/out"
fi
[ -e "$OUT" ] && { [ -d "$OUT" ] && [ -z "$(ls -A "$OUT")" ]; } ||
{ [ ! -e "$OUT" ] || fail "$OUT exists and is not empty; choose a new output directory"; }
check_key "$KEY_FILE"
build_sdist
phase_build
# The committed templates must be untouched: the build tree is the only
# place a release pins version, _tag and checksum.
if git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then
git -C "$ROOT" diff --quiet -- packaging/void/srcpkgs ||
fail "the build changed the committed templates"
fi
mkdir -p "$OUT"
OUT=$(cd "$OUT" && pwd)
phase_sign
phase_install
verify_set
printf '%s\n' "$SDIST_SHA256" >"$OUT/SDIST_SHA256"
echo "template bump for the post-release pull request: checksum=$SDIST_SHA256"
}
cmd=${1:-}
[ -n "$cmd" ] || usage
shift
case "$cmd" in
names)
[ "$#" -eq 1 ] || usage
derive "$1"
printf 'TAG=%s\nVERSION=%s\nXBPS_VERSION=%s\nSDIST_NAME=%s\n' \
"$TAG" "$VERSION" "$XBPS_VERSION" "$SDIST_NAME"
;;
check-tag)
[ "$#" -eq 1 ] || usage
derive "$1"
check_template
echo "tag gate passed: $TAG (xbps $XBPS_VERSION)"
;;
release)
[ "$#" -ge 1 ] || usage
TAG=$1
shift
KEY_FILE=""
OUT=""
while [ "$#" -gt 0 ]; do
case "$1" in
--key-file) KEY_FILE=${2:-}; shift 2 ;;
--out-dir) OUT=${2:-}; shift 2 ;;
*) usage ;;
esac
done
[ -n "$OUT" ] || usage
MODE=release
run_pipeline "$TAG"
echo "RELEASE BUILT: $TAG staged in $OUT (not published by this script)"
;;
smoke)
[ "$#" -eq 0 ] || usage
MODE=smoke
run_pipeline "v$(source_version)"
echo "SMOKE PASSED: $TAG built, signed with a throwaway key, installed and smoked; nothing published"
;;
*)
usage
;;
esac
-51
View File
@@ -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 <pkgname> [-- <xbps-src args...>]
#
# 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 "<pkgname>" [-- "<xbps-src args>"]}
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"
-24
View File
@@ -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"
-144
View File
@@ -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
+178
View File
@@ -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 </dev/null 2>&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
-129
View File
@@ -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 </dev/null 2>&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 &lt;/&gt; itself on import
# and rejects a plist carrying raw angle brackets.
ESCAPED_SIGNEDBY=$(printf '%s' "$SIGNEDBY" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g')
{
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
printf '%s\n' '<plist version="1.0">' '<dict>' ' <key>public-key</key>'
printf ' <data>%s</data>\n' "$PUB64"
printf '%s\n' ' <key>public-key-size</key>' ' <integer>4096</integer>' ' <key>signature-by</key>'
printf ' <string>%s</string>\n' "$ESCAPED_SIGNEDBY"
printf '%s\n' '</dict>' '</plist>'
} > "$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"
+71
View File
@@ -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/<pkgname>-<version>/<basename>), 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-<XBPS_VERSION>.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"
-97
View File
@@ -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 <m>" signer identity (default: Voculinux <xbps@voculinux.com>)
#
# 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 <xbps@voculinux.com>"
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 <mail>"} ; 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"
+11 -22
View File
@@ -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
+10 -6
View File
@@ -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 <xavier@bongbetic.com>"
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
+1 -1
View File
@@ -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
+95
View File
@@ -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:]))