Files
DroidDeck/.github/workflows/build.yml
T
The412Banner 69aba7d0b7 audio: the client's sound through the DirectAudio relay - module-directaudio-sink feeds the relay's render ring
A second sink module: connects to the relay's socket (lazily, the relay comes up after the
daemon; a clocked null sink until then), sends the hello, maps the render ring it gets back and
keeps it topped up to the relay's target with a futex wait per burst. Every AAudio call, the
adaptive buffer and the route handling stay in the relay, outside proot. With DirectAudio on the
daemon loads this sink instead of module-aaudio-sink; the bundle carries both.
2026-09-23 09:12:39 -04:00

427 lines
22 KiB
YAML

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'
# A pull request against main builds by itself: the contributor's branch, as it would be once
# merged onto main, comes back as an apk on the run without anyone here pressing a button.
# Every push to the pull request rebuilds it and cancels the build it replaces; a draft waits
# until it is marked ready. pull_request_target rather than pull_request: GitHub does not run
# the latter at all while a pull request conflicts with main, and saying what conflicts is the
# point. The workflow file is always main's; the pull request's own code is checked out below.
pull_request_target:
branches: [ main ]
types: [ opened, synchronize, reopened, ready_for_review ]
paths-ignore:
- '**/*.md'
- 'LICENSE'
- '.gitignore'
workflow_dispatch:
permissions:
contents: read
# The pull-request report below is one comment on the pull request, kept up to date.
pull-requests: write
# A newer push makes the running build pointless; stop paying for it.
concurrency:
group: build-${{ github.event.pull_request.number || 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:
if: ${{ github.event_name != 'pull_request_target' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
# A pull request is checked out at its own head with full history, not at GitHub's merge
# ref: that ref does not exist while the pull request conflicts with main, and the point of
# the next step is to say so and show where.
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
# Full history: the merge below needs it (a shallow clone refuses to merge at all).
fetch-depth: 0
# The pull request as it would be once merged onto main is what gets built. When it cannot
# be merged, the conflicting files and the conflicting hunks go into the run summary and the
# pull-request comment, and the job stops here: there is nothing coherent to build.
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request_target'
run: |
set -uo pipefail
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
git fetch --no-tags origin main
BASE=$(git rev-parse --short origin/main)
HEAD=$(git rev-parse --short HEAD)
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
{
echo "### Mergeable"
echo
echo "\`$HEAD\` merges cleanly onto main (\`$BASE\`). What follows was built from the merged result."
} > /tmp/pr-merge.md
cat /tmp/pr-merge.md >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
FILES=$(git diff --name-only --diff-filter=U)
if [ -z "$FILES" ]; then
# The merge did not even start (git said why): quote it rather than list nothing.
{
echo "### ERROR: Could not merge onto main (\`$BASE\`)"
echo
echo '```'
cat /tmp/merge.out
echo '```'
} > /tmp/pr-merge.md
cat /tmp/pr-merge.md >> "$GITHUB_STEP_SUMMARY"
echo "::error::git could not merge main: $(head -3 /tmp/merge.out | tr '\n' ' ')"
exit 1
fi
{
echo "### ERROR: Not mergeable - conflicts with main (\`$BASE\`)"
echo
echo "Files that conflict:"
echo
for f in $FILES; do echo "- \`$f\`"; done
echo
echo "Each conflicting hunk, as git leaves it: \`<<<<<<< HEAD\` is this pull request, \`>>>>>>> origin/main\` is main."
echo
for f in $FILES; do
echo "<details><summary><code>$f</code></summary>"
echo
echo '```'
awk '/^<<<<<<< /{p=1} p{print} /^>>>>>>> /{p=0}' "$f" | head -300
echo '```'
echo "</details>"
echo
done
echo "Not built: rebase or merge main into the branch and push again."
} > /tmp/pr-merge.md
cat /tmp/pr-merge.md >> "$GITHUB_STEP_SUMMARY"
git merge --abort || true
echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')"
exit 1
- uses: actions/setup-java@v4
with:
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. 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_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu >/dev/null
D=app/src/main/assets/linuxfs
mkdir -p "$D"
# Statically linked C++ runtime: this is preloaded into every Steam process, and Steam
# ships its own libstdc++ while rewriting LD_LIBRARY_PATH. Pulling the system one in
# ahead of it is a version conflict waiting to happen. Only libc is left as a dependency.
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
-pthread -std=c++17 -static-libstdc++ -static-libgcc \
-o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
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"
# 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
# The SteamOS helpers the client calls in Deck mode, as no-op stubs (see each file).
for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do
[ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}"
done
# The patched gamescope, when a build of it has been published (tools/gamescope/release.env
# names the release and the archive's sha256); see tools/gamescope/PATCHES.md.
if [ -f tools/gamescope/release.env ]; then
. tools/gamescope/release.env
# Through the API: the repository is private, so the plain download URL answers 404.
gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
test -f "$D/usr/local/bin/gamescope"
rm gamescope.tzst
fi
# And the desktop's launcher and labwc defaults, staged over the desktop package's copies.
install -Dm644 tools/linuxfs/desktop/steamdeck-desktop "$D/usr/local/bin/steamdeck-desktop"
install -Dm644 tools/linuxfs/desktop/autostart "$D/etc/xdg/labwc/autostart"
install -Dm644 tools/linuxfs/desktop/rc.xml "$D/etc/xdg/labwc/rc.xml"
install -Dm644 tools/linuxfs/desktop/panel.conf "$D/etc/xdg/lxqt/panel.conf"
install -Dm644 tools/linuxfs/desktop/firefox-steamdeck.js "$D/usr/lib/firefox/defaults/pref/steamdeck.js"
ls -lR "$D"
# The daemon's AAudio sink (tools/aaudio-sink), compiled here against upstream PulseAudio 13
# headers and the app's own libpulse*, then put into the audio bundle the apk carries. The
# committed bundle holds every other module and no sink, so a build without this step would
# have no audio - the unpack check in PulseAudioComponent would then fail loudly, not quietly.
- name: Cache PulseAudio 13.0 source
id: pa-src
uses: actions/cache@v4
with:
path: pa13
key: pulseaudio-src-13.0
- name: Fetch PulseAudio 13.0 source
if: steps.pa-src.outputs.cache-hit != 'true'
run: |
set -euo pipefail
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
- name: Build the AAudio sink into the audio bundle
run: |
set -euo pipefail
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/sink-out"
rm -rf pa-bundle && mkdir -p pa-bundle
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-directaudio-sink.so pa-bundle/modules/arm64/module-directaudio-sink.so
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
rm -rf pa-bundle sink-out
# 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: |
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 statfs statvfs; do
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
done
test -f "$D/usr/local/bin/bannerlator-session"
test -f "$D/usr/local/bin/bannerlator-proton-extra"
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 }}-
# Release, not debug: AGP quietly drops v1 (JAR) signing from a debug build once minSdk is
# 24 or higher, and the apk has to carry all three schemes. Nothing is minified either way.
# The whole log is kept so a failed build's errors can be quoted back on a pull request.
- name: Build
id: build
run: |
set -o pipefail
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
- name: Describe the apk
run: |
set -euo pipefail
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
echo "apk: $APK"
ls -l "$APK"
sha256sum "$APK"
unzip -l "$APK" | grep -E 'lib/arm64-v8a|assets/linuxfs' | head -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.
- name: Check the native dependency closure
run: |
set -euo pipefail
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
WORK=$(mktemp -d)
unzip -q "$APK" 'lib/arm64-v8a/*' -d "$WORK"
cd "$WORK/lib/arm64-v8a"
# Provided by the platform, so never packaged.
SYSTEM="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so
libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so
libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so"
fail=0
for so in *.so; do
for need in $(readelf -d "$so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p'); do
[ -f "$need" ] && continue
# $SYSTEM spans lines; unquoted echo folds its newlines to spaces, or the last
# name on each line could never match (libaaudio.so did not, for the relay helper).
case " $(echo $SYSTEM) " in *" $need "*) continue ;; esac
echo "::error::$so needs $need, which is neither in the apk nor a platform library"
fail=1
done
done
[ "$fail" = 0 ] && echo "every NEEDED resolves"
exit $fail
# AGP signs with v2+v3 and drops v1 whenever minSdk is 24 or higher, and setting
# enableV1Signing in the DSL does not move it. Re-signing here with apksigner is the one way
# to be sure of what the apk carries: zipalign first, then all three schemes explicitly.
- name: Sign with the testkey (v1 + v2 + v3)
run: |
set -euo pipefail
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
TOOLS=$(find "$ANDROID_HOME/build-tools" -maxdepth 1 -type d | sort -V | tail -1)
"$TOOLS/zipalign" -p -f 4 "$APK" "$APK.aligned"
"$TOOLS/apksigner" sign \
--ks keystore/testkey.p12 --ks-type PKCS12 --ks-pass pass:android \
--ks-key-alias testkey --key-pass pass:android \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
--out "$APK" "$APK.aligned"
rm -f "$APK.aligned" "$APK.idsig"
# The apk must stay installable over the last one, and on every Android version: that means
# one stable key and all three signature schemes. Cheap to assert, easy to lose silently.
- name: Check the signature
run: |
set -euo pipefail
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort -V | tail -1)
# --min-sdk-version 21: apksigner only verifies the schemes an apk's own minSdk requires,
# so at minSdk 26 it reports v1 as false whether or not the JAR signature is there.
# Asking it to verify from 21 makes it actually look.
"$APKSIGNER" verify --min-sdk-version 21 --verbose --print-certs "$APK" | tee /tmp/sig.txt
# And the files themselves, which is what an old installer reads.
unzip -l "$APK" | grep -qE 'META-INF/.*\.(SF|RSA|DSA)$' \
|| { echo "::error::apk carries no JAR signature files"; exit 1; }
for scheme in "v1 scheme (JAR signing): true" "v2 scheme (APK Signature Scheme v2): true" \
"v3 scheme (APK Signature Scheme v3): true"; do
grep -qF "$scheme" /tmp/sig.txt || { echo "::error::apk is not signed with $scheme"; exit 1; }
done
grep -qi "CN=Android, OU=Android, O=Android" /tmp/sig.txt \
|| { echo "::error::apk is not signed with the AOSP testkey"; exit 1; }
# ── The pull-request report ──────────────────────────────────────────────────────────
# One comment per pull request, rewritten on every push: whether it merges, and when the
# build failed, the compiler's own errors with paths made short. Fork pull requests carry a
# read-only token and cannot comment; the same text is in the run summary either way.
- name: Report on the pull request
if: always() && github.event_name == 'pull_request_target'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
BUILD_OUTCOME: ${{ steps.build.outcome }}
run: |
set -uo pipefail
MARK="<!-- steamdeck-pr-build -->"
{
echo "$MARK"
echo "## Build of this pull request"
echo
[ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
echo
case "$BUILD_OUTCOME" in
success)
echo "### Build passed"
echo
echo "The apk is the \`steamdeck-apk\` artifact on [this run]($RUN_URL) (signed with the same test key as every build, so it installs over any other)."
;;
failure)
echo "### ERROR: Build failed"
echo
echo "The compiler's errors ([full log]($RUN_URL)):"
echo
echo '```'
{ grep -E '^e: |^w: .*error|error:|CMake Error|fatal error|ninja: error|FAILED|What went wrong|Execution failed for task|Unresolved reference|Type mismatch|Unresolved' /tmp/build.log 2>/dev/null \
| sed -e 's#file:///home/runner/work/[^/]*/[^/]*/##' -e 's#/home/runner/work/[^/]*/[^/]*/##' \
| grep -v '^w: ' | awk '!seen[$0]++' | head -80; } || true
echo '```'
echo
echo "<details><summary>What went wrong (Gradle)</summary>"
echo
echo '```'
awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60
echo '```'
echo "</details>"
;;
skipped|cancelled)
echo "### Not built"
echo
echo "The build did not run (see above, or [the run]($RUN_URL))."
;;
esac
echo
echo "_Updated on every push to this pull request._"
} > /tmp/pr-report.md
cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY"
ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARK\")) | .id" | head -1 || true)
if [ -n "$ID" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${ID}" -F body=@/tmp/pr-report.md >/dev/null
else
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null
fi
- uses: actions/upload-artifact@v4
with:
name: steamdeck-apk
path: app/build/outputs/apk/release/*.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