* 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>
384 lines
16 KiB
Python
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"
|