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