* feat(packaging): build thin .deb and .rpm with nfpm and attach them to releases Adds packaging/native: an nfpm config that produces a .deb and an .rpm from the release wheel. The packages stay thin - Python, GTK, PyGObject and the typelibs are distro Depends; only pywhispercpp and pynput are vendored because the first is unpackaged on both distros and the second is missing on Fedora. The Debian 12 build image and the nfpm download are pinned in tool_checksums.txt. just native-packages builds both in the pinned container, and native-smoke installs the built package per distro: debian:12 and ubuntu:24.04 exercise the .deb, fedora:42 the .rpm, then each runs vocalinux --version plus an import smoke of the app and the vendored extension. unified-pipeline.yml gates on the same three-distro matrix, and release.yml attaches both arches to the release and covers them in SHA256SUMS and the provenance manifest. Refs #600 * fix(packaging): preserve launcher args and document Wayland input group The launcher joined $* into an unquoted exec, re-splitting file paths that contain spaces before argparse saw them. Exec "$@" directly and re-quote each argument for the inner shell of 'sg input -c'. On Wayland the packages could not grant the 'input' group membership evdev hotkeys need; name the usermod step and its caveat in INSTALL.md and the release notes. * test(packaging): add return types to the native packaging tests --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
339 lines
14 KiB
Python
339 lines
14 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",
|
|
"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",
|
|
"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}"
|