fix(packaging): every channel that ships punktfunk-gamescope now builds it

The docs already told users where to get it — the Bazzite sysext, an Arch
package, a NixOS option — and none of the three were true. `rpm.yml` built the
sysext without ever passing `--gamescope`, `arch.yml` did not know the PKGBUILD
existed, and the nix derivation had never been evaluated once.

Evaluating it found its central assumption wrong: `gamescope.unwrapped` does
not exist on current nixpkgs, where `gamescope` IS the buildable derivation, so
the override threw on every build. It now prefers `.unwrapped` where a future
nixpkgs wraps it and checks the RESULT is patchable — `overrideAttrs` on a
symlinkJoin succeeds and does nothing, which would install an UNPATCHED
gamescope under a name the host reads as a promise of HDR.

Both it and the PKGBUILD had also drifted to a stale patch list: two patches
named where three exist, one of them under the level-1 filename retired when
the cursor patch landed. Both read the patch directory now, so the list cannot
go stale again. The PKGBUILD additionally delegates the whole build to
`build-punktfunk-gamescope.sh` rather than re-deriving the meson invocation —
its copy had already lost `force_fallback_for=wlroots`, and unlike Fedora 43,
Arch ships wlroots, so that package would have linked it shared and shipped a
binary that starts only on machines carrying the dev library. It asserts its
own pinned rev matches the script's, the one thing makepkg needs statically.

Both CI builds are cached on `packaging/gamescope/**`, which is the only reason
this is affordable: that tree depends on nothing else in the repo, so a normal
push restores a binary instead of spending ten minutes on someone else's C++.
And both are best-effort. punktfunk works without this binary — SDR on the
gamescope backend, which is what every release before this one did — so a
hiccup building gamescope must not cost the packages those workflows exist to
publish. A failure warns and is never cached, so the next run retries.

`rpm.yml` also installs Fedora's own gamescope for its runtime libraries: the
sysext verifies our binary by executing `--version`, and on a cache hit nothing
else in the job would have pulled libavif/luajit/seatd in.

Verified by evaluating and patch-phase-building the nix derivation against
nixos-unstable: all three patches apply to nixpkgs' already-patched 3.16.25
tree, and the banner stamps +pfhdr2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 18:03:30 +02:00
co-authored by Claude Opus 5
parent 0c4e582b29
commit c801465469
5 changed files with 202 additions and 30 deletions
+38
View File
@@ -112,10 +112,48 @@ jobs:
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
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')
+54
View File
@@ -59,6 +59,11 @@ jobs:
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
# luajit / seatd / SDL2 in. Cheap, and it tracks gamescope's dep list for us.
dnf -y install gamescope || true
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
command -v bun >/dev/null || {
@@ -124,13 +129,62 @@ jobs:
done
echo "published to $OWNER/rpm/$GROUP"
# The HDR-capable gamescope the sysext carries (packaging/gamescope) — what lets the
# gamescope backend stream 10-bit BT.2020 PQ instead of 8-bit SDR.
#
# CACHED, and that is the whole reason this is affordable: it is a ~10-minute C++ meson build
# of an entirely separate tree that depends on NOTHING in this repo except
# `packaging/gamescope/**` (the patches and the upstream pin, which lives in the build
# script). So the key is that directory's hash and a normal push restores a binary instead of
# building one. Per-Fedora-major, because the binary is soname-coupled to its base exactly
# like the RPM is — an f43 build does not start on f44 (libavutil.so.59 vs .60).
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-f${{ matrix.fedver }}-${{ hashFiles('packaging/gamescope/**') }}
- name: Build the HDR gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort ON PURPOSE. The sysext is the primary Bazzite delivery path and works without
# this binary (the host just stays SDR on the gamescope backend, which is what every
# release before this one did) — so a hiccup building someone else's tree must not cost the
# whole image. It is loud, though: the warning below, and `--gamescope` silently absent
# downstream is impossible because build-sysext.sh verifies the +pfhdr marker itself.
run: |
set -x
# `dnf builddep` resolves Fedora's PACKAGED gamescope, which is older than the master we
# pin, so it can come up short — xorg-x11-server-Xwayland-devel is the one that actually
# bites (wlroots' configure dies on a missing xserver.wrap several minutes in).
dnf -y install dnf-plugins-core meson ninja-build glslc || true
dnf builddep -y gamescope || true
dnf -y install xorg-x11-server-Xwayland-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
- name: Build the sysext image
run: |
# Execute it here rather than only handing it over: build-sysext.sh treats an unusable
# --version as fatal (rightly — it is how the +pfhdr marker is read), and a cached binary
# whose runtime libs are missing from this container must cost the image its HDR, not the
# image itself.
gs=()
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope gs-cache/punktfunk-gamescope)
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
else
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
"${gs[@]}" \
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
+38 -17
View File
@@ -13,9 +13,13 @@
# See ../README.md (packaging/gamescope/README.md) for what the patches do and why.
pkgname=punktfunk-gamescope
# `_gsver` is the upstream gamescope this is built from; `pkgrel` moves when only our patches do.
# `_gsrev` MUST name the same commit as GAMESCOPE_REV in build-punktfunk-gamescope.sh — makepkg
# needs it statically here, so it is the one thing that cannot be shared; build() asserts it.
# The `.pfhdrN` suffix is the patch-set revision the binary stamps into its banner (see README.md);
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=8c676c399c761e4540587f61004c957993d12fea
pkgver="${_gsver}.pfhdr1"
pkgver="${_gsver}.pfhdr2"
pkgrel=1
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
@@ -32,36 +36,53 @@ depends=('glslang' 'libcap' 'libdrm' 'libinput' 'libx11' 'libxcomposite' 'libxda
'vulkan-icd-loader' 'wayland' 'xcb-util-errors' 'xcb-util-wm' 'xorg-xwayland')
makedepends=('git' 'meson' 'ninja' 'cmake' 'glm' 'vulkan-headers' 'wayland-protocols'
'benchmark' 'libxcursor')
# The submodules are pinned by the rev, so the git source is reproducible; `#commit=` makes
# makepkg check it out and `--recurse-submodules` pulls wlroots/vkroots/libliftoff/… with it.
source=("git+https://github.com/ValveSoftware/gamescope.git#commit=${_gsrev}"
'0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch'
'0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch')
# SKIP on the git source: the commit hash above IS the integrity check.
sha256sums=('SKIP' 'SKIP' 'SKIP')
# Only the upstream tree is a `source=`. The patches are NOT listed: they are applied by the shared
# build script out of `patches/` beside this file, which is what keeps this package from drifting
# from the sysext / by-hand builds (a stale filename here used to mean a silently different binary).
# The submodules are pinned by the rev, so the git source is reproducible; `#commit=` makes makepkg
# check it out and `--recurse-submodules` pulls wlroots/vkroots/libliftoff/… with it.
source=("git+https://github.com/ValveSoftware/gamescope.git#commit=${_gsrev}")
# SKIP: the commit hash above IS the integrity check.
sha256sums=('SKIP')
options=('!lto' '!debug')
install="${pkgname}.install"
prepare() {
cd gamescope
git submodule update --init --recursive --depth 1
# `git am` (not `patch`): the patches are format-patch output, and applying them as commits keeps
# `git describe --dirty` from stamping a `+` into the version banner on top of our `+pfhdr1`.
git -c user.name=punktfunk -c user.email=packages@unom.io \
am "${srcdir}"/000*.patch
}
build() {
arch-meson gamescope build \
--buildtype=release \
-Dpipewire=enabled
meson compile -C build
# ONE recipe. The patch set, the meson options and — load-bearing for a package — the
# `force_fallback_for` that keeps wlroots STATIC all live in the shared script. Arch has
# `wlroots` in its repos, so a plain `arch-meson` here would link it shared and ship a
# punktfunk-gamescope that dies with `libwlroots-0.19.so: cannot open shared object file` on
# every machine that installs the package but not that dev library.
#
# `$startdir` is this file's directory: the package is built from the punktfunk checkout
# (`makepkg -si` in packaging/gamescope), which is the only documented way to build it.
grep -q "^GAMESCOPE_REV=\"${_gsrev}\"" "${startdir}/build-punktfunk-gamescope.sh" || {
echo "PKGBUILD _gsrev drifted from GAMESCOPE_REV in build-punktfunk-gamescope.sh" >&2
return 1
}
bash "${startdir}/build-punktfunk-gamescope.sh" \
--srcdir "${srcdir}/gamescope" \
--destdir "${srcdir}/stage" \
--prefix /usr \
--jobs "$(nproc)"
# The marker is the host's whole HDR detection (`gamescope_patch_level`) — a build that lost the
# stamp would install a package that promises nothing and says so nowhere.
"${srcdir}/stage/usr/bin/punktfunk-gamescope" --version 2>&1 | grep -q '+pfhdr' || {
echo "built binary carries no +pfhdr marker" >&2
return 1
}
}
package() {
# ONLY the compositor, under our own name: a full `meson install` would also lay down
# gamescopectl / gamescopereaper / gamescopestream and the WSI layer, colliding file-for-file
# with the distro's `gamescope` package. The host only ever execs the compositor.
install -Dm755 build/src/gamescope "${pkgdir}/usr/bin/punktfunk-gamescope"
install -Dm755 "${srcdir}/stage/usr/bin/punktfunk-gamescope" \
"${pkgdir}/usr/bin/punktfunk-gamescope"
install -Dm644 gamescope/LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
+34 -2
View File
@@ -47,8 +47,18 @@ Bump it whenever a patch adds or changes something the host must know about befo
SteamOS PATH shim) pass these flags through `PF_HDR_ARGS`, so they share one dependency: if the
session ignores `GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope, it gets neither the HDR
formats nor the cursor flag. HDR fails loudly there (the capture negotiation times out and latches
an SDR downgrade); a missing cursor would be silent. Worth a post-spawn `/proc/<pid>/cmdline` check
if that ever bites.
an SDR downgrade) — but a missing cursor would be silent, because the host was told the compositor
would paint the pointer and so painted none itself.
Both managed paths therefore **verify after spawn**: once the session's node appears,
`verify_managed_spawn_flags` reads the running compositor's `/proc/<pid>/cmdline` and refuses the
session if a flag we passed isn't there. The plan is fixed by then (`cursor_blend` feeds the encoder
open, which precedes the display), so the session cannot be corrected in place — instead the
capability is latched off for the process and the spawn fails, and the retry resolves a correct SDR
host-composited session. One rejected attempt per boot, then it converges.
The check fails **open** at every ambiguity: no flags expected, or no readable gamescope in
`/proc`, says nothing. Only a compositor we can see, missing a flag we can name, fails.
## Which binary the host runs
@@ -107,6 +117,28 @@ own binary. Mirror it if you install ours system-wide:
setcap 'CAP_SYS_NICE=eip' /usr/bin/punktfunk-gamescope
```
## How each channel ships it
Four packaging paths, one build recipe — `build-punktfunk-gamescope.sh` — because the two things
that are easy to get wrong (which patches get applied, and whether wlroots is linked statically)
must not be decided twice.
| Channel | Built by | Notes |
|---|---|---|
| Bazzite / Fedora Atomic | `.gitea/workflows/rpm.yml``build-sysext.sh --gamescope` | Inside the matching Fedora container, per major — the binary is soname-coupled to its base exactly like the RPM |
| Arch / SteamOS | `.gitea/workflows/arch.yml``makepkg` on `./PKGBUILD` | Its own pkgbase in the same pacman repo; `pacman -S punktfunk-gamescope` |
| NixOS | `packaging/nix/gamescope.nix` (an `overrideAttrs` on nixpkgs' gamescope) | The one path that does NOT call the script — nixpkgs already solves the submodules, and a nix closure names every library it links |
| Anything else | the script, by hand | See *Building* above |
Both CI builds are **cached on `packaging/gamescope/**`** and **best-effort**. Cached because this
tree depends on nothing else in the repo, so a normal push restores a binary instead of spending
ten minutes on someone else's C++; best-effort because punktfunk works without it (SDR on the
gamescope backend, which is what every release before this one did) and a hiccup building gamescope
must not cost the packages those workflows exist to publish. A failed build emits a `::warning::`
and is never cached, so the next run retries.
Note what is NOT in that table: the `.deb`. Debian/Ubuntu boxes build it by hand for now.
## Verifying the patch on a box (P0 exit)
```sh
+38 -11
View File
@@ -3,7 +3,13 @@
#
# An override rather than a from-scratch derivation on purpose: gamescope vendors wlroots,
# vkroots, libliftoff, libdisplay-info, SPIRV-Headers and reshade as git submodules plus two meson
# wraps, and nixpkgs already solves all of that. What we add is two patches and a rename.
# wraps, and nixpkgs already solves all of that. What we add is the patch set and a rename.
#
# This is also why the override needs none of the `force_fallback_for` armour
# `build-punktfunk-gamescope.sh` carries: that exists because meson silently prefers a SYSTEM
# wlroots when the build host has one and links it shared, producing a binary that starts only on
# machines with that dev library. A nix closure names every library it links, so the outcome is
# whatever nixpkgs' own gamescope already does — reproducibly.
#
# `gamescope` in nixpkgs is a wrapper (it wires the WSI layer + capabilities); the buildable
# derivation is `gamescope.unwrapped` — patching the wrapper would be a no-op, so this asserts on
@@ -20,23 +26,44 @@
patchDir,
}:
let
# As of nixos-unstable (checked 2026-07-28) `gamescope` IS the buildable derivation — pname
# "gamescope", version 3.16.25, carrying `src`/`patches`/`mesonFlags`. Revisions that wrap it
# (to wire the WSI layer + capabilities) expose the build as `.unwrapped`, so prefer that where
# it exists and take `gamescope` itself otherwise.
#
# The check is on the RESULT, not on which attribute we found: `overrideAttrs` on a symlinkJoin
# wrapper succeeds and does nothing, which would hand us an UNPATCHED gamescope installed under
# our own name — the single worst outcome here, because the host reads the name as a promise of
# HDR. (`installCheckPhase` below greps for the marker as the second line of defence; this one
# fails at eval, before anything is built.)
base = gamescope.unwrapped or gamescope;
unwrapped =
gamescope.unwrapped or (throw ''
punktfunk-gamescope needs `gamescope.unwrapped` (the buildable derivation behind nixpkgs'
gamescope wrapper) and this nixpkgs does not expose it. Update nixpkgs, or build the
compositor with packaging/gamescope/build-punktfunk-gamescope.sh instead.
'');
if base ? src then
base
else
throw ''
punktfunk-gamescope needs a buildable gamescope derivation (one with a `src` that
`overrideAttrs` can patch); this nixpkgs' `gamescope` is neither that nor a wrapper
exposing `.unwrapped`. Update nixpkgs, or build the compositor with
packaging/gamescope/build-punktfunk-gamescope.sh instead.
'';
in
unwrapped.overrideAttrs (old: {
pname = "punktfunk-gamescope";
patches = (old.patches or [ ]) ++ [
"${patchDir}/0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch"
"${patchDir}/0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch"
];
# Read the patch DIRECTORY rather than naming files: `builtins.attrNames` sorts
# lexicographically, which for `000N-` prefixes is exactly the apply order, and a patch added or
# renamed upstream of this file can no longer leave nix silently building a subset. (That already
# happened once — this list still named the level-1 banner patch after level 2 landed, so a nix
# build would have shipped a binary with no cursor patch and a marker claiming otherwise.)
patches =
(old.patches or [ ])
++ map (f: "${patchDir}/${f}") (
builtins.filter (lib.hasSuffix ".patch") (builtins.attrNames (builtins.readDir patchDir))
);
# nixpkgs builds from a `fetchFromGitHub` src, so there is no `.git` for `git describe` and the
# banner would read `+pfhdr1 (gcc …)` with no version at all — which the host's diagnostic
# banner would read `+pfhdr2 (gcc …)` with no version at all — which the host's diagnostic
# version gate then misreads (it takes the first X.Y.Z triple it finds, i.e. the compiler's).
# Substituting the real version in keeps `--version` honest AND keeps our marker.
postPatch = (old.postPatch or "") + ''