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}"