Files
plezy/linux/packaging/check-package-deps.py
edde746 e9a213807f ci(linux): check the runner's libraries reach the package metadata
The plane added three runtime libraries that bundle-libs.sh deliberately does not
bundle, so they have to be declared per distro by hand - and two hand-maintained
lists drifting apart is the failure this guard exists to prevent.

check_linux_package_deps.py parses the runner's CMake for every pkg-config module
it links, follows target_link_libraries to prove each one actually reaches the
binary, and requires a package name for it in every distro's depends list. It
fails closed on the shapes a naive parser gets wrong: a pkg_check_modules call
naming several modules, options preceding the module name, and version
constraints like mpv>=0.40 that would otherwise be read as a package nobody
ships.

The smoke job builds the three packages and reads the dependencies back out of
the artifacts, deriving what to expect from build-packages.py rather than
restating it - so a library is declared once and verified everywhere. That job is
off by default, which is exactly why it must not carry its own copy of the list.

The Linux native job names libwayland-dev and libegl-dev instead of riding
GTK's and epoxy's transitive dev dependencies, matching the CMake comment's own
rationale. In CI the host-dependency guard runs once: the named step covers the
staged bundle, and build-packages.py's internal run - which exists for by-hand
packaging - is skipped. The smoke job also drops patchelf, which nothing
invokes.
2026-08-10 08:48:14 +02:00

149 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Read the declared dependencies back out of the packages fpm just produced.
check-bundle-host-deps.py proves the depends lists in build-packages.py cover
every library the staged bundle loads from the host. Nothing proved those lists
survived fpm. A renamed flag or a dropped `--depends` produces a package that
installs cleanly and then dies in the loader before main(), with every earlier
check green - and only the artifact itself can show it. So this reads the
dependency metadata out of the finished .deb, .rpm and .pkg.tar.zst and fails
when a dependency build-packages.py declares is missing from any of them.
Every name comes from build-packages.py, so adding a library there is verified
here without a second edit. The release job and the package smoke build both run
this against the packages they built, which is why the assertions live here
rather than inline in two workflows that drift apart.
"""
import argparse
import importlib.util
import re
import shutil
import subprocess
import sys
from pathlib import Path
# The checkout this script lives in, derived from its own location so the
# packages may be anywhere; --root stays as the override.
PROJECT_ROOT = Path(__file__).resolve().parents[2]
class Unreadable(Exception):
"""A package whose dependency metadata could not be read at all.
Kept distinct from "the metadata says nothing depends on X": a missing tool
or an unreadable archive proves nothing, and must never be reported as a
package that simply declared everything.
"""
def run(*command: str) -> str:
program, *arguments = command
# Resolved once and forked by full path, so what was probed for existence is
# exactly what ran.
resolved = shutil.which(program)
if resolved is None:
raise Unreadable(f"{program} is not installed, so this package's metadata cannot be read")
result = subprocess.run([resolved, *arguments], capture_output=True, text=True)
if result.returncode != 0:
raise Unreadable(f"{program} failed: {result.stderr.strip() or result.stdout.strip() or 'no diagnostic'}")
return result.stdout
def load_packaging(root: Path):
"""build-packages.py itself, so the expected names are never re-typed here."""
spec = importlib.util.spec_from_file_location("build_packages", root / "linux/packaging/build-packages.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def read_metadata(distro: str, package: Path) -> str:
"""The dependency text the built package carries, in its own format."""
if distro == "deb":
return run("dpkg-deb", "-f", str(package), "Depends")
if distro == "rpm":
return run("rpm", "-qpR", str(package))
if distro == "pacman":
# A pattern, not a literal name: fpm's pacman writer may store the entry
# as `.PKGINFO` or `./.PKGINFO`. Guard the extraction rather than the
# filtered list, so "the archive member was not found" stays separable
# from "fpm dropped every dependency" - the second is the regression this
# script exists to name, and it has to reach the comparison below.
pkginfo = run("bsdtar", "-xOf", str(package), "--include", "*.PKGINFO")
if not pkginfo.strip():
raise Unreadable("could not read .PKGINFO out of the pacman package")
return "\n".join(line.removeprefix("depend = ") for line in pkginfo.splitlines() if line.startswith("depend = "))
# An unrecognised format is an error, not a skip: a distro added to DISTROS
# without a reader here would otherwise ship entirely unverified.
raise Unreadable(f"no reader for the {distro} package format is recorded in {Path(__file__).name}")
def declared_names(blob: str) -> set[str]:
"""Every package name the metadata requires, without version constraints.
Names, not a substring search over the whole blob: `libegl1` is a substring
of `libegl1-mesa`, so a package that declared neither used to pass on the
strength of some unrelated longer dependency. Split on the separators all
three formats use, then drop version constraints, rpm's soname decorations
and deb's architecture qualifier.
"""
found = set()
for token in re.split(r"[,|\s]+", blob):
name = re.sub(r"[<>=].*$", "", token).split("(")[0].split(":")[0]
if name:
found.add(name)
return found
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("directory", type=Path, help="where build-packages.py wrote the packages")
parser.add_argument("--arch", default="x64", help="the ARCH_SUFFIX the packages were built with")
parser.add_argument("--root", type=Path, default=PROJECT_ROOT, help="repository root")
arguments = parser.parse_args()
packaging = load_packaging(arguments.root)
errors: list[str] = []
# An empty roster would walk no packages and still print success, which is
# the one verdict a guard must never reach without evidence.
if not packaging.DISTROS:
errors.append("build-packages.py defines no package formats, so there was nothing to read back")
for distro, config in packaging.DISTROS.items():
package = arguments.directory / f"{packaging.METADATA['name']}-linux-{arguments.arch}.{config['ext']}"
if not package.is_file():
errors.append(f"{package} was not produced")
continue
try:
metadata = read_metadata(distro, package)
except Unreadable as failure:
errors.append(f"{package.name}: {failure}")
continue
print(f"{distro}: {' '.join(metadata.split()) or '(nothing)'}")
# A format that declares nothing cannot be checked against the package,
# so emptying the list would otherwise turn this guard into a no-op.
if not config["depends"]:
errors.append(f"build-packages.py declares no dependencies for {distro}, so this proved nothing")
continue
declared = declared_names(metadata)
for dependency in config["depends"]:
# `libmpv2 | libmpv1` is one dependency with two acceptable names.
if not any(name.strip() in declared for name in dependency.split("|")):
errors.append(f"the {distro} package does not require {dependency}")
for error in errors:
print(f"::error::{error}", file=sys.stderr)
if errors:
return 1
print(f"every dependency build-packages.py declares survived fpm into all {len(packaging.DISTROS)} packages")
return 0
if __name__ == "__main__":
sys.exit(main())