Files
voculinux/tests/test_native_packaging.py
T
xavierk 80a3792576
CI / security (pull_request) Failing after 1m25s
CI / lint (pull_request) Failing after 34s
CI / ai-review (pull_request) Successful in 25s
chore: import vocalinux project
Import the vocalinux source tree (tray app, Next.js site in web/,
packaging, installers, docs and tests) on top of the Gitea template
commit. The project README replaces the template README, and the
template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
2026-11-06 11:51:44 +05:30

392 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_deb_recommends_the_appindicator_glib_tray_backend() -> None:
"""The tray prefers libayatana-appindicator-glib; the GTK 3 typelib stays a
hard dependency so Debian 12, which lacks the glib one, still installs."""
deps = _nfpm()["overrides"]["deb"]
assert "gir1.2-ayatanaappindicatorglib-2.0" in deps["recommends"]
assert "gir1.2-ayatanaappindicator3-0.1" in deps["depends"]
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"