# Build the punktfunk-host / punktfunk-client / punktfunk-web / punktfunk-scripting pacman packages from # packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes # get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree). # Arch is rolling, so the packages build against whatever the archlinux:base-devel container # resolves today — the same sonames an up-to-date Arch box runs. # # Registry (public, unom org) — box setup (once), see packaging/arch/README.md. The registry # SIGNS the DB + packages, so the box imports the registry key first (pacman-key --add + # --lsign-key), then no SigLevel line is needed (pacman's default Required verifies): # [punktfunk] # or [punktfunk-canary] for main-push builds # Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch # # REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml). # NOTE: this token + the registry-held private key are the trust root — a token holder can # publish a validly-signed package (the signature attests "via the registry", not "built by CI"). name: arch # One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels # it (a canary only needs the latest commit; each release tag is its own ref so tag runs never # cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the # runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933). concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true on: push: branches: [main] # Scope canary builds to what this artifact is built FROM — a docs-only or # web-only push should not light up the whole fleet. Applies to branch pushes; # tag runs are matched by `tags:` (proven by flatpak/windows-msix releases). paths: - 'crates/**' - 'clients/linux/**' - 'clients/session/**' - 'clients/shared/**' - 'clients/cli/**' - 'web/**' - 'sdk/**' - 'packaging/arch/**' - 'packaging/gamescope/**' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'scripts/ci/**' - '.gitea/workflows/arch.yml' # Single project version: a `vX.Y.Z` tag is THE release. main publishes to the # `punktfunk-canary` pacman repo as X.Y.Z-0. (sorts below the eventual X.Y.Z-1), # tags to `punktfunk` — separate repos, so neither channel can shadow the other. tags: ['v*'] workflow_dispatch: env: REGISTRY: git.unom.io OWNER: unom # Shared compile cache: sccache -> RustFS S3 (storage.unom.io). NOTE: makepkg runs # behind `sudo -u builder env ...`, which strips ambient env — the makepkg step # re-exports these explicitly. RUSTC_WRAPPER: sccache SCCACHE_BUCKET: unom-ci-sccache SCCACHE_ENDPOINT: https://storage.unom.io SCCACHE_REGION: home-central AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} CARGO_INCREMENTAL: "0" jobs: build-publish: runs-on: ubuntu-24.04 container: # Everything the two pacman steps below used to download (~1 GB/run) is baked in, # plus bun, sccache and node (ci/arch-ci.Dockerfile). The steps stay as --needed # no-op guards for the one push where :latest lags an image-content change. image: 192.168.1.58:5010/punktfunk-arch-ci:latest timeout-minutes: 90 env: CARGO_HOME: /usr/local/cargo steps: # git + nodejs must exist before actions/checkout — base-devel ships neither, and # act_runner runs the action's JS with the CONTAINER's node, it does not inject one. - name: Build + runtime-dev deps (no-op guard — baked into arch-ci) # No -Syu: the image's snapshot IS the build environment (see the Dockerfile's # rolling-release note); with everything installed this resolves locally and # does nothing. It only matters on the push that adds a dep before the image # rebuild lands — same bootstrap note as ci.yml's GTK4 step. run: | pacman -S --noconfirm --needed \ git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \ gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \ mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages" command -v bun >/dev/null || { curl -fsSL https://bun.sh/install | bash install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun } bun --version - uses: actions/checkout@v4 # Cache cargo's git dir too, not just the registry: the workspace includes # clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE # them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows # is ever compiled here. Cached, that cost is paid once per runner. - uses: actions/cache@v4 with: path: | /usr/local/cargo/registry /usr/local/cargo/git key: cargo-home-arch-${{ hashFiles('Cargo.lock') }} restore-keys: cargo-home-arch- - name: Version + channel # vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> -0. in # `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the # monotonic ordering; the commit sha is stamped into the binary via the workflow log). # # The run number is ZERO-PADDED to a fixed width, and that padding is load-bearing. # pacman's own vercmp compares numeric segments numerically and gets this right either # way, but Gitea's Arch registry picks the version it advertises in `punktfunk-canary.db` # by STRING order. Unpadded, the run counter crossing a power of ten inverts that order # ("0.9907" > "0.10095" because '9' > '1'), so the db pins itself to the last build # before the rollover and every later canary becomes invisible to `pacman -Syu` — the # packages publish fine, the index just never names them. That is exactly what happened # on 2026-07-29 when run #10000 landed; it cost an evening and needed a manual purge of # every 4-digit `0.22.0-0.9xxx` version to unstick. Padding keeps string order and # numeric order in agreement, so the two can never disagree again. # # Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable # release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising # it to `1.` would sort canaries ABOVE the release and is not an option.) run: | eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable) case "$GITHUB_REF" in refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;; *) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;; esac echo "PF_PKGVER=$V" >> "$GITHUB_ENV" echo "PF_PKGREL=$R" >> "$GITHUB_ENV" echo "REPO=$REPO" >> "$GITHUB_ENV" echo "pacman $V-$R -> repo '$REPO'" - name: Build packages (makepkg) run: | git config --global --add safe.directory "$PWD" # libcuda link stub — same trick as packaging/rpm/build-rpm.sh: the zerocopy FFI # links -lcuda but the builder has no GPU; synthesize every cu* symbol the source # references so a newly-added call can't silently break the link. CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)" if [ -n "$CU_SYMS" ] && [ ! -e /usr/lib/libcuda.so ]; then STUB_C="$(mktemp --suffix=.c)" for s in $CU_SYMS; do printf 'int %s(void){return 0;}\n' "$s" >> "$STUB_C"; done gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib/libcuda.so.1 "$STUB_C" ln -sf libcuda.so.1 /usr/lib/libcuda.so rm -f "$STUB_C"; ldconfig echo "== libcuda stub: $(printf '%s\n' "$CU_SYMS" | wc -l) symbols ==" fi # makepkg refuses to run as root; deps are already installed above (-d skips the # RPM-level check that can't see the script-installed bun anyway). useradd -m builder mkdir -p "$CARGO_HOME" # actions/cache doesn't create it on a cache miss chown -R builder: "$PWD" "$CARGO_HOME" sudo -u builder git config --global --add safe.directory "$PWD" mkdir -p dist && chown builder: dist cd packaging/arch # sudo env_reset strips the ambient env, so the sccache wiring must cross the # boundary explicitly (same values as the workflow env block). sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \ PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \ CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \ RUSTC_WRAPPER="$RUSTC_WRAPPER" CARGO_INCREMENTAL="$CARGO_INCREMENTAL" \ SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \ SCCACHE_REGION="$SCCACHE_REGION" \ AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \ makepkg -f -d --holdver ls -lh "$GITHUB_WORKSPACE/dist" # The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a # completely different dependency set, published into the same repo so `pacman -S # punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ. # # CACHED on `packaging/gamescope/**`: it depends on nothing else in this repo, so a normal # push restores the built package instead of spending ~10 minutes on someone else's C++ tree. # Arch is rolling, so the cache is invalidated by our own patch changes only — a stale binary # against newer system libs is the same risk the distro's own package carries between rebuilds. - uses: actions/cache@v4 id: gamescope with: path: dist-gamescope key: punktfunk-gamescope-arch-${{ hashFiles('packaging/gamescope/**') }} - name: Build punktfunk-gamescope (makepkg) if: steps.gamescope.outputs.cache-hit != 'true' # Best-effort: punktfunk-host works without it (SDR on the gamescope backend), and a # failure building gamescope must not cost the packages this workflow exists to publish. run: | set -x # Baked into arch-ci — a no-op guard, like the dep step above. pacman -S --noconfirm --needed \ glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \ libxkbcommon libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \ hwdata luajit pipewire seatd sdl2-compat vulkan-icd-loader wayland \ xcb-util-errors xcb-util-wm xorg-xwayland \ meson cmake glm wayland-protocols benchmark libxcursor || true mkdir -p dist-gamescope && chown builder: dist-gamescope chown -R builder: packaging/gamescope if sudo -u builder env PKGDEST="$GITHUB_WORKSPACE/dist-gamescope" \ bash -c 'cd packaging/gamescope && makepkg -f -d --holdver'; then ls -lh dist-gamescope else echo "::warning::punktfunk-gamescope failed to build — Arch boxes stay SDR on the gamescope backend this run" rm -rf dist-gamescope # never cache a failed build (an empty path is not saved) fi - name: Publish to the Gitea Arch registry env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | # The gamescope companion rides the same loop (same repo, same channel). cp -f dist-gamescope/*.pkg.tar.zst dist/ 2>/dev/null || true for pkg in dist/*.pkg.tar.zst; do echo "uploading $pkg" NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p') VER=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p') ARCH=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^arch = //p') # A re-tagged release re-fires this workflow and the registry 409s on duplicate # package versions — delete any prior copy first (404 on the first publish is fine). curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \ "https://$REGISTRY/api/packages/$OWNER/arch/$REPO/$NAME/$VER/$ARCH" || true curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$pkg" \ "https://$REGISTRY/api/packages/$OWNER/arch/$REPO" done echo "published to $OWNER/arch/$REPO" # On a real release, also attach the packages to the unified Gitea Release. - name: Attach packages 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 pkg in dist/*.pkg.tar.zst; do upsert_asset "$RID" "$pkg" done