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:
edde746
2026-08-10 08:48:14 +02:00
parent bcd6fe9906
commit e9a213807f
14 changed files with 2335 additions and 41 deletions
+51 -5
View File
@@ -18,6 +18,23 @@ print(value)
PY
}
# For keys that are genuinely optional, where absence means "not offered"
# rather than a broken manifest. Pinned values never come through here: a
# missing checksum or commit has to stay fatal.
manifest_optional() {
python3 - "$NATIVE_INPUTS_MANIFEST" "$1" "$2" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as source:
manifest = json.load(source)
value = manifest["inputs"][sys.argv[2]].get(sys.argv[3], "")
if not isinstance(value, str):
raise SystemExit(f"invalid manifest value: {sys.argv[2]}.{sys.argv[3]}")
print(value)
PY
}
FFMPEG_VERSION="$(manifest_value ffmpeg version)"
FFMPEG_URL="$(manifest_value ffmpeg url)"
FFMPEG_SHA256="$(manifest_value ffmpeg sha256)"
@@ -27,6 +44,7 @@ SHADERC_REF="$(manifest_value shaderc ref)"
SHADERC_COMMIT="$(manifest_value shaderc commit)"
LIBPLACEBO_VERSION="$(manifest_value libplacebo version)"
LIBPLACEBO_URL="$(manifest_value libplacebo url)"
LIBPLACEBO_MIRROR="$(manifest_optional libplacebo mirror)"
LIBPLACEBO_REF="$(manifest_value libplacebo ref)"
LIBPLACEBO_COMMIT="$(manifest_value libplacebo commit)"
MPV_VERSION="$(manifest_value mpv version)"
@@ -55,11 +73,18 @@ download_verified() {
mkdir -p "$(dirname "$destination")"
temporary="$(mktemp "${destination}.tmp.XXXXXX")"
# Retries cover the transfer only. A checksum mismatch below is never retried:
# that is a tampered or moved artefact, not a flaky connection, and trying
# again would only turn a loud failure into an intermittent one.
if ! curl \
--fail \
--location \
--silent \
--show-error \
--retry 3 \
--retry-connrefused \
--retry-delay 5 \
--connect-timeout 30 \
--proto '=https,file' \
--tlsv1.2 \
--output "$temporary" \
@@ -85,23 +110,44 @@ checkout_verified_ref() {
local ref="$2"
local expected_commit="$3"
local destination="$4"
local mirror="${5:-}"
local actual_commit
local source
local attempt
if [[ ! "$expected_commit" =~ ^[0-9a-f]{40}$ ]]; then
echo "Invalid Git commit pin for $url at $ref" >&2
return 1
fi
# Retries and the mirror cover the transfer, never the verification. The commit
# pin below is checked identically whichever source answered, so a mirror can
# only supply the same tree or fail - it cannot substitute another one.
#
# This exists because code.videolan.org, the only source fetched over git,
# refused connections for well over two minutes at a time across several CI
# runs and took every build with it.
rm -rf "$destination"
if ! git clone --quiet --depth 1 --branch "$ref" --no-checkout \
"$url" "$destination"; then
rm -rf "$destination"
for source in "$url" ${mirror:+"$mirror"}; do
for attempt in 1 2 3; do
if git clone --quiet --depth 1 --branch "$ref" --no-checkout \
"$source" "$destination"; then
break 2
fi
rm -rf "$destination"
# No point pausing before giving up on this source.
if [ "$attempt" -lt 3 ]; then sleep $((attempt * 5)); fi
done
echo "Could not clone $source at $ref after 3 attempts" >&2
done
if [ ! -d "$destination" ]; then
echo "No source produced $ref for $url" >&2
return 1
fi
actual_commit="$(git -C "$destination" rev-parse 'HEAD^{commit}')"
if [ "$actual_commit" != "$expected_commit" ]; then
echo "Git ref mismatch for $url at $ref" >&2
echo "Git ref mismatch for $ref" >&2
echo "Expected: $expected_commit" >&2
echo "Actual: $actual_commit" >&2
rm -rf "$destination"
@@ -201,7 +247,7 @@ main() {
echo "==> Building libplacebo $LIBPLACEBO_VERSION (static)..."
checkout_verified_ref \
"$LIBPLACEBO_URL" "$LIBPLACEBO_REF" "$LIBPLACEBO_COMMIT" \
"$srcdir/libplacebo-v${LIBPLACEBO_VERSION}"
"$srcdir/libplacebo-v${LIBPLACEBO_VERSION}" "$LIBPLACEBO_MIRROR"
cd "libplacebo-v${LIBPLACEBO_VERSION}"
git submodule update --init --recursive
+214 -5
View File
@@ -14,6 +14,13 @@ assert_absent() {
[ ! -e "$1" ] || fail "unexpected path remains: $1"
}
init_repository() {
mkdir -p "$1"
git -C "$1" init --quiet
git -C "$1" config user.name "Plezy provenance test"
git -C "$1" config user.email "provenance-test@invalid.example"
}
temporary="$(mktemp -d)"
trap 'rm -rf "$temporary"' EXIT
@@ -36,10 +43,7 @@ fi
repository="$temporary/repository"
checkout="$temporary/checkout"
mkdir -p "$repository"
git -C "$repository" init --quiet
git -C "$repository" config user.name "Plezy provenance test"
git -C "$repository" config user.email "provenance-test@invalid.example"
init_repository "$repository"
printf 'first\n' >"$repository/input.txt"
git -C "$repository" add input.txt
git -C "$repository" commit --quiet -m first
@@ -49,6 +53,27 @@ checkout_verified_ref "file://$repository" release "$approved_commit" "$checkout
[ "$(git -C "$checkout" rev-parse HEAD)" = "$approved_commit" ] ||
fail "verified checkout selected the wrong commit"
# A dead primary must fall through to the mirror, because the whole point is
# that one unreachable host cannot stop the build. This runs while the tag still
# points at the approved commit; the moved-tag case is below.
unreachable="file://$temporary/definitely-not-a-repository"
checkout_verified_ref "$unreachable" release "$approved_commit" "$checkout" "file://$repository"
[ "$(git -C "$checkout" rev-parse HEAD)" = "$approved_commit" ] ||
fail "mirror fallback selected the wrong commit"
# And the mirror answers to the same pin. A mirror serving a different tree is
# the one thing a fallback must never quietly accept.
mirror_repository="$temporary/mirror"
init_repository "$mirror_repository"
printf 'substituted\n' >"$mirror_repository/input.txt"
git -C "$mirror_repository" add input.txt
git -C "$mirror_repository" commit --quiet -m substituted
git -C "$mirror_repository" tag release
if checkout_verified_ref "$unreachable" release "$approved_commit" "$checkout" "file://$mirror_repository"; then
fail "mirror serving another commit was accepted"
fi
assert_absent "$checkout"
printf 'second\n' >"$repository/input.txt"
git -C "$repository" commit --quiet -am second
git -C "$repository" tag --force release >/dev/null
@@ -57,4 +82,188 @@ if checkout_verified_ref "file://$repository" release "$approved_commit" "$check
fi
assert_absent "$checkout"
echo "Linux native acquisition verification passed"
# ─── The build plan ─────────────────────────────────────────────────────────
# Everything above checks how sources arrive. What they are then configured
# with decides whether the feature works at all, and it fails quietly: a libmpv
# built without -Dwayland=enabled still compiles, still links and still plays,
# it just has no Wayland backend, so the render context cannot accept
# MPV_RENDER_PARAM_WL_DISPLAY, vaapi finds no device and hardware decoding
# drops to a copy-back path. Nothing crashes, so nothing else notices. Run
# main() for real against stub build tools and assert the argument vectors they
# were handed.
stub_bin="$temporary/stub-bin"
stub_extra="$temporary/stub-extra"
records="$temporary/records"
mkdir -p "$stub_bin" "$stub_extra" "$records" "$temporary/tmp"
# Deliberately unlike the pinned versions: every path below is derived from the
# manifest, so a stub that matched by accident would prove nothing.
ffmpeg_version="9.9.9"
shaderc_version="6.6.6"
libplacebo_version="7.7.7"
mpv_version="8.8.8"
# Each stub records the vector it was called with, one argument per line, plus
# the directory it ran in - which is what tells mpv's meson call apart from
# libplacebo's. An argument containing a newline would corrupt the record, and
# none of these ever does: they are literal flags and mktemp -d paths.
make_stub() {
local directory="$1" name="$2" extra="${3:-}"
cat >"$directory/$name" <<STUB
#!/usr/bin/env bash
set -euo pipefail
record="\$(mktemp "$records/record.XXXXXX")"
{ printf '%s\n' "$name" "\$(pwd)"; [ \$# -eq 0 ] || printf '%s\n' "\$@"; } >"\$record"
$extra
STUB
chmod +x "$directory/$name"
}
payload="$temporary/payload.bin"
printf 'stub archive payload\n' >"$payload"
payload_sha256="$(sha256_file "$payload")"
curl_extra='
output=""
previous=""
for argument in "$@"; do
if [ "$previous" = "--output" ]; then output="$argument"; fi
previous="$argument"
done
[ -n "$output" ] || { echo "stub curl: no --output argument" >&2; exit 1; }
cp -- "'"$payload"'" "$output"
'
# The unmapped case is fatal on purpose: a new archive in the build plan has to
# come here and be described rather than silently extract to nothing.
tar_extra='
archive="$(basename -- "${!#}")"
case "$archive" in
ffmpeg.tar.xz) directory="ffmpeg-'"$ffmpeg_version"'" ;;
mpv.tar.gz) directory="mpv-'"$mpv_version"'" ;;
*) echo "stub tar: unexpected archive $archive" >&2; exit 1 ;;
esac
mkdir -p "$directory"
cp -- "'"$stub_extra"'/configure" "$directory/configure"
chmod +x "$directory/configure"
'
# ffmpeg runs ./configure out of its own tarball, so that one is planted by the
# tar stub rather than found on PATH.
make_stub "$stub_extra" configure
make_stub "$stub_bin" curl "$curl_extra"
make_stub "$stub_bin" tar "$tar_extra"
for tool in make cmake meson ninja; do
make_stub "$stub_bin" "$tool"
done
# git stays real, pointed at local repositories: the checkout is pinned by
# commit, and a stub that answered rev-parse would be asserting its own input.
shaderc_repository="$temporary/shaderc-source"
init_repository "$shaderc_repository"
mkdir -p "$shaderc_repository/utils"
printf '#!/bin/sh\nexit 0\n' >"$shaderc_repository/utils/git-sync-deps"
chmod +x "$shaderc_repository/utils/git-sync-deps"
git -C "$shaderc_repository" add utils/git-sync-deps
# Windows checkouts do not track the mode bit, and the build plan executes it.
git -C "$shaderc_repository" update-index --chmod=+x utils/git-sync-deps
git -C "$shaderc_repository" commit --quiet -m shaderc
git -C "$shaderc_repository" tag release
shaderc_commit="$(git -C "$shaderc_repository" rev-parse HEAD)"
libplacebo_repository="$temporary/libplacebo-source"
init_repository "$libplacebo_repository"
printf 'stub libplacebo\n' >"$libplacebo_repository/meson.build"
git -C "$libplacebo_repository" add meson.build
git -C "$libplacebo_repository" commit --quiet -m libplacebo
git -C "$libplacebo_repository" tag release
libplacebo_commit="$(git -C "$libplacebo_repository" rev-parse HEAD)"
manifest="$temporary/native-inputs.json"
cat >"$manifest" <<JSON
{
"inputs": {
"ffmpeg": {
"version": "$ffmpeg_version",
"url": "https://stub.invalid/ffmpeg.tar.xz",
"sha256": "$payload_sha256"
},
"shaderc": {
"version": "$shaderc_version",
"url": "file://$shaderc_repository",
"ref": "release",
"commit": "$shaderc_commit"
},
"libplacebo": {
"version": "$libplacebo_version",
"url": "file://$libplacebo_repository",
"ref": "release",
"commit": "$libplacebo_commit"
},
"mpv": {
"version": "$mpv_version",
"url": "https://stub.invalid/mpv.tar.gz",
"sha256": "$payload_sha256"
}
}
}
JSON
# JOBS keeps nproc out of it; TMPDIR keeps main()'s own mktemp -d inside the
# directory this test already cleans up.
build_log="$temporary/build.log"
if ! (
export NATIVE_INPUTS_MANIFEST="$manifest"
export PATH="$stub_bin:$PATH"
export TMPDIR="$temporary/tmp"
export PREFIX="$temporary/prefix"
export JOBS=1
bash "$SCRIPT_DIR/build-libmpv.sh"
) >"$build_log" 2>&1; then
cat "$build_log" >&2
fail "the stubbed build plan did not run to completion"
fi
recorded_call() {
local program="$1" directory="$2" candidate
for candidate in "$records"/record.*; do
[ -f "$candidate" ] || continue
if [ "$(sed -n 1p "$candidate")" = "$program" ] &&
[ "$(basename -- "$(sed -n 2p "$candidate")")" = "$directory" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
# -Fxq, so this matches a whole recorded argument. A substring search would
# accept -Dwayland=enabled inside a comment, which is exactly the hole here.
assert_argument() {
local record="$1" argument="$2" description="$3"
tail -n +3 "$record" | grep -Fxq -- "$argument" ||
fail "$description does not pass $argument"
}
mpv_meson="$(recorded_call meson "mpv-$mpv_version")" ||
fail "the build plan never ran meson in the mpv source tree"
[ "$(sed -n 3p "$mpv_meson")" = "setup" ] ||
fail "mpv's first meson call is no longer 'setup'"
assert_argument "$mpv_meson" "-Dwayland=enabled" "mpv's meson setup"
assert_argument "$mpv_meson" "-Dx11=disabled" "mpv's meson setup"
assert_argument "$mpv_meson" "-Dvaapi=enabled" "mpv's meson setup"
assert_argument "$mpv_meson" "-Dgl=enabled" "mpv's meson setup"
# vaapi has to reach ffmpeg too, or mpv's hwdec has no decoder behind it.
ffmpeg_configure="$(recorded_call configure "ffmpeg-$ffmpeg_version")" ||
fail "the build plan never configured ffmpeg"
assert_argument "$ffmpeg_configure" "--enable-vaapi" "ffmpeg's configure"
# libplacebo runs meson as well, and its call carries no Wayland flag: finding
# it separately is what proves the directory above really discriminated.
recorded_call meson "libplacebo-v$libplacebo_version" >/dev/null ||
fail "the build plan never ran meson in the libplacebo source tree"
echo "Linux native acquisition and build plan verification passed"
+125 -6
View File
@@ -3,6 +3,7 @@ import os
import re
import subprocess
import shutil
import sys
from pathlib import Path
# Paths
@@ -41,16 +42,50 @@ DISTROS = {
# The native video plane links wayland-client, wayland-egl and EGL, and
# bundle-libs.sh deliberately never bundles those: they are coupled to
# the running compositor and GPU driver. So they have to be declared.
#
# libmpv itself travels *in* the package rather than being depended on,
# because the plane needs the pinned Wayland-enabled build - a distro
# libmpv without Wayland silently drops hwdec to vaapi-copy. That means
# the libraries the host libmpv used to drag in transitively are now ours
# to declare: libva and its drm/wayland backends for hwdec, and drm/gbm
# underneath them.
#
# The X11 and xcb entries are not a mistake on a Wayland-only video
# path: GTK links them whatever backend it runs, and ffmpeg's VA-API
# brings libva-x11 with it. They used to arrive via the host gtk3 and
# mpv packages.
#
# Miss any one of these and the loader fails before main() runs, so
# check-bundle-host-deps.py re-derives the whole list from the built
# bundle rather than trusting this comment - main() below runs it before
# anything is packaged, and the release workflow runs it again.
"depends": [
"libgtk-3-0",
"libmpv2 | libmpv1",
"libepoxy0",
"libasound2",
"libevdev2",
"libglib2.0-0",
"libwayland-client0",
"libwayland-cursor0",
"libwayland-egl1",
"libegl1",
# libEGL.so.1 defers to libGLdispatch.so.0, which is a separate
# package Debian only pulls in behind libegl1. The runner links it
# directly, so it is declared directly.
"libglvnd0",
"libva2",
"libva-drm2",
"libva-wayland2",
"libva-x11-2",
"libdrm2",
"libgbm1",
"libx11-6",
"libx11-xcb1",
"libxext6",
"libxcb1",
"libxcb-dri3-0",
"libxcb-render0",
"libxcb-shm0",
],
},
"rpm": {
@@ -58,16 +93,26 @@ DISTROS = {
"category": "Multimedia",
"ext": "rpm",
"compression": ["--rpm-compression", "xzmt"],
# Fedora splits glvnd: libglvnd-egl carries libEGL.so.1 but the
# libGLdispatch.so.0 it defers to lives in the base libglvnd package.
"depends": [
"gtk3",
"mpv-libs",
"libepoxy",
"alsa-lib",
"libevdev",
"glib2",
"libwayland-client",
"libwayland-cursor",
"libwayland-egl",
"libglvnd",
"libglvnd-egl",
"libva",
"libdrm",
"mesa-libgbm",
"libX11",
"libX11-xcb",
"libXext",
"libxcb",
],
},
"pacman": {
@@ -75,17 +120,24 @@ DISTROS = {
"category": None,
"ext": "pkg.tar.zst",
"compression": ["--pacman-compression", "zstd"],
# Arch ships every libwayland-* in the one `wayland` package, and
# libglvnd is what provides libEGL.so.1.
# Arch ships every libwayland-* in the one `wayland` package, libglvnd is
# what provides both libEGL.so.1 and libGLdispatch.so.0, mesa is what
# provides libgbm.so.1, and libx11 carries libX11-xcb.so.1 alongside
# libX11.so.6.
"depends": [
"gtk3",
"mpv",
"libepoxy",
"alsa-lib",
"libevdev",
"glib2",
"wayland",
"libglvnd",
"libva",
"libdrm",
"mesa",
"libx11",
"libxext",
"libxcb",
],
},
}
@@ -184,9 +236,76 @@ def main():
# Verify build exists
if not BUILD_DIR.exists():
print(f"Error: Build directory not found at {BUILD_DIR}")
print("Please run 'flutter build linux --release' first or set BUILD_DIR")
print("Please run 'flutter build linux --release' first or set BUILD_DIR.")
# The runner requires pkg-config to find mpv, and build-libmpv.sh exports
# PKG_CONFIG_PATH only inside its own process. Without carrying it across,
# the build either cannot configure at all or - worse, because it looks
# like success - links whatever distro libmpv happens to be installed
# instead of the pinned Wayland-enabled one these packages assume. CI sets
# this explicitly for the same reason and installs no libmpv-dev.
print("The build needs the pinned libmpv on its pkg-config path, which build-libmpv.sh")
print("exports only for itself:")
print(' PKG_CONFIG_PATH="$(pwd)/libmpv-prefix/lib/pkgconfig:$(pwd)/libmpv-prefix/lib/x86_64-linux-gnu/pkgconfig" \\')
print(" flutter build linux --release")
exit(1)
# None of the three package manifests declares a host libmpv any more,
# because the plane needs the pinned Wayland-enabled build and so ships it.
# That makes a bundle without one unpackageable rather than merely thinner:
# the runner's DT_NEEDED libmpv.so.2 would be satisfied by nothing and the
# loader would fail before main(), on a package that installed cleanly.
# Both workflows stage it first; this refuses the standalone path that the
# message above otherwise invites.
# The versioned soname, not `libmpv.so`. That bare name is the development
# linker name; the runner records DT_NEEDED libmpv.so.2 and the loader will
# not accept the unversioned file in its place. Matching `libmpv.so*` would
# pass a staging tree carrying only the linker name - the exact failure this
# check is named for, and the only thing standing between a hand-staged
# bundle and a broken package when the host-dep guard is opted out of below.
if not sorted((BUILD_DIR / "lib").glob("libmpv.so.[0-9]*")):
print(f"Error: no libmpv found in {BUILD_DIR / 'lib'}")
print("The packages declare no host libmpv, so one has to travel inside them.")
print("Stage the bundle first, exactly as both workflows do:")
# meson installs libmpv under an architecture triple on Debian and Ubuntu
# while CMake installed shaderc straight into lib/, which is why the two
# lines below are not the same shape. The workflows locate libmpv the same
# way; a hardcoded libmpv-prefix/lib/libmpv.so* finds nothing there.
print(' cp -a "$(dirname "$(find libmpv-prefix -name libmpv.so | head -1)")"/libmpv.so* <bundle>/lib/')
# The pinned libmpv links libshaderc_shared, which build-libmpv.sh leaves
# in libmpv-prefix - not a directory the loader searches. bundle-libs.sh
# cannot recover it either: it resolves what ldd reports, and ldd cannot
# find a soname that is only in the build prefix. So it is copied by hand
# before the walk, or the bundle gets an unpinned host shaderc at best.
print(" cp -a libmpv-prefix/lib/libshaderc_shared.so* <bundle>/lib/")
print(" bash linux/packaging/bundle-libs.sh <bundle>")
exit(1)
# Nothing above reconciles the hand-maintained depends lists with what the
# staged bundle actually loads, and a package that is short one host library
# installs cleanly and then dies in the loader. Run the guard that does
# reconcile them before any package exists. As a subprocess under this
# interpreter, so its exit code and ::error:: annotations arrive unaltered.
guard = SCRIPT_DIR / "check-bundle-host-deps.py"
# Truthy tokens only. `PLEZY_SKIP_HOST_DEP_CHECK=0` reads to almost everyone
# as "do not skip", and a bare non-empty test would have done the opposite -
# which is the single outcome this whole block exists to prevent.
skip = os.environ.get("PLEZY_SKIP_HOST_DEP_CHECK", "").strip().lower()
if skip in {"1", "true", "yes", "on"}:
# The guard reads deb ownership out of dpkg-query, which Fedora and Arch
# do not have, so it cannot run there at all. The opt-out is for that
# case and is an environment variable precisely so it cannot happen by
# accident: a skip the caller did not ask for would hand back exactly the
# unverified package this check exists to prevent, so it is never silent.
print("WARNING: PLEZY_SKIP_HOST_DEP_CHECK is set, so the host dependency guard did not run.")
print(f"WARNING: the depends lists in {Path(__file__).name} are unverified against {BUILD_DIR}.")
else:
guard_result = subprocess.run([sys.executable, str(guard), str(BUILD_DIR)])
if guard_result.returncode != 0:
print(f"Error: {guard.name} failed, so no packages were built.")
print("Declare the libraries it named, or - only on a host without dpkg-query, such as")
print("Fedora or Arch - set PLEZY_SKIP_HOST_DEP_CHECK=1 to package without the check.")
exit(guard_result.returncode)
version = get_version()
print(f"Building {ARCH_SUFFIX} packages for {METADATA['name']} version {version}")
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Check that every library the built bundle needs from the host is declared.
bundle-libs.sh ships most of what the app links, but deliberately leaves the
graphics stack, the audio stack and the C runtime to the host: those are coupled
to the running compositor, the GPU driver and the kernel, so a bundled copy is
worse than useless. Everything it leaves behind has to be named as a package
dependency instead. Miss one and the loader fails before main() runs - which is
invisible to any amount of reading package metadata, and invisible to CI unless
something derives the requirement from the artifact.
That matters more than it used to. libmpv now travels inside the package instead
of being depended on, because the plane needs the pinned Wayland-enabled build.
The host `mpv` dependency that went away had been quietly providing libva, libdrm
and their friends transitively.
The deb answer is derived, not written down: dpkg owns the authoritative mapping
from a file to the package providing it, so a library that appears in the bundle
cannot slip past by being absent from a list someone forgot to update. rpm and
pacman names cannot be resolved on a Debian-family runner, so those stay
recorded below; their job is to fail loudly when a new library lands and nobody
thought about Fedora and Arch.
Coverage is checked in one direction only. A declared dependency that nothing
links is harmless - it installs a package the user probably has. A linked
library that nothing declares is a broken install, so that is the direction
worth failing on.
"""
import argparse
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
# The checkout this script lives in. Derived from its own location rather than
# the working directory, so build-packages.py is still found when CI invokes
# the check from the build tree; --root stays as the override.
PROJECT_ROOT = Path(__file__).resolve().parents[2]
# Sonames provided by the base system on every distro. No package declares a
# dependency on the C runtime or the dynamic loader; they are the floor that
# has to exist for dpkg itself to run.
BASELINE_PREFIXES = (
"linux-vdso.so",
"ld-linux",
"libc.so",
"libm.so",
"libpthread.so",
"libdl.so",
"librt.so",
"libmvec.so",
"libresolv.so",
"libnss_",
)
# Where the loader actually looks for a soname, relative to the bundle root:
# linux/CMakeLists.txt gives plezy RPATH $ORIGIN/lib and plezy.sh exports
# $INSTALL_DIR/lib. Nothing in the bundle carries a RUNPATH of its own, so this
# one directory is the whole search path for every object in it.
LOADER_DIRECTORIES = ("lib",)
# The non-deb package that provides each host soname. Fedora and Arch cannot be
# queried from here, so this is the one place where those names are asserted
# rather than derived - and an unlisted soname is an error, so adding a library
# forces the decision instead of silently shipping a package that cannot start.
OTHER_DISTROS = {
"libEGL.so.1": {"rpm": "libglvnd-egl", "pacman": "libglvnd"},
"libGL.so.1": {"rpm": "libglvnd-glx", "pacman": "libglvnd"},
# Fedora splits glvnd by API: -egl, -gles, -glx and -opengl sit over the
# base libglvnd that carries libGLdispatch, so GLES is not the EGL package.
"libGLESv2.so.2": {"rpm": "libglvnd-gles", "pacman": "libglvnd"},
"libGLX.so.0": {"rpm": "libglvnd-glx", "pacman": "libglvnd"},
"libGLdispatch.so.0": {"rpm": "libglvnd", "pacman": "libglvnd"},
"libOpenGL.so.0": {"rpm": "libglvnd-opengl", "pacman": "libglvnd"},
"libX11-xcb.so.1": {"rpm": "libX11-xcb", "pacman": "libx11"},
"libX11.so.6": {"rpm": "libX11", "pacman": "libx11"},
"libXext.so.6": {"rpm": "libXext", "pacman": "libxext"},
"libasound.so.2": {"rpm": "alsa-lib", "pacman": "alsa-lib"},
"libdrm.so.2": {"rpm": "libdrm", "pacman": "libdrm"},
"libepoxy.so.0": {"rpm": "libepoxy", "pacman": "libepoxy"},
"libgbm.so.1": {"rpm": "mesa-libgbm", "pacman": "mesa"},
"libva-drm.so.2": {"rpm": "libva", "pacman": "libva"},
"libva-wayland.so.2": {"rpm": "libva", "pacman": "libva"},
"libva-x11.so.2": {"rpm": "libva", "pacman": "libva"},
"libva.so.2": {"rpm": "libva", "pacman": "libva"},
"libvdpau.so.1": {"rpm": "libvdpau", "pacman": "libvdpau"},
"libvulkan.so.1": {"rpm": "vulkan-loader", "pacman": "vulkan-icd-loader"},
"libwayland-client.so.0": {"rpm": "libwayland-client", "pacman": "wayland"},
"libwayland-cursor.so.0": {"rpm": "libwayland-cursor", "pacman": "wayland"},
"libwayland-egl.so.1": {"rpm": "libwayland-egl", "pacman": "wayland"},
"libwayland-server.so.0": {"rpm": "libwayland-server", "pacman": "wayland"},
"libxcb-dri3.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
"libxcb-present.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
"libxcb-randr.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
"libxcb-render.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
"libxcb-shm.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
"libxcb-sync.so.1": {"rpm": "libxcb", "pacman": "libxcb"},
"libxcb-xfixes.so.0": {"rpm": "libxcb", "pacman": "libxcb"},
"libxcb.so.1": {"rpm": "libxcb", "pacman": "libxcb"},
}
# ldd and dpkg-query are the entire evidence base for this check, so they have
# to be substitutable: PLEZY_HOST_TOOLS names a directory searched ahead of
# PATH, which is what lets scripts/test_check_bundle_host_deps.py drive real
# runs of this script against a synthetic bundle on any machine.
HOST_TOOLS = os.environ.get("PLEZY_HOST_TOOLS")
def locate(program: str) -> str | None:
"""Where `program` resolves, PLEZY_HOST_TOOLS ahead of PATH."""
override = shutil.which(program, path=HOST_TOOLS) if HOST_TOOLS else None
return override or shutil.which(program)
def run(*command: str) -> subprocess.CompletedProcess:
program, *arguments = command
return subprocess.run([locate(program) or program, *arguments], capture_output=True, text=True)
def load_distros(root: Path) -> dict:
"""Read DISTROS out of build-packages.py rather than duplicating it."""
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.DISTROS
def host_libraries(bundle: Path) -> tuple[dict[str, str], list[str]]:
"""soname -> host path for what the bundle resolves outside itself.
Also returns problems worth failing on. An unresolved entry is the loudest
possible version of the failure this whole check exists to prevent - the
loader finding nothing at all - so it must never be quietly skipped for not
looking like a path.
"""
# Only what the loader will search counts as shipped. The dlopen'd
# gdk-pixbuf loaders and GIO modules below lib/ are opened by explicit path
# and never resolve a soname for anyone, so a nested module whose filename
# happens to equal one the executable really takes from the host would
# otherwise make that host library look bundled and pass the check
# vacuously.
shipped = {path.name for directory in LOADER_DIRECTORIES for path in (bundle / directory).glob("*.so*")}
# rglob for the objects to inspect, though: those same modules pull in host
# libraries nothing else in the bundle links, and scanning only lib/*.so*
# left their dependencies out of ldd entirely.
binaries = [bundle / "plezy", *sorted(bundle.rglob("*.so*"))]
needed: dict[str, str] = {}
problems: list[str] = []
for binary in binaries:
result = run("ldd", str(binary))
# ldd exits non-zero for a file it cannot read as an object. Statically
# linked objects are the benign case and say so on stdout.
if result.returncode != 0 and "not a dynamic executable" not in (result.stdout + result.stderr):
problems.append(f"ldd could not read {binary}: {result.stderr.strip() or 'no diagnostic'}")
continue
for line in result.stdout.splitlines():
soname, separator, remainder = line.partition("=>")
soname = soname.strip()
remainder = remainder.strip()
if not separator or not soname:
continue
if remainder.startswith("not found"):
# Unless the bundle ships it. Bundled objects carry no RUNPATH of
# their own - bundle-libs.sh copies and strips, it does not
# patchelf - so running ldd on one in isolation resolves its
# DT_NEEDED entries against the system cache only, and a library
# that exists nowhere but the bundle reads as missing. At runtime
# the executable's own $ORIGIN/lib finds it, which is what makes
# shipping it sufficient. libshaderc_shared is exactly this case:
# no distro package installs it, which is why both workflows copy
# it in by hand.
if soname in shipped:
continue
problems.append(f"{binary.name} needs {soname}, which resolves to nothing on this machine")
continue
path = remainder.split(" (")[0]
if not path.startswith("/"):
continue
# A library that ships beside the binary needs nothing declared,
# even when the loader happened to resolve this copy from the host.
if soname in shipped or soname.startswith(BASELINE_PREFIXES):
continue
needed[soname] = path
return needed, problems
def deb_owner(path: str) -> str:
"""The deb package owning a file, following symlinks when dpkg needs it."""
for candidate in (path, os.path.realpath(path)):
owner = run("dpkg-query", "-S", candidate).stdout.partition(":")[0].strip()
if owner:
# A diverted or multi-arch answer can carry an architecture suffix.
return owner.split(",")[0].split(":")[0]
return ""
def deb_names(package: str) -> set[str]:
"""The package's own name plus everything it Provides."""
provides = run("dpkg-query", "-W", "-f", "${Provides}", package).stdout
names = {package}
for entry in provides.split(","):
name = entry.split("(")[0].strip()
if name:
names.add(name)
return names
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("bundle", type=Path, help="the built Flutter bundle directory")
parser.add_argument("--root", type=Path, default=PROJECT_ROOT, help="repository root")
arguments = parser.parse_args()
if not (arguments.bundle / "plezy").exists():
print(f"::error::{arguments.bundle}/plezy does not exist - nothing to check", file=sys.stderr)
return 1
# ldd and dpkg-query are the entire evidence base. Without them this walk
# finds nothing and would otherwise report a bundle with no host libraries
# at all, which is the one failure mode a guard must never have. dpkg-query
# also pins the check to a Debian-family host: Fedora and Arch cannot answer
# which package owns a file, so the check genuinely cannot run there and says
# so instead of passing. build-packages.py carries the deliberate opt-out for
# anyone who has to package from such a host anyway.
absent = [tool for tool in ("ldd", "dpkg-query") if locate(tool) is None]
if absent:
print(
f"::error::{' and '.join(absent)} not found - this check derives deb package ownership "
"from dpkg-query, so it only runs on a Debian or Ubuntu host",
file=sys.stderr,
)
return 1
distros = load_distros(arguments.root)
# "a | b" is satisfied by either name, so compare against the flattened set.
declared = {
distro: {name.strip() for dependency in config["depends"] for name in dependency.split("|")}
for distro, config in distros.items()
}
needed, errors = host_libraries(arguments.bundle)
# A real bundle always needs *something* from the host: bundle-libs.sh
# deliberately leaves the graphics stack behind, so an empty result means the
# walk found nothing rather than that nothing is needed - a silently
# successful no-op is the worst outcome for a guard.
if not needed and not errors:
errors.append(
f"no host libraries were found for {arguments.bundle} at all, which cannot be right - "
"ldd produced nothing usable, so this proved nothing"
)
for soname, path in sorted(needed.items()):
owner = deb_owner(path)
if not owner:
errors.append(f"{soname} ({path}) belongs to no deb package, so it cannot be checked or declared")
elif not (deb_names(owner) & declared["deb"]):
errors.append(f"{soname} comes from deb package '{owner}', which linux/packaging/build-packages.py does not declare")
others = OTHER_DISTROS.get(soname)
if others is None:
errors.append(f"{soname} has no rpm/pacman package recorded in {Path(__file__).name}")
continue
for distro, package in sorted(others.items()):
if distro in declared and package not in declared[distro]:
errors.append(f"{soname} needs '{package}' on {distro}, which build-packages.py does not declare")
for error in errors:
print(f"::error::{error}", file=sys.stderr)
if errors:
return 1
print(f"every one of the {len(needed)} host libraries the bundle needs is declared on all {len(distros)} distros:")
for soname in sorted(needed):
print(f" {soname:<28} {deb_owner(needed[soname])}")
return 0
if __name__ == "__main__":
sys.exit(main())
+148
View File
@@ -0,0 +1,148 @@
#!/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())
+2 -1
View File
@@ -29,9 +29,10 @@
"kind": "git",
"version": "7.351.0",
"url": "https://code.videolan.org/videolan/libplacebo.git",
"mirror": "https://github.com/haasn/libplacebo.git",
"ref": "v7.351.0",
"commit": "3188549fba13bbdf3a5a98de2a38c2e71f04e21e",
"provenance": "Official VideoLAN GitLab tag record v7.351.0 contains a PGP-signed release message and dereferences to this root commit; its gitlinks pin recursive submodules."
"provenance": "Official VideoLAN GitLab tag record v7.351.0 contains a PGP-signed release message and dereferences to this root commit; its gitlinks pin recursive submodules. The mirror is upstream's own author repository (haasn/libplacebo, not a fork), whose annotated v7.351.0 tag object 98c1416e95b21cd767b84ac9ee430ccadf263ff6 dereferences to that same commit. It is consulted only after the primary fails, and the commit pin is verified identically either way, so it cannot introduce a different tree."
},
"mpv": {
"kind": "archive",