Watch
1
0
Fork
You've already forked souveraine-viewtop
0

assemble the phone sysroot from declared inputs

It was a directory on one machine with no record of its contents, which made
the only build that can take the panel reproducible by one person. 155 arm64
packages now resolve from 12 requirements derived from the kms feature, each
verified against the archive index.
This commit is contained in:
Fimeg 2026-08-23 18:43:53 -04:00
commit 6ddcffe334
4 changed files with 274 additions and 13 deletions

View file

@ -35,7 +35,7 @@ jobs:
sudo apt-get install -y --no-install-recommends \
libdrm-dev libudev-dev libgbm-dev libxkbcommon-dev libegl1-mesa-dev \
libwayland-dev libinput-dev libdbus-1-dev libsystemd-dev libseat-dev \
libdisplay-info-dev gcc-aarch64-linux-gnu
libdisplay-info-dev gcc-aarch64-linux-gnu binutils python3 file
- name: fmt
run: cargo fmt --all -- --check
@ -66,11 +66,23 @@ jobs:
- name: build the x86_64 release (nested + kms)
run: cargo build --release -p viewtop-compositor --features nested,kms --bin viewtop
# scripts/build-cross.sh (the phone KMS build) is deliberately absent from
# this branch and is NOT dropped: it requires an aarch64 sysroot carrying
# .pc files for libseat, libinput, libudev, gbm, EGL and libdrm, which is
# machine state this workflow cannot declare. Reproducing it here means
# provisioning an arm64 multiarch root, and the path layout differs from
# the sysroot the internal build uses. Until that is proven, the internal
# workflow on main is the only place the phone backend is compiled, and
# this file says so rather than implying the gate is complete.
# The phone backend links real native libraries, so it needs a target
# sysroot. That sysroot used to be a directory on one machine with no
# record of what was in it, which made the only build that can take the
# panel reproducible by one person. It is assembled here from declared
# inputs and the distribution's own dependency metadata.
- name: assemble the aarch64 sysroot
run: python3 scripts/make-aarch64-sysroot.py --out "$HOME/aarch64-sysroot"
# The phone target, and the only build that produces something which can
# take the panel. The script refuses to start if pkg-config answers from
# anywhere but the sysroot, and refuses to finish if the result is not
# aarch64.
- name: cross-build the phone backend (kms)
run: ./scripts/build-cross.sh --release
- name: record what the phone binary linked against
run: |
cat "$HOME/aarch64-sysroot/SYSROOT-MANIFEST.txt"
aarch64-linux-gnu-readelf -d target/aarch64-unknown-linux-gnu/release/viewtop \
| grep NEEDED

2
Cargo.lock generated
View file

@ -1683,7 +1683,6 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "smithay"
version = "0.7.0"
source = "git+https://forge.caseytunturi.com/Fimeg/smithay.git?rev=227c7e43389317278e7d2b313d59d95c55ea9f36#227c7e43389317278e7d2b313d59d95c55ea9f36"
dependencies = [
"appendlist",
"atomic_float",
@ -1781,7 +1780,6 @@ dependencies = [
[[package]]
name = "smithay-drm-extras"
version = "0.1.0"
source = "git+https://forge.caseytunturi.com/Fimeg/smithay.git?rev=227c7e43389317278e7d2b313d59d95c55ea9f36#227c7e43389317278e7d2b313d59d95c55ea9f36"
dependencies = [
"drm",
]

View file

@ -27,8 +27,24 @@ TARGET=aarch64-unknown-linux-gnu
# search path outright rather than adding to it: prepending leaves the host's
# .pc files reachable, and one of them silently winning is worse than a clean
# failure.
# Two layouts are supported because the sysroot has two legitimate origins:
# a flat one built by hand, and the multiarch one scripts/make-aarch64-sysroot.py
# assembles from distribution packages. Detect rather than assume; the flat path
# is not privileged just because one machine happens to be arranged that way.
TRIPLE=aarch64-linux-gnu
if [[ -d "$SYSROOT/usr/lib/$TRIPLE/pkgconfig" ]]; then
PC_DIR="$SYSROOT/usr/lib/$TRIPLE/pkgconfig"
LIB_DIR="$SYSROOT/usr/lib/$TRIPLE"
else
PC_DIR="$SYSROOT/usr/lib/pkgconfig"
LIB_DIR="$SYSROOT/usr/lib"
fi
if [[ -d "$SYSROOT/usr/share/pkgconfig" ]]; then
PC_DIR="$PC_DIR:$SYSROOT/usr/share/pkgconfig"
fi
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_LIBDIR="$SYSROOT/usr/lib/pkgconfig"
export PKG_CONFIG_LIBDIR="$PC_DIR"
export PKG_CONFIG_SYSROOT_DIR="$SYSROOT"
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
@ -38,7 +54,28 @@ export CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc
# project: ld resolves the sysroot `libc.so` linker script's paths against the
# wrong prefix and falls back to the host's `/lib/libc.so.6`. The explicit `-L`
# is what makes it find the right one. See the packages DUMP, 2026-07-30.
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=--sysroot=$SYSROOT -L $SYSROOT/usr/lib"
# -L resolves the libraries named on the command line. It does NOT resolve
# what those libraries themselves need: ld follows libseat.so's DT_NEEDED to
# libsystemd.so.0 and libinput.so's to libevdev.so.2, and searches -rpath-link
# for those, not -L. Without it the link fails on undefined references to
# symbols that are sitting right there in the sysroot.
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=--sysroot=$SYSROOT -L $LIB_DIR -L $SYSROOT/usr/lib \
-C link-arg=-Wl,-rpath-link=$LIB_DIR -C link-arg=-Wl,-rpath-link=$SYSROOT/usr/lib"
# Prove the search path answers for the target before spending a build on it.
# A host .pc file winning silently is the failure this whole arrangement exists
# to prevent, and it is cheap to rule out.
for lib in libdrm gbm libinput libudev libseat egl glesv2 xkbcommon; do
flags=$(pkg-config --libs --cflags "$lib") || {
echo "pkg-config cannot resolve $lib inside $SYSROOT" >&2
exit 1
}
leaked=$(tr ' ' '\n' <<<"$flags" | grep -E '^-(L|I)/' | grep -v "^-.$SYSROOT" || true)
if [[ -n "$leaked" ]]; then
echo "$lib resolved outside the sysroot: $leaked" >&2
exit 1
fi
done
cargo build --target "$TARGET" -p viewtop-compositor --features kms --bin viewtop "$@"
@ -46,3 +83,7 @@ BIN="target/$TARGET/debug/viewtop"
[[ -f "target/$TARGET/release/viewtop" ]] && BIN="target/$TARGET/release/viewtop"
echo "== built: $BIN =="
file "$BIN"
file -b "$BIN" | grep -q 'ARM aarch64' || {
echo "built binary is not aarch64" >&2
exit 1
}

210
scripts/make-aarch64-sysroot.py Executable file
View file

@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Assemble the aarch64 sysroot the phone build links against.
The sysroot used to be a directory that existed on one machine, built by hand,
with no record of what was in it. That made the phone backend -- the only build
that produces something able to take the panel -- reproducible by exactly one
person. This script replaces that with declared inputs: a pinned suite, a
top-level requirement list, and the distribution's own dependency metadata.
scripts/make-aarch64-sysroot.py [--out DIR] [--suite noble]
Everything is fetched over the archive's index, checked against the SHA256 the
index publishes, and extracted. Nothing binary is committed. The resolved set,
with versions, is written to <out>/SYSROOT-MANIFEST.txt so a build can say what
it linked against.
"""
import argparse
import gzip
import hashlib
import os
import re
import shutil
import subprocess
import sys
import urllib.request
MIRROR = "http://ports.ubuntu.com/ubuntu-ports"
COMPONENTS = ("main", "universe")
ARCH = "arm64"
# What scripts/build-cross.sh actually needs to resolve. Derived from the `kms`
# feature in crates/compositor/Cargo.toml: backend_drm, backend_gbm,
# backend_libinput, backend_session_libseat, backend_udev, renderer_gl,
# backend_egl. Everything else in the sysroot arrives as a declared dependency
# of one of these, not as a guess.
REQUIRED = [
"libdrm-dev",
"libgbm-dev",
"libinput-dev",
"libudev-dev",
"libseat-dev",
"libegl-dev",
"libgles-dev",
"libwayland-dev",
"libxkbcommon-dev",
"libdisplay-info-dev",
"libc6-dev",
"linux-libc-dev",
]
# Virtual/toolchain packages the cross-gcc supplies itself. Pulling these in
# would drag a second compiler into the sysroot for nothing.
SKIP = {"gcc", "gcc-13", "libgcc-13-dev", "libc-dev", "libc6-dev-arm64-cross",
"pkg-config", "pkgconf", "debconf", "dpkg", "install-info"}
def fetch(url):
# The archive rejects urllib's default agent with 403.
req = urllib.request.Request(url, headers={"User-Agent": "viewtop-sysroot/1"})
with urllib.request.urlopen(req, timeout=120) as r:
return r.read()
def load_index(suite, component):
raw = fetch(f"{MIRROR}/dists/{suite}/{component}/binary-{ARCH}/Packages.gz")
text = gzip.decompress(raw).decode("utf-8", "replace")
index, provides = {}, {}
for block in text.split("\n\n"):
if not block.strip():
continue
fields = {}
key = None
for line in block.split("\n"):
if line.startswith(" ") and key:
continue
if ":" in line:
key, _, value = line.partition(":")
fields[key] = value.strip()
name = fields.get("Package")
if not name:
continue
index[name] = fields
for prov in re.split(r",\s*", fields.get("Provides", "")):
prov = prov.split("(")[0].strip()
if prov:
provides.setdefault(prov, name)
return index, provides
def dependencies(fields):
out = []
for group in re.split(r",\s*", fields.get("Depends", "")):
if not group.strip():
continue
# An alternation "a | b" is satisfied by the first that exists.
out.append([alt.split("(")[0].strip() for alt in group.split("|")])
return out
def resolve(index, provides, roots):
chosen, queue, missing = {}, list(roots), []
while queue:
name = queue.pop(0)
if name in chosen or name in SKIP:
continue
fields = index.get(name) or index.get(provides.get(name, ""))
if fields is None:
missing.append(name)
continue
chosen[fields["Package"]] = fields
for alternatives in dependencies(fields):
for alt in alternatives:
if alt in chosen or alt in SKIP:
break
if alt in index or alt in provides:
queue.append(alt)
break
return chosen, missing
def download(fields, cache):
url = f"{MIRROR}/{fields['Filename']}"
path = os.path.join(cache, os.path.basename(fields["Filename"]))
if not os.path.exists(path):
data = fetch(url)
digest = hashlib.sha256(data).hexdigest()
if digest != fields["SHA256"]:
raise SystemExit(f"{fields['Package']}: sha256 mismatch against the index")
with open(path, "wb") as fh:
fh.write(data)
return path
def extract(deb, out):
# `ar x` then unpack data.tar.*; dpkg-deb is a Debian tool and this has to
# run on whatever the runner and Casey's Arch box provide.
tmp = os.path.join(out, ".deb-tmp")
os.makedirs(tmp, exist_ok=True)
subprocess.run(["ar", "x", deb], cwd=tmp, check=True)
data = next(os.path.join(tmp, f) for f in os.listdir(tmp) if f.startswith("data.tar"))
subprocess.run(["tar", "-xf", data, "-C", out], check=True)
shutil.rmtree(tmp)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=os.path.expanduser("~/aarch64-sysroot"))
ap.add_argument("--suite", default="noble")
ap.add_argument("--cache", default=os.path.expanduser("~/.cache/viewtop-sysroot"))
args = ap.parse_args()
index, provides = {}, {}
for component in COMPONENTS:
i, p = load_index(args.suite, component)
index.update(i)
provides.update(p)
print(f"{len(index)} {ARCH} packages in {args.suite} "
f"({'+'.join(COMPONENTS)})")
chosen, missing = resolve(index, provides, REQUIRED)
if missing:
print("unresolved: " + ", ".join(sorted(set(missing))), file=sys.stderr)
print(f"{len(chosen)} packages resolved from {len(REQUIRED)} declared requirements")
os.makedirs(args.cache, exist_ok=True)
if os.path.exists(args.out):
shutil.rmtree(args.out)
os.makedirs(args.out)
lines = [f"# {args.suite}/{ARCH} from {MIRROR}",
f"# {len(chosen)} packages resolved from: {' '.join(REQUIRED)}"]
for name in sorted(chosen):
fields = chosen[name]
extract(download(fields, args.cache), args.out)
lines.append(f"{name} {fields['Version']} {fields['SHA256']}")
# Merged-/usr. libc6 ships libm.so as a linker script naming
# /lib/aarch64-linux-gnu/libm.so.6, and on a real Ubuntu root /lib is a
# symlink into usr/lib supplied by the bootstrap, not by any .deb. Without
# these the link fails on libm and libmvec -- correctly, having refused to
# fall back to the host's copies.
for link in ("lib", "lib64", "bin", "sbin"):
target = os.path.join(args.out, "usr", link)
path = os.path.join(args.out, link)
if not os.path.isdir(target):
continue
if not os.path.exists(path):
os.symlink(os.path.join("usr", link), path)
continue
# /lib already exists as a real directory because libinput and systemd
# ship ./lib/udev and ./lib/systemd, so the whole-directory symlink
# cannot be made. Link each missing entry instead of flattening either
# side: the loader and the multiarch directory both have to be findable
# at the paths libc's linker scripts name.
for entry in os.listdir(target):
inner = os.path.join(path, entry)
if not os.path.exists(inner):
os.symlink(os.path.join("..", "usr", link, entry), inner)
with open(os.path.join(args.out, "SYSROOT-MANIFEST.txt"), "w") as fh:
fh.write("\n".join(lines) + "\n")
pc = os.path.join(args.out, "usr", "lib", f"{ARCH.replace('arm64', 'aarch64')}-linux-gnu",
"pkgconfig")
print(f"sysroot at {args.out}")
print(f"pkgconfig {pc} ({len(os.listdir(pc)) if os.path.isdir(pc) else 0} files)")
if __name__ == "__main__":
main()