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())
|