fix(void): build the xbps release from the tag, not an attached sdist #37

Merged
xavierk merged 6 commits from fix/release-sdist-build into main 2026-11-06 15:31:47 +00:00
23 changed files with 1918 additions and 1218 deletions
+70 -116
View File
@@ -1,28 +1,28 @@
name: xbps-release
# Signed Void xbps repository release (ticket #29). A human pushes a version
# tag; this workflow builds every Void template in the pinned Void glibc
# container with podman (packaging/void/repo.sh), indexes the full x86_64
# repository, signs every package and the repository metadata with the RSA4096
# repository key, and attaches the complete signed repository to two releases:
# Signed Void xbps repository release (tickets #28 and #29). A human pushes a
# version tag; this job does everything else through packaging/void/
# build-release.sh, the same script a local dry run and the smoke gate call:
#
# - the tag's own release (the permanent record of what that tag shipped), and
# - the rolling release named `latest`, because the client registration URL
# (README.md) serves the newest release's downloads:
# https://git.bongbetic.com/xavierk/voculinux/releases/download/latest
# 1. the tag must EXACTLY equal "v" + __version__ (src/vocalinux/version.py),
# and the committed Void template must already name that version;
# 2. the XBPS_SIGNING_KEY secret must be non-empty and RSA 4096, and its public
# half must be packaging/void/repository-key.plist;
# 3. the canonical sdist is built from the tag (Python 3.11,
# uv 0.12/0.13, scripts/build_reproducible_wheel.py);
# 4. every template builds in the pinned Void glibc image with podman (no
# signing key in that phase), then the repository is signed in a second
# container with no network, then voculinux is installed and smoked in a
# clean image with no key;
# 5. the sdist, the signed repository and the client key are published to the
# tag's release and to the rolling `latest` release that the client
# registration URL serves (README.md).
#
# Hard gates, each exiting 1 before anything is published:
# 1. the tag must EXACTLY equal "v" + __version__ in src/vocalinux/version.py;
# 2. the XBPS_SIGNING_KEY secret must be non-empty (no key, no repo, never a
# silent skip and never an unsigned upload);
# 3. every class of repository file must exist, nothing but glibc x86_64 data
# may exist, every package must carry its signature, and the signing key's
# fingerprint must equal the one the README tells users to pin.
#
# Nothing here commits or pushes to any branch, and nothing bumps the Void
# templates: the post-release template bump is a normal pull request
# (docs/RELEASE_PROCESS.md). The private key exists only as the CI secret and a
# 0600 temp file that the last step removes. Never Docker: podman only.
# Nothing here is built or uploaded by hand, nothing commits or pushes to any
# branch, and no template is bumped: the post-release template bump is a normal
# pull request (docs/RELEASE_PROCESS.md section 3.5). The private key lives only
# as this secret and a 0600 file on tmpfs that the last step removes. Podman
# only, never Docker. Runner requirements: packaging/void/README.md.
on:
push:
@@ -40,14 +40,13 @@ jobs:
xbps-release:
name: Build, sign and publish the xbps repository
runs-on: bongbetic-ci
timeout-minutes: 180
timeout-minutes: 240
env:
GITEA_API: https://git.bongbetic.com/api/v1/repos/xavierk/voculinux
# Must equal the fingerprint in README.md, packaging/void/README.md and
# packaging/void/repository-key.plist; tests/test_xbps_release_workflow.py
# enforces it. A secret holding any other key fails before publishing.
XBPS_KEY_FINGERPRINT: "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25"
XBPS_KEY_FILE: /tmp/xbps-sign-key-${{ gitea.run_id }}.pem
# The key file lives on tmpfs (/dev/shm). Its name is fixed per run so the
# cleanup step can find it; it is created with noclobber, so a file that
# already exists makes the key step fail rather than be reused.
XBPS_KEY_FILE: /dev/shm/xbps-sign-key-${{ gitea.run_id }}.pem
steps:
- name: Checkout
env:
@@ -58,26 +57,20 @@ jobs:
run: |
git init -q . && git remote add origin "${SERVER_URL}/${REPOSITORY}.git" && git -c http.extraheader="Authorization: token ${GITEA_TOKEN}" fetch -q --depth=1 origin "${COMMIT_SHA}" && git checkout -q FETCH_HEAD
# Fail first and cheaply: the gate needs no tools and no secret.
- name: Require the tag to equal the version in version.py
shell: bash
env:
TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
VERSION=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' src/vocalinux/version.py)
if [ -z "$VERSION" ]; then
echo "::error::could not read __version__ from src/vocalinux/version.py; refusing to publish" >&2
packaging/void/build-release.sh check-tag "$TAG" || {
echo "::error::tag ${TAG} does not match src/vocalinux/version.py and the committed Void template; refusing to publish" >&2
exit 1
fi
echo "tag: ${TAG}"
echo "version: v${VERSION}"
if [ "$TAG" != "v${VERSION}" ]; then
echo "::error::tag ${TAG} is not exactly v${VERSION} (src/vocalinux/version.py); refusing to publish the signed repository" >&2
exit 1
fi
}
# Fail closed: no key, no repo, no publish. This runs before any
# container so a missing secret costs seconds, not a build.
# Fail closed: no key, no repository. The key is written with noclobber
# (set -C) to a tmpfs file with mode 0600, and never echoed.
- name: Require the repository signing key
shell: bash
env:
@@ -88,95 +81,52 @@ jobs:
echo "::error::XBPS_SIGNING_KEY is unset or empty; refusing to publish an unsigned repository (packaging/void/README.md)" >&2
exit 1
fi
KEY_DIR=$(dirname -- "$XBPS_KEY_FILE")
if [ "$(stat -f -c %T "$KEY_DIR")" != "tmpfs" ]; then
echo "::error::${KEY_DIR} is not tmpfs; the signing key must never touch disk (packaging/void/README.md)" >&2
exit 1
fi
umask 077
rm -f "$XBPS_KEY_FILE"
printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE"
(set -C; printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE")
chmod 600 "$XBPS_KEY_FILE"
- name: Install the tools repo.sh and the publish step need
- name: Check the runner has the release tools
shell: bash
run: |
set -euo pipefail
missing=""
for tool in podman openssl curl python3; do
for tool in podman openssl git sha256sum python3.11 uv; do
command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
done
if [ -n "$missing" ]; then
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
$SUDO apt-get update
# shellcheck disable=SC2086
$SUDO apt-get install -y --no-install-recommends $missing
echo "::error::the bongbetic-ci runner lacks:${missing}; install them on the runner (packaging/void/README.md, Runner requirements)" >&2
exit 1
fi
uv_version=$(uv --version | awk '{print $2}')
case "$uv_version" in
0.12.* | 0.13.*) ;;
*)
echo "::error::uv ${uv_version} is outside the pinned 0.12.x / 0.13.x range" >&2
exit 1
;;
esac
podman --version
# repo.sh is the only path that builds or signs anything: pinned Void
# glibc image, every template in smoke-gate order, full x86_64 index,
# signed packages and metadata. It re-validates the key (RSA4096) before
# any container starts.
- name: Build and sign the repository
# The only path that builds, signs or verifies anything. It stages the
# complete set under publish/ and exits 1 before publishing when any check
# fails.
- name: Build, sign and stage the repository
shell: bash
env:
TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
XBPS_SIGNING_KEY_FILE="$XBPS_KEY_FILE" bash packaging/void/repo.sh
ls -la publish/xbps/repo publish/xbps/keys
- name: Verify the signed repository is complete
shell: bash
run: |
set -euo pipefail
ROOT=$PWD
cd publish/xbps
fail() { echo "::error::$*" >&2; exit 1; }
# Every class must exist, so a partial repository never attaches.
for pat in 'repo/*.x86_64.xbps' 'repo/*.sig2' 'repo/*-repodata*' 'keys/FINGERPRINT' 'keys/*.plist'; do
compgen -G "$pat" > /dev/null || fail "missing from the signed repository: $pat"
done
# Every template the smoke gate builds must be in the repository.
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$ROOT/packaging/void/smoke.sh")
[ -n "$PKGS" ] || fail "could not read the package order from packaging/void/smoke.sh"
for name in $PKGS; do
compgen -G "repo/${name}-[0-9]*.x86_64.xbps" > /dev/null || fail "package missing from the repository: ${name}"
done
# Glibc x86_64 data only: no musl, no other architecture, no noarch.
while IFS= read -r name; do
case "$name" in
*musl*) fail "musl data in the repository: $name" ;;
*.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;;
*) fail "unexpected file (only glibc x86_64 data is published): repo/$name" ;;
esac
done < <(find repo -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
while IFS= read -r name; do
case "$name" in
FINGERPRINT | *.plist) ;;
*) fail "unexpected file among the client keys: keys/$name" ;;
esac
done < <(find keys -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
# Never publish an unsigned package, or a signature without its package.
for pkg in repo/*.x86_64.xbps; do
[ -s "${pkg}.sig2" ] || fail "package has no signature: ${pkg}"
done
for sig in repo/*.sig2; do
[ -f "${sig%.sig2}" ] || fail "signature without its package: ${sig}"
done
# The repository must be signed by the key the README tells users to
# pin, and the staged client key must be that key.
FP=$(sed -n 's/^fingerprint: //p' keys/FINGERPRINT)
if [ "$FP" != "$XBPS_KEY_FINGERPRINT" ]; then
fail "signing key fingerprint ${FP:-<none>} is not the pinned ${XBPS_KEY_FINGERPRINT}; refusing to publish"
fi
[ "$(find keys -name '*.plist' | wc -l)" -eq 1 ] || fail "expected exactly one client key plist"
[ -s "keys/${FP}.plist" ] || fail "client key plist keys/${FP}.plist is missing"
echo "OK: complete signed glibc x86_64 repository, key ${FP}"
packaging/void/build-release.sh release "$TAG" --key-file "$XBPS_KEY_FILE" --out-dir publish
ls -la publish publish/sdist publish/xbps/repo publish/xbps/keys
# Releases go through the Gitea REST API. Any non-2xx response fails the
# job. Only files staged by repo.sh upload; nothing is built or signed
# here. Package files, signatures and the client key go first and the
# job. Only what build-release.sh staged under publish/ is uploaded. The
# sdist, packages and signatures and the client key go first and the
# repodata goes last, so a client never sees an index that lists packages
# which are not there yet.
- name: Publish to the tag release and the latest release
@@ -187,7 +137,7 @@ jobs:
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
cd publish/xbps
cd publish
BODY=$(mktemp)
trap 'rm -f "$BODY"' EXIT
@@ -288,14 +238,18 @@ jobs:
|| fail "release $1 is missing uploaded assets"
}
# Packages, signatures and the client key first, the repodata last.
mapfile -t FILES < <(printf '%s\n' repo/*.x86_64.xbps repo/*.sig2 keys/FINGERPRINT keys/*.plist repo/*-repodata* | awk '!seen[$0]++')
# The sdist first, then packages and signatures and the client key,
# then the repodata last.
mapfile -t FILES < <(printf '%s\n' sdist/*.tar.gz xbps/repo/*.x86_64.xbps xbps/repo/*.sig2 \
xbps/keys/FINGERPRINT xbps/keys/*.plist xbps/repo/*-repodata* | awk '!seen[$0]++')
NAMES=$(for f in "${FILES[@]}"; do basename "$f"; done)
echo "assets to publish: $(printf '%s\n' "$NAMES" | wc -l)"
NOTES=$(printf '%s\n\n%s\n' \
"Signed xbps repository, Void Linux glibc x86_64. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \
"$(cat keys/FINGERPRINT)")
SDIST_SHA256=$(cat SDIST_SHA256)
NOTES=$(printf '%s\n\n%s\n%s\n' \
"Signed xbps repository, Void Linux glibc x86_64, built from ${TAG} by the release workflow. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \
"$(cat xbps/keys/FINGERPRINT)" \
"sdist sha256: ${SDIST_SHA256} (voculinux-*.tar.gz, the source the packages are built from)")
echo "== tag release ${TAG}"
ensure_release "$TAG" "$TAG" "$NOTES"
+60 -53
View File
@@ -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=<sha256>` 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
+10 -8
View File
@@ -134,14 +134,16 @@ native-smoke distro="debian:12":
void-smoke:
bash packaging/void/smoke.sh
# Build every Void template, index the full repository and sign packages
# plus metadata with the RSA4096 repository key, in the pinned Void
# container via podman (never Docker), as ticket #29 pipelines. Fails
# closed without a key: set XBPS_SIGNING_KEY_FILE (CI writes the
# XBPS_SIGNING_KEY secret to a file first). Needs no host Void install;
# run `just void-smoke` first — the repo assembles tested packages.
void-repo:
bash packaging/void/repo.sh
# Dry-run or perform the signed Void release for TAG (vX.Y.Z[-phase]) exactly
# as the Gitea tag workflow does: packaging/void/build-release.sh release.
# Stages the complete set under publish/ and publishes nothing. Needs
# XBPS_SIGNING_KEY_FILE (an RSA 4096 private key whose public half is
# packaging/void/repository-key.plist) and the release tools in
# packaging/void/README.md. Podman only, never Docker.
void-release TAG:
bash packaging/void/build-release.sh release "{{TAG}}" \
--key-file "${XBPS_SIGNING_KEY_FILE:?set XBPS_SIGNING_KEY_FILE to the RSA 4096 key}" \
--out-dir publish
# Build the canonical sdist, prepare an explicitly unattested local PKGBUILD,
# then build/install it unchanged on current Arch (needs uv and docker).
+143 -100
View File
@@ -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=<sha256>`, 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: <pkgname>` 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 <soubarna@live.in>`.
`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-<version>.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.
+367
View File
@@ -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-<xbps version>.tar.gz.
# The sdist keeps its PEP 440 name inside (voculinux-0.19.0b0/); the xbps-src
# do-extract hook renames the sdist's single top-level directory to the
# package's wrksrc, so the two spellings need not match.
#
# Environment: PYTHON (default python3.11) and uv 0.12.x or 0.13.x on PATH, both
# for the sdist. The signing key is never echoed; it reaches the signing
# container only as a read-only mount.
set -eu
CDPATH=""
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
ROOT=$(cd -- "$HERE/../.." && pwd)
TEMPLATE="$HERE/srcpkgs/voculinux/template"
PLIST="$HERE/repository-key.plist"
PYTHON=${PYTHON:-python3.11}
SIGNEDBY="Voculinux <xbps@voculinux.com>"
MODE=""
WORK=""
fail() {
echo "build-release.sh: $*" >&2
exit 1
}
usage() {
cat >&2 <<'EOF'
usage: build-release.sh names TAG
build-release.sh check-tag TAG
build-release.sh release TAG --key-file FILE --out-dir DIR
build-release.sh smoke
EOF
exit 2
}
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || fail "pins missing from tool_checksums.txt"
# The package order is owned by smoke.sh and shared with every release path.
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh")
[ -n "$PKGS" ] || fail "could not read the package order from smoke.sh"
# source_version: __version__ from src/vocalinux/version.py, defined once.
source_version() {
count=$(sed -n '/^__version__ = /p' "$ROOT/src/vocalinux/version.py" | wc -l)
[ "$count" -eq 1 ] || fail "src/vocalinux/version.py must define __version__ exactly once"
value=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "$ROOT/src/vocalinux/version.py")
[ -n "$value" ] || fail "could not read __version__ from src/vocalinux/version.py"
printf '%s\n' "$value"
}
# xbps_version VERSION: the xbps spelling, or nothing when VERSION is not a
# release form.
xbps_version() {
printf '%s\n' "$1" |
sed -n -E 's/^([0-9]+\.[0-9]+\.[0-9]+)(-(alpha|beta|rc)[0-9]*)?$/\1\2/p' |
tr -d '-'
}
# derive TAG: sets TAG, VERSION, XBPS_VERSION and SDIST_NAME, or fails.
derive() {
TAG=${1:-}
VERSION=$(source_version)
[ "$TAG" = "v$VERSION" ] ||
fail "tag '$TAG' is not exactly v$VERSION (src/vocalinux/version.py); refusing to publish"
XBPS_VERSION=$(xbps_version "$VERSION")
[ -n "$XBPS_VERSION" ] ||
fail "version $VERSION is not a release form (X.Y.Z, or X.Y.Z-alpha, -beta, -rcN)"
SDIST_NAME="voculinux-$XBPS_VERSION.tar.gz"
}
# present PATTERN: true when at least one file matches the glob PATTERN. A plain
# POSIX loop: the release runs under /bin/sh, which is dash on Void, and bash-only
# builtins are not available there.
present() {
for match in $1; do
[ -e "$match" ] && return 0
done
return 1
}
# template_value KEY: the value of the single KEY= line of the committed template.
template_value() {
count=$(grep -c "^$1=" "$TEMPLATE" || true)
[ "$count" -eq 1 ] || fail "$TEMPLATE must hold exactly one $1= line (found $count)"
sed -n "s/^$1=//p" "$TEMPLATE"
}
# check_template: the committed template must already name this release. The
# build tree is pinned from the tag; the committed file is what a reader sees
# and what the next release starts from.
check_template() {
have=$(template_value version)
[ "$have" = "$XBPS_VERSION" ] ||
fail "committed template version is '$have', not '$XBPS_VERSION' for $TAG; update packaging/void/srcpkgs/voculinux/template in the release pull request"
have=$(template_value _tag)
[ "$have" = "$TAG" ] ||
fail "committed template _tag is '$have', not '$TAG'; update packaging/void/srcpkgs/voculinux/template"
}
# check_key KEY_FILE: RSA 4096, and for a release the public half must be the
# committed repository-key.plist key. Sets FINGERPRINT and writes the client
# key to $WORK/client.plist.
check_key() {
KEY_FILE=$1
[ -n "$KEY_FILE" ] ||
fail "no signing key: pass --key-file (the workflow writes the XBPS_SIGNING_KEY secret to a file first)"
[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] ||
fail "signing key missing or empty; refusing to publish an unsigned repository"
openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 ||
fail "the signing key is not a valid RSA private key; refusing to publish"
openssl rsa -in "$KEY_FILE" -noout -text 2>/dev/null | head -n1 | grep -q "4096 bit" ||
fail "the signing key is not RSA 4096; refusing to publish"
openssl rsa -in "$KEY_FILE" -pubout -out "$WORK/pub.pem" 2>/dev/null ||
fail "could not derive the public key from the signing key"
FINGERPRINT=$(python3 -I "$HERE/xbps-fingerprint.py" fingerprint "$WORK/pub.pem")
if [ "$MODE" = release ]; then
python3 -I -c 'import plistlib, sys; sys.stdout.buffer.write(plistlib.load(open(sys.argv[1], "rb"))["public-key"])' \
"$PLIST" >"$WORK/plist-pub.pem"
cmp -s "$WORK/pub.pem" "$WORK/plist-pub.pem" ||
fail "the signing key does not match packaging/void/repository-key.plist; refusing to publish"
cp "$PLIST" "$WORK/client.plist"
else
python3 -I "$HERE/xbps-fingerprint.py" plist "$WORK/pub.pem" "$SIGNEDBY" "$WORK/client.plist"
fi
echo "signing key: RSA 4096, fingerprint $FINGERPRINT ($MODE)"
}
# build_sdist: the canonical sdist (the reproducible build path; only its wheel is
# compared byte for byte), copied under its xbps name.
build_sdist() {
command -v "$PYTHON" >/dev/null 2>&1 ||
fail "$PYTHON not found: the sdist is built with Python 3.11 (set PYTHON)"
"$PYTHON" -c 'import sys; sys.exit(0 if sys.version_info[:2] == (3, 11) else 1)' ||
fail "$PYTHON is not Python 3.11"
command -v uv >/dev/null 2>&1 || fail "uv not found: the release pins uv 0.12.x or 0.13.x"
UV_VERSION=$(uv --version | awk '{print $2}')
case "$UV_VERSION" in
0.12.* | 0.13.*) ;;
*) fail "uv $UV_VERSION is outside the pinned 0.12.x / 0.13.x range" ;;
esac
(cd "$ROOT" && "$PYTHON" scripts/build_reproducible_wheel.py --python "$PYTHON" \
--output "$WORK/dist" --diagnostics "$WORK/diagnostics") ||
fail "the reproducible build failed; diagnostics are in $WORK/diagnostics"
set -- "$WORK"/dist/voculinux-*.tar.gz
[ "$#" -eq 1 ] && [ -f "$1" ] || fail "expected exactly one sdist in $WORK/dist"
mkdir -p "$WORK/inputs"
cp "$1" "$WORK/inputs/$SDIST_NAME"
SDIST_SHA256=$(sha256sum "$WORK/inputs/$SDIST_NAME" | cut -d' ' -f1)
echo "sdist: $SDIST_NAME sha256 $SDIST_SHA256"
}
# The podman phases. Only the build phase runs build scripts (with network);
# only the sign phase sees the key (and has no network); only the install phase
# runs the packages (in a clean image, without the key).
phase_build() {
mkdir -p "$WORK/packages"
podman pull -q "$BASE_IMAGE" >/dev/null
podman run --rm \
-v "$HERE/srcpkgs:/overlay:ro" \
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
-v "$HERE/render-voculinux.sh:/mnt/render-voculinux.sh:ro" \
-v "$WORK/inputs:/mnt/inputs:ro" \
-v "$WORK/packages:/mnt/packages" \
-e "VOID_COMMIT=$VOID_COMMIT" \
-e "PKGS=$PKGS" \
-e "TAG=$TAG" \
-e "XBPS_VERSION=$XBPS_VERSION" \
-e "SDIST_NAME=$SDIST_NAME" \
-e "SDIST_SHA256=$SDIST_SHA256" \
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh build
}
phase_sign() {
mkdir -p "$OUT/xbps"
podman run --rm --network none \
-v "$WORK/packages:/mnt/packages:ro" \
-v "$OUT/xbps:/mnt/xbps" \
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
-v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \
-e "SIGNEDBY=$SIGNEDBY" \
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh sign
mkdir -p "$OUT/xbps/keys"
cp "$WORK/client.plist" "$OUT/xbps/keys/$FINGERPRINT.plist"
printf 'signed-by: %s\nfingerprint: %s\n' "$SIGNEDBY" "$FINGERPRINT" >"$OUT/xbps/keys/FINGERPRINT"
}
phase_install() {
podman run --rm \
-v "$OUT/xbps:/mnt/xbps:ro" \
-v "$WORK/packages:/mnt/packages:ro" \
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
-e "PKGS=$PKGS" \
-e "XBPS_VERSION=$XBPS_VERSION" \
-e "VERSION=$VERSION" \
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh install
}
# verify_set: the staged set is complete and holds nothing that may not be
# published.
verify_set() {
cd "$OUT"
for pattern in 'sdist/*.tar.gz' 'xbps/repo/*.x86_64.xbps' 'xbps/repo/*.sig2' \
'xbps/repo/*-repodata*' 'xbps/keys/FINGERPRINT' 'xbps/keys/*.plist'; do
present "$pattern" || fail "missing from the signed set: $pattern"
done
[ "$(ls sdist | wc -l)" -eq 1 ] && [ -f "sdist/$SDIST_NAME" ] ||
fail "the sdist directory must hold exactly sdist/$SDIST_NAME"
[ "$(sha256sum "sdist/$SDIST_NAME" | cut -d' ' -f1)" = "$SDIST_SHA256" ] ||
fail "the staged sdist does not match its digest"
for f in xbps/repo/*; do
base=${f##*/}
case "$base" in
*musl*) fail "musl data in the repository: $base" ;;
*.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;;
*) fail "unexpected file (only glibc x86_64 data is published): xbps/repo/$base" ;;
esac
done
for f in xbps/keys/*; do
base=${f##*/}
case "$base" in
FINGERPRINT | *.plist) ;;
*) fail "unexpected file among the client keys: xbps/keys/$base" ;;
esac
done
for pkg in xbps/repo/*.x86_64.xbps; do
[ -s "$pkg.sig2" ] || fail "package has no signature: $pkg"
done
for sig in xbps/repo/*.sig2; do
[ -f "${sig%.sig2}" ] || fail "signature without its package: $sig"
done
for name in $PKGS; do
if [ "$name" = voculinux ]; then
[ -f "xbps/repo/voculinux-${XBPS_VERSION}_1.x86_64.xbps" ] ||
fail "the repository lacks voculinux-${XBPS_VERSION}_1"
else
present "xbps/repo/${name}-[0-9]*.x86_64.xbps" ||
fail "package missing from the repository: $name"
fi
done
FP=$(sed -n 's/^fingerprint: //p' xbps/keys/FINGERPRINT)
[ "$FP" = "$FINGERPRINT" ] || fail "FINGERPRINT says '$FP', the signing key is '$FINGERPRINT'"
[ -s "xbps/keys/$FINGERPRINT.plist" ] || fail "client key xbps/keys/$FINGERPRINT.plist is missing"
if [ "$MODE" = release ]; then
cmp -s "$PLIST" "xbps/keys/$FINGERPRINT.plist" ||
fail "the staged client key is not the committed repository-key.plist"
fi
cd "$ROOT"
echo "OK: complete signed glibc x86_64 set for $TAG, key $FINGERPRINT"
}
run_pipeline() {
derive "$1"
check_template
WORK=$(mktemp -d "${TMPDIR:-/tmp}/voculinux-release.XXXXXX")
cleanup() {
status=$?
rm -f "$WORK/throwaway.pem"
if [ "$status" -eq 0 ]; then
rm -rf "$WORK"
else
echo "build-release.sh: failed; diagnostics retained in $WORK" >&2
fi
}
trap cleanup EXIT
if [ "$MODE" = smoke ]; then
openssl genrsa -out "$WORK/throwaway.pem" 4096 2>/dev/null
KEY_FILE="$WORK/throwaway.pem"
OUT="$WORK/out"
fi
if [ -e "$OUT" ]; then
[ -d "$OUT" ] && [ -z "$(ls -A "$OUT")" ] ||
fail "$OUT exists and is not empty; choose a new output directory"
fi
check_key "$KEY_FILE"
build_sdist
phase_build
# The committed templates must be untouched: the build tree is the only
# place a release pins version, _tag and checksum.
if git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then
git -C "$ROOT" diff --quiet -- packaging/void/srcpkgs ||
fail "the build changed the committed templates"
fi
mkdir -p "$OUT/sdist"
OUT=$(cd "$OUT" && pwd)
cp "$WORK/inputs/$SDIST_NAME" "$OUT/sdist/$SDIST_NAME"
phase_sign
phase_install
verify_set
printf '%s\n' "$SDIST_SHA256" >"$OUT/SDIST_SHA256"
echo "template bump for the post-release pull request: checksum=$SDIST_SHA256"
}
cmd=${1:-}
[ -n "$cmd" ] || usage
shift
case "$cmd" in
names)
[ "$#" -eq 1 ] || usage
derive "$1"
printf 'TAG=%s\nVERSION=%s\nXBPS_VERSION=%s\nSDIST_NAME=%s\n' \
"$TAG" "$VERSION" "$XBPS_VERSION" "$SDIST_NAME"
;;
check-tag)
[ "$#" -eq 1 ] || usage
derive "$1"
check_template
echo "tag gate passed: $TAG (xbps $XBPS_VERSION)"
;;
release)
[ "$#" -ge 1 ] || usage
TAG=$1
shift
KEY_FILE=""
OUT=""
while [ "$#" -gt 0 ]; do
case "$1" in
--key-file) KEY_FILE=${2:-}; shift 2 ;;
--out-dir) OUT=${2:-}; shift 2 ;;
*) usage ;;
esac
done
[ -n "$OUT" ] || usage
MODE=release
run_pipeline "$TAG"
echo "RELEASE BUILT: $TAG staged in $OUT (not published by this script)"
;;
smoke)
[ "$#" -eq 0 ] || usage
MODE=smoke
run_pipeline "v$(source_version)"
echo "SMOKE PASSED: $TAG built, signed with a throwaway key, installed and smoked; nothing published"
;;
*)
usage
;;
esac
-51
View File
@@ -1,51 +0,0 @@
#!/bin/sh
# Host-side harness for packaging/void: runs the container flow (see
# container-main.sh and container-build.sh) in the pinned Void glibc
# container via podman, never Docker.
#
# Usage: packaging/void/build.sh <pkgname> [-- <xbps-src args...>]
#
# packaging/void/build.sh python3-pywhispercpp
# packaging/void/build.sh python3-pywhispercpp -- -o ~vulkan pkg
#
# Anything after -- is passed to `xbps-src` before the package name (use it
# for -o ~vulkan CPU-only rebuilds). The package build itself fetches
# nothing from the network beyond the template's declared distfiles:
# xbps-src verifies distfile checksums before unpacking.
set -eu
CDPATH=""
HERE=$(dirname -- "$0")
HERE=$(cd -- "$HERE" && pwd)
REPO_ROOT=$(cd -- "$HERE/../.." && pwd)
PKGNAME=${1:?usage: build.sh "<pkgname>" [-- "<xbps-src args>"]}
shift
if [ "${1:-}" = "--" ]; then
shift
fi
XBPS_ARGS=${*:-pkg}
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || {
echo "build.sh: pins missing from tool_checksums.txt" >&2
exit 1
}
case "$XBPS_ARGS" in
*~vulkan*) EXPECT_VULKAN=0 ;;
*) EXPECT_VULKAN=1 ;;
esac
echo "Pulling pinned base image: $BASE_IMAGE"
podman pull "$BASE_IMAGE"
podman run --rm \
-v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \
-v "$HERE/container-main.sh:/mnt/container-main.sh:ro" \
-v "$HERE/container-build.sh:/mnt/container-build.sh:ro" \
-e "VOID_COMMIT=$VOID_COMMIT" \
-e "PKGNAME=$PKGNAME" \
-e "XBPS_ARGS=$XBPS_ARGS" \
-e "EXPECT_VULKAN=$EXPECT_VULKAN" \
"$BASE_IMAGE" /bin/sh /mnt/container-main.sh
echo "OK: $PKGNAME built and imported cleanly"
-24
View File
@@ -1,24 +0,0 @@
#!/bin/sh
# Builder-side half of packaging/void/build.sh. Runs inside the pinned Void
# container as the unprivileged builder user (xbps-src refuses root).
#
# Not executed directly: build.sh mounts the repo's srcpkgs next to this
# file, copies it into the container, and runs it after cloning
# void-packages at the pinned commit. PKGNAME and XBPS_ARGS arrive via the
# environment. Keeping this a real file (instead of an inline `sh -c`
# string) means normal quoting, heredocs, and shellcheck apply.
set -eu
export HOME=/home/builder
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
cd /void-packages
echo "== xlint $PKGNAME"
xlint "srcpkgs/$PKGNAME/template"
echo "== show-options $PKGNAME"
./xbps-src show-options "$PKGNAME" || true
echo "== xbps-src $XBPS_ARGS $PKGNAME"
# shellcheck disable=SC2086
./xbps-src $XBPS_ARGS "$PKGNAME"
-144
View File
@@ -1,144 +0,0 @@
#!/bin/sh
# Root-side half of the packaging/void container harness. Runs inside the
# pinned Void container as root: system setup, the void-packages checkout,
# then the builder half (container-build.sh) as the builder user, then the
# install plus import smoke as root again.
#
# Not executed directly: build.sh mounts this file and runs it. PKGNAME,
# XBPS_ARGS, EXPECT_VULKAN and VOID_COMMIT arrive via the environment.
set -eu
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
xbps-install -Suy xbps >/dev/null
xbps-install -y git base-devel xtools binutils zstd >/dev/null
git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages
git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT"
git -C /void-packages checkout --quiet "$VOID_COMMIT"
cp -r /overlay/. /void-packages/srcpkgs/
id builder >/dev/null 2>&1 || useradd -m builder
cp /mnt/container-build.sh /run-build.sh
chown builder:builder /void-packages /run-build.sh
chmod +x /run-build.sh
su -s /bin/sh builder -c /run-build.sh
echo "== install $PKGNAME from the local repository"
PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKGNAME"-*.x86_64.xbps /host/binpkgs/"$PKGNAME"-*.x86_64.xbps 2>/dev/null | head -n1)
[ -n "$PKGFILE" ] || {
echo "built package not found" >&2
exit 1
}
echo "built: $PKGFILE"
xbps-install -Sy >/dev/null
xbps-install -y --repository="$(dirname "$PKGFILE")" "$PKGNAME" >/dev/null
case "$PKGNAME" in
voculinux)
echo "== payload $PKGNAME: desktop entry, icons, packaged marker"
tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/applications/voculinux.desktop" || {
echo "renamed desktop entry missing from the built package" >&2
exit 1
}
tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/icons/hicolor/scalable/apps/voculinux.svg" || {
echo "renamed icons missing from the built package" >&2
exit 1
}
tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/voculinux/packaged" || {
echo "packaged-install marker missing from the built package" >&2
exit 1
}
echo "== payload $PKGNAME: no speech models, no vendored wheels"
if tar -tf "$PKGFILE" 2>/dev/null | grep -E "models/|\.ggml$|\.bin$|\.pt$|\.whl$"; then
echo "package payload carries models or wheels" >&2
exit 1
fi
echo "no models or wheels in the shipped package"
echo "== payload $PKGNAME: no pre-rename leftovers"
if tar -tf "$PKGFILE" 2>/dev/null | grep -E "usr/share/applications/vocalinux|usr/share/icons/.*/vocalinux"; then
echo "pre-rename file names in the OS-visible payload" >&2
exit 1
fi
echo "no pre-rename leftovers in the shipped package"
echo "== version and import smoke $PKGNAME"
voculinux --version
cat > /voculinux-smoke.py <<'PYEOF'
# The importable package keeps its retained internal name (vocalinux); the
# distribution metadata is the fork's own name (voculinux).
import importlib
MODULES = (
"vocalinux.main",
"vocalinux.ui.tray_indicator",
"vocalinux.speech_recognition.recognition_manager",
"vocalinux.speech_recognition.command_processor",
"vocalinux.text_injection.text_injector",
"vocalinux.text_injection.ibus_engine",
"vocalinux.ui.keyboard_backends",
"pywhispercpp.model",
)
for module in MODULES:
importlib.import_module(module)
print("import-smoke OK:", ", ".join(MODULES))
from importlib.metadata import version
print("voculinux", version("voculinux"))
PYEOF
python3 /voculinux-smoke.py
;;
python3-pywhispercpp)
echo "== compiler flag scan of the shipped ggml library"
LIBMEMBER=$(tar -tf "$PKGFILE" 2>/dev/null | grep -E "libggml-cpu\.so" | head -n1)
[ -n "$LIBMEMBER" ] || {
echo "libggml-cpu missing from the built package" >&2
exit 1
}
echo "member: $LIBMEMBER"
mkdir -p /flagscan
tar -xf "$PKGFILE" -C /flagscan "$LIBMEMBER"
LIBSCAN="/flagscan/${LIBMEMBER#./}"
echo "-- .GCC.command.line:"
readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null || echo "(section absent)"
echo "-- .comment:"
readelf -p .comment "$LIBSCAN" 2>/dev/null || echo "(section absent)"
if readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null | grep -E "march=native|mcpu=native|mtune=native"; then
echo "host-native CPU tuning in the shipped library" >&2
exit 1
fi
echo "no host-native tuning in the shipped library"
echo "== import smoke $PKGNAME"
python3 -c "from pywhispercpp.model import Model; print('model submodule ok')"
cat > /smoke.py <<'PYEOF'
import glob
import os
import pywhispercpp
pkgdir = os.path.dirname(pywhispercpp.__file__)
sitedir = os.path.dirname(pkgdir)
libs = sorted(os.path.basename(p) for p in glob.glob(sitedir + "/lib*.so*"))
print("shipped libraries:", libs)
assert any(n.startswith("libwhisper") for n in libs), "libwhisper missing"
exts = [n for n in os.listdir(sitedir) if n.startswith("_pywhispercpp") and n.endswith(".so")]
assert exts, "compiled extension missing"
print("extension:", exts)
has_vulkan = any("vulkan" in n for n in libs)
want_vulkan = bool(int(os.environ["EXPECT_VULKAN"]))
assert has_vulkan == want_vulkan, "vulkan backend present=%s, expected=%s" % (has_vulkan, want_vulkan)
print("smoke ok")
PYEOF
EXPECT_VULKAN="$EXPECT_VULKAN" python3 /smoke.py
;;
python3-PyAudio)
echo "== import smoke $PKGNAME"
python3 -c "import pyaudio; print('pyaudio', pyaudio.__version__)"
;;
python3-pynput)
# pynput selects a display backend at import and raises without one;
# the container has no display, so presence plus the installed
# version (the same proof the native smoke accepts) is the smoke.
echo "== import smoke $PKGNAME"
python3 -c "import importlib.metadata, importlib.util; assert importlib.util.find_spec('pynput'), 'pynput not importable'; print('pynput', importlib.metadata.version('pynput'))"
;;
*)
echo "no smoke recipe for package '$PKGNAME' — add one alongside the template" >&2
exit 1
;;
esac
+180
View File
@@ -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 </dev/null 2>&1)
REFUSAL_RC=$?
set -e
printf '%s\n' "$REFUSAL" | grep -i "fingerprint" || true
[ "$REFUSAL_RC" -ne 0 ] || fail "the repository installed without its key being trusted"
xbps-query voculinux >/dev/null 2>&1 && fail "voculinux was installed by the untrusted attempt"
echo "refused without the key, as required (rc=$REFUSAL_RC)"
echo "== trust the staged key, then install voculinux from the signed repository"
cp "$XBPS_STAGE"/keys/*.plist /var/db/xbps/keys/
xbps-install -y --repository="$XBPS_STAGE/repo" --repository="$OFFICIAL" -S voculinux >/dev/null
xbps-query voculinux | grep -qx "pkgver: voculinux-${XBPS_VERSION}_1" ||
fail "installed package is not voculinux-${XBPS_VERSION}_1"
echo "installed: voculinux-${XBPS_VERSION}_1"
echo "== the renamed binary reports its version"
VERSION_OUT=$(voculinux --version 2>&1) || fail "voculinux --version exited non-zero"
printf '%s\n' "$VERSION_OUT"
printf '%s' "$VERSION_OUT" | grep -qF "$VERSION" || fail "voculinux --version does not show $VERSION"
echo "== payload of the built packages; 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
-129
View File
@@ -1,129 +0,0 @@
#!/bin/sh
# Repository half of packaging/void/repo.sh (ticket #29). Runs inside the
# pinned Void container as root: system setup, the void-packages checkout
# at the pinned commit, then every template built in smoke-gate order as
# the unprivileged builder user (xbps-src refuses root), then index, sign,
# verify and stage as root again.
#
# Not executed directly: repo.sh mounts this file and runs it. VOID_COMMIT,
# PKGS and SIGNEDBY arrive via the environment; the RSA4096 private key is
# mounted read-only at /run/xbps-sign-key.pem (repo.sh validated it before
# any container ran).
#
# Keeping this a real file (instead of an inline `sh -c` string) means
# normal quoting, heredocs, and shellcheck apply.
set -eu
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
KEY=/run/xbps-sign-key.pem
OUT=/mnt/out
REPO="$OUT/repo"
KEYS="$OUT/keys"
[ -r "$KEY" ] || {
echo "container-repo.sh: signing key not mounted at $KEY" >&2
exit 1
}
xbps-install -Suy xbps >/dev/null
xbps-install -y git base-devel xtools binutils zstd openssl >/dev/null
git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages
git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT"
git -C /void-packages checkout --quiet "$VOID_COMMIT"
cp -r /overlay/. /void-packages/srcpkgs/
id builder >/dev/null 2>&1 || useradd -m builder
chown builder:builder /void-packages
echo "== build every template in order: $PKGS"
cd /void-packages
# shellcheck disable=SC2086
for PKG in $PKGS; do
echo "== xlint $PKG"
su -s /bin/sh builder -c "cd /void-packages && xlint 'srcpkgs/$PKG/template'"
echo "== xbps-src pkg $PKG"
su -s /bin/sh builder -c "cd /void-packages && ./xbps-src pkg '$PKG'"
done
echo "== collect built packages"
mkdir -p "$REPO" "$KEYS"
# shellcheck disable=SC2086
for PKG in $PKGS; do
PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKG"-*.x86_64.xbps 2>/dev/null | head -n1)
[ -n "$PKGFILE" ] || {
echo "FAIL: $PKG (built package missing from hostdir/binpkgs)" >&2
exit 1
}
echo "collected: $(basename "$PKGFILE")"
cp "$PKGFILE" "$REPO/"
done
echo "== index the full repository"
# shellcheck disable=SC2086
xbps-rindex -a "$REPO"/*.x86_64.xbps
echo "== sign every binary package"
xbps-rindex -S --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO"/*.x86_64.xbps
echo "== sign the repository metadata"
xbps-rindex -s --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO"
echo "== prove an unsigned install is refused"
set +e
PROBE=$(xbps-install -r /tmp/keyprobe --repository="$REPO" -Sy voculinux </dev/null 2>&1)
PROBE_RC=$?
set -e
if [ "$PROBE_RC" -eq 0 ] || ! printf '%s' "$PROBE" | grep -q "RSA signed"; then
echo "FAIL: unsigned install was not refused — signatures are not enforced" >&2
printf '%s\n' "$PROBE" >&2
exit 1
fi
echo "unsigned install refused as required"
FP=$(printf '%s' "$PROBE" | grep -i Fingerprint | awk '{print $2}' | head -n1)
[ -n "$FP" ] || {
echo "FAIL: could not read the key fingerprint from the refusal" >&2
exit 1
}
echo "key fingerprint: $FP"
echo "== stage the client key"
openssl rsa -in "$KEY" -pubout -out /tmp/repo-pub.pem 2>/dev/null
PUB64=$(base64 -w0 /tmp/repo-pub.pem)
# XML-escape the signer identity: xbps writes &lt;/&gt; itself on import
# and rejects a plist carrying raw angle brackets.
ESCAPED_SIGNEDBY=$(printf '%s' "$SIGNEDBY" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g')
{
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
printf '%s\n' '<plist version="1.0">' '<dict>' ' <key>public-key</key>'
printf ' <data>%s</data>\n' "$PUB64"
printf '%s\n' ' <key>public-key-size</key>' ' <integer>4096</integer>' ' <key>signature-by</key>'
printf ' <string>%s</string>\n' "$ESCAPED_SIGNEDBY"
printf '%s\n' '</dict>' '</plist>'
} > "$KEYS/$FP.plist"
{
printf 'signed-by: %s\n' "$SIGNEDBY"
printf 'fingerprint: %s\n' "$FP"
} > "$KEYS/FINGERPRINT"
echo "staged: keys/$FP.plist"
echo "== verify: install the app from the signed repo in a clean rootdir"
VERIFY=/tmp/verify-install
mkdir -p "$VERIFY/var/db/xbps/keys"
# The app's full dependency tree resolves from the official remote, so the
# verify rootdir trusts the official keys too; our repo stays enforced
# because its packages verify only against the staged key below.
cp /var/db/xbps/keys/*.plist "$VERIFY/var/db/xbps/keys/"
cp "$KEYS/$FP.plist" "$VERIFY/var/db/xbps/keys/"
xbps-install -y -r "$VERIFY" \
--repository="$REPO" \
--repository=https://repo-default.voidlinux.org/current \
-Sy voculinux
xbps-query -r "$VERIFY" -l | grep -q "^ii voculinux-" || {
echo "FAIL: voculinux not installed in the verify rootdir" >&2
exit 1
}
echo "verified: voculinux installs from the signed repository"
ls "$REPO"
echo "SIGNED REPO OK: fingerprint $FP"
+71
View File
@@ -0,0 +1,71 @@
#!/bin/sh
# Renders the voculinux template for one release inside a void-packages build
# tree (ticket #27, spec #16 "Release pipeline and upstream intake").
#
# The committed template is never edited by a build. This script runs against
# the container's copy of srcpkgs and does three things, each refused unless it
# lands exactly once:
#
# 1. pins version, _tag and checksum to the release being built;
# 2. checks the sdist's digest is the checksum it just pinned;
# 3. places the sdist where xbps-src looks for a distfile of this package
# (hostdir/sources/<pkgname>-<version>/<basename>), so xbps-src verifies
# the local file and never downloads it.
#
# Run as root, by container-release.sh (release) and container-main.sh (smoke).
# Inputs arrive as environment variables from build-release.sh:
# VOID_PACKAGES void-packages checkout (default /void-packages)
# SDIST_DIR directory holding the sdist (read only)
# XBPS_VERSION xbps spelling of the release, e.g. 0.18.5 or 0.19.0beta
# TAG the release tag, e.g. v0.19.0-beta
# SDIST_NAME distfile basename, voculinux-<XBPS_VERSION>.tar.gz
# SDIST_SHA256 sha256 of $SDIST_DIR/$SDIST_NAME
set -eu
fail() {
echo "render-voculinux.sh: $*" >&2
exit 1
}
VP=${VOID_PACKAGES:-/void-packages}
TEMPLATE="$VP/srcpkgs/voculinux/template"
for name in SDIST_DIR XBPS_VERSION TAG SDIST_NAME SDIST_SHA256; do
eval "value=\${$name:-}"
[ -n "$value" ] || fail "$name is not set"
done
for name in XBPS_VERSION TAG SDIST_NAME SDIST_SHA256; do
eval "value=\${$name}"
case "$value" in
*[!A-Za-z0-9._-]*) fail "$name has characters outside [A-Za-z0-9._-]: $value" ;;
esac
done
[ -f "$TEMPLATE" ] || fail "no voculinux template at $TEMPLATE"
[ -s "$SDIST_DIR/$SDIST_NAME" ] || fail "sdist is missing: $SDIST_DIR/$SDIST_NAME"
digest=$(sha256sum "$SDIST_DIR/$SDIST_NAME" | cut -d' ' -f1)
[ "$digest" = "$SDIST_SHA256" ] || fail "sdist digest $digest is not the expected $SDIST_SHA256"
# set_line KEY VALUE: the template must hold exactly one KEY= line, and the
# rewritten line must read back exactly as asked.
set_line() {
key=$1
value=$2
count=$(grep -c "^$key=" "$TEMPLATE" || true)
[ "$count" = 1 ] || fail "template must hold exactly one $key= line, found $count"
sed -i "s|^$key=.*|$key=$value|" "$TEMPLATE"
grep -qxF "$key=$value" "$TEMPLATE" || fail "could not pin $key=$value in the build tree"
}
set_line version "$XBPS_VERSION"
set_line _tag "$TAG"
set_line checksum "$SDIST_SHA256"
DEST="$VP/hostdir/sources/voculinux-$XBPS_VERSION"
mkdir -p "$DEST"
cp "$SDIST_DIR/$SDIST_NAME" "$DEST/$SDIST_NAME"
[ "$(sha256sum "$DEST/$SDIST_NAME" | cut -d' ' -f1)" = "$SDIST_SHA256" ] ||
fail "the copied distfile does not match $SDIST_SHA256"
echo "render-voculinux.sh: pinned version=$XBPS_VERSION _tag=$TAG checksum=$SDIST_SHA256"
echo "render-voculinux.sh: distfile staged at $DEST/$SDIST_NAME"
-97
View File
@@ -1,97 +0,0 @@
#!/bin/sh
# Signed xbps repository pipeline (ticket #29): build every template in
# smoke-gate order, index the full repository, and sign every binary
# package plus the repository metadata with the RSA4096 repository key.
# Runs in the pinned Void container via podman, never Docker; no host
# Void install needed.
#
# Usage: XBPS_SIGNING_KEY_FILE=/path/to/key.pem packaging/void/repo.sh
# or: XBPS_SIGNING_KEY_FILE=/path/to/key.pem just void-repo
#
# Options (all optional):
# --key-file PATH repository private key (default: $XBPS_SIGNING_KEY_FILE)
# --out-dir DIR stage the signed repo here (default: publish/xbps)
# --signed-by "N <m>" signer identity (default: Voculinux <xbps@voculinux.com>)
#
# The private key arrives as a file so CI can write the XBPS_SIGNING_KEY
# secret to disk with newlines intact:
#
# printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem
# XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem packaging/void/repo.sh
#
# Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before
# any container runs, so an unsigned repository can never publish.
set -eu
CDPATH=""
HERE=$(dirname -- "$0")
HERE=$(cd -- "$HERE" && pwd)
REPO_ROOT=$(cd -- "$HERE/../.." && pwd)
KEY_FILE=${XBPS_SIGNING_KEY_FILE:-}
OUT_DIR="$REPO_ROOT/publish/xbps"
SIGNEDBY="Voculinux <xbps@voculinux.com>"
while [ $# -gt 0 ]; do
case "$1" in
--key-file) KEY_FILE=${2:?usage: repo.sh --key-file PATH} ; shift 2 ;;
--out-dir) OUT_DIR=${2:?usage: repo.sh --out-dir DIR} ; shift 2 ;;
--signed-by) SIGNEDBY=${2:?usage: repo.sh --signed-by "Name <mail>"} ; shift 2 ;;
-*) echo "repo.sh: unknown option: $1" >&2 ; exit 1 ;;
*) echo "repo.sh: unexpected argument: $1" >&2 ; exit 1 ;;
esac
done
# Fail closed before any container runs: no key, no repo, no publish.
[ -n "$KEY_FILE" ] || {
echo "repo.sh: no signing key: pass --key-file or set XBPS_SIGNING_KEY_FILE" >&2
echo "repo.sh: CI writes the XBPS_SIGNING_KEY secret to a file first:" >&2
echo "repo.sh: printf '%s' \"\$XBPS_SIGNING_KEY\" > /tmp/xbps-sign-key.pem" >&2
exit 1
}
[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] || {
echo "repo.sh: signing key missing or empty: $KEY_FILE" >&2
echo "repo.sh: refusing to publish an unsigned repository" >&2
exit 1
}
openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 || {
echo "repo.sh: not a valid RSA private key: $KEY_FILE" >&2
echo "repo.sh: refusing to publish an unsigned repository" >&2
exit 1
}
openssl rsa -in "$KEY_FILE" -text -noout 2>/dev/null | head -1 | grep -q "4096 bit" || {
echo "repo.sh: signing key is not RSA4096: $KEY_FILE" >&2
echo "repo.sh: generate one with: openssl genrsa -out xbps-sign-key.pem 4096" >&2
exit 1
}
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || {
echo "repo.sh: pins missing from tool_checksums.txt" >&2
exit 1
}
# One dependency order for the whole track: the signed repo builds the
# same set in the same order as the smoke gate (the app last), so a
# renamed or added template cannot silently drop out of either.
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh")
[ -n "$PKGS" ] || {
echo "repo.sh: could not read the package order from smoke.sh" >&2
exit 1
}
mkdir -p "$OUT_DIR"
echo "Pulling pinned base image: $BASE_IMAGE"
podman pull "$BASE_IMAGE"
podman run --rm \
-v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \
-v "$HERE/container-repo.sh:/mnt/container-repo.sh:ro" \
-v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \
-v "$OUT_DIR:/mnt/out" \
-e "VOID_COMMIT=$VOID_COMMIT" \
-e "PKGS=$PKGS" \
-e "SIGNEDBY=$SIGNEDBY" \
"$BASE_IMAGE" /bin/sh /mnt/container-repo.sh
echo "OK: signed repository staged in $OUT_DIR"
+12 -22
View File
@@ -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
+10 -6
View File
@@ -2,6 +2,10 @@
pkgname=voculinux
version=0.18.5
revision=1
# The release tag this template names: v + __version__, with the pre-release
# hyphen kept (the tag is v0.19.0-beta while version is 0.19.0beta). The release
# gate (packaging/void/build-release.sh check-tag) refuses any mismatch.
_tag=v0.18.5
# The release sdist ships no setup.py (setuptools pyproject-only), so the
# build goes through the PEP 517 wheel (cf. Solaar, PhotoCollage).
build_style=python3-pep517
@@ -16,12 +20,12 @@ short_desc="Free, offline voice dictation for Linux"
maintainer="Xavier Karma <xavier@bongbetic.com>"
license="AGPL-3.0-only"
homepage="https://git.bongbetic.com/xavierk/voculinux"
distfiles="https://git.bongbetic.com/xavierk/voculinux/releases/download/v${version}/voculinux-${version}.tar.gz"
# Pending: set to the sha256 of voculinux-${version}.tar.gz once the fork's
# v${version} release exists (see packaging/void/README.md, "Checksum").
# Until then xbps-src fails closed on this value; it never falls back to the
# upstream archive.
checksum=PENDING_FORK_RELEASE_SHA256
distfiles="https://git.bongbetic.com/xavierk/voculinux/releases/download/${_tag}/voculinux-${version}.tar.gz"
# sha256 of that release sdist. Recorded here by the post-release pull request
# after each release. Until the first fork release it is a placeholder that is
# never a real digest: every release build replaces it, in its build tree only,
# with the digest of the sdist it builds (packaging/void/render-voculinux.sh).
checksum=0000000000000000000000000000000000000000000000000000000000000000
post_install() {
# The fork's sdist already names the desktop entry, icons and launchers
+1 -1
View File
@@ -1,4 +1,4 @@
# Pinned build inputs for packaging/void/build.sh.
# Pinned build inputs for packaging/void/build-release.sh (and smoke.sh, which calls it).
#
# Same contract as packaging/appimage/tool_checksums.txt and
# packaging/native/tool_checksums.txt: everything the package build
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""xbps repository-key helper for packaging/void/build-release.sh.
xbps-fingerprint.py fingerprint PEM
print the xbps fingerprint of an RSA public key (PEM): the MD5 of the
ssh-rsa blob of the key, the identifier libxbps calls xbps_pubkey2fp.
xbps-fingerprint.py plist PEM SIGNER OUT
write the client key plist xbps expects for that public key. Used only
for a throwaway smoke key; the release publishes the committed
repository-key.plist byte for byte.
Only the public key is ever read here. Run with -I: the interpreter must not
pick up modules from the directory a downloaded file sits in.
"""
from __future__ import annotations
import hashlib
import plistlib
import re
import struct
import subprocess
import sys
from pathlib import Path
def _rsa_numbers(pem: Path) -> tuple[int, int]:
"""Modulus and exponent of a PEM public key, read through openssl."""
text = subprocess.run(
["openssl", "rsa", "-pubin", "-in", str(pem), "-noout", "-text"],
check=True,
capture_output=True,
text=True,
).stdout
modulus_hex: list[str] = []
exponent = None
in_modulus = False
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("Modulus"):
in_modulus = True
continue
if stripped.startswith("Exponent"):
in_modulus = False
exponent = int(stripped.split()[1])
continue
if in_modulus and re.fullmatch(r"[0-9a-f:]+", stripped):
modulus_hex.append(stripped.replace(":", ""))
if exponent is None or not modulus_hex:
raise SystemExit(f"xbps-fingerprint.py: not an RSA public key: {pem}")
return int("".join(modulus_hex), 16), exponent
def _mpint(value: int) -> bytes:
"""An SSH mpint: big-endian, with a leading zero when the top bit is set."""
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
if raw[0] & 0x80:
raw = b"\x00" + raw
return struct.pack(">I", len(raw)) + raw
def fingerprint(pem: Path) -> str:
"""Colon-separated lowercase MD5 of the ssh-rsa blob of the key."""
modulus, exponent = _rsa_numbers(pem)
blob = struct.pack(">I", 7) + b"ssh-rsa" + _mpint(exponent) + _mpint(modulus)
digest = hashlib.md5(blob, usedforsecurity=False).hexdigest()
return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
def write_plist(pem: Path, signer: str, out: Path) -> None:
"""Write the client key plist xbps reads from /var/db/xbps/keys."""
modulus, _ = _rsa_numbers(pem)
entry = {
"public-key": pem.read_bytes(),
"public-key-size": modulus.bit_length(),
"signature-by": signer,
}
with out.open("wb") as handle:
plistlib.dump(entry, handle, fmt=plistlib.FMT_XML)
def main(argv: list[str]) -> int:
if len(argv) == 2 and argv[0] == "fingerprint":
print(fingerprint(Path(argv[1])))
return 0
if len(argv) == 4 and argv[0] == "plist":
write_plist(Path(argv[1]), argv[2], Path(argv[3]))
return 0
print(__doc__, file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+2 -2
View File
@@ -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:
+30 -34
View File
@@ -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/<tag>``) 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:
+6 -7
View File
@@ -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}"
+567
View File
@@ -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 <t@example.com>",
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 <t@example.com>"
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"
+26 -166
View File
@@ -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 "&lt;" in text and "&gt;" 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"
+31 -34
View File
@@ -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:
+237 -224
View File
@@ -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
(``<pkg>.x86_64.xbps`` plus ``<pkg>.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 <xbps@voculinux.com>\nfingerprint: {fingerprint}\n",
encoding="utf-8",
)
(keys / f"{fingerprint}.plist").write_text("<plist></plist>\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 <xbps@voculinux.com>\nfingerprint: {FINGERPRINT}\n",
encoding="utf-8",
)
(keys / f"{FINGERPRINT}.plist").write_text("<plist></plist>\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"