Files
voculinux/tests/test_native_packaging.py
T
ed0ed8ba49 fix(packaging): build pywhispercpp at the portable x86-64 baseline (#959)
* fix(packaging): build pywhispercpp at the portable x86-64 baseline

Every packaged channel shipped a libggml-cpu compiled for AVX2+FMA:
the manylinux PyPI wheel itself carries -mavx2 -mfma -mbmi2 code, so
any package that bundles it dies with SIGILL when whisper.cpp loads a
model on a pre-Haswell CPU (the reporter's i5-3337U). GGML_NATIVE=OFF
alone is not the portable baseline either; while the native default
is on, ggml turns every GGML_* SIMD option on, so each is pinned off.

- Snap: force the sdist (PIP_NO_BINARY) with the flag floor via
  CMAKE_ARGS in the part's build-environment.
- Flatpak: extend the module's CMAKE_ARGS with the same floor.
- Native .deb/.rpm: the build still vendors the wheels, then rebuilds
  libggml-cpu alone from the same hash-pinned sdist and swaps it into
  the vendored copies, asserting no AVX/FMA survives; the container
  image gains cmake, g++, make and binutils.
- AppImage: extract the pinned-sdist reinstall into a shared helper
  and route every CPU-only fallback of the Vulkan path through a new
  baseline rebuild at the same floor; the Vulkan compile keeps its
  own flags via the same helper. Keeping the wheel on those paths was
  the bug's exact binary.

Fixes #957

* style(tests): apply black/isort to the new packaging guards

* fix(native): keep the wheel's libggml providers, alias its base/gomp sonames

Swapping the whole libggml set for the baseline build broke the import
smoke: the wheel's auditwheel-mangled core lib doubles as the
extension's __cxa_call_terminate provider, and its NEEDED entries are
the mangled names. Swap only libggml-cpu, and satisfy the baseline
library's own unmangled NEEDEDs (libggml-base.so.0, libgomp.so.1) with
aliases of the vendored copies, which carry no generated SIMD and are
the same upstream version. Fail closed when the vendored set has
restructured instead of silently shipping the AVX2 wheel.

* fix(packaging): fail builds that lose the speech engine, unblock the ISA assert

Greptile findings on the baseline work:

- A failed baseline rebuild in the AppImage returned success after
  remove_appdir_pywhispercpp, so the bundle would ship with no speech
  engine at all while logging that the wheel was kept. Propagate the
  failure through every CPU-only fallback so the build stops instead.
- The native libggml-cpu assertion used grep -q under pipefail, where
  grep's early exit SIGPIPEs objdump and the pipeline reports a miss,
  letting an AVX2 library through. Read the full disassembly instead.

* fix(installer): build pywhispercpp from sdist on the CPU path

install_cpu_pywhispercpp let pip pick the manylinux wheel, whose
libggml-cpu is compiled for AVX2+FMA and SIGILLs on pre-Haswell CPUs
(#957). Pass --no-binary pywhispercpp like the Vulkan/CUDA paths so the
sdist compiles against this machine's own instruction set.

* fix(installer): install C++ toolchain on distros that ship without one

Forcing the pywhispercpp sdist on the CPU path needs a compiler, and the
fedora, mageia, alpine, void and solus package maps did not request one.
Add gcc-c++/make (rpm), build-base (alpine), base-devel (void) and
gcc/g++/make (solus) so source installs compile on minimal systems.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-16 09:24:15 -07:00

384 lines
16 KiB
Python

"""Regression guards for the native .deb/.rpm packaging (#600).
Offline consistency checks in the shape of test_appimage_packaging.py: the
nfpm config, the pinned build inputs, the smoke gate, and the CI wiring must
agree with pyproject.toml, requirements/runtime.txt, and each other.
"""
import re
import tomllib
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
NATIVE = REPO_ROOT / "packaging" / "native"
NFPM_YAML = NATIVE / "nfpm.yaml"
BUILD_SH = NATIVE / "build.sh"
DOCKER_BUILD_SH = NATIVE / "docker-build.sh"
CONTAINER_BUILD_SH = NATIVE / "container-build.sh"
SMOKE_SH = NATIVE / "smoke-test.sh"
PINS = NATIVE / "tool_checksums.txt"
PYPROJECT = REPO_ROOT / "pyproject.toml"
RUNTIME = REPO_ROOT / "requirements" / "runtime.txt"
WORKFLOWS = REPO_ROOT / ".github" / "workflows"
RELEASE_YML = WORKFLOWS / "release.yml"
PIPELINE_YML = WORKFLOWS / "unified-pipeline.yml"
#: Runtime dependencies no distro packages; build.sh vendors their wheels
#: under /usr/lib/vocalinux/vendor instead of declaring distro deps.
VENDORED = ("pywhispercpp", "pynput")
#: pip name -> the Debian/Ubuntu package that Provides it. pywhispercpp's own
#: runtime deps (tqdm, platformdirs) are the package's deps too.
DEB_PROVIDES = {
"numpy": "python3-numpy",
"requests": "python3-requests",
"tqdm": "python3-tqdm",
"platformdirs": "python3-platformdirs",
"psutil": "python3-psutil",
"evdev": "python3-evdev",
"pyaudio": "python3-pyaudio",
"pysocks": "python3-socks",
"xlib": "python3-xlib",
"pygobject": "python3-gi",
}
#: pip name -> the Fedora package that Provides it. Note python3-pynput is
#: absent on purpose: Fedora has no pynput package, which is half of why
#: VENDORED exists.
RPM_PROVIDES = {
"numpy": "python3-numpy",
"requests": "python3-requests",
"tqdm": "python3-tqdm",
"platformdirs": "python3-platformdirs",
"psutil": "python3-psutil",
"evdev": "python3-evdev",
"pyaudio": "python3-pyaudio",
"pysocks": "python3-pysocks",
"xlib": "python3-xlib",
"pygobject": "python3-gobject",
}
#: Host binaries the injectors and helpers shell out to. Both formats
#: recommend them; install.d documents the same set for install.sh.
HOST_TOOLS = ("xdotool", "wtype", "ydotool", "wl-clipboard", "xclip", "xsel")
#: pywhispercpp's own Requires-Dist (extras excluded): the vendored wheel's
#: dependencies are the package's dependencies too.
PYWHISPERCPP_DEPS = ("numpy", "requests", "tqdm", "platformdirs")
def _nfpm() -> dict:
"""nfpm.yaml as parsed YAML (VOCA_* placeholders parse as strings)."""
return yaml.safe_load(NFPM_YAML.read_text(encoding="utf-8"))
def _deb_dep_names() -> set:
names = set()
for dep in _nfpm()["overrides"]["deb"]["depends"]:
names.add(dep.split(" ", 1)[0])
return names
def _rpm_dep_names() -> set:
names = set()
for dep in _nfpm()["overrides"]["rpm"]["depends"]:
names.add(dep.split(" ", 1)[0])
return names
def _pins() -> dict:
"""name -> (kind, value, source) from tool_checksums.txt."""
pins = {}
for line in PINS.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
name, kind, value, source = line.split(None, 3)
pins[name] = (kind, value, source.strip())
return pins
def _project_dependencies() -> set:
"""pip names of every runtime dependency in pyproject.toml."""
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
names = set()
for dep in data["project"]["dependencies"]:
match = re.match(r"^([A-Za-z0-9._-]+)", dep)
assert match, f"unparseable dependency {dep!r}"
names.add(match.group(1).lower().replace("_", "-"))
return names
def _exported_versions() -> dict:
"""package -> version out of the hash-pinned runtime export."""
return {
match.group(1).lower(): match.group(2)
for match in (
re.match(r"^([A-Za-z0-9._-]+)==([^\s;\\]+)", line)
for line in RUNTIME.read_text(encoding="utf-8").splitlines()
)
if match
}
def test_vendored_set_matches_build_sh_and_runtime_pins() -> None:
"""The vendored wheels are exactly the pinned ones: anything else vendored
drifts from the lock, anything not vendored goes undeclared."""
build = BUILD_SH.read_text(encoding="utf-8")
exported = _exported_versions()
for name in VENDORED:
assert name in exported, f"{name} is not pinned in requirements/runtime.txt"
assert "pip3 download" in build and name in build, f"{name} is not vendored by build.sh"
# Only these two may be downloaded: the awk that writes the vendor
# requirements file must name exactly this set.
match = re.search(r"\(([^)]+)\)==", build)
vendor_names = set(match.group(1).split("|")) if match else set()
assert vendor_names == set(
VENDORED
), f"build.sh vendors {vendor_names}, expected {set(VENDORED)}"
def test_vendored_packages_are_not_declared_as_distro_deps() -> None:
"""A vendored name declared as a dep either fails dependency resolution
(pynput does not exist on Fedora) or shadows the vendored copy."""
deb, rpm = _deb_dep_names(), _rpm_dep_names()
for name in VENDORED:
python_name = f"python3-{name}"
assert python_name not in deb, f"{python_name} declared in deb but {name} is vendored"
assert python_name not in rpm, f"{python_name} declared in rpm but {name} is vendored"
assert name not in deb and name not in rpm
def test_every_project_dependency_is_covered_in_both_formats() -> None:
"""pyproject.toml dependencies must be either vendored or satisfied by a
declared distro package in each format — a miss is an uninstallable
package or an import failure at first run."""
project = _project_dependencies() | set(PYWHISPERCPP_DEPS)
deb, rpm = _deb_dep_names(), _rpm_dep_names()
missing_deb, missing_rpm = [], []
for name in sorted(project):
if name in VENDORED:
continue
if DEB_PROVIDES.get(name) not in deb:
missing_deb.append(name)
if RPM_PROVIDES.get(name) not in rpm:
missing_rpm.append(name)
assert not missing_deb, f"deps with no deb coverage: {missing_deb}"
assert not missing_rpm, f"deps with no rpm coverage: {missing_rpm}"
def test_distro_substrate_is_a_hard_dependency() -> None:
"""Interpreter, PyGObject, GTK and the AppIndicator typelib are what the
app cannot start without; the smoke proves only what the package pulls."""
deb, rpm = _deb_dep_names(), _rpm_dep_names()
for name in (
"python3",
"python3-gi",
"python3-gi-cairo",
"gir1.2-gtk-3.0",
"gir1.2-ayatanaappindicator3-0.1",
):
assert name in deb, f"{name} missing from deb depends"
for name in (
"python3",
"python3-gobject",
"python3-cairo",
"gtk3",
"libayatana-appindicator-gtk3",
):
assert name in rpm, f"{name} missing from rpm depends"
def test_python_floor_matches_requires_python() -> None:
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
floor = re.search(r">=\s*(\d+\.\d+)", data["project"]["requires-python"]).group(1)
deps = _nfpm()["overrides"]
assert f"python3 (>= {floor})" in deps["deb"]["depends"]
assert f"python3 >= {floor}" in deps["rpm"]["depends"]
# VENDOR_PYS in build.sh must start at the same floor and cover the CI
# matrix (3.11..3.14): a missing version means a distro Python with no
# wheel to load.
build = BUILD_SH.read_text(encoding="utf-8")
match = re.search(r'VENDOR_PYS="([^"]+)"', build)
assert match, "VENDOR_PYS not set in build.sh"
versions = match.group(1).split()
assert versions[0] == floor, f"VENDOR_PYS floor {versions[0]} != requires-python {floor}"
assert versions == sorted(versions, key=lambda v: tuple(map(int, v.split("."))))
def test_host_tools_are_recommended_in_both_formats() -> None:
deps = _nfpm()["overrides"]
for tool in HOST_TOOLS:
assert tool in deps["deb"]["recommends"], f"{tool} missing from deb recommends"
assert tool in deps["rpm"]["recommends"], f"{tool} missing from rpm recommends"
def test_the_pins_are_digests_rather_than_names() -> None:
for name, (kind, value, source) in _pins().items():
if kind == "sha256":
assert re.fullmatch(r"[0-9a-f]{64}", value), f"{name}: not a sha256"
assert source.startswith("https://"), f"{name}: {source}"
assert (
"/continuous/" not in source and "/master/" not in source
), f"{name} points at a moving ref, so its digest is a coincidence"
elif kind == "docker":
assert "@sha256:" in value, f"{name}: a tag is not a pin"
elif kind == "version":
assert re.fullmatch(r"\d+(\.\d+)+", value), f"{name}: {value}"
else:
raise AssertionError(f"{name}: unknown pin kind {kind!r}")
def test_every_download_goes_through_the_verifying_helper() -> None:
"""Same contract as the AppImage gate: an unpinned download is a build
nobody can reproduce."""
build = BUILD_SH.read_text(encoding="utf-8")
assert "fetch_pinned" in build
assert build.count("curl -fSL") == 1, "downloads must go through fetch_pinned"
pinned = set(_pins())
fetched = set(re.findall(r'fetch_pinned\s+"?([^"\s]+)"?', build))
fetched.discard("$NFPM_PIN") # resolves to the per-arch nfpm-* pin
for arch in ("nfpm-x86_64", "nfpm-aarch64"):
assert arch in pinned, f"{arch} missing from {PINS.name}"
for name in fetched:
assert name in pinned, f"build.sh fetches '{name}', which {PINS.name} does not pin"
def test_docker_build_uses_the_pinned_base_image() -> None:
text = DOCKER_BUILD_SH.read_text(encoding="utf-8")
assert 'awk \'$1=="base-image"' in text
pins = _pins()
assert "base-image" in pins and pins["base-image"][0] == "docker"
assert pins["base-image"][1].startswith("docker.io/library/debian@sha256:")
def test_nfpm_contents_and_launcher_env_agree() -> None:
"""The launcher and the import smoke must point at where the payload tree
actually lands."""
text = BUILD_SH.read_text(encoding="utf-8")
smoke = SMOKE_SH.read_text(encoding="utf-8")
for where in (text, smoke):
assert "/usr/lib/vocalinux/app" in where
assert "/usr/lib/vocalinux/vendor" in where
assert "/usr/bin/vocalinux" in text and "/usr/bin/vocalinux-gui" in text
assert "pywhispercpp.libs" in text and "pywhispercpp.libs" in smoke
# Arguments keep their boundaries: $* into an unquoted exec re-splits a
# path like "Meeting notes.wav" before argparse sees it (PR #913).
assert '"$@"' in text and "QUOTED_ARGS" in text
assert "EXEC_CMD" not in text
contents = _nfpm()["contents"]
tree = [c for c in contents if c.get("type") == "tree"]
assert len(tree) == 1 and tree[0]["dst"] == "/"
packagers = {c.get("packager") for c in contents}
assert "deb" in packagers and "rpm" in packagers, "license placement must differ per format"
def test_smoke_gate_covers_both_formats() -> None:
smoke = SMOKE_SH.read_text(encoding="utf-8")
assert "debian|ubuntu)" in smoke, "smoke must install the .deb on Debian-family images"
assert "fedora)" in smoke, "smoke must install the .rpm on Fedora"
assert "vocalinux --version" in smoke
assert "vocalinux-gui --version" in smoke
assert "pywhispercpp.model" in smoke, "the smoke must prove the vendored extension loads"
assert (
'find_spec("pynput")' in smoke
), "pynput raises ImportError without a display; check presence, not import"
def test_release_workflow_builds_and_attaches_both_arches() -> None:
text = RELEASE_YML.read_text(encoding="utf-8")
assert "build-native:" in text and "build-native-arm64:" in text
assert "ubuntu-24.04-arm" in text
assert text.count("packaging/native/docker-build.sh") == 2
assert "name: native-x86_64" in text and "name: native-aarch64" in text
assert "dist/*.deb dist/*.rpm" in text
def test_checksums_cover_the_packages() -> None:
text = RELEASE_YML.read_text(encoding="utf-8")
publish = re.search(r"publish-checksums:.*?(?=\n [a-z])", text, re.S)
assert publish, "publish-checksums job not found"
block = publish.group(0)
assert "build-native" in block and "build-native-arm64" in block, (
"publish-checksums must wait for the package jobs or SHA256SUMS is "
"published before the files exist"
)
assert re.search(
r"sha256sum -- .*\*\.deb.*\*\.rpm", block
), "SHA256SUMS must cover .deb and .rpm"
def test_wayland_input_group_is_documented() -> None:
"""The packages cannot run usermod, and `sg input` only helps users
already listed in /etc/group: a fresh Wayland install leaves the default
hotkey dead unless the docs name the step."""
install_md = (REPO_ROOT / "docs" / "INSTALL.md").read_text(encoding="utf-8")
section = install_md.split("## Distro packages", 1)[1].split("\n## ", 1)[0]
assert (
"usermod -aG input" in section
), "INSTALL.md's .deb/.rpm section must document the Wayland input-group step"
release = RELEASE_YML.read_text(encoding="utf-8")
assert (
"usermod -aG input" in release
), "the release notes' .deb/.rpm block must document the same step"
def test_pipeline_gates_the_packages() -> None:
text = PIPELINE_YML.read_text(encoding="utf-8")
assert "- 'packaging/native/**'" in text, "changes filter must watch packaging/native"
assert "native-build:" in text and "native-smoke:" in text
assert "native-x86_64" in text
for distro in ("debian:12", "fedora:42"):
assert distro in text, f"{distro} missing from the smoke matrix"
# Each distro in the smoke matrix needs a recipe in smoke-test.sh.
smoke = SMOKE_SH.read_text(encoding="utf-8")
matrix = re.search(r"native-smoke:.*?distro:\n((?:\s+- [^\n]+\n)+)", text, re.S)
assert matrix
for distro in re.findall(r"- (\S+)", matrix.group(1)):
family = distro.split(":")[0].split("/")[-1]
assert family in smoke, f"smoke-test.sh has no recipe for {distro}"
#: ggml SIMD options pinned off for the portable x86-64 baseline in every
#: packaged pywhispercpp build (#957). GGML_NATIVE=OFF alone leaves each of
#: these on while ggml's native default is on.
PORTABLE_GGML_OPTIONS = (
"GGML_NATIVE",
"GGML_SSE42",
"GGML_AVX",
"GGML_AVX2",
"GGML_BMI2",
"GGML_FMA",
"GGML_F16C",
)
def test_vendored_libggml_cpu_is_rebuilt_at_the_portable_baseline() -> None:
"""The pinned pywhispercpp wheels build.sh vendors carry a libggml-cpu
compiled for AVX2+FMA, which dies with SIGILL at model load on pre-Haswell
CPUs (#957). The package must rebuild just that library from the same
pinned sdist at the plain x86-64 baseline and swap it into the vendored
copies; without a toolchain in the container image that build cannot run."""
build = BUILD_SH.read_text(encoding="utf-8")
assert (
"--no-binary pywhispercpp" in build
), "the baseline libggml-cpu must come from the hash-pinned sdist, not a wheel"
command_lines = [line for line in build.splitlines() if not line.lstrip().startswith("#")]
cmake_args = "\n".join(
line for line in command_lines if "-DGGML_" in line or "cmake -S" in line
)
for flag in PORTABLE_GGML_OPTIONS:
assert (
f"-D{flag}=OFF" in cmake_args
), f"the vendored libggml-cpu rebuild does not pin {flag} off"
assert "libggml-cpu" in build and "find" in build, (
"the rebuild must swap into the vendored copies in place so the"
" wheels' NEEDED entries still resolve"
)
container = CONTAINER_BUILD_SH.read_text(encoding="utf-8")
for tool in ("cmake", "g++", "binutils"):
assert (
tool in container
), f"container-build.sh installs no {tool}; the baseline rebuild fails"