Version 0.19.0 across version.py, the Void template and the synced docs; changelog entry; Gitea project URLs in pyproject.toml; per-backend ladder thresholds measured in #19; the xbps release workflow is manual-only, and packaging/void/publish-release.sh publishes a locally built, verified repository to the Gitea releases.
Void packaging
Void Linux (glibc, x86_64) templates in void-packages layout, built with the standard Void source tooling. Submission to upstream void-packages is out of scope for spec #16; these templates serve the signed personal xbps repository first.
Layout
packaging/void/
├── README.md this file
├── build-release.sh the release path: gate, clean checkout, sdist, signed repo, install smoke
├── publish-release.sh publishes a built out-dir to the Gitea release API (dry run first)
├── 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
├── python3-pynput/ ticket #25: the input library Void does not ship
├── python3-PyAudio/ ticket #25: the audio binding, updated past Void's copy
├── python3-pysocks/ spec #16 gap: the SOCKS module, 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/
(the release path does this inside the container).
Templates
| Template | Ticket | Notes |
|---|---|---|
python3-pywhispercpp |
#26 | Vulkan backend on by default; see below |
python3-pynput |
#25 | A dependency Void does not ship |
python3-PyAudio, python3-pysocks |
spec #16 gap | Void's copies are below the project floors; see Version floors |
voculinux |
#27 | The app, from the canonical release sdist; see below |
Version floors
pyproject.toml declares floors that Void's own packages do not meet. On
2026-11-06 the stock packages on void-packages master were:
| Package | Stock Void | Project floor | Carried template | voculinux depends |
|---|---|---|---|---|
python3-PyAudio |
0.2.12_5 | pyaudio>=0.2.13 |
0.2.14_1 | python3-PyAudio>=0.2.14_1 |
python3-pysocks |
1.7.0_8 | PySocks>=1.7.1,<2 |
1.7.1_1 | python3-pysocks>=1.7.1_1 |
The gap is closed in the personal repository, not hidden. The carried
templates build the versions uv.lock pins, and voculinux names each one
with a >= constraint in the xbps name>=version_revision form (the xbps-src
manual lists <, >, <= and >=). A bare name would be satisfied by the
stock package a user already has, so xbps would leave it in place and the app
would run below its floor. With the constraint, xbps takes the carried
template from the signed repository instead. Dependency verification stays on.
The install smoke checks the upgrade, not just a clean install: it installs
the stock packages from Void first, then voculinux, and fails unless each
package reads back as its carried pkgver.
tests/test_void_app_template.py requires every constraint to name its
template's current version and revision, so a template bump without a
depends bump fails offline. tests/test_void_small_deps.py requires the
stock version below the floor and the carried version at or above it.
Known limit: xbps has one comparator per dependency, so PySocks' <2 cannot
be written. The carried 1.7.1 satisfies it. A future Void python3-pysocks
2.x would also satisfy >=1.7.1_1, so it is not excluded by the dependency.
When a project floor rises, or Void's stock package moves up to the carried
version, change the carried template and its constraint in the same pull
request. The live signal is the install smoke: once the stock package reaches
the carried version, the constraint is already met, the stock package is kept,
and the pkgver check fails. That is the signal to retire the carried template
deliberately. The offline stock-version test in tests/test_void_small_deps.py
only records the versions verified on 2026-11-06; it does not query Void.
python3-pywhispercpp
Version 1.5.0, pinned to the same version install.sh
(PYWHISPERCPP_VERSION) and uv.lock pin — never newest upstream.
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 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. - CPU variants (
#44):GGML_BACKEND_DL=ONandGGML_CPU_ALL_VARIANTS=ONbuild one CPU library per instruction-set level (libggml-cpu-x64.sofor the plain x86-64 baseline, thenhaswell,skylakex,zen4, ...). ggml loads the best one the host supports, so a host with AVX2 runs AVX2 code, and the baseline variant ships everywhere.GGML_NATIVEstaysOFF: ggml refuses it withGGML_BACKEND_DL, and it keeps-march=nativeout of every variant (#938).patches/load-ggml-backends-beside-module.patchmakes the extension load the variants from its own directory: ggml's default search covers the interpreter's directory and the working directory, and neither holds them, so without the patch no CPU backend is registered. The install smoke checks thatlibggml-cpu-x64.soholds no AVX (ymm/zmm) code, thatlibggml-cpu-haswell.sodoes, that no variant carries host-native tuning (.GCC.command.line), and that the CPU and Vulkan backends load from the installed package. - Version (
#43):setup.pypasses no version tosetup(). The version comes from setuptools-scm, which the sdist declares as a build requirement, sohostmakedependsprovidespython3-setuptools_scmandSETUPTOOLS_SCM_PRETEND_VERSIONpins1.5.0without git metadata. Without setuptools-scm, setuptools writes0.0.0, which the app reads as an unknown binding version and then skips device selection. The install smoke asserts the installed package reports1.5.0. - Shared libraries:
BUILD_SHARED_LIBS=ONwith an$ORIGINrpath (asinstall.shdoes), so the extension resolves the whisper.cpp libraries installed beside it with noLD_LIBRARY_PATH. - Offline build: the sdist vendors whisper.cpp, pybind11, and the ggml sources; the build fetches nothing beyond the declared distfiles. The sdist carries no git metadata, which is why the version is pinned through setuptools-scm (see Version above).
voculinux
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 and Checksum
before changing version, _tag or checksum.
- Runtime deps are Void's own interpreter, GTK and desktop bindings,
the packaged Python libraries, the carried templates (
python3-pynput,python3-PyAudio,python3-pysocksandpython3-pywhispercpp; the audio binding and SOCKS module are version-constrained, see Version floors), and the helper tools the app shells out to: PipeWire (capture),wtype(direct typing on niri),wl-clipboard,xdotoolandpulseaudio-utils.ibusis not a dependency: it cannot inject on niri. Every name was verified to exist in current void-packages; the 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 install smoke fails the release if the payload carries model weights or wheels.
- Installed names: the
voculinux.desktopentry, the hicolor icons, and ausr/share/voculinux/packagedmarker the app reads to present package-manager updates. The fork's sdist already carries the final names, so the template installs them as they are; the payload smoke rejects any pre-rename leftovers in the OS-visible paths. - INSTALL.msg covers what xbps cannot express: the
inputgroup for the evdev hotkey backend, first-run model downloads, optional engines as manual additions (vosk, OpenAI whisper, Parakeet, faster-whisper, onnxruntime), the niri first run, the absentydotoolrung, and updating through xbps. - Update checker: packaged installs keep background checks as a
signal, but every update affordance (tray entry, notification, About
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.19.0 |
0.19.0 |
voculinux-0.19.0.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 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 build output and the published release notes both carry
checksum=<sha256>, and the pull request copies it into
packaging/void/srcpkgs/voculinux/template. Nothing commits to main.
Building
just void-smoke # the gate: build, sign with a throwaway key, install, smoke
just void-release v0.19.0 # build the release set, 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
packaging/void/publish-release.sh v0.19.0 --out-dir DIR --dry-run # every check, the planned calls, no request
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
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:
- the tag gate (
check-tag) and the committed template's version and_tag; - the canonical sdist, built from the working tree;
build: every template inPKGSorder (smoke.showns the list), linted withxlint, built withxbps-src, the sdist resolved byxbps-src show;sign: the packages indexed and signed, with no network;install: in a clean image with no key, an unsigned install is refused, the signed repository is trusted, Void's stockpython3-PyAudioandpython3-pysocksare installed, thenvoculinuxinstalls and upgrades both to the carried versions (see Version floors),voculinux --versionshows the version, the payload checks pass (desktop entry, icon, marker, no models, no pre-rename names, the Vulkan backend, the CPU variants with an AVX-free x86-64 baseline, no host-native CPU tuning), the installed pywhispercpp reports1.5.0, and the imports load (pynputthrough its dummy backend,evdev,giwith Gtk 3,pywhispercpp.model,pyaudio,socks, and the app's own modules, which keep their retained internal name).
tests/test_void_smoke_gate.py asserts offline that the gate stays wired to
every srcpkgs template.
Signed repository
packaging/void/build-release.sh release TAG --key-file FILE --out-dir DIR
is the whole release, and the only path that signs anything. The maintainer runs
it on the release host (see "Releasing" below); just void-release runs the same
command with publish/ as the out-dir.
Fail-closed, in this order, before any container starts:
- the tag must be exactly
vplus__version__, and the committed template must name that version and tag; - the checkout must be clean: no uncommitted or untracked change. The sdist is built
from the working tree, so anything else is not the tag. The commit it is built from
is recorded in
BUILT_FROM; - the key must be non-empty, valid, RSA 4096, and its public half must equal
repository-key.plistbyte 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.
Then three containers, each with only what it needs:
| 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 and DIR/BUILT_FROM (the tag and the commit it was built
from). A final check accepts only that set: nothing from musl, no other
architecture, every package signed, the key the pinned one.
The repository key
One RSA 4096 key signs everything. Its fingerprint, which clients pin:
fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25
- Public half: committed as
packaging/void/repository-key.plist(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.pyderives the fingerprint from this file 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 is kept in the maintainer's own backup,
and the release reads it as a file on tmpfs (
/dev/shm) that is removed afterwards. TheXBPS_SIGNING_KEYCI secret holds the same PEM text for the workflow, which is manual dispatch only. - Generating a new key (only if the key is ever replaced; clients then have to re-pin, so this is a release-notes event):
openssl genrsa -out xbps-sign-key.pem 4096
# back the key up somewhere safe, then store it as XBPS_SIGNING_KEY
openssl rsa -in xbps-sign-key.pem -pubout # the public half, to build the .plist from
- Checking a private key against the committed public key, before it is stored as the secret (both lines must print the same digest):
openssl rsa -in xbps-sign-key.pem -pubout 2>/dev/null | openssl md5
python3 -c "import plistlib,sys; sys.stdout.buffer.write(plistlib.load(open('packaging/void/repository-key.plist','rb'))['public-key'])" | openssl md5
Releasing: the local sequence
Pushing a tag starts nothing. .gitea/workflows/xbps-release.yml is manual dispatch
only, kept for a future x86_64 host-mode runner; its one runner today is ARM64 and
cannot build this release. The sequence is:
- Merge the release pull request to
main.origin/mainis then the commit to release. - Create and push the annotated tag on that commit:
git tag -a vX.Y.Z <sha> -m "Voculinux X.Y.Z", thengit push origin refs/tags/vX.Y.Z. Nevergit push --tags: the clone holds upstream tags. - Build in a clean checkout of the tag:
git worktree add /tmp/voculinux-vX.Y.Z vX.Y.Z, then, in that worktree,packaging/void/build-release.sh release vX.Y.Z --key-file /dev/shm/xbps-sign-key.pem --out-dir /path/to/out. Remove the key file when the run ends. - Dry-run the publish:
packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run. It checks that origin'svX.Y.Zpeels to the commit inBUILT_FROM, that the sdist matchesSDIST_SHA256, and that the client key is the committed one, then prints the calls it would make. It sends no request and reads no token. - Publish:
packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out. The tag's release gets the sdist, packages, signatures,FINGERPRINTand the client.plist, and the rollinglatestrelease that the client registration URL serves is replaced, with the repodata last. The token comes fromGITEA_TOKEN, or from thetealogin forhttps://git.bongbetic.com. It goes into a 0600 header file that is removed on exit and is never printed. - Post-release pull request: record the
checksum=the build printed in the template (section 3.5 ofdocs/RELEASE_PROCESS.md).
publish-release.sh and the workflow's publish step make the same API calls: the sdist,
packages, signatures and client key, then the repodata, to the tag's release and then to
latest. Any non-2xx response stops the publish. Publishing an older tag refreshes latest
with the older repository, so publish in version order.
Secret setup for the workflow, once: Gitea repository settings, Actions, Secrets, add
XBPS_SIGNING_KEY with the PEM private key. The job token must be allowed to create
releases and upload release assets.
Runner requirements (the release host)
The release runs on the maintainer's x86_64 Void host. The workflow's runs-on label
(bongbetic-ci) is ARM64 today and cannot run it. Requirements, which a future x86_64
host-mode runner would also need. It never uses Docker:
| 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 |
A cold build is long: the whisper.cpp Vulkan build is the long step.
Client setup
# as root on the Void system
curl -fsSL -o '/var/db/xbps/keys/fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25.plist' \
https://git.bongbetic.com/xavierk/voculinux/raw/branch/main/packaging/void/repository-key.plist
echo 'repository=https://git.bongbetic.com/xavierk/voculinux/releases/download/latest' > /etc/xbps.d/voculinux.conf
xbps-install -S voculinux
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 registration text stays correct, and
tests/test_void_release_build.py asserts the release path refuses to
publish under any other key.