build(ci): cross-compile and publish an aarch64 Linux client

Adds punktfunk-rust-ci-arm64cross (the rust-ci toolchain + an Ubuntu ports
arm64 multiarch sysroot) and a deb.yml job that cross-builds the client
package on the ordinary amd64 runner — no arm64 runner in the fleet and
none needed. Client only: the Linux host encodes with NVENC/QSV/AMF, all
x86. The apt registry keys pool entries by the .deb's own arch, so an arm64
box picks the package up with no client-side configuration.

Three things this had to get right, each of which cost a failed build:

  * rustup target add must run against the toolchain rust-toolchain.toml
    PINS, not the image's default `stable` — otherwise the pinned toolchain
    has no aarch64 std and the build dies ~230 crates in on "can't find
    crate for core". Copying the pin file in also pre-downloads the
    toolchain every CI job currently re-fetches on first use.

  * ffmpeg-sys-next compiles a probe it intends to RUN, so it forces
    .target(HOST) while still passing the TARGET's include paths; the arm64
    dir then shadows the host's own libc headers and the x86 compiler dies
    in bits/math-vector.h on NEON/SVE types. Prepending the host dir does
    NOT help — GCC drops a -I that duplicates a system directory, keeping
    its original later position — so ci/pf-host-cc strips target include
    dirs from host compiles instead.

  * the job hard-fails on a non-AArch64 session binary, rather than
    shipping an amd64 payload under an arm64 package name.

skia needs no special handling: the aarch64-linux-gnu textlayout+vulkan
prebuilt resolves, so arm64 ships the FULL client, OSD included — unlike
Windows ARM64, which still builds --no-default-features.

The cross image builds in its own docker.yml job (needs: build-push) since
it is FROM punktfunk-rust-ci:latest and must not race the entry that
publishes that base. rpm/Arch/Flatpak stay x86_64 for now — their builders
have no arm64 sysroot, which is its own piece of work.

Plan: punktfunk-planning design/embedded-arm64-client.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:32:45 +02:00
co-authored by Claude Opus 5
parent 915fc3ef9e
commit 000f8b85b0
6 changed files with 285 additions and 9 deletions
+96 -1
View File
@@ -1,9 +1,13 @@
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
# apt distribution/component:
#
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
# build-publish-client-arm64
# — the same client package for arm64, CROSS-compiled on the same amd64
# runner in the rust-ci-arm64cross image. No host counterpart: the Linux
# host's encode stack is x86 (NVENC/QSV/AMF).
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
@@ -269,3 +273,94 @@ jobs:
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
# ---------------------------------------------------------------------------------------------
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
# ci/rust-ci-arm64cross.Dockerfile); there is no arm64 runner in the fleet and none is needed.
# Client only, by decision: the Linux host encodes with NVENC/QSV/AMF, all x86.
# Publishes to the same distribution/component as the amd64 jobs — the apt registry keys pool
# entries by arch, so `apt` on an arm64 box picks this one up with no client-side configuration.
build-publish-client-arm64:
runs-on: ubuntu-24.04
container:
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Byte-identical to build-publish's version step (pf-version.sh is deterministic per
# commit), so the arm64 package always shares the amd64 version line.
- name: Version + channel
run: |
eval "$(bash scripts/ci/pf-version.sh)"
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "package version $V -> apt distribution '$DIST' (arm64)"
# dpkg-shlibdeps + dpkg-deb. The arm64 link deps themselves are the cross image's whole
# point and are already baked in; python3 is for scripts/ci/gitea-release.sh.
- name: dpkg-dev
run: |
apt-get update
apt-get install -y --no-install-recommends dpkg-dev python3
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
with:
path: target
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
# and must never share the amd64 jobs' target cache.
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
- name: Build the arm64 client .deb
env:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
run: |
git config --global --add safe.directory "$PWD"
ARCH=arm64 TARGET=aarch64-unknown-linux-gnu \
bash packaging/debian/build-client-deb.sh
# Fail here rather than shipping an amd64 binary under an arm64 package name.
readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \
| grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; }
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for DEB in dist/*.deb; do
echo "uploading $DEB"
NAME=$(dpkg-deb -f "$DEB" Package)
VER=$(dpkg-deb -f "$DEB" Version)
ARCH=$(dpkg-deb -f "$DEB" Architecture)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
done
echo "published arm64 client to $OWNER/debian $DISTRIBUTION/$COMPONENT"
- name: Attach the arm64 .deb to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
+36
View File
@@ -2,6 +2,7 @@
# punktfunk-web — management console (web/Dockerfile, repo-root context)
# punktfunk-docs — documentation site (docs-site/Dockerfile)
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml
# punktfunk-rust-ci-arm64cross — the above + an arm64 sysroot, for the aarch64 client legs
# punktfunk-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM)
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
#
@@ -80,6 +81,41 @@ jobs:
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
# and so must not race the matrix entry that publishes that base. Consumed by the arm64
# client legs in deb.yml/rpm.yml/arch.yml. Root context: it needs rust-toolchain.toml to
# install the target against the toolchain the workspace actually pins.
build-push-arm64cross:
runs-on: ubuntu-24.04
needs: build-push
timeout-minutes: 45
env:
IMAGE: punktfunk-rust-ci-arm64cross
steps:
- uses: actions/checkout@v4
- name: Login to registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login "$REGISTRY" -u enricobuehler --password-stdin
- name: Build
run: |
EXTRA=""
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
docker build --pull \
-f ci/rust-ci-arm64cross.Dockerfile \
-t "$REGISTRY/$OWNER/$IMAGE:latest" \
-t "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}" \
$EXTRA \
.
- name: Push
run: |
docker push "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}"
docker push "$REGISTRY/$OWNER/$IMAGE:latest"
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
# as unom/website's deploy: DEPLOY_HOST/DEPLOY_USER/DEPLOY_PORT/DEPLOY_SSH_KEY (the