From c461cf51478b5bdebe6ee86a1f95d505aa8f3daa Mon Sep 17 00:00:00 2001 From: Sergey Silaev Date: Mon, 2 Nov 2026 21:31:14 +0100 Subject: [PATCH] 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 --- .github/workflows/release.yml | 8 +- .github/workflows/unified-pipeline.yml | 45 ++- AGENTS.md | 3 +- docs/INSTALL.md | 3 + docs/RELEASE_PROCESS.md | 4 +- docs/SNAP.md | 81 ++++ justfile | 10 +- snap/local/build.py | 165 ++++++++ snap/local/check_runtime.py | 148 ++++++++ snap/local/smoke-test.sh | 22 ++ snap/snapcraft.yaml | 25 +- tests/test_clipboard_restore.py | 36 +- tests/test_paste_shortcut.py | 21 +- tests/test_snap_build.py | 497 +++++++++++++++++++++++++ tests/test_snap_packaging.py | 20 +- 15 files changed, 1047 insertions(+), 41 deletions(-) create mode 100644 docs/SNAP.md create mode 100644 snap/local/build.py create mode 100644 snap/local/check_runtime.py create mode 100644 snap/local/smoke-test.sh create mode 100644 tests/test_snap_build.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78ffbb6..6f1d327 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index 6073242..8329b14 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 61dda6f..84f6de3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index b3a790f..03bac2b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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__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 diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 56fbfc0..1252a4a 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -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 diff --git a/docs/SNAP.md b/docs/SNAP.md new file mode 100644 index 0000000..ba1a002 --- /dev/null +++ b/docs/SNAP.md @@ -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__amd64.snap +``` + +Replace `` 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`. diff --git a/justfile b/justfile index 9e2a380..b48ac0c 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/snap/local/build.py b/snap/local/build.py new file mode 100644 index 0000000..c260289 --- /dev/null +++ b/snap/local/build.py @@ -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() diff --git a/snap/local/check_runtime.py b/snap/local/check_runtime.py new file mode 100644 index 0000000..7368c03 --- /dev/null +++ b/snap/local/check_runtime.py @@ -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() diff --git a/snap/local/smoke-test.sh b/snap/local/smoke-test.sh new file mode 100644 index 0000000..cc2b983 --- /dev/null +++ b/snap/local/smoke-test.sh @@ -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" +' diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 24908ef..6f5ebdd 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -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" \ diff --git a/tests/test_clipboard_restore.py b/tests/test_clipboard_restore.py index e3f37b3..c9b0102 100644 --- a/tests/test_clipboard_restore.py +++ b/tests/test_clipboard_restore.py @@ -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") diff --git a/tests/test_paste_shortcut.py b/tests/test_paste_shortcut.py index be18a2f..91ad03a 100644 --- a/tests/test_paste_shortcut.py +++ b/tests/test_paste_shortcut.py @@ -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() diff --git a/tests/test_snap_build.py b/tests/test_snap_build.py new file mode 100644 index 0000000..04d5116 --- /dev/null +++ b/tests/test_snap_build.py @@ -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" diff --git a/tests/test_snap_packaging.py b/tests/test_snap_packaging.py index d176936..1c8c906 100644 --- a/tests/test_snap_packaging.py +++ b/tests/test_snap_packaging.py @@ -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 (