fix(snap): build from locked python inputs and verify artifacts (#1041)

* fix(snap): build and verify payload from locked python inputs

* docs(snap): document locked inputs and artifact verification

* test(clipboard): isolate restore workers between tests

* test(packaging): tighten isolation and clarify snap audit guarantees

* fix(ci): run snap recipe tests on snap-only changes

A change to snap/snapcraft.yaml or snap/local/smoke-test.sh only matched
the snap filter, so the Python tests that guard the recipe (including the
portable whisper.cpp flags from #957) and the smoke script were skipped.
Match all of snap/ in the python filter, as scripts/ already is.

* docs(snap): use a version placeholder in the smoke-test example

---------

Co-authored-by: Jatin <jatinkrmalik@gmail.com>
This commit is contained in:
Sergey Silaev
2026-11-02 12:31:14 -08:00
committed by GitHub
co-authored by Jatin
parent 80e3019a74
commit c461cf5147
15 changed files with 1047 additions and 41 deletions
+7 -1
View File
@@ -1028,7 +1028,8 @@ jobs:
# Secret: SNAPCRAFT_STORE_CREDENTIALS (snapcraft export-login).
build-snap:
name: Build Snap
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
contents: read
steps:
@@ -1039,6 +1040,11 @@ jobs:
id: build
uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1
- name: Verify the installed snap
env:
SNAP_FILE: ${{ steps.build.outputs.snap }}
run: bash snap/local/smoke-test.sh "$SNAP_FILE"
- name: Upload the snap
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
+42 -3
View File
@@ -25,6 +25,7 @@ jobs:
python: ${{ steps.filter.outputs.python }}
aur: ${{ steps.filter.outputs.aur }}
native: ${{ steps.filter.outputs.native }}
snap: ${{ steps.filter.outputs.snap }}
deploy_website: ${{ steps.deploy_check.outputs.deploy }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -57,6 +58,10 @@ jobs:
# lint job: a scripts-only change that skipped this filter would
# skip both the checker and the tests that validate it.
- 'scripts/**'
# Same for snap/: tests/test_snap_*.py check the recipe, the
# portable whisper.cpp flags (#957) and smoke-test.sh, and the
# lint job checks snap/local/*.py.
- 'snap/**'
- '.pre-commit-config.yaml'
- '.github/workflows/unified-pipeline.yml'
- '.github/workflows/release.yml'
@@ -78,6 +83,13 @@ jobs:
native:
- 'packaging/native/**'
- '.github/workflows/unified-pipeline.yml'
snap:
- 'snap/**'
- 'MANIFEST.in'
- 'README.md'
- 'LICENSE'
- '.github/workflows/release.yml'
- '.github/workflows/unified-pipeline.yml'
- name: Check if website should deploy
id: deploy_check
env:
@@ -157,15 +169,15 @@ jobs:
# tool versions are the ones `just lint` uses locally.
- name: Lint with flake8 (critical errors)
run: |
uv run --locked --only-group lint flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
uv run --locked --only-group lint flake8 src/ tests/ snap/local/ --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Check formatting with black
run: |
uv run --locked --only-group lint black --check --diff src/ tests/
uv run --locked --only-group lint black --check --diff src/ tests/ snap/local/
- name: Check import sorting with isort
run: |
uv run --locked --only-group lint isort --check-only --diff --profile black src/ tests/
uv run --locked --only-group lint isort --check-only --diff --profile black src/ tests/ snap/local/
- name: Type check (mypy)
run: |
@@ -501,6 +513,33 @@ jobs:
"${{ matrix.distro }}" \
bash /pk/smoke-test.sh
# Build the same recipe as release.yml and inspect the installed artifact.
# No Store credentials or publication are needed, including on fork PRs.
snap-build-test:
name: 📦 Build and install Snap (gate)
runs-on: ubuntu-24.04
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.snap == 'true'
timeout-minutes: 45
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build snap
id: build
uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1
- name: Verify the installed snap
env:
SNAP_FILE: ${{ steps.build.outputs.snap }}
run: bash snap/local/smoke-test.sh "$SNAP_FILE"
- name: Upload the verified snap
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: vocalinux-snap-amd64
path: ${{ steps.build.outputs.snap }}
if-no-files-found: error
retention-days: 7
web-build:
name: 🌐 Build Website
runs-on: ubuntu-latest
+2 -1
View File
@@ -137,6 +137,7 @@ Use **uv 0.13.3** for `just lock` and `just export-check`, matching CI. Other ve
| 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 |
| Snap Python inputs | `snap/local/build.py` uses `requirements/installer-build.txt` in an isolated, build-only venv and installs `requirements/runtime.txt` with hashes, no dependency resolution or build isolation. The project installs without dependencies or index access; never strip PyGObject from `pyproject.toml`. GI comes from the GNOME extension. Keep portable CPU flags and force the pinned pywhispercpp sdist without a wheel cache. PR/release CI installs the real artifact via `snap/local/smoke-test.sh` before publication. Native Ubuntu/GNOME/Snapcraft inputs remain channel-controlled, not byte-for-byte reproducible — see `docs/SNAP.md` |
| Flatpak deps | `packaging/flatpak/python3-dependencies.yaml` is generated from `requirements/vad.txt` (runtime dependencies plus the `[vad]` extra) 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`; `onnxruntime` is pinned to CPython wheels for each Flatpak architecture and the GNOME 50 Python version. 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` |
@@ -241,7 +242,7 @@ Site-only work: `web/AGENTS.md` (commands, layout map). Product claims: `web/PRO
## Runtime (any Linux dev machine)
- Create runtime/dev venvs with `--system-site-packages`. Build-only venvs for `scripts/build_reproducible_wheel.py` and `just aur-gate` preparation are the exception: exclude host packages to keep the locked release tools isolated; never prune the developer environment to run a packaging gate
- Create runtime/dev venvs with `--system-site-packages`. Build-only venvs for `scripts/build_reproducible_wheel.py`, `just aur-gate` preparation and `snap/local/build.py` are the exception: exclude host packages to keep the locked tools isolated; the Snap runtime gets GI from its GNOME command chain. Never prune the developer environment to run a packaging gate
- Kill the app by PID, never `pkill -f vocalinux` (matches editors, shells, and cwd paths)
- Stale instance: `$XDG_DATA_HOME/vocalinux/instance.lock` (default `~/.local/share/vocalinux/instance.lock`)
- Default dictation shortcut: hold Right Alt (push-to-talk). Existing `~/.config/vocalinux/config.json` wins
+3
View File
@@ -170,6 +170,9 @@ Desktop shortcuts only toggle dictation, so they cannot support push-to-talk.
Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux) (issue [#48](https://github.com/VocaHQ/vocalinux/issues/48)). Recipe: `snap/snapcraft.yaml`. Tagged `v*` releases attach `vocalinux_<version>_amd64.snap` to the GitHub Release and try Snap Store `edge` and `candidate` when credentials are set. `stable` is promoted from `candidate` after QA.
Build inputs, the PR/release artifact gate, and its verification limits are
documented in [SNAP.md](SNAP.md).
Store install, once Canonical lists the revision:
```bash
+3 -1
View File
@@ -451,7 +451,9 @@ After pushing the tag, the GitHub Actions workflow will automatically:
`FLATPAK_GPG_PRIVATE_KEY` + `FLATPAK_REPO_TOKEN` are configured — see
docs/FLATPAK_REMOTE.md)
8. Deploy the website to vocalinux.com
9. Build the amd64 snap, attach it to the GitHub Release, and try Snap Store
9. Build the amd64 snap from locked Python exports and run the installed-artifact
gate described in [SNAP.md](SNAP.md), then attach it to the GitHub Release and
try Snap Store
`edge`/`candidate`. Store human review (for example a super-privileged
`allow-installation` plug) must not block the GitHub `.snap`. `stable` does not auto-release (#783):
after candidate QA, dispatch `snap-promote.yml`, which releases the
+81
View File
@@ -0,0 +1,81 @@
# Snap build inputs and verification
The supported Snap build is **amd64, core24 (Ubuntu 24.04 / Python 3.12),
strict confinement, GNOME extension**. whisper.cpp is built for the portable
CPU baseline, not Vulkan/CUDA. Optional local engines are not bundled.
User installation and interface connections are documented in
[INSTALL.md](INSTALL.md#snap-ubuntu-snap-store).
## Python inputs
`uv.lock` owns Python versions. The Snap reuses these generated exports:
- `requirements/installer-build.txt`: the complete hash-pinned build toolchain,
installed wheel-only into a temporary, isolated build virtualenv;
- `requirements/runtime.txt`: the runtime pins, platform markers and SHA-256
hashes, already excluding PyGObject.
`snap/local/build.py` replaces the Python plugin's implicit resolution. A distro
`ensurepip` seeds the build environment offline; its first network operation
installs the locked toolchain with `--require-hashes --no-deps`. Runtime
dependencies use the same flags plus `--no-build-isolation`. Native builds find
the locked CMake/Ninja on PATH. pywhispercpp always builds from its pinned sdist,
without a wheel cache, with the portable `GGML_*` CPU options in the recipe.
The project installs with `--no-deps --no-build-isolation --no-index`. Neither
`pyproject.toml` nor its dependency metadata is patched. PyGObject/GTK come from
the GNOME platform, not pip. The packaged virtualenv contains no pip or build
toolchain. Its launchers are relocatable and prioritize the locked Python
packages over incidental copies in the GNOME platform.
The build audits build-tool and installed runtime versions against the active
export pins and records expected runtime versions and both export digests in
`usr/share/vocalinux/python-inputs.json` inside the snap. Missing or wrong-version
build tools fail the build. For the packaged runtime, extra and duplicate
Python distributions are rejected as well.
Regenerate exports with the repository's `just lock` workflow, not by editing
them or adding independent pins to `snapcraft.yaml`. `just export-check` checks
them against `uv.lock` in CI.
## Build and artifact gate
On a Linux machine with Snapcraft and LXD or Multipass:
```bash
snapcraft pack
```
On a **disposable Ubuntu host with snapd**, inspect the resulting artifact:
```bash
bash snap/local/smoke-test.sh ./vocalinux_<version>_amd64.snap
```
Replace `<version>` with the version that was built. The script refuses to overwrite an
existing Vocalinux snap. It installs the supplied file with `--dangerous`,
runs the actual application launcher with `--version`, then enters the GNOME
command-chain environment to audit Python versions and import the native,
GTK, recognition and text-injection modules. It leaves the installation in
place for inspection; use a disposable host, not your normal Vocalinux install.
`unified-pipeline.yml` builds and runs this gate on relevant Python/Snap PRs and
main pushes, including recipe-only changes. `release.yml` runs it before the
snap artifact can be attached or sent to the Store. Both use the same pinned
builder action and Ubuntu 24.04 runner. PR checks need no Store credentials and
never publish. Historical-tag backfills retain the tagged recipe's behavior.
## Deliberate limits
- This pins Python downloads and build tools, **not every native input**.
Ubuntu packages are controlled by the core24/Ubuntu archive policy; the
GNOME SDK/platform, Snapcraft and base snaps follow their upstream channels.
Their exact revisions and native package versions remain visible in the
Snapcraft build records. They are not an immutable snapshot owned by our
Python lock.
- This is not a claim of byte-for-byte reproducible `.snap` archives. There is
no independent Snap rebuild comparison, and native inputs can change.
- The smoke imports real modules under confinement but does not download a
model, run inference, capture audio, test shortcuts, or type into an app.
Candidate desktop QA and promotion to stable remain separate operations;
this gate does not trigger `snap-promote.yml`.
+5 -5
View File
@@ -77,18 +77,18 @@ test-cov: _tooling
# Run linters (flake8, black, isort)
lint: _tooling
@echo "Running flake8..."
uv run --no-sync flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
uv run --no-sync flake8 src/ tests/ snap/local/ --count --select=E9,F63,F7,F82 --show-source --statistics
@echo "Checking black formatting..."
uv run --no-sync black --check --diff src/ tests/
uv run --no-sync black --check --diff src/ tests/ snap/local/
@echo "Checking isort..."
uv run --no-sync isort --check-only --diff --profile black src/ tests/
uv run --no-sync isort --check-only --diff --profile black src/ tests/ snap/local/
# Auto-format code (black + isort)
format: _tooling
@echo "Formatting with black..."
uv run --no-sync black src/ tests/
uv run --no-sync black src/ tests/ snap/local/
@echo "Sorting imports with isort..."
uv run --no-sync isort --profile black src/ tests/
uv run --no-sync isort --profile black src/ tests/ snap/local/
# Run type checking (mypy)
typecheck: _tooling
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Install locked Python inputs without changing the project's dependency metadata.
Snapcraft's default Python plugin resolves the project and its build environment
again. Use a separate, hash-pinned build virtualenv instead, and install only the
runtime export and the unchanged project into the snap's relocatable virtualenv.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import tempfile
import venv
from pathlib import Path
def run(argv: list[str], source: Path, env: dict[str, str]) -> None:
"""Run a build command, preserving its output and failure status."""
print(f"+ {' '.join(argv)}", flush=True)
subprocess.run(argv, cwd=source, env=env, check=True)
def relocate_scripts(install: Path, tooling: Path) -> None:
"""Remove temporary interpreter paths, including pip's long-shebang shim."""
prefix = str(tooling).encode()
for script in (install / "bin").iterdir():
if script.is_symlink() or not script.is_file():
continue
lines = script.read_bytes().splitlines(keepends=True)
if lines and lines[0].startswith(b"#!" + prefix + b"/"):
script.write_bytes(b"#!/usr/bin/env python3\n" + b"".join(lines[1:]))
elif (
len(lines) >= 3
and lines[0] == b"#!/bin/sh\n"
and lines[1].startswith(b"'''exec' ")
and prefix in lines[1]
and lines[2] == b"' '''\n"
):
script.write_bytes(b"#!/usr/bin/env python3\n" + b"".join(lines[3:]))
# GNOME's command chain adds platform Python paths. Our locked packages
# must win over incidental copies in that platform, while GI stays there.
python_version = f"python{sys.version_info.major}.{sys.version_info.minor}"
launcher = (
"#!/usr/bin/env python3\n"
"import sys\n"
"from pathlib import Path\n"
f"sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "
f"'lib/{python_version}/site-packages'))\n"
"from vocalinux.main import main\n"
"if __name__ == '__main__':\n"
" sys.exit(main())\n"
)
for name in ("vocalinux", "vocalinux-gui"):
path = install / "bin" / name
if path.is_symlink() or not path.is_file():
raise RuntimeError(f"Missing installed entry point: {path}")
path.write_text(launcher, encoding="utf-8")
def build(source: Path, install: Path, work: Path) -> None:
"""Build using committed exports; never stage the build tools or a new GI."""
metadata = source / "pyproject.toml"
original_metadata = metadata.read_bytes()
for export in ("installer-build.txt", "runtime.txt"):
if not (source / "requirements" / export).is_file():
raise RuntimeError(f"Missing locked export: requirements/{export}")
env = {
key: value
for key, value in os.environ.items()
if not key.startswith("PIP_") and key not in {"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"}
}
env["PIP_CONFIG_FILE"] = os.devnull
env["PYTHONNOUSERSITE"] = "1"
work.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="snap-python-", dir=work) as temporary:
tooling = Path(temporary)
# Both environments are build outputs, not the contributor's dev venv.
# ensurepip seeds pip from the distro; the first network operation
# replaces it with the complete hash-pinned installer toolchain.
venv.EnvBuilder(with_pip=True, symlinks=True).create(tooling)
python = str(tooling / "bin" / "python3")
env["PATH"] = f"{tooling / 'bin'}{os.pathsep}{env['PATH']}"
pip = [
python,
"-m",
"pip",
"--isolated",
"--disable-pip-version-check",
"install",
"--no-cache-dir",
]
run(
pip
+ [
"--require-hashes",
"--no-deps",
"--only-binary=:all:",
"--upgrade",
"-r",
str(source / "requirements/installer-build.txt"),
],
source,
env,
)
venv.EnvBuilder(with_pip=False, symlinks=True).create(install)
target = ["--prefix", str(install), "--ignore-installed", "--no-compile"]
run(
pip
+ target
+ [
"--require-hashes",
"--no-deps",
"--no-build-isolation",
# A cached upstream wheel can require AVX2/FMA (#957).
"--no-binary=pywhispercpp",
"-r",
str(source / "requirements/runtime.txt"),
],
source,
env,
)
run(
pip + target + ["--no-deps", "--no-build-isolation", "--no-index", str(source)],
source,
env,
)
relocate_scripts(install, tooling)
run(
[
python,
str(source / "snap/local/check_runtime.py"),
"--root",
str(install),
"--record",
str(source),
],
source,
env,
)
if metadata.read_bytes() != original_metadata:
raise RuntimeError("The Snap build modified pyproject.toml")
def main() -> None:
"""Build the Python payload inside Snapcraft's core24 environment."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--install", type=Path, required=True)
parser.add_argument("--work", type=Path, required=True)
args = parser.parse_args()
if sys.platform != "linux" or sys.version_info[:2] != (3, 12):
parser.error("Build this core24 snap with Linux's system Python 3.12")
try:
build(args.source.resolve(), args.install.resolve(), args.work.resolve())
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
parser.exit(1, f"Snap Python build failed: {error}\n")
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Record locked Python versions at build time and audit the installed Snap.
Only recording uses packaging, from the locked build toolchain. The packaged
audit needs only the standard library and the runtime it is checking.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib
import importlib.metadata
import json
import re
import runpy
import sys
from pathlib import Path
MANIFEST = Path("usr/share/vocalinux/python-inputs.json")
def normalize(name: str) -> str:
"""Normalize a Python distribution name."""
return re.sub(r"[-_.]+", "-", name).lower()
def locked_versions(export: Path) -> dict[str, str]:
"""Evaluate the complete export for the build interpreter, rejecting GI."""
from packaging.requirements import Requirement
expected = {}
blocks = re.split(r"(?=^[A-Za-z0-9_.-]+==)", export.read_text(), flags=re.MULTILINE)[1:]
if not blocks:
raise RuntimeError(f"No pinned requirements in {export}")
for block in blocks:
requirement = Requirement(re.split(r"\s+--hash=|\\", block, maxsplit=1)[0].strip())
if not re.search(r"--hash=sha256:[0-9a-f]{64}(?:\s|$)", block):
raise RuntimeError(f"Missing SHA-256 for {requirement.name}")
name = normalize(requirement.name)
if name == "pygobject":
raise RuntimeError("GI must come from the GNOME extension, not pip")
if requirement.marker and not requirement.marker.evaluate():
continue
pins = list(requirement.specifier)
if len(pins) != 1 or pins[0].operator != "==" or "*" in pins[0].version:
raise RuntimeError(f"Not an exact pin: {requirement}")
if name in expected:
raise RuntimeError(f"Multiple active pins for {name}")
expected[name] = pins[0].version
if not expected:
raise RuntimeError(f"No active requirements in {export}")
return expected
def audit(root: Path, expected: dict[str, str]) -> Path:
"""Check every bundled distribution, including missing/extra/build packages."""
site = root / f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages"
actual = {}
for distribution in importlib.metadata.distributions(path=[str(site)]):
name = normalize(distribution.metadata["Name"])
if name in actual:
raise RuntimeError(f"Duplicate installed distribution: {name}")
actual[name] = distribution.version
if actual != expected:
differences = {
name: {"expected": expected.get(name), "installed": actual.get(name)}
for name in sorted(expected.keys() | actual.keys())
if expected.get(name) != actual.get(name)
}
raise RuntimeError(f"Snap Python versions differ from the lock: {differences}")
return site
def record(root: Path, source: Path) -> None:
"""Retain the expected versions and original export digests in the payload."""
for name, expected_version in locked_versions(
source / "requirements/installer-build.txt"
).items():
if importlib.metadata.version(name) != expected_version:
raise RuntimeError(f"Build tool {name} differs from its locked version")
expected = locked_versions(source / "requirements/runtime.txt")
expected["vocalinux"] = runpy.run_path(str(source / "src/vocalinux/version.py"))["__version__"]
audit(root, expected)
manifest = {
"python": list(sys.version_info[:2]),
"packages": expected,
"exports": {
name: hashlib.sha256((source / "requirements" / name).read_bytes()).hexdigest()
for name in ("runtime.txt", "installer-build.txt")
},
}
destination = root / MANIFEST
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def smoke(root: Path) -> None:
"""Audit a packaged runtime and import native/UI modules without a model."""
manifest = json.loads((root / MANIFEST).read_text())
if manifest["python"] != list(sys.version_info[:2]):
raise RuntimeError("The Snap is running with a different Python ABI")
site = audit(root, manifest["packages"])
sys.path.insert(0, str(site))
for name in ("numpy", "requests", "psutil", "pyaudio", "cairo", "evdev", "vocalinux"):
module = importlib.import_module(name)
if not module.__file__ or not Path(module.__file__).resolve().is_relative_to(
site.resolve()
):
raise RuntimeError(f"{name} is shadowed by a platform/host package")
from vocalinux.utils.pywhispercpp_loader import preload_shared_libraries
preload_shared_libraries()
for name in (
"pywhispercpp.model",
"vocalinux.main",
"vocalinux.ui.tray_indicator",
"vocalinux.speech_recognition.recognition_manager",
"vocalinux.text_injection.text_injector",
):
importlib.import_module(name)
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa: F401
if Path(gi.__file__).resolve().is_relative_to(site.resolve()):
raise RuntimeError("The Snap bundled GI instead of using the GNOME platform")
print("PASS: locked Snap Python versions, native modules and GNOME GTK import")
def main() -> None:
"""Record build inputs or check the installed, confined artifact."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--record", type=Path)
args = parser.parse_args()
try:
if args.record:
record(args.root, args.record)
else:
smoke(args.root)
except (OSError, ValueError, KeyError, RuntimeError, ImportError) as error:
parser.exit(1, f"Snap runtime verification failed: {error}\n")
if __name__ == "__main__":
main()
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Install the actual artifact on a disposable Ubuntu host with snapd. This is
# not desktop QA: no microphone, model download, hotkey or typing is exercised.
set -euo pipefail
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo "Usage: bash snap/local/smoke-test.sh PATH.snap" >&2
exit 1
fi
if snap list vocalinux >/dev/null 2>&1; then
echo "Refusing to replace an existing Vocalinux snap; use a disposable host" >&2
exit 1
fi
sudo snap install --dangerous "$1"
snap run vocalinux --version
# --shell does not run the application's command chain. Explicitly enter the
# same GNOME desktop-launch environment before checking the installed payload.
PYNPUT_BACKEND=dummy snap run --shell vocalinux -c '
exec "$SNAP/snap/command-chain/desktop-launch" "$SNAP/bin/python3" \
"$SNAP/usr/lib/vocalinux/check_runtime.py" --root "$SNAP"
'
+12 -13
View File
@@ -6,7 +6,7 @@
#
# Build (from repo root, with snapcraft + LXD or Multipass):
# snapcraft pack
# See docs/INSTALL.md for pack and store upload steps.
# See docs/SNAP.md for controlled inputs, the build gate and verification limits.
name: vocalinux
base: core24 # Ubuntu 24.04 / Python 3.12 (project requires-python >=3.11)
@@ -20,7 +20,7 @@ description: |
Speech recognition runs on your machine after you download a model. No Voca account. No cloud transcription on the default path.
This snap ships the whisper.cpp engine plus VOSK. The optional OpenAI Whisper / PyTorch stack is not bundled (too large for the snap).
This snap ships the whisper.cpp engine. Optional local speech engines are not bundled.
Features:
- System tray control and GTK settings
@@ -104,28 +104,27 @@ layout:
parts:
vocalinux:
plugin: python
# The Python plugin would resolve dependencies and build tools again.
plugin: nil
source: .
source-type: local
build-environment:
- PIP_DISABLE_PIP_VERSION_CHECK: "1"
# pywhispercpp's PyPI wheel ships a libggml-cpu compiled for AVX2+FMA,
# which dies with SIGILL at model load on pre-Haswell CPUs (#957), so
# the pinned sdist is built instead of installing that wheel.
# GGML_NATIVE=OFF alone is not the portable baseline either: while
# ggml's native default is on it turns every GGML_* SIMD option on, so
# each one is pinned off for the plain x86-64 baseline.
- PIP_NO_BINARY: pywhispercpp
- CMAKE_ARGS: "-DGGML_NATIVE=OFF -DGGML_SSE42=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_BMI2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF"
build-packages:
- build-essential
- cmake
- git
- pkg-config
- portaudio19-dev
- libgirepository1.0-dev
- libcairo2-dev
- python3-dev
- python3-venv
- libdbus-1-dev
stage-packages:
# Only extras not provided by the gnome-46-2404 platform content snap.
@@ -141,18 +140,18 @@ parts:
- xsel
- xclip
- wl-clipboard
python-packages:
- pip
- setuptools
- wheel
override-pull: |
craftctl default
version="$(python3 -c "import pathlib, re; t = pathlib.Path('${CRAFT_PART_SRC}/src/vocalinux/version.py').read_text(); m = re.search(r'__version__\\s*=\\s*[\\'\"]([^\\'\"]+)', t); assert m, 'version not found'; print(m.group(1))")"
craftctl set version="$version"
# PyGObject/GI provided by gnome extension.
sed -i '/^[[:space:]]*"PyGObject/d' "${CRAFT_PART_SRC}/pyproject.toml"
override-build: |
craftctl default
# The runtime export already excludes PyGObject; GI comes from the gnome
# extension. Keep pyproject.toml and installed metadata unchanged.
/usr/bin/python3 "${CRAFT_PART_SRC}/snap/local/build.py" \
--source "${CRAFT_PART_BUILD}" --install "${CRAFT_PART_INSTALL}" \
--work "${CRAFT_PART_BUILD}/.snap-python-build"
install -Dm644 "${CRAFT_PART_SRC}/snap/local/check_runtime.py" \
"${CRAFT_PART_INSTALL}/usr/lib/vocalinux/check_runtime.py"
install -Dm644 "${CRAFT_PROJECT_DIR}/snap/gui/vocalinux.desktop" \
"${CRAFT_PART_INSTALL}/usr/share/applications/vocalinux.desktop"
install -Dm644 "${CRAFT_PROJECT_DIR}/resources/icons/scalable/vocalinux.svg" \
+31 -5
View File
@@ -10,6 +10,8 @@ import subprocess
import threading
import time
import unittest
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any, cast
from unittest.mock import MagicMock, patch
@@ -207,6 +209,25 @@ class TestReadClipboard(unittest.TestCase):
self.assertEqual(result, "x11 clipboard")
@contextmanager
def _joined_restore_workers() -> Iterator[None]:
"""Finish real restore workers before the enclosing clipboard mocks exit."""
started: list[threading.Thread] = []
original_start = threading.Thread.start
def capture_start(worker: threading.Thread) -> None:
original_start(worker)
started.append(worker)
with patch.object(threading.Thread, "start", capture_start):
try:
yield
finally:
for worker in started:
worker.join(timeout=3)
assert not any(worker.is_alive() for worker in started), "restore worker leaked"
class TestClipboardRestoreAfterInjection(unittest.TestCase):
"""Behavioural tests for the save/restore logic in _inject_via_clipboard_paste."""
@@ -234,8 +255,8 @@ class TestClipboardRestoreAfterInjection(unittest.TestCase):
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
result = obj._inject_via_clipboard_paste("injected text")
time.sleep(0.5) # wait for the 300ms restore thread
with _joined_restore_workers():
result = obj._inject_via_clipboard_paste("injected text")
self.assertTrue(result)
self.assertEqual(copy_calls[0], "injected text")
@@ -267,8 +288,8 @@ class TestClipboardRestoreAfterInjection(unittest.TestCase):
"vocalinux.text_injection.text_injector.subprocess.run",
return_value=MagicMock(returncode=0),
):
obj._inject_via_clipboard_paste("text")
time.sleep(0.5)
with _joined_restore_workers():
obj._inject_via_clipboard_paste("text")
self.assertEqual(call_order[0], "read", "read must happen before any copy")
self.assertIn("copy:text", call_order)
@@ -365,7 +386,7 @@ class TestClipboardRestoreAfterInjection(unittest.TestCase):
# First copy: injection; second copy: immediate restore after paste failure
self.assertEqual(copy_calls, ["text", "prev"])
def test_restore_thread_is_a_daemon(self):
def test_restore_thread_is_a_daemon(self) -> None:
"""The restore thread is a daemon so it never blocks interpreter shutdown."""
obj = _make_injector()
obj.wayland_tool = "ydotool"
@@ -390,6 +411,11 @@ class TestClipboardRestoreAfterInjection(unittest.TestCase):
):
with patch.object(threading.Thread, "start", capture_start):
obj._inject_via_clipboard_paste("text")
# Keep the clipboard mocks installed until the
# real worker exits, not merely until start().
for worker in started:
worker.join(timeout=3)
self.assertFalse(worker.is_alive(), "restore worker leaked")
self.assertEqual(len(started), 1, "exactly one restore thread should start")
self.assertTrue(started[0].daemon, "restore thread must be a daemon")
+18 -3
View File
@@ -30,6 +30,7 @@ def _make_injector() -> Any:
obj._clipboard_timeout = 0.35
obj._clipboard_restore_generation = 0
obj._clipboard_restore_target = None
obj._should_copy_to_clipboard = MagicMock(return_value=False)
return obj
@@ -212,7 +213,11 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_clipboard_paste_uses_wtype_argv_when_chosen(self, mock_run, mock_which):
@patch("vocalinux.text_injection.text_injector.threading.Thread")
def test_clipboard_paste_uses_wtype_argv_when_chosen(
self, mock_thread: MagicMock, mock_run: MagicMock, mock_which: MagicMock
) -> None:
"""Check wtype arguments without launching an unrelated restore worker."""
mock_which.side_effect = lambda cmd: cmd in ("wl-copy", "wtype", "ydotool")
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
injector = _make_injector()
@@ -222,6 +227,9 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
with patch.object(injector, "_read_clipboard", return_value="old"):
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
# Restore behaviour is covered separately; never let a delayed worker
# outlive these clipboard/subprocess patches and reach the next test.
mock_thread.return_value.start.assert_called_once_with()
paste_cmds = [c.args[0] for c in mock_run.call_args_list if c.args]
self.assertTrue(
any(c == ["wtype", "-M", "ctrl", "v"] for c in paste_cmds),
@@ -244,7 +252,11 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_clipboard_paste_sends_terminal_chord(self, mock_run, mock_which):
@patch("vocalinux.text_injection.text_injector.threading.Thread")
def test_clipboard_paste_sends_terminal_chord(
self, mock_thread: MagicMock, mock_run: MagicMock, mock_which: MagicMock
) -> None:
"""Check the terminal chord without launching an unrelated restore worker."""
mock_which.side_effect = lambda cmd: cmd in ("wl-copy", "ydotool")
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
injector = _make_injector()
@@ -258,8 +270,10 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
) as mock_cmd:
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
mock_cmd.assert_called_once_with(terminal=True)
mock_thread.return_value.start.assert_called_once_with()
def test_clipboard_paste_survives_mocked_subprocess_run(self):
@patch("vocalinux.text_injection.text_injector.threading.Thread")
def test_clipboard_paste_survives_mocked_subprocess_run(self, mock_thread: MagicMock) -> None:
"""Patching text_injector.subprocess.run also shadows stdlib subprocess.
Existing clipboard-restore tests do that. Window detection must fail
@@ -279,3 +293,4 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
return_value=MagicMock(returncode=0),
):
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
mock_thread.return_value.start.assert_called_once_with()
+497
View File
@@ -0,0 +1,497 @@
"""Exercise locked Snap build orchestration, audit failures and offline hashes."""
from __future__ import annotations
import hashlib
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import venv
import zipfile
from pathlib import Path
from types import ModuleType
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[1]
def load_helper(name: str) -> ModuleType:
"""Load packaging helpers independently of the app and the machine's GTK."""
spec = importlib.util.spec_from_file_location(f"snap_{name}", ROOT / f"snap/local/{name}.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
BUILD = load_helper("build")
CHECK = load_helper("check_runtime")
# Legacy recognition tests replace sys.modules['tempfile'] during collection.
# Bind a private real stdlib copy without changing their global module state.
_TEMPFILE_SPEC = importlib.util.spec_from_file_location(
"snap_stdlib_tempfile", Path(os.__file__).with_name("tempfile.py")
)
_TEMPFILE = importlib.util.module_from_spec(_TEMPFILE_SPEC)
_TEMPFILE_SPEC.loader.exec_module(_TEMPFILE)
BUILD.tempfile = _TEMPFILE
def export_pin(name: str, version: str = "1.0", marker: str = "") -> str:
"""Make a syntactically hash-pinned requirement, optionally with a marker."""
condition = f" ; {marker}" if marker else ""
return f"{name}=={version}{condition} \\\n --hash=sha256:{'a' * 64}\n"
def distribution(root: Path, name: str, version: str) -> Path:
"""Write real dist-info metadata for the standard-library audit."""
site = root / f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages"
info = site / f"{name}-{version}.dist-info"
info.mkdir(parents=True, exist_ok=True)
(info / "METADATA").write_text(f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n")
return site
def test_locked_versions_evaluates_markers_and_normalizes_names(tmp_path: Path) -> None:
"""The other interpreter's numpy pin must not overwrite the active one."""
export = tmp_path / "runtime.txt"
export.write_text(
export_pin("Some_Package", "2.0")
+ export_pin("numpy", "2.4", 'python_version < "3.12"')
+ export_pin("numpy", "2.5", 'python_version >= "3.12"')
)
assert CHECK.locked_versions(export) == {
"some-package": "2.0",
"numpy": "2.5" if sys.version_info >= (3, 12) else "2.4",
}
@pytest.mark.parametrize(
("text", "message"),
[
("# empty export\n", "No pinned requirements"),
("demo==1.0\n", "Missing SHA-256"),
(export_pin("PyGObject"), "GI must come from"),
(export_pin("demo") + export_pin("demo", "2.0"), "Multiple active pins"),
(export_pin("demo", marker='python_version < "2"'), "No active requirements"),
],
)
def test_locked_versions_fails_closed(tmp_path: Path, text: str, message: str) -> None:
"""Reject exports that cannot define an unambiguous locked runtime."""
export = tmp_path / "runtime.txt"
export.write_text(text)
with pytest.raises(RuntimeError, match=message):
CHECK.locked_versions(export)
@pytest.mark.parametrize("change", ["missing", "extra", "version", "duplicate"])
def test_runtime_audit_rejects_drift(tmp_path: Path, change: str) -> None:
"""Reject missing, extra, duplicate and wrong-version runtime distributions."""
distribution(tmp_path, "demo", "1.0")
expected = {"demo": "1.0"}
if change == "missing":
expected["absent"] = "2.0"
elif change == "extra":
distribution(tmp_path, "setuptools", "84.0")
elif change == "version":
expected["demo"] = "2.0"
else:
distribution(tmp_path, "demo", "2.0")
with pytest.raises(RuntimeError, match="Duplicate|differ from the lock"):
CHECK.audit(tmp_path, expected)
def test_record_audits_payload_and_preserves_export_digests(tmp_path: Path) -> None:
"""Record audited runtime pins and the exact input export digests."""
source = tmp_path / "source"
(source / "requirements").mkdir(parents=True)
(source / "requirements/runtime.txt").write_text(export_pin("demo"))
(source / "requirements/installer-build.txt").write_text(
export_pin("packaging", importlib.metadata.version("packaging"))
)
(source / "src/vocalinux").mkdir(parents=True)
(source / "src/vocalinux/version.py").write_text('__version__ = "0.18.5"\n')
root = tmp_path / "install"
distribution(root, "demo", "1.0")
distribution(root, "vocalinux", "0.18.5")
CHECK.record(root, source)
manifest = json.loads((root / CHECK.MANIFEST).read_text())
assert manifest["packages"] == {"demo": "1.0", "vocalinux": "0.18.5"}
assert manifest["python"] == list(sys.version_info[:2])
for name, digest in manifest["exports"].items():
assert digest == hashlib.sha256((source / "requirements" / name).read_bytes()).hexdigest()
@pytest.mark.parametrize(
("text", "message"),
[
(export_pin("packaging", "0.0"), "Build tool packaging"),
("# empty build export\n", "No pinned requirements"),
],
)
def test_record_rejects_unlocked_build_tools(tmp_path: Path, text: str, message: str) -> None:
"""Refuse incorrect build-tool versions or an empty toolchain export."""
source = tmp_path / "source"
(source / "requirements").mkdir(parents=True)
(source / "requirements/installer-build.txt").write_text(text)
with pytest.raises(RuntimeError, match=message):
CHECK.record(tmp_path / "install", source)
@pytest.mark.parametrize("long_shebang", [False, True])
def test_launchers_are_relocatable_and_keep_locked_packages_first(
tmp_path: Path, long_shebang: bool
) -> None:
"""Run relocated launchers against locked packages despite platform copies."""
install = tmp_path / "install"
binary = install / "bin"
binary.mkdir(parents=True)
tooling = tmp_path / "tooling"
python = tooling / "bin/python3"
header = (
f"#!/bin/sh\n'''exec' {python} \"$0\" \"$@\"\n' '''\n" if long_shebang else f"#!{python}\n"
)
for name in ("vocalinux", "vocalinux-gui", "pwcpp"):
path = binary / name
path.write_text(header + "print('entry point')\n")
path.chmod(0o755)
(binary / "python3").symlink_to(sys.executable)
site = distribution(install, "vocalinux", "0.18.5")
package = site / "vocalinux"
package.mkdir()
(package / "__init__.py").write_text("")
(package / "main.py").write_text("def main():\n print('locked runtime')\n")
# Simulate a platform with an incidental conflicting copy.
platform = tmp_path / "platform/vocalinux"
platform.mkdir(parents=True)
(platform / "__init__.py").write_text("raise RuntimeError('wrong runtime')\n")
BUILD.relocate_scripts(install, tooling)
for name in ("vocalinux", "vocalinux-gui", "pwcpp"):
text = (binary / name).read_text()
assert text.startswith("#!/usr/bin/env python3\n")
assert str(tooling) not in text
assert os.access(binary / name, os.X_OK)
result = subprocess.run(
[str(binary / "vocalinux")],
env={
**os.environ,
"PATH": f"{binary}{os.pathsep}{os.environ['PATH']}",
"PYTHONPATH": str(platform.parent),
},
capture_output=True,
text=True,
timeout=5,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "locked runtime"
def test_build_orchestration_uses_hashes_and_separates_tooling(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Stub expensive builds, not the command construction or source protection."""
source = tmp_path / "source"
(source / "requirements").mkdir(parents=True)
metadata = source / "pyproject.toml"
metadata.write_text('[project]\ndependencies = ["PyGObject"]\n')
for name in ("installer-build.txt", "runtime.txt"):
(source / "requirements" / name).write_text(export_pin("demo"))
install = tmp_path / "install"
work = tmp_path / "work"
environments = []
calls = []
def create(builder: venv.EnvBuilder, path: Path) -> None:
(path / "bin").mkdir(parents=True, exist_ok=True)
environments.append((path, builder.with_pip, builder.system_site_packages))
def run(argv: list[str], cwd: Path, env: dict[str, str]) -> None:
calls.append((argv, cwd, env.copy()))
if str(source) == argv[-1]:
for name in ("vocalinux", "vocalinux-gui"):
(install / "bin" / name).write_text(f"#!{argv[0]}\n")
monkeypatch.setattr(BUILD.venv.EnvBuilder, "create", create)
monkeypatch.setattr(BUILD, "run", run)
monkeypatch.setenv("PIP_INDEX_URL", "https://untrusted.invalid")
monkeypatch.setenv("PYTHONPATH", "/unlocked-platform")
monkeypatch.setenv("CMAKE_ARGS", "-DGGML_NATIVE=OFF")
before = metadata.read_bytes()
BUILD.build(source, install, work)
assert metadata.read_bytes() == before
assert len(calls) == 4
tools, runtime, project, audit = [call[0] for call in calls]
for argv in (tools, runtime):
assert {
"--isolated",
"--disable-pip-version-check",
"--no-cache-dir",
"--require-hashes",
"--no-deps",
} <= set(argv)
assert "--only-binary=:all:" in tools
assert tools[-1] == str(source / "requirements/installer-build.txt")
assert {"--no-build-isolation", "--ignore-installed", "--no-binary=pywhispercpp"} <= set(
runtime
)
assert runtime[-1] == str(source / "requirements/runtime.txt")
assert runtime[runtime.index("--prefix") + 1] == str(install)
assert {"--no-deps", "--no-build-isolation", "--no-index"} <= set(project)
assert project[-1] == str(source)
assert "--record" in audit and str(install) in audit
assert environments[0][1:] == (True, False)
assert environments[1] == (install, False, False)
assert not environments[0][0].exists()
for _, cwd, env in calls:
assert cwd == source
assert "PIP_INDEX_URL" not in env and "PYTHONPATH" not in env
assert env["PIP_CONFIG_FILE"] == os.devnull
assert env["CMAKE_ARGS"] == "-DGGML_NATIVE=OFF"
assert env["PATH"].split(os.pathsep)[0] == str(environments[0][0] / "bin")
def test_ci_builds_recipe_only_changes_and_checks_before_publication() -> None:
"""Require equivalent PR/release artifact gates before uploading the Snap."""
ci = yaml.safe_load((ROOT / ".github/workflows/unified-pipeline.yml").read_text())
jobs = ci["jobs"]
changes = jobs["changes"]
filters = yaml.safe_load(
next(
step["with"]["filters"]
for step in changes["steps"]
if "filters" in step.get("with", {})
)
)
assert "snap/**" in filters["snap"]
# A recipe- or smoke-script-only change must still run the tests guarding them.
assert "snap/**" in filters["python"]
assert changes["outputs"]["snap"] == "${{ steps.filter.outputs.snap }}"
pr = jobs["snap-build-test"]
assert "outputs.snap == 'true'" in pr["if"]
assert "outputs.python == 'true'" in pr["if"]
assert pr["permissions"] == {"contents": "read"}
release = yaml.safe_load((ROOT / ".github/workflows/release.yml").read_text())
production = release["jobs"]["build-snap"]
builders = []
for job in (pr, production):
steps = job["steps"]
build = next(
index
for index, step in enumerate(steps)
if step.get("uses", "").startswith("snapcore/action-build@")
)
smoke = next(
index
for index, step in enumerate(steps)
if "snap/local/smoke-test.sh" in step.get("run", "")
)
upload = next(
index
for index, step in enumerate(steps)
if step.get("uses", "").startswith("actions/upload-artifact@")
)
assert build < smoke < upload
assert job["runs-on"] == "ubuntu-24.04"
assert job["timeout-minutes"] >= 30
assert "continue-on-error" not in job
assert "continue-on-error" not in steps[smoke]
assert "secrets." not in json.dumps(job)
builders.append(steps[build]["uses"])
assert builders[0] == builders[1]
@pytest.mark.timeout(30)
def test_runtime_pip_flags_reject_corrupt_artifact_offline(tmp_path: Path) -> None:
"""Exercise pip's real hash check with the same runtime install flags."""
environment = tmp_path / "tooling"
venv.EnvBuilder(with_pip=True).create(environment)
wheel = tmp_path / "demo-1.0-py3-none-any.whl"
real_zipfile = getattr(sys, "_vocalinux_real_zipfile", zipfile)
with real_zipfile.ZipFile(wheel, "w") as archive:
archive.writestr("demo.py", "VALUE = 1\n")
archive.writestr(
"demo-1.0.dist-info/METADATA", "Metadata-Version: 2.1\nName: demo\nVersion: 1.0\n"
)
archive.writestr(
"demo-1.0.dist-info/WHEEL",
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
)
archive.writestr("demo-1.0.dist-info/RECORD", "")
digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
export = tmp_path / "runtime.txt"
export.write_text(f"demo==1.0 --hash=sha256:{digest}\n")
python = str(environment / "bin/python3")
command = [
python,
"-m",
"pip",
"--isolated",
"install",
"--no-cache-dir",
"--require-hashes",
"--no-deps",
"--no-build-isolation",
"--no-binary=pywhispercpp",
"--ignore-installed",
"--no-compile",
"--prefix",
str(tmp_path / "install"),
"--no-index",
"--find-links",
str(tmp_path),
"-r",
str(export),
]
env = {**os.environ, "PIP_CONFIG_FILE": os.devnull}
result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10)
assert result.returncode == 0, result.stderr
CHECK.audit(tmp_path / "install", {"demo": "1.0"})
with wheel.open("ab") as stream:
stream.write(b"corrupted after the hash was pinned")
result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10)
assert result.returncode != 0
assert "DO NOT MATCH THE HASHES" in result.stderr
@pytest.mark.timeout(60)
def test_full_python_payload_build_is_offline_and_keeps_project_metadata(tmp_path: Path) -> None:
"""Run the real build pipeline with tiny local wheels and a PEP 517 fixture."""
import packaging
source = tmp_path / "source"
(source / "requirements").mkdir(parents=True)
(source / "snap/local").mkdir(parents=True)
shutil.copy2(ROOT / "snap/local/check_runtime.py", source / "snap/local/check_runtime.py")
(source / "src/vocalinux").mkdir(parents=True)
(source / "src/vocalinux/version.py").write_text('__version__ = "0.18.5"\n')
wheelhouse = tmp_path / "wheels"
wheelhouse.mkdir()
real_zipfile = getattr(sys, "_vocalinux_real_zipfile", zipfile)
def wheel(name: str, version: str, files: dict[str, str]) -> Path:
path = wheelhouse / f"{name}-{version}-py3-none-any.whl"
with real_zipfile.ZipFile(path, "w") as archive:
for filename, text in files.items():
archive.writestr(filename, text)
info = f"{name}-{version}.dist-info"
archive.writestr(
f"{info}/METADATA", f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n"
)
archive.writestr(
f"{info}/WHEEL", "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n"
)
archive.writestr(f"{info}/RECORD", "")
return path
# The build-only packaging library is real, but every byte is served by
# the local wheelhouse. No test index, shared pip cache or network is used.
library = Path(packaging.__file__).parent
tools = wheel(
"packaging",
packaging.__version__,
{
f"packaging/{path.relative_to(library)}": path.read_text()
for path in library.rglob("*.py")
},
)
runtime = wheel("demo", "1.0", {"demo.py": "VALUE = 1\n"})
app = wheel(
"vocalinux",
"0.18.5",
{
"vocalinux/__init__.py": "",
"vocalinux/main.py": "def main():\n print('fixture app')\n",
"vocalinux-0.18.5.dist-info/entry_points.txt": "[console_scripts]\nvocalinux = vocalinux.main:main\n"
"[gui_scripts]\nvocalinux-gui = vocalinux.main:main\n",
},
)
for filename, name, version, artifact in (
("installer-build.txt", "packaging", packaging.__version__, tools),
("runtime.txt", "demo", "1.0", runtime),
):
digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
(source / "requirements" / filename).write_text(
f"--no-index\n--find-links {wheelhouse}\n{name}=={version} --hash=sha256:{digest}\n"
)
# An entirely stdlib backend lets the fixture build without downloading
# setuptools. The real project uses the locked setuptools toolchain.
metadata = source / "pyproject.toml"
metadata.write_text(
'[build-system]\nrequires = []\nbuild-backend = "backend"\nbackend-path = ["."]\n'
'[project]\nname = "vocalinux"\nversion = "0.18.5"\ndependencies = ["PyGObject"]\n'
)
(source / "backend.py").write_text(
"import shutil\nfrom pathlib import Path\n"
"def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):\n"
f" source = Path({str(app)!r})\n"
" shutil.copy2(source, Path(wheel_directory) / source.name)\n"
" return source.name\n"
)
before = metadata.read_bytes()
install = tmp_path / "install"
BUILD.build(source, install, tmp_path / "work")
assert metadata.read_bytes() == before
CHECK.audit(install, {"demo": "1.0", "vocalinux": "0.18.5"})
assert not list((tmp_path / "work").iterdir())
assert not (install / "bin/pip").exists()
result = subprocess.run(
[str(install / "bin/python3"), str(install / "bin/vocalinux")],
capture_output=True,
text=True,
timeout=5,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "fixture app"
@pytest.mark.parametrize("existing", [False, True])
def test_artifact_smoke_refuses_user_install_and_checks_the_supplied_file(
tmp_path: Path, existing: bool
) -> None:
"""Execute the real shell gate; fake only snapd/sudo, never touch a host app."""
tools = tmp_path / "tools"
tools.mkdir()
log = tmp_path / "calls.jsonl"
for name in ("snap", "sudo"):
path = tools / name
path.write_text(
f"#!{sys.executable}\n"
"import json, os, sys\n"
"from pathlib import Path\n"
"with open(os.environ['SNAP_TEST_LOG'], 'a') as stream:\n"
" stream.write(json.dumps([Path(sys.argv[0]).name, sys.argv[1:], "
"os.environ.get('PYNPUT_BACKEND')]) + '\\n')\n"
"if sys.argv[1:3] == ['list', 'vocalinux']:\n"
" sys.exit(0 if os.environ['SNAP_TEST_EXISTING'] == '1' else 1)\n"
)
path.chmod(0o755)
artifact = tmp_path / "selected.snap"
artifact.write_bytes(b"artifact stand-in")
result = subprocess.run(
["bash", str(ROOT / "snap/local/smoke-test.sh"), str(artifact)],
env={
**os.environ,
"PATH": f"{tools}{os.pathsep}{os.environ['PATH']}",
"SNAP_TEST_LOG": str(log),
"SNAP_TEST_EXISTING": "1" if existing else "0",
},
capture_output=True,
text=True,
timeout=5,
)
calls = [json.loads(line) for line in log.read_text().splitlines()]
if existing:
assert result.returncode != 0
assert "Refusing to replace" in result.stderr
assert len(calls) == 1
else:
assert result.returncode == 0, result.stderr
assert calls[1][:2] == ["sudo", ["snap", "install", "--dangerous", str(artifact)]]
assert calls[2][:2] == ["snap", ["run", "vocalinux", "--version"]]
assert calls[3][1][:4] == ["run", "--shell", "vocalinux", "-c"]
assert '"$SNAP/snap/command-chain/desktop-launch"' in calls[3][1][4]
assert '"$SNAP/usr/lib/vocalinux/check_runtime.py"' in calls[3][1][4]
assert calls[3][2] == "dummy"
+11 -9
View File
@@ -84,14 +84,16 @@ def test_snap_docs_explain_typing_without_uinput_plug() -> None:
assert "portal" in lowered
def test_snap_strips_pygobject_and_uses_gnome_gi() -> None:
"""Pip must not build PyGObject; GI comes from the gnome extension."""
def test_snap_preserves_metadata_and_uses_gnome_gi() -> None:
"""GI comes from GNOME without rewriting project dependencies."""
text = SNAPCRAFT_YAML.read_text(encoding="utf-8")
doc = yaml.safe_load(text)
override_pull = doc["parts"]["vocalinux"]["override-pull"]
assert "PyGObject" in override_pull
assert "pyproject.toml" in override_pull
assert "gnome extension" in override_pull
part = doc["parts"]["vocalinux"]
assert part["plugin"] == "nil"
assert "python-packages" not in part
assert "sed -i" not in part["override-pull"]
assert "snap/local/build.py" in part["override-build"]
assert "craftctl default" not in part["override-build"]
stage = doc["parts"]["vocalinux"].get("stage-packages") or []
assert "python3-gi" not in stage
assert "python3-gi-cairo" not in stage
@@ -222,9 +224,9 @@ def test_snap_builds_pywhispercpp_at_the_portable_baseline() -> None:
env: dict = {}
for entry in doc["parts"]["vocalinux"].get("build-environment") or []:
env.update(entry)
assert (
env.get("PIP_NO_BINARY") == "pywhispercpp"
), "without --no-binary the snap installs the AVX2+FMA wheel (#957)"
build = (REPO_ROOT / "snap/local/build.py").read_text()
assert '"--no-binary=pywhispercpp"' in build
assert '"--no-cache-dir"' in build
cmake_args = env.get("CMAKE_ARGS") or ""
for flag in PORTABLE_GGML_OPTIONS:
assert (