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.
149 lines
6.6 KiB
Python
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())
|