From 4b7d57c077cb7621bb04140c49a7e6d60585509b Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:10:00 -0700 Subject: [PATCH] feat(packaging): build thin .deb and .rpm and attach them to releases (#913) * feat(packaging): build thin .deb and .rpm with nfpm and attach them to releases Adds packaging/native: an nfpm config that produces a .deb and an .rpm from the release wheel. The packages stay thin - Python, GTK, PyGObject and the typelibs are distro Depends; only pywhispercpp and pynput are vendored because the first is unpackaged on both distros and the second is missing on Fedora. The Debian 12 build image and the nfpm download are pinned in tool_checksums.txt. just native-packages builds both in the pinned container, and native-smoke installs the built package per distro: debian:12 and ubuntu:24.04 exercise the .deb, fedora:42 the .rpm, then each runs vocalinux --version plus an import smoke of the app and the vendored extension. unified-pipeline.yml gates on the same three-distro matrix, and release.yml attaches both arches to the release and covers them in SHA256SUMS and the provenance manifest. Refs #600 * fix(packaging): preserve launcher args and document Wayland input group The launcher joined $* into an unquoted exec, re-splitting file paths that contain spaces before argparse saw them. Exec "$@" directly and re-quote each argument for the inner shell of 'sg input -c'. On Wayland the packages could not grant the 'input' group membership evdev hotkeys need; name the usermod step and its caveat in INSTALL.md and the release notes. * test(packaging): add return types to the native packaging tests --------- Co-authored-by: Jatin K Malik --- .github/workflows/release.yml | 129 +++++++++- .github/workflows/unified-pipeline.yml | 78 ++++++ AGENTS.md | 5 +- README.md | 9 +- docs/INSTALL.md | 25 ++ docs/RELEASE_PROCESS.md | 11 +- justfile | 11 + packaging/native/build.sh | 163 ++++++++++++ packaging/native/container-build.sh | 11 + packaging/native/docker-build.sh | 26 ++ packaging/native/nfpm.yaml | 108 ++++++++ packaging/native/smoke-test.sh | 97 +++++++ packaging/native/tool_checksums.txt | 23 ++ tests/test_native_packaging.py | 338 +++++++++++++++++++++++++ 14 files changed, 1021 insertions(+), 13 deletions(-) create mode 100755 packaging/native/build.sh create mode 100755 packaging/native/container-build.sh create mode 100755 packaging/native/docker-build.sh create mode 100644 packaging/native/nfpm.yaml create mode 100755 packaging/native/smoke-test.sh create mode 100644 packaging/native/tool_checksums.txt create mode 100644 tests/test_native_packaging.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0439449..61ac2df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,6 +257,26 @@ jobs: ``` Still requires `xdotool`/`wtype`/`ydotool` on the host for text injection, same as the PyPI path. + ### Distro packages (.deb / .rpm) + Download the package matching your distro and CPU (`amd64`/`x86_64`, or `arm64`/`aarch64` + attached shortly after this release is published): + ```bash + # Debian / Ubuntu + sudo apt install ./vocalinux_*_amd64.deb + + # Fedora + sudo dnf install ./vocalinux-*.x86_64.rpm + ``` + Thin packages: Python, GTK, PyGObject and the AppIndicator typelib come from the + distro, and `apt`/`dnf` resolves them. Only `pywhispercpp` and `pynput` are + vendored (the first is packaged by neither distro, the second is missing on + Fedora). whisper.cpp runs on CPU in these packages; the Vulkan build is the + AppImage. A PPA and a COPR are not published yet. + + On Wayland the default Right Alt hotkey uses evdev: `sudo usermod -aG input $USER`, + then log out and back in (the `input` group can read every keyboard and pointer + device; X11 needs nothing). + ${{ steps.flatpak_notes.outputs.flatpak }} ### Snap @@ -353,6 +373,104 @@ jobs: --repo ${{ github.repository }} \ --clobber + # The .deb and .rpm are thin: they depend on the distro's Python, GTK, + # PyGObject and typelibs and vendor only pywhispercpp and pynput (#600). + # nfpm builds both from one config inside the pinned Debian 12 image, and + # the same wheel PyPI ships is the payload. + build-native: + name: Build native packages (x86_64) + needs: build-and-release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Extract version from tag + id: get_version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + # Same reason the aarch64 AppImage downloads the artifact: one wheel, + # everywhere, or the checksums cannot describe what the packages hold. + - name: Download the built distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-dist + path: dist + + - name: Build .deb and .rpm + run: | + bash packaging/native/docker-build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist + ls -la dist/*.deb dist/*.rpm + + - name: Upload the x86_64 packages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-x86_64 + path: | + dist/*.deb + dist/*.rpm + if-no-files-found: error + retention-days: 7 + + - name: Attach to release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ github.ref_name }}" \ + dist/*.deb dist/*.rpm \ + --repo ${{ github.repository }} \ + --clobber + + build-native-arm64: + name: Build native packages (aarch64) + needs: build-and-release + runs-on: ubuntu-24.04-arm + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Extract version from tag + id: get_version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Download the built distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-dist + path: dist + + - name: Build .deb and .rpm (aarch64) + run: | + bash packaging/native/docker-build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist + ls -la dist/*.deb dist/*.rpm + + - name: Upload the aarch64 packages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-aarch64 + path: | + dist/*.deb + dist/*.rpm + if-no-files-found: error + retention-days: 7 + + - name: Attach to release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ github.ref_name }}" \ + dist/*.deb dist/*.rpm \ + --repo ${{ github.repository }} \ + --clobber + # Pins match .github/workflows/flatpak.yml. These jobs start with the tag # (no needs) so they overlap the wheel / x86_64 AppImage job. The builder # image is Freedesktop SDK, not a GitHub-hosted runner: no `gh`, so attach @@ -656,12 +774,13 @@ jobs: --repo ${{ github.repository }} \ --clobber - # Runs last because the aarch64 AppImage, both Flatpak bundles, and the snap - # land after the release is created: a checksum manifest that covers some of - # the files is worse than none, since the missing one looks like the tampered one. + # Runs last because the aarch64 AppImage, both Flatpak bundles, the native + # packages, and the snap land after the release is created: a checksum + # manifest that covers some of the files is worse than none, since the + # missing one looks like the tampered one. publish-checksums: name: Checksums and Provenance - needs: [build-and-release, build-appimage-arm64, build-flatpak-amd64, build-flatpak-arm64, attach-flatpak, attach-snap] + needs: [build-and-release, build-appimage-arm64, build-native, build-native-arm64, build-flatpak-amd64, build-flatpak-arm64, attach-flatpak, attach-snap] runs-on: ubuntu-latest permissions: contents: write @@ -681,7 +800,7 @@ jobs: ls -la # Bare names, so `sha256sum -c` works in whatever directory the user # downloaded the release into. - sha256sum -- *.whl *.tar.gz *.AppImage *.flatpak *.snap | tee SHA256SUMS + sha256sum -- *.whl *.tar.gz *.AppImage *.deb *.rpm *.flatpak *.snap | tee SHA256SUMS # Attesting from the manifest rather than a second glob: the file we # publish and the set we attest cannot drift apart. diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index 61d6207..914c50b 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -24,6 +24,7 @@ jobs: web: ${{ steps.filter.outputs.web }} python: ${{ steps.filter.outputs.python }} aur: ${{ steps.filter.outputs.aur }} + native: ${{ steps.filter.outputs.native }} deploy_website: ${{ steps.deploy_check.outputs.deploy }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -66,6 +67,13 @@ jobs: aur: - 'packaging/aur/**' - '.github/workflows/unified-pipeline.yml' + # Same carve-out for the .deb/.rpm gate: nfpm.yaml mirrors + # pyproject.toml's dependencies against distro package names, so a + # native-only change runs its own build and smoke without the + # python matrix. + native: + - 'packaging/native/**' + - '.github/workflows/unified-pipeline.yml' - name: Check if website should deploy id: deploy_check env: @@ -360,6 +368,76 @@ jobs: archlinux:latest \ bash /repo/packaging/aur/build-test.sh + # The .deb and .rpm exist to be installed and run, so the gate does exactly + # that: build both in the pinned Debian 12 image release.yml uses, then + # install them in distro containers and run vocalinux --version plus an + # import smoke. debian:12 and ubuntu:24.04 exercise the .deb's dependency + # list (and the vendored cp311/cp312 wheels), fedora:42 the .rpm's. + native-build: + name: 📦 Build native packages (x86_64) + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.python == 'true' || needs.changes.outputs.native == 'true' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + cache: 'pip' + + - name: Build wheel + run: | + python -m pip install --upgrade pip build + python -m build + ls -la dist/ + + - name: Build .deb and .rpm + run: | + VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' src/vocalinux/version.py) + WHEEL=$(ls dist/*.whl) + bash packaging/native/docker-build.sh "$WHEEL" "$VERSION" dist + ls -la dist/*.deb dist/*.rpm + + - name: Upload the x86_64 packages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-x86_64 + path: | + dist/*.deb + dist/*.rpm + retention-days: 7 + + native-smoke: + name: 🐧 Install native package (${{ matrix.distro }}) + runs-on: ubuntu-latest + needs: [changes, native-build] + if: needs.changes.outputs.python == 'true' || needs.changes.outputs.native == 'true' + strategy: + fail-fast: false + matrix: + distro: + - debian:12 + - ubuntu:24.04 + - fedora:42 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download the packages built above + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: native-x86_64 + path: dist + + - name: Install and smoke on ${{ matrix.distro }} + run: | + docker run --rm \ + -v "$PWD/dist:/out:ro" \ + -v "$PWD/packaging/native:/pk:ro" \ + "${{ matrix.distro }}" \ + bash /pk/smoke-test.sh + web-build: name: 🌐 Build Website runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index b042b6e..1ce1bb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,8 @@ just flatpak-deps-check # fail if that manifest is behind the export just model-checksums # refresh pinned model digests after adding a model just appimage # build the AppImage in its pinned base image (needs docker) just appimage-boot fedora:42 # boot that AppImage in a distro container +just native-packages # build the .deb and .rpm in the pinned base image (needs docker) +just native-smoke fedora:42 # install the built package in a distro container just aur-gate # build the AUR PKGBUILD on current Arch (needs docker) just install-gate debian:12 # run install.sh unattended in a distro container just verify-release # check a published release as published (needs gh) @@ -128,6 +130,7 @@ Website: `web/AGENTS.md`, `web/PRODUCT.md`, `web/DESIGN.md`. Do not duplicate si | AppImage PyGObject | Pinned separately in `requirements/appimage.in`, and below 3.52: the AppImage bundles its own interpreter and builds PyGObject against the base image's girepository-1.0, while uv.lock's 3.56 needs girepository-2.0 (glib 2.80+) | | AppImage build inputs | Base image, tooling, interpreter, shaderc and the Vulkan headers are pinned in `packaging/appimage/tool_checksums.txt`. Build with `just appimage` (docker) — building on the host ships the host's glibc, which is what kept the AppImage off Debian 12 | | AppImage boot matrix | `packaging/appimage/boot-test.sh` runs the finished AppImage in distro containers, and the matrix in `unified-pipeline.yml` must keep distros both older and newer than the build image: the old ones prove the glibc floor, the new ones catch a bundle that breaks the host binaries it spawns. A distro added there needs its package recipe in the script — `tests/test_appimage_packaging.py` checks both | +| Native packages (.deb / .rpm) | Thin packages built by nfpm in the pinned Debian 12 image (`packaging/native/tool_checksums.txt` pins the image and the nfpm releases). Python, GTK and PyGObject come from the distro; only `pywhispercpp` and `pynput` are vendored under `/usr/lib/vocalinux/vendor` (unpackaged on Debian/Fedora). `packaging/native/smoke-test.sh` installs the package per-distro and runs `vocalinux --version` plus an import smoke — `tests/test_native_packaging.py` keeps the deps lists, vendored set and CI matrix consistent offline | | Flatpak deps | `packaging/flatpak/python3-dependencies.yaml` is generated from `requirements/runtime.txt` by `scripts/sync_flatpak_deps.py` (`just flatpak-deps`, which `just lock` runs itself). It resolves each artifact by the digest uv already recorded, so the Flatpak downloads the bytes in `uv.lock`. The project installs there with `pip3 install --no-deps`, so nothing inside the build evaluates `pyproject.toml` — `tests/test_flatpak_packaging.py` checks the invariant offline | | Speech models | Verified against digests pinned in `src/vocalinux/utils/model_checksums.txt` before install, by both `install.sh` and the runtime downloaders. **Fails closed** — an unpinned model is refused. Regenerate with `just model-checksums` (never by hand); `tests/test_model_checksums.py` fails if it falls behind. whisper.cpp URLs use a pinned Hugging Face commit, never `main` | @@ -169,7 +172,7 @@ src/vocalinux/ └── resources/ # SVG icons + WAV cues (also repo resources/) ``` -Also: `tests/`, `docs/`, `packaging/` (AppImage, AUR, Flatpak), `scripts/`, +Also: `tests/`, `docs/`, `packaging/` (AppImage, AUR, Flatpak, native .deb/.rpm), `scripts/`, `install.sh` (bootstrap and orchestration), `install.d/` (sourced installer modules), `uninstall.sh`, `web/`. `scripts/distro-package-map.yaml` owns installer package names; `install.d/package_map.sh` is generated by `just distro-packages`. diff --git a/README.md b/README.md index a360797..1fcaa67 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ No Voca account is required. Models download once. After that, speech-to-text st - **Toggle or push-to-talk**: New installs default to hold Right Alt; existing configs keep their shortcut - **System tray + settings**: Searchable sidebar, Speech Model simple setup with Advanced as an island, status icons, audio feedback - **Start on login**: XDG autostart (desktop session, not a systemd service) -- **Packaging**: install script, AppImage, AUR, PyPI, Snap (`--edge`), Flatpak (release bundles and local build; not on Flathub) +- **Packaging**: install script, `.deb` / `.rpm`, AppImage, AUR, PyPI, Snap (`--edge`), Flatpak (release bundles and local build; not on Flathub) ## Screenshots @@ -164,6 +164,10 @@ See [docs/AUR.md](docs/AUR.md). Download the `x86_64` or `aarch64` AppImage from [Releases](https://github.com/VocaHQ/vocalinux/releases), mark it executable, and run it. Built against glibc 2.35 (Debian 12+, Ubuntu 22.04+, Fedora 36+, Arch, Tumbleweed). Host text-injection tools (`xdotool` on X11; `wtype` / `ydotool` / clipboard tools on Wayland) are still required. Current AppImages rebuild whisper.cpp with Vulkan and use the host GPU driver. Prefer the installer when you want system deps, a CUDA build, and models set up automatically. +### Distro packages (.deb / .rpm) + +Download the `.deb` (Debian 12+, Ubuntu 24.04+) or `.rpm` (Fedora) matching your CPU from [Releases](https://github.com/VocaHQ/vocalinux/releases) and install with `sudo apt install ./vocalinux_*.deb` or `sudo dnf install ./vocalinux-*.rpm`. Thin packages: Python and GTK come from the distro. No auto-update; CPU whisper.cpp. Details in [docs/INSTALL.md](docs/INSTALL.md). + ### Flatpak (any distro) Install and auto-update via the self-hosted VocaHQ remote (`flatpak update` picks up each release): @@ -351,12 +355,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the two-venv layout, `just` recipes, ## Roadmap -Shipped: graphical settings, multi-language support, whisper.cpp default, Vulkan GPU, Wayland/IBus, Flatpak packaging, AppImage, in-app update checker, Parakeet and Faster Whisper engines, Snap recipe. +Shipped: graphical settings, multi-language support, whisper.cpp default, Vulkan GPU, Wayland/IBus, Flatpak packaging, AppImage, `.deb` / `.rpm` packages, in-app update checker, Parakeet and Faster Whisper engines, Snap recipe. Planned: - [ ] Application-specific voice commands -- [ ] Debian/Ubuntu package (`.deb`) - [ ] User-customizable voice command map - [ ] Flathub publication (not currently listed; see #167) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ae2865e..4ba9684 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -6,6 +6,7 @@ How to install Vocalinux on Linux. Short overview: [project README](../README.md |------|-------------| | [Recommended installer](#recommended-installer) | Most users | | [AppImage](#appimage) | Portable binary; no system package install | +| [Distro packages (.deb / .rpm)](#distro-packages-deb--rpm) | Debian 12+, Ubuntu 24.04+, Fedora | | [AUR](#arch-linux-aur) | Arch / Manjaro | | [Flatpak](#flatpak) | Release `.flatpak` or local build | | [Snap](#snap-ubuntu-snap-store) | Ubuntu Snap Store (`--edge`) or GitHub `.snap` | @@ -91,6 +92,30 @@ Built against glibc 2.35, so it starts on Debian 12+, Ubuntu 22.04+, Fedora 36+, Still needs host text-injection tools (`xdotool` on X11; `wtype` / `ydotool` / clipboard tools on Wayland). Current AppImages rebuild whisper.cpp with Vulkan and use the host GPU driver (`vulkaninfo --summary`). Prefer the installer when you want system deps, a CUDA build, and models set up automatically. +## Distro packages (.deb / .rpm) + +From [GitHub Releases](https://github.com/VocaHQ/vocalinux/releases), pick the file matching your distro and CPU: + +```bash +# Debian / Ubuntu (amd64; arm64 files are attached too) +sudo apt install ./vocalinux_*_amd64.deb + +# Fedora (x86_64; aarch64 files are attached too) +sudo dnf install ./vocalinux-*.x86_64.rpm +``` + +These are thin packages: Python, GTK, PyGObject and the AppIndicator typelib come from the distribution, and `apt`/`dnf` resolves them. `xdotool`, `wtype`, `ydotool`, the clipboard tools and IBus are `Recommends`, matching the optional feature set of the other paths. Only `pywhispercpp` and `pynput` are vendored inside the package — `pywhispercpp` is packaged by neither distro and `pynput` is missing on Fedora. They land under `/usr/lib/vocalinux/vendor`, on `sys.path` via the `vocalinux` launcher. + +The `.deb` needs a distro that ships Python 3.11+: Debian 12+ and Ubuntu 24.04+ (Ubuntu 22.04 ships 3.10). whisper.cpp runs on CPU in these packages — the PyPI wheel, not the Vulkan build the AppImage carries. There is no auto-update: a new release is a new package install. A PPA and a COPR are not published yet. + +On Wayland, the default Right Alt shortcut reads keyboard devices through evdev, so your user needs `input` group membership or the hotkey is silently disabled: + +```bash +sudo usermod -aG input $USER # then log out and back in +``` + +Joining `input` grants read access to every keyboard and pointer device — X11 needs none of this. The launcher re-execs through `sg input` when `/etc/group` already lists the membership but the current session has not picked it up, so an `usermod` between package install and next login applies without a reboot. The source installer does this step for you; the packages cannot. + ## Arch Linux (AUR) ```bash diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 31b2a36..f1b5dea 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -168,7 +168,8 @@ Use these rules for every GitHub Release body (and for the draft pasted into the 5. `## Bug Fixes` - group by area (IBus, Installer, AUR, Text injection, …) 6. Optional: `## Improvements`, `## Docs`, `## Packaging` 7. `## Thanks` - external PR authors and issue reporters by `@handle` -8. `## Install / Upgrade` - `install.sh`, AUR, PyPI, **AppImage**, Flatpak, Snap +8. `## Install / Upgrade` - `install.sh`, AUR, PyPI, **AppImage**, Flatpak, Snap, + `.deb` / `.rpm` (Store channels plus GitHub `.snap` sideload when Store review is pending) 9. `### Verifying what you downloaded` (required, and easy to lose). `release.yml` generates it, with `sha256sum -c --ignore-missing SHA256SUMS` and @@ -384,7 +385,8 @@ After pushing the tag, the GitHub Actions workflow will automatically: 2. Build and attach AppImages for x86_64 and aarch64, both from that same wheel 3. Create a GitHub Release with auto-generated notes 4. Attach `SHA256SUMS` covering every GitHub asset (wheel, sdist, both AppImages, - both Flatpaks, the amd64 snap) and generate build provenance from that + both Flatpaks, the amd64 snap, the `.deb` and `.rpm` for both arches) and + generate build provenance from that manifest (runs after the aarch64 AppImage, both Flatpaks, and the snap land, so a partial manifest never gets published) 5. Publish to PyPI via trusted publishing @@ -407,8 +409,9 @@ Monitor at: https://github.com/VocaHQ/vocalinux/actions - [ ] Verify GitHub Release was created correctly - [ ] Verify `SHA256SUMS` is attached and lists every GitHub asset (wheel, sdist, - both AppImages, both Flatpaks, the amd64 snap) - the release notes tell - users to run `sha256sum -c` against it + both AppImages, both Flatpaks, the amd64 snap, the `.deb` and `.rpm` for + both arches) - the release notes tell users to run `sha256sum -c` + against it - [ ] If the Store held the snap for `uinput` review, confirm the GitHub `.snap` is still attached and the notes document `snap install --dangerous` - [ ] After candidate QA (install, tray, mic, model download, typing into a real diff --git a/justfile b/justfile index 07ffb2c..dd8edc7 100644 --- a/justfile +++ b/justfile @@ -105,6 +105,17 @@ appimage: build appimage-boot distro="debian:12": docker run --rm -v "$PWD/dist:/dist:ro" -v "$PWD/packaging/appimage:/pk:ro" {{distro}} bash /pk/boot-test.sh "/dist/$(basename "$(ls dist/*.AppImage)")" +# Build the .deb and .rpm in the pinned container, as release.yml does (needs +# docker). Thin packages: the distro supplies Python, GTK and the typelibs; +# only pywhispercpp and pynput are vendored. +native-packages: build + bash packaging/native/docker-build.sh dist/*.whl "$(grep -oP '__version__\s*=\s*"\K[^"]+' src/vocalinux/version.py)" dist + +# Install the built package in a matching distro container, as the CI matrix +# does: debian:12 / ubuntu:24.04 exercise the .deb, fedora:42 the .rpm. +native-smoke distro="debian:12": + docker run --rm -v "$PWD/dist:/out:ro" -v "$PWD/packaging/native:/pk:ro" {{distro}} bash /pk/smoke-test.sh + # Build the AUR package from this checkout on current Arch, as the CI gate # does. Answers "does this commit build on Arch" — the tag tarball source= is # swapped for a git archive of HEAD (needs docker). diff --git a/packaging/native/build.sh b/packaging/native/build.sh new file mode 100755 index 0000000..4da4ecd --- /dev/null +++ b/packaging/native/build.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Build vocalinux__.deb and vocalinux-..rpm. +# +# Usage: build.sh [output-dir] +# version release version, e.g. 0.18.0 or 0.19.0-beta +# +# CI and releases run this inside docker-build.sh's pinned Debian 12 image; +# it works on any host with bash, python3 + pip, curl and tar. The packages +# stay thin on purpose (#600): the interpreter, GTK, PyGObject and the gi +# typelibs come from the distribution, and so does every Python dependency +# the distros package. Only pywhispercpp and pynput are vendored — the first +# is unpackaged on both distros and the second is missing from Fedora. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../.." && pwd)" +PINS="$REPO_ROOT/packaging/native/tool_checksums.txt" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +WHEEL="$(readlink -f "${1:?usage: build.sh [outdir]}")" +RAW_VERSION="${2:?usage: build.sh [outdir]}" +OUTDIR="$(mkdir -p "${3:-dist}" && cd "${3:-dist}" && pwd)" +[ -f "$WHEEL" ] || fail "wheel not found: $WHEEL" + +# Split "0.19.0-beta" into version + prerelease; nfpm renders the prerelease +# as a "~beta" suffix, which orders before the stable release in both dpkg +# and rpm. An empty prerelease expands to nothing. +VERSION="${RAW_VERSION#v}" +PRERELEASE="" +case "$VERSION" in + *-*) + PRERELEASE="${VERSION#*-}" + VERSION="${VERSION%%-*}" + ;; +esac + +case "$(uname -m)" in + x86_64) NFPM_PIN="nfpm-x86_64"; NFPM_ARCH="amd64" ;; + aarch64|arm64) NFPM_PIN="nfpm-aarch64"; NFPM_ARCH="arm64" ;; + *) fail "unsupported architecture $(uname -m)" ;; +esac + +TOOLS="$(mktemp -d)" +trap 'rm -rf "$TOOLS"' EXIT + +fetch_pinned() { + local name="$1" dest="$2" url sum + sum="$(awk -v n="$name" '$1==n && $2=="sha256" {print $3}' "$PINS")" + url="$(awk -v n="$name" '$1==n && $2=="sha256" {print $4}' "$PINS")" + [ -n "$sum" ] && [ -n "$url" ] || fail "no sha256 pin for $name in $PINS" + curl -fSL --retry 3 -o "$dest" "$url" + echo "$sum $dest" | sha256sum -c - >/dev/null +} + +# nfpm is a single static binary inside the release tarball; unpack just it. +fetch_pinned "$NFPM_PIN" "$TOOLS/nfpm.tar.gz" +tar -xzf "$TOOLS/nfpm.tar.gz" -C "$TOOLS" nfpm +NFPM="$TOOLS/nfpm" + +PAYLOAD="$TOOLS/payload" +DOC="$TOOLS/pkg-doc" +install -d \ + "$PAYLOAD/usr/lib/vocalinux/app" \ + "$PAYLOAD/usr/lib/vocalinux/vendor" \ + "$PAYLOAD/usr/bin" \ + "$PAYLOAD/usr/share/applications" \ + "$PAYLOAD/usr/share/icons/hicolor/scalable/apps" \ + "$DOC" + +# --- application payload ------------------------------------------------ +# The wheel is a zip; -m zipfile lays out vocalinux/ and its dist-info +# exactly where pip --target would, without pip's EXTERNALLY-MANAGED refusal +# on Debian 12. +python3 -m zipfile -e "$WHEEL" "$PAYLOAD/usr/lib/vocalinux/app/" + +# --- vendored wheels ----------------------------------------------------- +# Download each supported CPython's pywhispercpp wheel — it has no abi3 tag, +# so there is one wheel per interpreter — plus the pure-Python pynput, per +# the hashes pinned in requirements/runtime.txt, and merge them flat into +# the vendor dir. The extension filenames carry their tag +# (_pywhispercpp.cpython-311-*.so next to .cpython-313-*.so) so all four +# coexist, and the flat layout reproduces what pip would place in +# site-packages, which is what utils/pywhispercpp_loader.py scans for. +# +# VENDOR_PYS tracks requires-python in pyproject.toml and the CI matrix. +VENDOR_PYS="3.11 3.12 3.13 3.14" +PINS_VENDOR="$TOOLS/vendor-requirements.txt" +awk '/^(pywhispercpp|pynput)==/{p=1} p{print; if ($0 !~ /\\$/) p=0}' \ + "$REPO_ROOT/requirements/runtime.txt" > "$PINS_VENDOR" +grep -q "^pywhispercpp==" "$PINS_VENDOR" || fail "pywhispercpp pin not found in requirements/runtime.txt" +grep -q "^pynput==" "$PINS_VENDOR" || fail "pynput pin not found in requirements/runtime.txt" + +for PYV in $VENDOR_PYS; do + mkdir -p "$TOOLS/wheels/$PYV" + pip3 download --require-hashes --no-deps --only-binary=:all: \ + --python-version "$PYV" -r "$PINS_VENDOR" -d "$TOOLS/wheels/$PYV" >/dev/null +done +for VENDOR_WHEEL in "$TOOLS"/wheels/*/*.whl; do + python3 -m zipfile -e "$VENDOR_WHEEL" "$PAYLOAD/usr/lib/vocalinux/vendor/" +done + +# --- launchers ----------------------------------------------------------- +# Same environment install.sh's wrapper establishes: the vendored +# pywhispercpp.libs on LD_LIBRARY_PATH, user site-packages off so they cannot +# shadow the distro modules, and sg input so evdev hotkeys work on Wayland +# when the user is already in the input group but the session has not picked +# up the membership yet. vocalinux-gui exists because the wheel declares a +# gui-script of that name; on Linux it runs the same entry. +write_launcher() { + local path="$1" + cat > "$path" <<'LAUNCHER_EOF' +#!/bin/sh +# Launcher for the distro-packaged Vocalinux. The vendored wheels (the +# pywhispercpp/pynput copies Debian and Fedora do not ship) live under +# /usr/lib/vocalinux/vendor with the app itself under .../app; the loader in +# utils/pywhispercpp_loader.py finds the bundled .so files from sys.path. +PYTHONNOUSERSITE=1 +VOCALINUX_VENDOR="/usr/lib/vocalinux/vendor" +if [ -d "$VOCALINUX_VENDOR/pywhispercpp.libs" ]; then + LD_LIBRARY_PATH="$VOCALINUX_VENDOR/pywhispercpp.libs${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export LD_LIBRARY_PATH +fi +export PYTHONNOUSERSITE PYTHONPATH="/usr/lib/vocalinux/app:$VOCALINUX_VENDOR${PYTHONPATH:+:$PYTHONPATH}" +if grep -q "^input:.*\b$(whoami)\b" /etc/group 2>/dev/null \ + && ! groups | grep -q "\binput\b" && command -v sg >/dev/null 2>&1; then + # sg(1) takes a single command string, so each argument is re-quoted for + # the inner shell; joining $* would re-split paths containing spaces. + QUOTED_ARGS="" + for arg in "$@"; do + arg="$(printf %s "$arg" | sed "s/'/'\\\\''/g")" + QUOTED_ARGS="$QUOTED_ARGS '$arg'" + done + exec sg input -c "/usr/bin/python3 -m vocalinux.main$QUOTED_ARGS" +fi +exec /usr/bin/python3 -m vocalinux.main "$@" +LAUNCHER_EOF + chmod 755 "$path" +} +write_launcher "$PAYLOAD/usr/bin/vocalinux" +write_launcher "$PAYLOAD/usr/bin/vocalinux-gui" + +# --- desktop entry, icon, license ---------------------------------------- +install -Dm644 "$REPO_ROOT/vocalinux.desktop" \ + "$PAYLOAD/usr/share/applications/vocalinux.desktop" +install -Dm644 "$REPO_ROOT/resources/icons/scalable/vocalinux.svg" \ + "$PAYLOAD/usr/share/icons/hicolor/scalable/apps/vocalinux.svg" +install -Dm644 "$REPO_ROOT/LICENSE" "$DOC/LICENSE" + +# --- package ------------------------------------------------------------- +# nfpm resolves contents src paths relative to its working directory, so run +# it from $TOOLS where ./payload and ./pkg-doc live. +cd "$TOOLS" +VOCA_ARCH="$NFPM_ARCH" VOCA_VERSION="$VERSION" VOCA_PRERELEASE="$PRERELEASE" \ + "$NFPM" package --config "$REPO_ROOT/packaging/native/nfpm.yaml" \ + --packager deb --target "$OUTDIR/" +VOCA_ARCH="$NFPM_ARCH" VOCA_VERSION="$VERSION" VOCA_PRERELEASE="$PRERELEASE" \ + "$NFPM" package --config "$REPO_ROOT/packaging/native/nfpm.yaml" \ + --packager rpm --target "$OUTDIR/" + +ls -la "$OUTDIR"/*.deb "$OUTDIR"/*.rpm diff --git a/packaging/native/container-build.sh b/packaging/native/container-build.sh new file mode 100755 index 0000000..064d59b --- /dev/null +++ b/packaging/native/container-build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Inside the pinned Debian 12 image: install the tools build.sh needs, then +# build both packages. Called by docker-build.sh, never directly on a host. +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends \ + ca-certificates curl python3 python3-pip xz-utils + +exec bash "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/build.sh" "$@" diff --git a/packaging/native/docker-build.sh b/packaging/native/docker-build.sh new file mode 100755 index 0000000..1209db0 --- /dev/null +++ b/packaging/native/docker-build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Run the native package build inside the pinned Debian 12 image. +# +# Usage: docker-build.sh [output-dir] +# +# This is what CI calls and what you should call locally, so that what you +# get is what CI gets: the base image pins the pip that resolves the vendored +# wheels, and building on the host instead means shipping whatever that host +# happens to run. Debian 12 is old enough that its pip accepts every manylinux +# tag in the pinned wheels. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../.." && pwd)" +IMAGE="$(awk '$1=="base-image" {print $3}' "$REPO_ROOT/packaging/native/tool_checksums.txt")" +if [ -z "$IMAGE" ]; then + echo "No base-image pin in packaging/native/tool_checksums.txt" >&2 + exit 1 +fi + +# The wheel and output dir are given as host paths; the repo is mounted at +# the same place it lives, so relative arguments keep working inside. The +# container needs python3, pip, curl and tar — install-build-tools.sh adds +# them before handing off to build.sh. +exec docker run --rm \ + -v "$REPO_ROOT:$REPO_ROOT" -w "$REPO_ROOT" \ + "$IMAGE" bash packaging/native/container-build.sh "$@" diff --git a/packaging/native/nfpm.yaml b/packaging/native/nfpm.yaml new file mode 100644 index 0000000..129ea74 --- /dev/null +++ b/packaging/native/nfpm.yaml @@ -0,0 +1,108 @@ +# One nfpm config for both the .deb and the .rpm. +# +# Do not run nfpm on this file directly: build.sh assembles the payload tree +# this config expects (./payload and ./pkg-doc next to the directory it runs +# nfpm from) and sets the VOCA_* variables the fields below expand. +# +# Packaging stays thin on purpose (#600): the interpreter, GTK, PyGObject and +# the gi typelibs come from the distribution, and so do every Python +# dependency the distros package. Only pywhispercpp and pynput are vendored — +# pywhispercpp is not packaged by Debian or Fedora at all, and pynput is +# missing from Fedora — both under /usr/lib/vocalinux/vendor/, on sys.path via +# the launcher in /usr/bin/vocalinux. Vendoring the pywhispercpp wheel per +# supported CPython is what keeps the package free of a bundled interpreter. +# +# Version: VOCA_VERSION is the tag without its leading v and without a +# prerelease suffix; VOCA_PRERELEASE carries that suffix (empty for stable). +# nfpm maps a prerelease to 0.19.0~beta-1, which orders before the stable +# 0.19.0-1 in both formats. + +name: vocalinux +arch: ${VOCA_ARCH} +platform: linux +version: ${VOCA_VERSION} +release: 1 +prerelease: ${VOCA_PRERELEASE} +section: utils +priority: optional +maintainer: "Jatin K Malik " +description: | + Free, offline voice dictation for Linux. Hold the push-to-talk key, + transcribe speech locally (whisper.cpp by default), and type into the + focused window. GTK, PyGObject and the interpreter come from the + distribution; pywhispercpp and pynput are vendored under + /usr/lib/vocalinux/vendor because neither distro packages them. +homepage: https://github.com/VocaHQ/vocalinux +license: AGPL-3.0-only + +contents: + # The staged payload tree: /usr/lib/vocalinux/{app,vendor}, the launchers in + # /usr/bin, the desktop file and the hicolor icon. build.sh writes it. + - src: ./payload/ + dst: / + type: tree + + # License placement differs between the formats: Debian policy wants the + # copyright file under /usr/share/doc, Fedora marks /usr/share/licenses. + - src: ./pkg-doc/LICENSE + dst: /usr/share/doc/vocalinux/copyright + packager: deb + - src: ./pkg-doc/LICENSE + dst: /usr/share/licenses/vocalinux/LICENSE + packager: rpm + +overrides: + deb: + depends: + - python3 (>= 3.11) + - python3-gi + - python3-gi-cairo + - gir1.2-gtk-3.0 + - gir1.2-ayatanaappindicator3-0.1 + - python3-numpy + - python3-requests + - python3-tqdm + - python3-platformdirs + - python3-psutil + - python3-evdev + - python3-pyaudio + - python3-xlib + recommends: + # Host tools the injectors fall back through; install.d documents the + # same set for install.sh. The app runs without them on degraded paths. + - xdotool + - wtype + - ydotool + - wl-clipboard + - xclip + - xsel + # IBus engine (Wayland text injection): typelib plus the daemon itself. + - gir1.2-ibus-1.0 + - ibus + # Desktop notifications. + - gir1.2-notify-0.7 + rpm: + depends: + - python3 >= 3.11 + - python3-gobject + - python3-cairo + - gtk3 + - libayatana-appindicator-gtk3 + - python3-numpy + - python3-requests + - python3-tqdm + - python3-platformdirs + - python3-psutil + - python3-evdev + - python3-pyaudio + - python3-xlib + recommends: + - xdotool + - wtype + - ydotool + - wl-clipboard + - xclip + - xsel + # ibus carries the IBus-1.0 typelib itself on Fedora. + - ibus + - libnotify diff --git a/packaging/native/smoke-test.sh b/packaging/native/smoke-test.sh new file mode 100755 index 0000000..471aa81 --- /dev/null +++ b/packaging/native/smoke-test.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Install the built native package inside its distro container and prove the +# app it ships actually runs. +# +# Usage (mount the package dir at /out and this script's dir at /pk): +# docker run --rm -v "$PWD/dist:/out:ro" -v "$PWD/packaging/native:/pk:ro" \ +# debian:12 bash /pk/smoke-test.sh +# or: just native-smoke debian:12 +# +# What this proves: the package manager accepts the package and resolves its +# declared dependencies, the /usr/bin launcher runs, and the import graph — +# gi, GTK, the app modules and the vendored wheels — resolves against the +# distro interpreter. It is the native-package counterpart of install-gate +# and the AppImage boot matrix: Debian and Ubuntu exercise the .deb, Fedora +# the .rpm. +# +# What it does not prove: dictation. No container here has audio or a +# display, and the point is the package, not the app — the app's own tests +# cover behaviour. +set -euo pipefail + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[ -r /etc/os-release ] || fail "/etc/os-release missing — not a distro container?" +# shellcheck disable=SC1091 +. /etc/os-release + +export DEBIAN_FRONTEND=noninteractive +case "$ID" in + debian|ubuntu) + PKG="$(find /out -name '*.deb' -print -quit)" + [ -n "$PKG" ] || fail "no .deb under /out" + apt-get update + apt-get install -y "$PKG" + ;; + fedora) + PKG="$(find /out -name '*.rpm' -print -quit)" + [ -n "$PKG" ] || fail "no .rpm under /out" + dnf install -y "$PKG" + ;; + *) + fail "no smoke recipe for distro ID '$ID' — add one alongside the dependency list it is meant to exercise" + ;; +esac + +echo "== installed: $PKG" + +# The launcher must answer: --version is an argparse action that exits +# before check_dependencies() touches GTK, so it proves the wrapper, the +# PYTHONPATH layout and the entry module's import chain — the dependency +# typelibs are exercised by the import smoke below. +vocalinux --version +vocalinux-gui --version + +# Import smoke in the shape of the AUR gate: the app modules, the vendored +# pywhispercpp wheel, and the distro gi/GTK typelibs, all through the same +# interpreter and environment the launcher sets up. pynput is only checked +# for presence: its __init__ selects a display backend and raises ImportError +# with no DISPLAY, which is why the app guards it with PYNPUT_AVAILABLE. +PYTHONPATH="/usr/lib/vocalinux/app:/usr/lib/vocalinux/vendor" \ +PYTHONNOUSERSITE=1 \ +LD_LIBRARY_PATH="/usr/lib/vocalinux/vendor/pywhispercpp.libs" \ +/usr/bin/python3 - <<'PY' +import importlib +import importlib.util +import sys +from pathlib import Path + +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) + +spec = importlib.util.find_spec("pynput") +assert spec and spec.origin and spec.origin.startswith("/usr/lib/vocalinux/vendor/"), \ + f"vendored pynput not found on sys.path: {spec}" + +tag = f"cpython-{sys.version_info.major}{sys.version_info.minor}-" +exts = [p.name for p in Path("/usr/lib/vocalinux/vendor").glob("_pywhispercpp.*.so")] +assert any(tag in name for name in exts), \ + f"no _pywhispercpp extension for {tag[:-1]} among {exts}" + +print("import-smoke OK:", ", ".join(MODULES)) +PY + +echo "SMOKE-TEST PASSED" diff --git a/packaging/native/tool_checksums.txt b/packaging/native/tool_checksums.txt new file mode 100644 index 0000000..c1fb5fe --- /dev/null +++ b/packaging/native/tool_checksums.txt @@ -0,0 +1,23 @@ +# Pinned build inputs for packaging/native/build.sh. +# +# Same contract as packaging/appimage/tool_checksums.txt: everything the +# package build downloads is pinned and verified here, so two builds of the +# same commit cannot differ. +# +# Columns: name, kind, value, source — parseable by awk. +# +# sha256 the file downloaded from must hash to +# docker build inside image ; records the tag it came from +# version recorded for humans; the hash pins pin the artifacts themselves +# +# To bump nfpm: change the version in the URLs, download the +# tarballs, and replace with `sha256sum` of each. The base image +# digest comes from `docker buildx imagetools inspect debian:12` (use the +# index digest, not a per-architecture one — the same line has to work on +# x86_64 and aarch64). + +base-image docker docker.io/library/debian@sha256:2c037a04925515fdd6ea85ea14a682d0e79931f5e9f5d07b6dbfc6ba12f9e858 debian:12 +nfpm version 2.47.0 https://github.com/goreleaser/nfpm/releases/tag/v2.47.0 + +nfpm-x86_64 sha256 0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783 https://github.com/goreleaser/nfpm/releases/download/v2.47.0/nfpm_2.47.0_Linux_x86_64.tar.gz +nfpm-aarch64 sha256 1c0f5f2999b9a974bfb04fdb0cc3306096de530ac5dbb25d739cc5f5219c919c https://github.com/goreleaser/nfpm/releases/download/v2.47.0/nfpm_2.47.0_Linux_arm64.tar.gz diff --git a/tests/test_native_packaging.py b/tests/test_native_packaging.py new file mode 100644 index 0000000..a339399 --- /dev/null +++ b/tests/test_native_packaging.py @@ -0,0 +1,338 @@ +"""Regression guards for the native .deb/.rpm packaging (#600). + +Offline consistency checks in the shape of test_appimage_packaging.py: the +nfpm config, the pinned build inputs, the smoke gate, and the CI wiring must +agree with pyproject.toml, requirements/runtime.txt, and each other. +""" + +import re +import tomllib +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +NATIVE = REPO_ROOT / "packaging" / "native" +NFPM_YAML = NATIVE / "nfpm.yaml" +BUILD_SH = NATIVE / "build.sh" +DOCKER_BUILD_SH = NATIVE / "docker-build.sh" +CONTAINER_BUILD_SH = NATIVE / "container-build.sh" +SMOKE_SH = NATIVE / "smoke-test.sh" +PINS = NATIVE / "tool_checksums.txt" +PYPROJECT = REPO_ROOT / "pyproject.toml" +RUNTIME = REPO_ROOT / "requirements" / "runtime.txt" +WORKFLOWS = REPO_ROOT / ".github" / "workflows" +RELEASE_YML = WORKFLOWS / "release.yml" +PIPELINE_YML = WORKFLOWS / "unified-pipeline.yml" + +#: Runtime dependencies no distro packages; build.sh vendors their wheels +#: under /usr/lib/vocalinux/vendor instead of declaring distro deps. +VENDORED = ("pywhispercpp", "pynput") + +#: pip name -> the Debian/Ubuntu package that Provides it. pywhispercpp's own +#: runtime deps (tqdm, platformdirs) are the package's deps too. +DEB_PROVIDES = { + "numpy": "python3-numpy", + "requests": "python3-requests", + "tqdm": "python3-tqdm", + "platformdirs": "python3-platformdirs", + "psutil": "python3-psutil", + "evdev": "python3-evdev", + "pyaudio": "python3-pyaudio", + "xlib": "python3-xlib", + "pygobject": "python3-gi", +} + +#: pip name -> the Fedora package that Provides it. Note python3-pynput is +#: absent on purpose: Fedora has no pynput package, which is half of why +#: VENDORED exists. +RPM_PROVIDES = { + "numpy": "python3-numpy", + "requests": "python3-requests", + "tqdm": "python3-tqdm", + "platformdirs": "python3-platformdirs", + "psutil": "python3-psutil", + "evdev": "python3-evdev", + "pyaudio": "python3-pyaudio", + "xlib": "python3-xlib", + "pygobject": "python3-gobject", +} + +#: Host binaries the injectors and helpers shell out to. Both formats +#: recommend them; install.d documents the same set for install.sh. +HOST_TOOLS = ("xdotool", "wtype", "ydotool", "wl-clipboard", "xclip", "xsel") + +#: pywhispercpp's own Requires-Dist (extras excluded): the vendored wheel's +#: dependencies are the package's dependencies too. +PYWHISPERCPP_DEPS = ("numpy", "requests", "tqdm", "platformdirs") + + +def _nfpm() -> dict: + """nfpm.yaml as parsed YAML (VOCA_* placeholders parse as strings).""" + return yaml.safe_load(NFPM_YAML.read_text(encoding="utf-8")) + + +def _deb_dep_names() -> set: + names = set() + for dep in _nfpm()["overrides"]["deb"]["depends"]: + names.add(dep.split(" ", 1)[0]) + return names + + +def _rpm_dep_names() -> set: + names = set() + for dep in _nfpm()["overrides"]["rpm"]["depends"]: + names.add(dep.split(" ", 1)[0]) + return names + + +def _pins() -> dict: + """name -> (kind, value, source) from tool_checksums.txt.""" + pins = {} + for line in PINS.read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + name, kind, value, source = line.split(None, 3) + pins[name] = (kind, value, source.strip()) + return pins + + +def _project_dependencies() -> set: + """pip names of every runtime dependency in pyproject.toml.""" + data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + names = set() + for dep in data["project"]["dependencies"]: + match = re.match(r"^([A-Za-z0-9._-]+)", dep) + assert match, f"unparseable dependency {dep!r}" + names.add(match.group(1).lower().replace("_", "-")) + return names + + +def _exported_versions() -> dict: + """package -> version out of the hash-pinned runtime export.""" + return { + match.group(1).lower(): match.group(2) + for match in ( + re.match(r"^([A-Za-z0-9._-]+)==([^\s;\\]+)", line) + for line in RUNTIME.read_text(encoding="utf-8").splitlines() + ) + if match + } + + +def test_vendored_set_matches_build_sh_and_runtime_pins() -> None: + """The vendored wheels are exactly the pinned ones: anything else vendored + drifts from the lock, anything not vendored goes undeclared.""" + build = BUILD_SH.read_text(encoding="utf-8") + exported = _exported_versions() + for name in VENDORED: + assert name in exported, f"{name} is not pinned in requirements/runtime.txt" + assert "pip3 download" in build and name in build, f"{name} is not vendored by build.sh" + # Only these two may be downloaded: the awk that writes the vendor + # requirements file must name exactly this set. + match = re.search(r"\(([^)]+)\)==", build) + vendor_names = set(match.group(1).split("|")) if match else set() + assert vendor_names == set( + VENDORED + ), f"build.sh vendors {vendor_names}, expected {set(VENDORED)}" + + +def test_vendored_packages_are_not_declared_as_distro_deps() -> None: + """A vendored name declared as a dep either fails dependency resolution + (pynput does not exist on Fedora) or shadows the vendored copy.""" + deb, rpm = _deb_dep_names(), _rpm_dep_names() + for name in VENDORED: + python_name = f"python3-{name}" + assert python_name not in deb, f"{python_name} declared in deb but {name} is vendored" + assert python_name not in rpm, f"{python_name} declared in rpm but {name} is vendored" + assert name not in deb and name not in rpm + + +def test_every_project_dependency_is_covered_in_both_formats() -> None: + """pyproject.toml dependencies must be either vendored or satisfied by a + declared distro package in each format — a miss is an uninstallable + package or an import failure at first run.""" + project = _project_dependencies() | set(PYWHISPERCPP_DEPS) + deb, rpm = _deb_dep_names(), _rpm_dep_names() + missing_deb, missing_rpm = [], [] + for name in sorted(project): + if name in VENDORED: + continue + if DEB_PROVIDES.get(name) not in deb: + missing_deb.append(name) + if RPM_PROVIDES.get(name) not in rpm: + missing_rpm.append(name) + assert not missing_deb, f"deps with no deb coverage: {missing_deb}" + assert not missing_rpm, f"deps with no rpm coverage: {missing_rpm}" + + +def test_distro_substrate_is_a_hard_dependency() -> None: + """Interpreter, PyGObject, GTK and the AppIndicator typelib are what the + app cannot start without; the smoke proves only what the package pulls.""" + deb, rpm = _deb_dep_names(), _rpm_dep_names() + for name in ( + "python3", + "python3-gi", + "python3-gi-cairo", + "gir1.2-gtk-3.0", + "gir1.2-ayatanaappindicator3-0.1", + ): + assert name in deb, f"{name} missing from deb depends" + for name in ( + "python3", + "python3-gobject", + "python3-cairo", + "gtk3", + "libayatana-appindicator-gtk3", + ): + assert name in rpm, f"{name} missing from rpm depends" + + +def test_python_floor_matches_requires_python() -> None: + data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + floor = re.search(r">=\s*(\d+\.\d+)", data["project"]["requires-python"]).group(1) + deps = _nfpm()["overrides"] + assert f"python3 (>= {floor})" in deps["deb"]["depends"] + assert f"python3 >= {floor}" in deps["rpm"]["depends"] + # VENDOR_PYS in build.sh must start at the same floor and cover the CI + # matrix (3.11..3.14): a missing version means a distro Python with no + # wheel to load. + build = BUILD_SH.read_text(encoding="utf-8") + match = re.search(r'VENDOR_PYS="([^"]+)"', build) + assert match, "VENDOR_PYS not set in build.sh" + versions = match.group(1).split() + assert versions[0] == floor, f"VENDOR_PYS floor {versions[0]} != requires-python {floor}" + assert versions == sorted(versions, key=lambda v: tuple(map(int, v.split(".")))) + + +def test_host_tools_are_recommended_in_both_formats() -> None: + deps = _nfpm()["overrides"] + for tool in HOST_TOOLS: + assert tool in deps["deb"]["recommends"], f"{tool} missing from deb recommends" + assert tool in deps["rpm"]["recommends"], f"{tool} missing from rpm recommends" + + +def test_the_pins_are_digests_rather_than_names() -> None: + for name, (kind, value, source) in _pins().items(): + if kind == "sha256": + assert re.fullmatch(r"[0-9a-f]{64}", value), f"{name}: not a sha256" + assert source.startswith("https://"), f"{name}: {source}" + assert ( + "/continuous/" not in source and "/master/" not in source + ), f"{name} points at a moving ref, so its digest is a coincidence" + elif kind == "docker": + assert "@sha256:" in value, f"{name}: a tag is not a pin" + elif kind == "version": + assert re.fullmatch(r"\d+(\.\d+)+", value), f"{name}: {value}" + else: + raise AssertionError(f"{name}: unknown pin kind {kind!r}") + + +def test_every_download_goes_through_the_verifying_helper() -> None: + """Same contract as the AppImage gate: an unpinned download is a build + nobody can reproduce.""" + build = BUILD_SH.read_text(encoding="utf-8") + assert "fetch_pinned" in build + assert build.count("curl -fSL") == 1, "downloads must go through fetch_pinned" + pinned = set(_pins()) + fetched = set(re.findall(r'fetch_pinned\s+"?([^"\s]+)"?', build)) + fetched.discard("$NFPM_PIN") # resolves to the per-arch nfpm-* pin + for arch in ("nfpm-x86_64", "nfpm-aarch64"): + assert arch in pinned, f"{arch} missing from {PINS.name}" + for name in fetched: + assert name in pinned, f"build.sh fetches '{name}', which {PINS.name} does not pin" + + +def test_docker_build_uses_the_pinned_base_image() -> None: + text = DOCKER_BUILD_SH.read_text(encoding="utf-8") + assert 'awk \'$1=="base-image"' in text + pins = _pins() + assert "base-image" in pins and pins["base-image"][0] == "docker" + assert pins["base-image"][1].startswith("docker.io/library/debian@sha256:") + + +def test_nfpm_contents_and_launcher_env_agree() -> None: + """The launcher and the import smoke must point at where the payload tree + actually lands.""" + text = BUILD_SH.read_text(encoding="utf-8") + smoke = SMOKE_SH.read_text(encoding="utf-8") + for where in (text, smoke): + assert "/usr/lib/vocalinux/app" in where + assert "/usr/lib/vocalinux/vendor" in where + assert "/usr/bin/vocalinux" in text and "/usr/bin/vocalinux-gui" in text + assert "pywhispercpp.libs" in text and "pywhispercpp.libs" in smoke + # Arguments keep their boundaries: $* into an unquoted exec re-splits a + # path like "Meeting notes.wav" before argparse sees it (PR #913). + assert '"$@"' in text and "QUOTED_ARGS" in text + assert "EXEC_CMD" not in text + contents = _nfpm()["contents"] + tree = [c for c in contents if c.get("type") == "tree"] + assert len(tree) == 1 and tree[0]["dst"] == "/" + packagers = {c.get("packager") for c in contents} + assert "deb" in packagers and "rpm" in packagers, "license placement must differ per format" + + +def test_smoke_gate_covers_both_formats() -> None: + smoke = SMOKE_SH.read_text(encoding="utf-8") + assert "debian|ubuntu)" in smoke, "smoke must install the .deb on Debian-family images" + assert "fedora)" in smoke, "smoke must install the .rpm on Fedora" + assert "vocalinux --version" in smoke + assert "vocalinux-gui --version" in smoke + assert "pywhispercpp.model" in smoke, "the smoke must prove the vendored extension loads" + assert ( + 'find_spec("pynput")' in smoke + ), "pynput raises ImportError without a display; check presence, not import" + + +def test_release_workflow_builds_and_attaches_both_arches() -> None: + text = RELEASE_YML.read_text(encoding="utf-8") + assert "build-native:" in text and "build-native-arm64:" in text + assert "ubuntu-24.04-arm" in text + assert text.count("packaging/native/docker-build.sh") == 2 + assert "name: native-x86_64" in text and "name: native-aarch64" in text + assert "dist/*.deb dist/*.rpm" in text + + +def test_checksums_cover_the_packages() -> None: + text = RELEASE_YML.read_text(encoding="utf-8") + publish = re.search(r"publish-checksums:.*?(?=\n [a-z])", text, re.S) + assert publish, "publish-checksums job not found" + block = publish.group(0) + assert "build-native" in block and "build-native-arm64" in block, ( + "publish-checksums must wait for the package jobs or SHA256SUMS is " + "published before the files exist" + ) + assert re.search( + r"sha256sum -- .*\*\.deb.*\*\.rpm", block + ), "SHA256SUMS must cover .deb and .rpm" + + +def test_wayland_input_group_is_documented() -> None: + """The packages cannot run usermod, and `sg input` only helps users + already listed in /etc/group: a fresh Wayland install leaves the default + hotkey dead unless the docs name the step.""" + install_md = (REPO_ROOT / "docs" / "INSTALL.md").read_text(encoding="utf-8") + section = install_md.split("## Distro packages", 1)[1].split("\n## ", 1)[0] + assert ( + "usermod -aG input" in section + ), "INSTALL.md's .deb/.rpm section must document the Wayland input-group step" + release = RELEASE_YML.read_text(encoding="utf-8") + assert ( + "usermod -aG input" in release + ), "the release notes' .deb/.rpm block must document the same step" + + +def test_pipeline_gates_the_packages() -> None: + text = PIPELINE_YML.read_text(encoding="utf-8") + assert "- 'packaging/native/**'" in text, "changes filter must watch packaging/native" + assert "native-build:" in text and "native-smoke:" in text + assert "native-x86_64" in text + for distro in ("debian:12", "fedora:42"): + assert distro in text, f"{distro} missing from the smoke matrix" + # Each distro in the smoke matrix needs a recipe in smoke-test.sh. + smoke = SMOKE_SH.read_text(encoding="utf-8") + matrix = re.search(r"native-smoke:.*?distro:\n((?:\s+- [^\n]+\n)+)", text, re.S) + assert matrix + for distro in re.findall(r"- (\S+)", matrix.group(1)): + family = distro.split(":")[0].split("/")[-1] + assert family in smoke, f"smoke-test.sh has no recipe for {distro}"