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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user