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.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Behavior tests for the package metadata read-back guard.
|
||||
|
||||
The guard exists because nothing between build-packages.py and the upload read
|
||||
anything back out of what fpm wrote: a dropped or renamed `--depends` shipped a
|
||||
package that installs cleanly and dies in the loader, with every earlier check
|
||||
green. These tests hold that line, and the substring case below is the specific
|
||||
regression the shell version it replaced once had - `libegl1` is a substring of
|
||||
`libegl1-mesa`, so a package declaring neither used to pass.
|
||||
|
||||
dpkg-deb, rpm and bsdtar only answer on a machine that has them, so the fixtures
|
||||
put stubs first on PATH and let the real script fork them. That keeps "an rpm
|
||||
missing libdrm" a fixture rather than a machine, and keeps every assertion
|
||||
running against the script CI runs.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# The Windows-safe stub installer, rather than a second copy of its PATHEXT
|
||||
# reasoning. scripts/ is sys.path[0] however this file is invoked.
|
||||
from test_check_bundle_host_deps import install_stub
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CHECKER = ROOT / "linux/packaging/check-package-deps.py"
|
||||
BUILD_PACKAGES = ROOT / "linux/packaging/build-packages.py"
|
||||
|
||||
# The expected names come from build-packages.py the same way the guard reads
|
||||
# them, so a library added there is exercised here without editing a fixture.
|
||||
_spec = importlib.util.spec_from_file_location("build_packages", BUILD_PACKAGES)
|
||||
PACKAGING = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(PACKAGING)
|
||||
DISTROS = PACKAGING.DISTROS
|
||||
NAME = PACKAGING.METADATA["name"]
|
||||
|
||||
# The tool each package format is read with, which is also what the fixtures stub.
|
||||
TOOLS = {"deb": "dpkg-deb", "rpm": "rpm", "pacman": "bsdtar"}
|
||||
|
||||
# One stub per tool: it prints the fixture's answer for its own name, or fails
|
||||
# the way an unreadable archive does. Baking the name in keeps the stub blind to
|
||||
# the arguments, so it cannot accidentally pass by echoing its input.
|
||||
STUB = """import json, os, sys
|
||||
|
||||
answers = json.loads(open(os.environ["PLEZY_TEST_ANSWERS"], encoding="utf-8").read())
|
||||
answer = answers.get({name!r})
|
||||
if answer is None:
|
||||
sys.stderr.write({name!r} + ": cannot read this archive\\n")
|
||||
sys.exit(1)
|
||||
sys.stdout.write(answer)
|
||||
"""
|
||||
|
||||
|
||||
def declared_first_names(distro: str) -> list[str]:
|
||||
"""What a correct package declares: one acceptable name per dependency."""
|
||||
return [dependency.split("|")[0].strip() for dependency in DISTROS[distro]["depends"]]
|
||||
|
||||
|
||||
def decorate(names: list[str], version: str, qualifier: str) -> list[str]:
|
||||
"""Dress the first two names the way the real tools report them.
|
||||
|
||||
A constraint, an architecture qualifier and rpm's soname decoration are noise
|
||||
around a package name. Applying them to fixture names rather than asserting
|
||||
on a hand-written blob means the stripping is tested against the list the
|
||||
packages really declare.
|
||||
"""
|
||||
if len(names) < 2:
|
||||
return names
|
||||
return [f"{names[0]} {version}", f"{names[1]}{qualifier}", *names[2:]]
|
||||
|
||||
|
||||
def deb_metadata(names: list[str] | None = None) -> str:
|
||||
"""`dpkg-deb -f ... Depends` output."""
|
||||
names = declared_first_names("deb") if names is None else names
|
||||
return ", ".join(decorate(names, "(>= 3.24.0)", ":amd64")) + "\n"
|
||||
|
||||
|
||||
def rpm_metadata(names: list[str] | None = None) -> str:
|
||||
"""`rpm -qpR` output, including the requires rpm adds by itself."""
|
||||
names = declared_first_names("rpm") if names is None else names
|
||||
automatic = ["/bin/sh", "libc.so.6(GLIBC_2.34)(64bit)", "rpmlib(PayloadIsXz) <= 5.2-1"]
|
||||
return "\n".join(automatic + decorate(names, ">= 3.24", "(x86-64)")) + "\n"
|
||||
|
||||
|
||||
def pkginfo_metadata(names: list[str] | None = None) -> str:
|
||||
"""A whole .PKGINFO, so the `depend = ` filter is what isolates the names."""
|
||||
names = declared_first_names("pacman") if names is None else names
|
||||
header = [f"pkgname = {NAME}", "pkgver = 1.2.3-1", "arch = x86_64"]
|
||||
depends = [f"depend = {name}" for name in decorate(names, ">=3.24", "")]
|
||||
return "\n".join(header + depends) + "\n"
|
||||
|
||||
|
||||
def correct_metadata() -> dict[str, str]:
|
||||
return {"dpkg-deb": deb_metadata(), "rpm": rpm_metadata(), "bsdtar": pkginfo_metadata()}
|
||||
|
||||
|
||||
class PackageDepsReadBackTest(unittest.TestCase):
|
||||
def _check(
|
||||
self,
|
||||
metadata: dict[str, str | None],
|
||||
arch: str = "x64",
|
||||
produce: tuple[str, ...] = ("deb", "rpm", "pacman"),
|
||||
install: tuple[str, ...] = ("dpkg-deb", "rpm", "bsdtar"),
|
||||
path: str | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Stage packages plus tool stubs and run the real guard over them."""
|
||||
with tempfile.TemporaryDirectory(prefix="plezy-package-deps-test-") as directory:
|
||||
staging = Path(directory)
|
||||
packages = staging / "packages"
|
||||
packages.mkdir()
|
||||
for distro in produce:
|
||||
(packages / f"{NAME}-linux-{arch}.{DISTROS[distro]['ext']}").write_bytes(b"")
|
||||
|
||||
answers = staging / "answers.json"
|
||||
answers.write_text(json.dumps(metadata), encoding="utf-8")
|
||||
scripts, tools = staging / "stubs", staging / "tools"
|
||||
scripts.mkdir()
|
||||
tools.mkdir()
|
||||
for tool in install:
|
||||
install_stub(scripts, tools, tool, STUB.format(name=tool))
|
||||
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CHECKER), str(packages), "--arch", arch],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
**os.environ,
|
||||
# The stubs first, so a machine that really has dpkg-deb
|
||||
# answers from the fixture. `path=""` leaves only the stubs,
|
||||
# which is how a tool is made genuinely absent.
|
||||
"PATH": str(tools) + os.pathsep + (os.environ.get("PATH", "") if path is None else path),
|
||||
"PLEZY_TEST_ANSWERS": str(answers),
|
||||
},
|
||||
)
|
||||
|
||||
def test_packages_carrying_every_declared_dependency_pass(self) -> None:
|
||||
result = self._check(correct_metadata())
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("survived fpm", result.stdout)
|
||||
# Version constraints, deb's arch qualifier and rpm's soname decorations
|
||||
# are noise around a name, not a different package.
|
||||
self.assertNotIn("::error::", result.stderr)
|
||||
|
||||
def test_a_dependency_fpm_dropped_is_named(self) -> None:
|
||||
"""The regression the guard exists for: a name that reached fpm and not the package."""
|
||||
for distro in DISTROS:
|
||||
with self.subTest(distro=distro):
|
||||
dropped = declared_first_names(distro)[0]
|
||||
kept = declared_first_names(distro)[1:]
|
||||
metadata = correct_metadata()
|
||||
metadata[TOOLS[distro]] = {
|
||||
"deb": deb_metadata,
|
||||
"rpm": rpm_metadata,
|
||||
"pacman": pkginfo_metadata,
|
||||
}[distro](kept)
|
||||
|
||||
result = self._check(metadata)
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout)
|
||||
self.assertIn(f"the {distro} package does not require {dropped}", result.stderr)
|
||||
self.assertNotIn("survived fpm", result.stdout)
|
||||
|
||||
def test_a_longer_package_name_does_not_satisfy_a_shorter_one(self) -> None:
|
||||
"""`libegl1-mesa` is not `libegl1`, however much of one it contains."""
|
||||
names = [f"{name}-mesa" if name == "libegl1" else name for name in declared_first_names("deb")]
|
||||
self.assertIn("libegl1-mesa", names, "the deb list no longer contains libegl1")
|
||||
metadata = correct_metadata() | {"dpkg-deb": deb_metadata(names)}
|
||||
|
||||
result = self._check(metadata)
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout)
|
||||
self.assertIn("the deb package does not require libegl1", result.stderr)
|
||||
|
||||
def test_a_package_fpm_never_wrote_fails(self) -> None:
|
||||
result = self._check(correct_metadata(), produce=("deb", "pacman"))
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout)
|
||||
self.assertIn(f"{NAME}-linux-x64.rpm", result.stderr)
|
||||
self.assertIn("was not produced", result.stderr)
|
||||
self.assertNotIn("survived fpm", result.stdout)
|
||||
|
||||
def test_an_unreadable_pkginfo_is_not_a_missing_dependency(self) -> None:
|
||||
""""the archive member was not found" must stay separable from "fpm dropped everything"."""
|
||||
result = self._check(correct_metadata() | {"bsdtar": ""})
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout)
|
||||
self.assertIn("could not read .PKGINFO", result.stderr)
|
||||
self.assertNotIn("does not require", result.stderr)
|
||||
|
||||
def test_a_tool_that_cannot_run_is_a_failure_not_a_pass(self) -> None:
|
||||
"""A guard that proved nothing must never report that it proved something.
|
||||
|
||||
Both halves matter: a reader absent from the machine, and one present but
|
||||
refusing the archive. Either way nothing was read, so nothing is declared.
|
||||
"""
|
||||
for distro, tool in TOOLS.items():
|
||||
with self.subTest(missing=tool):
|
||||
installed = tuple(name for name in TOOLS.values() if name != tool)
|
||||
absent = self._check(correct_metadata(), install=installed, path="")
|
||||
self.assertEqual(absent.returncode, 1, absent.stdout)
|
||||
self.assertIn(f"{tool} is not installed", absent.stderr)
|
||||
self.assertNotIn("survived fpm", absent.stdout)
|
||||
|
||||
with self.subTest(failing=tool):
|
||||
broken = self._check(correct_metadata() | {tool: None})
|
||||
self.assertEqual(broken.returncode, 1, broken.stdout)
|
||||
self.assertIn(f"{tool} failed", broken.stderr)
|
||||
self.assertNotIn(f"the {distro} package does not require", broken.stderr)
|
||||
|
||||
def test_the_release_architecture_is_read_from_its_own_files(self) -> None:
|
||||
"""The release job ships arm64 too, and x64 filenames must not stand in for it."""
|
||||
result = self._check(correct_metadata(), arch="arm64")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
missing = self._check(correct_metadata(), arch="x64", produce=())
|
||||
self.assertEqual(missing.returncode, 1, missing.stdout)
|
||||
self.assertIn(f"{NAME}-linux-x64.deb", missing.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user