name: Build APK run-name: ${{ github.event_name == 'pull_request_target' && format('building pr {0} on {1}', github.event.pull_request.number, github.event.pull_request.head.sha) || format('building {0} on {1}', github.ref_name, github.sha) }} # 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 "
$f" echo echo '```' awk '/^<<<<<<< /{p=1} p{print} /^>>>>>>> /{p=0}' "$f" | head -300 echo '```' echo "
" 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" - name: Set the APK build label id: build-label env: PR_NUMBER: ${{ github.event.pull_request.number }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} BUILD_REF: ${{ github.ref_name }} BUILD_SHA: ${{ github.sha }} run: | if [[ -n "$PR_NUMBER" ]]; then label="pr${PR_NUMBER} at ${PR_HEAD_SHA:0:7}" else label="${BUILD_REF} at ${BUILD_SHA:0:7}" fi echo "label=$label" >> "$GITHUB_OUTPUT" echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY" # ── 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', 'tools/wlroots/release.env', 'tools/mangoapp/*') }} - 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 # The patched wlroots for the desktop's GPU renderers (tools/wlroots), the same way. if [ -f tools/wlroots/release.env ]; then . tools/wlroots/release.env gh release download "${WLROOTS_TAG}" -R "${GITHUB_REPOSITORY}" -p wlroots.tzst -O wlroots.tzst echo "${WLROOTS_SHA256} wlroots.tzst" | sha256sum -c - tar --use-compress-program=unzstd -xf wlroots.tzst -C "$D" test -f "$D/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" rm wlroots.tzst fi # Valve's aarch64 mangoapp for Deck mode's performance overlay, plus the five libraries the # runtime lacks, pinned by sha256 in tools/mangoapp/packages.txt. Stored under the SONAME # each is loaded by, as real files: assets cannot carry symlinks. M="$D/usr/local/lib/mangoapp" mkdir -p "$M" mango-pkgs grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do [ -n "$url" ] || continue curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url" echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c - tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs rm mango-pkgs/pkg.tar.zst done install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp" for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do cp -L "mango-pkgs/usr/lib/$so" "$M/$so" done install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp" rm -rf mango-pkgs ls -l "$M" # And the desktop's launcher and labwc defaults, staged over the desktop package's copies. install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop" install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu" install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu" 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-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.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 env: DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }} 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' | awk 'NR <= 30' # awk reads to the end: head would break the pipe under pipefail # 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. libstdc++.so is Android's minimal system # C++ runtime; the Termux PTY JNI library has this in its NEEDED list. SYSTEM="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.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="" { 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 \`droiddeck-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 "
What went wrong (Gradle)" echo echo '```' awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60 echo '```' echo "
" ;; 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: droiddeck-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