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:]))