Cache everything CI can, and credit both authors

The repo is private, so each run is billed. Three caches now carry a rebuild:
Gradle's own through setup-gradle (dependencies, build cache, configuration
cache), the CMake/ninja tree keyed on the C sources and the NDK, and the two
cross-compiled preloads — the last of which also skips installing the cross
toolchain, since a run that changes only Kotlin has no reason to touch a C
compiler. Superseded pushes are cancelled, documentation pushes do not build,
and the apk is stored rather than recompressed.

The preload assertions moved from the cross binutils to readelf, which reads
any ELF whatever the host, so they keep running on a cache hit.

Credits name The412Banner and maxjivi05: the runtime under all of this is
Max's gamescope work from WinNative.
This commit is contained in:
The412Banner committed 2026-09-19 14:07:06 -04:00
1 parent 97ee688eff
commit 4031f512fc
6 files changed
+122 -32

No files matched your search

+89 -32
View File
@@ -2,15 +2,36 @@ name: Build APK
# One job: the two glibc preload libraries the session needs, then the app around them.
# Nothing is built on a developer machine — this workflow is the only thing that produces an apk.
#
# This repo is private, so every minute here is billed. Three caches carry the cost of a rebuild
# down to what actually changed:
# • Gradle's own (dependencies, wrapper, build cache, configuration cache) via setup-gradle
# • the CMake/ninja tree, so a Kotlin-only change does not recompile the compositor
# • the two cross-compiled preloads, which also skips installing the cross toolchain
# A run that changes only Kotlin should not touch the C compiler at all.
on:
push:
branches: [ main ]
# Documentation changes cannot alter the apk, so they do not buy a build.
paths-ignore:
- '**/*.md'
- 'LICENSE'
- '.gitignore'
workflow_dispatch:
permissions:
contents: read
# A newer push makes the running build pointless; stop paying for it.
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
env:
# Bump to force the preload cache to miss (e.g. after changing a compiler flag below).
PRELOAD_CACHE_VERSION: v1
jobs:
build:
runs-on: ubuntu-24.04
@@ -23,14 +44,33 @@ jobs:
distribution: temurin
java-version: '17'
# Caches ~/.gradle (dependencies, wrapper distribution, build cache, configuration cache)
# and prunes what the run did not use, so the entry cannot grow without bound.
- uses: gradle/actions/setup-gradle@v4
with:
cache-cleanup: on-success
- name: Resolve the runner's NDK
run: |
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
VERSION=$(sed -n 's/^Pkg.Revision *= *//p' "$NDK/source.properties")
echo "NDK $VERSION at $NDK"
echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV"
# ── The session's preloads ───────────────────────────────────────────────────────────
# Both are glibc aarch64 libraries that live inside the rootfs, so they are cross-compiled
# here rather than by the NDK. They ship in assets (not in the runtime tarball) so a fix in
# either reaches an already-installed runtime on the next launch instead of waiting on a
# ~790 MB runtime re-host.
# ~790 MB runtime re-host. Their inputs change rarely, so most runs restore them.
- name: Cache the preloads
id: preloads
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
run: |
set -euo pipefail
sudo apt-get update -qq
@@ -44,42 +84,57 @@ jobs:
-pthread -std=c++17 -static-libstdc++ -static-libgcc \
-o "$D/libfakeinput.so" app/src/main/cpp/fakeinput.cpp -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
NEED=$(aarch64-linux-gnu-readelf -d "$D/libfakeinput.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p')
echo "libfakeinput NEEDED: $NEED"
for bad in libstdc++.so.6 libgcc_s.so.1; do
if printf '%s\n' "$NEED" | grep -qx "$bad"; then
echo "::error::libfakeinput.so links $bad — the C++ runtime must stay static"; exit 1
fi
done
# The interposers are the whole point: if one is missing the client sees no controller.
SYMS=$(aarch64-linux-gnu-nm -D --defined-only "$D/libfakeinput.so" | awk '{print $NF}')
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
grep -qx "$sym" <<<"$SYMS" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
done
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so"
for sym in socket bind getsockname setsockopt; do
aarch64-linux-gnu-nm -D --defined-only "$D/libblsession.so" | awk '{print $NF}' \
| grep -qx "$sym" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
done
# The session's scripts travel the same way, for the same reason.
for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
done
ls -lR "$D"
- name: Resolve the runner's NDK
# Runs whether the preloads were built or restored: readelf reads any ELF whatever the host,
# so these assertions cost nothing and need no cross toolchain.
- name: Check the preloads
run: |
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
VERSION=$(sed -n 's/^Pkg.Revision *= *//p' "$NDK/source.properties")
echo "NDK $VERSION at $NDK"
echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV"
set -euo pipefail
D=app/src/main/assets/linuxfs
NEED=$(readelf -d "$D/libfakeinput.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p')
echo "libfakeinput NEEDED: $NEED"
for bad in libstdc++.so.6 libgcc_s.so.1; do
if printf '%s\n' "$NEED" | grep -qx "$bad"; then
echo "::error::libfakeinput.so links $bad — the C++ runtime must stay static"; exit 1
fi
done
syms() { readelf -Ws "$1" | awk '$4 == "FUNC" && $5 == "GLOBAL" {sub(/@.*/, "", $8); print $8}'; }
# The interposers are the whole point: if one is missing the client sees no controller.
FAKE=$(syms "$D/libfakeinput.so")
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
done
SESSION=$(syms "$D/libblsession.so")
for sym in socket bind getsockname setsockopt; do
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
done
test -f "$D/usr/local/bin/bannerlator-session"
echo "preloads check out"
# ── The native build ─────────────────────────────────────────────────────────────────
# The compositor and adrenotools are the slowest part of a build and change least often.
# The key pins the C sources and the NDK; the restore-key still hands an older tree to
# ninja, which then rebuilds only the files that differ.
- name: Cache the CMake build tree
uses: actions/cache@v4
with:
path: |
app/.cxx
app/build/intermediates/cxx
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**') }}
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
- name: Build
run: ./gradlew --no-daemon assembleDebug -PndkVersion="$NDK_VERSION"
run: ./gradlew assembleDebug -PndkVersion="$NDK_VERSION"
- name: Describe the apk
run: |
@@ -90,13 +145,6 @@ jobs:
sha256sum "$APK"
unzip -l "$APK" | grep -E 'lib/arm64-v8a|assets/linuxfs' | head -30
- uses: actions/upload-artifact@v4
with:
name: steamdeck-apk
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error
retention-days: 30
# Every NEEDED of every library we ship must resolve inside the apk or on the device. The
# first build shipped libwayland-server.so without libffi.so, and the app died on
# System.loadLibrary the moment a session started — a check this step would have caught.
@@ -122,3 +170,12 @@ jobs:
done
[ "$fail" = 0 ] && echo "every NEEDED resolves"
exit $fail
- uses: actions/upload-artifact@v4
with:
name: steamdeck-apk
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error
retention-days: 30
# An apk is a zip already; compressing it again buys nothing and costs seconds.
compression-level: 0
+6
View File
@@ -59,6 +59,12 @@ Files in `/sdcard/Download`, for a device that cannot be reached with a debugger
Session logs land in `/sdcard/Download/SteamDeck/`.
## Authors
- **The412Banner** — the app, the Wayland compositor, the runtime plumbing and the Steam session.
- **maxjivi05 (Max)** — the gamescope runtime this is built on: the proot session, the session
shim, the fake-evdev interposer and the controller work, from WinNative.
## Lineage and licence
GPL-3.0. The gamescope runtime, the session shim and the fake-evdev interposer come from
@@ -44,6 +44,13 @@ class MainActivity : Activity() {
playButton.setOnClickListener { startActivity(Intent(this, SessionActivity::class.java)) }
runtimeButton.setOnClickListener { onRuntimeButton() }
findViewById<TextView>(R.id.credits).setOnClickListener {
AlertDialog.Builder(this)
.setTitle(R.string.credits_title)
.setMessage(R.string.credits_body)
.setPositiveButton(android.R.string.ok, null)
.show()
}
// The session's logs land in Downloads so a failed run can be handed over as a folder
// rather than dug out of app-private storage. targetSdk 28 means the old permission still
+10
View File
@@ -63,4 +63,14 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/install_runtime" />
<TextView
android:id="@+id/credits"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:padding="8dp"
android:text="@string/credits_line"
android:textColor="#FF667788"
android:textSize="12sp" />
</LinearLayout>
+3
View File
@@ -18,4 +18,7 @@
<string name="install_failed">Install failed — nothing was changed</string>
<string name="session_starting">Starting the session…</string>
<string name="session_ended">The session ended (%1$d)\n%2$s</string>
<string name="credits_line">by The412Banner and maxjivi05 · credits</string>
<string name="credits_title">Credits</string>
<string name="credits_body">SteamDeck\n\nThe412Banner — the app, the Wayland compositor, the runtime and the Steam session.\n\nmaxjivi05 (Max) — the gamescope runtime this is built on: the proot session, the session shim, the fake-evdev interposer and the controller work, from WinNative.\n\nGPL-3.0. Valve, Steam, Steam Deck and Proton are trademarks of Valve Corporation; this project is not affiliated with Valve. gamescope, Mesa, Turnip, Xwayland, PulseAudio, proot and Arch Linux ARM are their authors\' own.</string>
</resources>
+7
View File
@@ -1,4 +1,11 @@
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# CI is the only thing that builds this repo and its minutes are billed, so every cache Gradle
# offers is on: task outputs are reused across runs from the cache the CI action restores, the
# configuration phase is skipped when nothing about the build scripts changed, and independent
# tasks run together.
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=true
# The NDK the compositor is built with. CI passes the one the runner has installed