Files
voculinux/tests/test_appimage_packaging.py
T
SergeyandGitHub 65381b4582 fix(appimage): build against a glibc floor users actually have (#743)
* build(appimage): pin the interpreter, packages and tooling

* ci(appimage): build inside a pinned ubuntu 22.04 image

* test(appimage): guard the pins and the base image

* docs: state the AppImage glibc floor

* fix(appimage): bundle libibus with its typelib

* ci(appimage): let the container run the isolated GI smoke test

* fix(appimage): keep the bundle's libraries out of host binaries

* fix(appimage): bundle libgpg-error with the libgcrypt that needs it

* docs(agents): require host_env() for spawned binaries

* ci: run the AppImage jobs when its other inputs change

* fix(appimage): check every bundled typelib, not just the seeded ones

* fix(ibus): keep the bundle when respawning our own interpreter

* fix(injection): pass host_env() to setxkbmap and notify-send

* test(ibus): wait for the IBus warmup thread before asserting
2026-08-29 14:46:50 -07:00

253 lines
10 KiB
Python

"""Regression guards for AppImage packaging."""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
APPIMAGE = REPO_ROOT / "packaging" / "appimage"
BUILD_SH = APPIMAGE / "build.sh"
PINS = APPIMAGE / "tool_checksums.txt"
REQUIREMENTS = REPO_ROOT / "requirements"
WORKFLOWS = REPO_ROOT / ".github" / "workflows"
#: Workflows that build an AppImage, and so decide which distros it runs on.
APPIMAGE_WORKFLOWS = ("unified-pipeline.yml", "release.yml", "nightly.yml")
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 _exported_versions(path: Path) -> dict:
"""package -> version out of a hash-pinned requirements export."""
return {
match.group(1).lower(): match.group(2)
for match in (
re.match(r"^([A-Za-z0-9._-]+)==([^\s;\\]+)", line)
for line in path.read_text(encoding="utf-8").splitlines()
)
if match
}
# Typelibs that must ship in the AppImage. xlib/Dbusmenu/GModule/fontconfig are
# transitive GIR deps — pruning them broke Gtk on openSUSE (#585).
REQUIRED_TYPELIBS = (
"Gtk-3.0",
"Gdk-3.0",
"xlib-2.0",
"GModule-2.0",
"Dbusmenu-0.4",
"fontconfig-2.0",
"Notify-0.7",
)
# Tray stacks are alternates (same order as tray_indicator.py).
INDICATOR_TYPELIBS = (
"AyatanaAppIndicator3-0.1",
"AyatanaAppindicator3-0.1",
"AppIndicator3-0.1",
)
def test_appimage_build_ships_transitive_typelibs():
text = BUILD_SH.read_text()
assert "TYPELIBS=(" in text
# Must not wipe linuxdeploy's typelib set down to a partial allowlist.
assert 'rm -rf "$APPDIR/usr/lib/girepository-1.0"' not in text
assert "keeping linuxdeploy extras" in text or "Ensuring required typelibs" in text
for typelib in REQUIRED_TYPELIBS:
assert typelib in text, f"missing typelib seed {typelib} in {BUILD_SH}"
for typelib in INDICATOR_TYPELIBS:
assert typelib in text, f"missing indicator typelib seed {typelib}"
assert "INDICATOR_TYPELIBS=" in text
assert "indicator_found" in text
assert "Need at least one of:" in text
def test_every_typelib_the_app_requires_is_seeded():
"""A required typelib the bundle does not seed sends GI to the host's search
path — the openSUSE/Fedora breakage from #585."""
seeded = BUILD_SH.read_text(encoding="utf-8")
required = set()
for path in (REPO_ROOT / "src" / "vocalinux").rglob("*.py"):
for namespace, version in re.findall(
r"require_version\(\s*[\"']([\w]+)[\"']\s*,\s*[\"']([\d.]+)[\"']", path.read_text()
):
required.add(f"{namespace}-{version}")
assert required, "no gi.require_version calls found; has the app stopped using GI?"
missing = sorted(name for name in required if name not in seeded)
assert not missing, f"the app requires these typelibs; TYPELIBS does not seed them: {missing}"
def test_bundled_typelibs_come_with_the_library_they_load():
"""Ship a typelib without the library it dlopens and GI loads the host's
copy against the bundle's older GLib. IBus did: on any host with GLib 2.76+
the app died on `undefined symbol: g_task_set_static_name`. The build host
cannot see it — its libraries match what it bundled."""
text = BUILD_SH.read_text(encoding="utf-8")
assert "libibus-1.0.so.5" in text, "the IBus typelib is seeded but its library is not bundled"
assert "verify_typelib_libraries" in text
assert "HOST_PROVIDED_LIBS" in text, (
"the check needs its list of libraries the excludelist keeps on the host, "
"or harfbuzz fails the build"
)
def test_appimage_build_bundles_gi_runtime_libs():
text = BUILD_SH.read_text()
for lib in (
"libappindicator3.so.1",
"libayatana-appindicator3.so.1",
"libdbusmenu-glib.so.4",
"libdbusmenu-gtk3.so.4",
"libnotify.so.4",
):
assert lib in text, f"missing GI runtime lib {lib} in {BUILD_SH}"
def test_appimage_build_smokes_gi_without_host_typelibs():
text = BUILD_SH.read_text()
assert "smoke_gi_imports" in text
assert "unshare --user --mount" in text
def test_appimage_build_rebuilds_pywhispercpp_with_vulkan():
text = BUILD_SH.read_text()
assert "GGML_VULKAN=1" in text
assert "libggml-vulkan" in text
assert "VOCALINUX_APPIMAGE_REQUIRE_VULKAN" in text
assert "VOCALINUX_APPIMAGE_SKIP_VULKAN" in text
assert "rebuild_pywhispercpp_vulkan" in text
assert 'CC="${CC:-gcc}"' in text
assert 'CXX="${CXX:-g++}"' in text
def test_pywhispercpp_is_pinned_once_and_the_pins_agree():
"""1.5.1 landed mid-review and broke both AppImage jobs with nothing in the
repo changed. The bundle and the Vulkan rebuild install the same sdist, so
install.sh and the lock export have to agree on which one."""
installer = (REPO_ROOT / "install.sh").read_text(encoding="utf-8")
declared = re.search(r'^PYWHISPERCPP_VERSION="([^"]+)"', installer, re.M)
assert declared, "install.sh no longer declares the version build.sh reads"
exported = _exported_versions(REQUIREMENTS / "vad.txt").get("pywhispercpp")
assert exported == declared.group(1), (
"install.sh and requirements/vad.txt pin different pywhispercpp releases; "
"change pyproject.toml and run `just lock`"
)
build = BUILD_SH.read_text(encoding="utf-8")
assert 'PYWHISPERCPP_VERSION="$(sed' in build, "the pin must come from install.sh"
assert "--no-binary pywhispercpp" in build, "the Vulkan rebuild must build from source"
def test_every_download_goes_through_the_verifying_helper():
"""`continuous` and `master` move under us, and nothing noticed."""
text = BUILD_SH.read_text(encoding="utf-8")
assert (
text.count("curl_retry -o") == 1
), "downloads must go through fetch_pinned, which checks the digest first"
assert "/continuous/" not in text
assert "linuxdeploy-plugin-gtk/master/" not in text
pinned = set(_pins())
for name in set(re.findall(r'fetch_pinned "([^"]+)"', text)):
wanted = (
[name.replace("$ARCH", arch) for arch in ("x86_64", "aarch64")]
if "$ARCH" in name
else [name]
)
for entry in wanted:
assert entry in pinned, f"build.sh fetches '{entry}', which {PINS.name} does not pin"
def test_the_pins_are_digests_rather_than_names():
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 == "git":
assert re.fullmatch(r"[0-9a-f]{40}", value), f"{name}: not a commit"
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_the_appimage_is_built_in_the_pinned_base_image():
"""An AppImage cannot run on a glibc older than the one that built it, and
ubuntu-latest (2.39) rules out Debian 12, Ubuntu 22.04 and RHEL 9 — which is
what we shipped as the universal option."""
for workflow in APPIMAGE_WORKFLOWS:
text = (WORKFLOWS / workflow).read_text(encoding="utf-8")
assert (
"packaging/appimage/build.sh" not in text
), f"{workflow} runs build.sh on the runner; go through docker-build.sh"
assert "packaging/appimage/docker-build.sh" in text, workflow
image = _pins()["base-image"][1]
assert "22.04" in _pins()["base-image"][2], (
"raising the base image raises the glibc floor and silently drops distros; "
"if that is intended, update docs/INSTALL.md in the same change"
)
assert image.startswith("docker.io/library/ubuntu@sha256:")
def test_the_bundle_is_installed_from_the_lock():
text = BUILD_SH.read_text(encoding="utf-8")
assert "-m pip install" not in text, "pip resolves at build time; install from the exports"
assert "--require-hashes" in text
for export in ("requirements/vad.txt", "requirements/appimage.txt"):
assert export in text, f"{export} is not what the bundle is built from"
def test_the_vad_export_still_covers_the_runtime_export():
"""build.sh installs vad.txt alone, on the assumption it carries everything
runtime.txt does. Two resolutions that drift would ship a third one."""
runtime = _exported_versions(REQUIREMENTS / "runtime.txt")
vad = _exported_versions(REQUIREMENTS / "vad.txt")
missing = {name: ver for name, ver in runtime.items() if vad.get(name) != ver}
assert not missing, f"requirements/vad.txt no longer covers runtime.txt: {missing}"
def test_pygobject_stays_on_the_line_the_base_image_can_build():
"""3.52 moved to girepository-2.0 (glib 2.80+) and the base image has 2.72,
so bumping this to the lock's version fails the build."""
version = _exported_versions(REQUIREMENTS / "appimage.txt").get("pygobject")
assert version, "requirements/appimage.txt no longer pins PyGObject"
assert tuple(int(part) for part in version.split(".")[:2]) <= (3, 50), version
def test_vulkan_builds_against_pinned_headers_not_the_base_image_ones():
"""ggml-vulkan.cpp does not compile against the base image's headers
(1.3.204): PipelineRobustnessCreateInfoEXT and friends are younger. Only the
compile sees them — the bundle carries no loader."""
assert "vulkan-headers" in _pins(), "the Vulkan headers are not pinned"
assert "-DVulkan_INCLUDE_DIR=" in BUILD_SH.read_text(
encoding="utf-8"
), "the rebuild does not point ggml at the pinned headers"
def test_vulkan_shaders_get_the_compiler_the_base_image_lacks():
"""ggml demands glslc specifically, and Ubuntu 22.04 packages none."""
text = BUILD_SH.read_text(encoding="utf-8")
assert "ensure_glslc" in text
assert "shaderc" in _pins()
for workflow in APPIMAGE_WORKFLOWS:
content = (WORKFLOWS / workflow).read_text(encoding="utf-8")
assert (
"vocalinux-appimage" in content
), f"{workflow} does not cache the built glslc; every run rebuilds it"