Skip to content

review(v3.1.0) slice A: crates and build files (do not merge) #992

review(v3.1.0) slice A: crates and build files (do not merge)

review(v3.1.0) slice A: crates and build files (do not merge) #992

Workflow file for this run

name: Android
# v1.8.0 "Android" — the platform-build gate. Host CI (ci.yml) remains the
# authoritative accuracy/determinism gate (AccuracyCoin 100%, oracles, nestest);
# this workflow only proves the mobile host *links* against the NDK toolchain and
# that the UniFFI binding pipeline + Gradle packaging are intact. It is NOT a
# required check — accuracy is never gated on a device toolchain.
on:
push:
branches: [main]
# Every workspace crate `rustynes-android` compiles, as `cargo tree -p
# rustynes-android -e normal,build --target all --prefix none` reports it.
# `--target all` matters: `rustynes-gfx-shaders` is an Android-target-only
# dependency that the host-target default does not list (caught in review
# on #547). `.cargo/**` because the cross-build reads `.cargo/config.toml`.
# Until
# 2026-09-23 this listed only mobile, android and core, so a change to
# netplay, script, cheevos, ra, hdpack or any chip crate that broke the
# Android build never ran this workflow. Re-derive when a dependency is
# added. The list is written once and aliased by `pull_request` below
# (YAML anchors, supported in workflow files since 2025-09-18).
paths: &android-paths
- 'crates/rustynes-android/**'
- 'crates/rustynes-mobile/**'
- 'crates/rustynes-core/**'
- 'crates/rustynes-cpu/**'
- 'crates/rustynes-ppu/**'
- 'crates/rustynes-apu/**'
- 'crates/rustynes-mappers/**'
- 'crates/rustynes-netplay/**'
- 'crates/rustynes-script/**'
- 'crates/rustynes-cheevos/**'
- 'crates/rustynes-ra/**'
- 'crates/rustynes-hdpack/**'
- 'crates/rustynes-gfx-shaders/**'
- 'android/**'
- '.github/workflows/android.yml'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- '.cargo/**'
pull_request:
branches: [main]
# `ready_for_review` starts the build on a PR opened as a draft; see the
# draft gate on `cross-build`.
types: [opened, synchronize, reopened, ready_for_review]
paths: *android-paths
workflow_dispatch:
permissions:
contents: read
# Superseded PR pushes are cancelled; every `main` run finishes. This workflow
# had no group at all, so each push to a PR left the previous run -- up to
# ~27 minutes of NDK build plus Gradle packaging -- running to completion for
# a commit nobody would look at again.
concurrency:
group: android-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
# v3.0.1: NDK r30, the newest stable (`ndk;30.0.16248370` in Google's SDK
# repository). The runner image ships r29 as its newest, so every job
# installs this one with `sdkmanager` in its "Resolve NDK" step. r30's
# changelog lists a newer clang, simpleperf prebuilts and sysroots up to
# API 37; nothing that touches a `--platform 26` build.
NDK_VERSION: "30.0.16248370"
jobs:
cross-build:
name: NDK cross-build + UniFFI bindings
runs-on: ubuntu-26.04
timeout-minutes: 45
# `pull-requests: read` is for the paths filter below, which asks the API
# which files a PR touches.
permissions:
contents: read
pull-requests: read
outputs:
app: ${{ steps.app.outputs.app }}
kotlin: ${{ steps.app.outputs.kotlin }}
# Draft gate: a PR opened as a draft skips the Android build until it is
# marked ready, the same rule ci.yml's `setup` applies to its heavy jobs.
# This workflow is not a required check, so a skip blocks nothing.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# Did this PR touch the Android APP (Kotlin, Compose, Gradle), as opposed
# to only the Rust it links? Only then is the Gradle packaging job worth
# its ~15 minutes on a PR; see `gradle-bundle`.
- uses: dorny/paths-filter@v4
id: app
if: github.event_name == 'pull_request'
with:
# `kotlin` drives the JVM unit-test job. It is wider than `app`: the
# Kotlin tests compile against the UniFFI bindings generated from
# rustynes-mobile, and a PR that edits this workflow must run the
# gate it edits (#571 did not, and so never exercised the job it
# added).
filters: |
app:
- 'android/**'
kotlin:
- 'android/**'
- 'crates/rustynes-mobile/**'
- '.github/workflows/android.yml'
# The project toolchain (rust-toolchain.toml = 1.99) plus the two shipped
# Android targets (arm64 ships; x86_64 is the emulator/CI ABI), through
# the shared composite. A bare `rustup target add` made rustup
# AUTO-INSTALL the pinned toolchain first, which rustup now deprecates
# with a five-line warning on every run (rust-lang/rustup#4836). The
# composite installs it explicitly, and its cargo cache saves from `main`
# only.
- uses: ./.github/actions/rust-setup
with:
targets: aarch64-linux-android,x86_64-linux-android
apt: "false"
cache-key: android-ndk
# Cached compiled binary instead of `cargo install` on every run (it
# compiled cargo-ndk from source, in both jobs, every time). `--locked` is
# the action's default. cargo-ndk publishes no prebuilt binary that
# `taiki-e/install-action` knows, which is why this is the caching form.
- uses: taiki-e/cache-cargo-install-action@v3
with:
tool: cargo-ndk@4
# ubuntu-26.04 ships the Android SDK + an NDK; resolve its path so
# cargo-ndk finds the toolchain. (We pin a recent NDK; r27+ aligns .so to
# 16 KB by default — a Play requirement for Android 15+.)
# Both variables, to the same NDK. The runner image sets ANDROID_NDK_ROOT
# to its default NDK (27.x) while this job selects its own (r30, above) via
# ANDROID_NDK_HOME, and cargo-ndk warned on every build that the two
# disagree -- a real ambiguity about which toolchain linked the library.
- name: Resolve NDK
run: |
set -euo pipefail
sdk="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
# `yes` answers the licence prompt. Under `pipefail` its own exit
# status (EPIPE / SIGPIPE once sdkmanager closes the pipe, 141 here)
# failed the step even when the install succeeded, so it is absorbed;
# sdkmanager's status, and the clang check below, still decide.
(yes || true) | "$sdk/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" > /dev/null
ndk="$sdk/ndk/$NDK_VERSION"
test -x "$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" \
|| { echo "::error::NDK $NDK_VERSION did not install at $ndk"; exit 1; }
echo "ANDROID_NDK_HOME=$ndk" >> "$GITHUB_ENV"
echo "ANDROID_NDK_ROOT=$ndk" >> "$GITHUB_ENV"
- name: Cross-compile mobile + android (arm64 + x86_64)
run: |
cargo ndk -t arm64-v8a -t x86_64 --platform 26 \
build --profile release-mobile -p rustynes-mobile -p rustynes-android
- name: Generate Kotlin bindings (UniFFI smoke)
run: |
cargo run -q -p rustynes-mobile --bin uniffi-bindgen -- \
generate \
--library target/aarch64-linux-android/release-mobile/librustynes_mobile.so \
--language kotlin --out-dir target/uniffi-kotlin --no-format
test -f target/uniffi-kotlin/uniffi/rustynes_mobile/rustynes_mobile.kt
# 16 KB page-alignment check on the shipped arm64 library. Every PT_LOAD
# segment must be aligned to at least 2**14; r27+ does this by default but
# the check keeps a toolchain regression from silently shipping a 4 KB AAB
# that Play would reject on Android 15+.
- name: Verify 16 KB ELF alignment (arm64)
run: |
set -euo pipefail
so=target/aarch64-linux-android/release-mobile/librustynes_mobile.so
bad=$("${ANDROID_NDK_HOME}"/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf -l "$so" \
| awk '/LOAD/ { if (strtonum($NF) < 0x4000) print }')
if [ -n "$bad" ]; then
echo "::error::LOAD segment under 16 KB alignment in $so"; echo "$bad"; exit 1
fi
echo "All LOAD segments >= 16 KB aligned."
- name: Upload cross-built libraries
uses: actions/upload-artifact@v7
with:
name: rustynes-android-so
path: |
target/aarch64-linux-android/release-mobile/librustynes_*.so
target/x86_64-linux-android/release-mobile/librustynes_*.so
kotlin-unit-tests:
name: Kotlin unit tests (foss debug, JVM)
runs-on: ubuntu-26.04
timeout-minutes: 45
# A GATE, unlike the bundle job below. Until v2.9.3 nothing in CI ran the
# app's JVM tests (`android/app/src/test/`: SocdTest, PersistenceTest), so
# they had only ever run by hand -- and v2.9.2 added SocdTest precisely to pin
# a mobile behaviour. `preBuild` depends on the cargo-ndk cross-build and the
# UniFFI binding generation, so this needs the same toolchain as the bundle.
needs: cross-build
# Every `main` push and dispatch, and a PR that changes the app, the UniFFI
# bridge it compiles against, or this workflow (the `kotlin` filter above).
if: >-
${{ github.event_name != 'pull_request'
|| needs.cross-build.outputs.kotlin == 'true' }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ./.github/actions/rust-setup
with:
targets: aarch64-linux-android,x86_64-linux-android
apt: "false"
cache-key: android-ndk
- uses: taiki-e/cache-cargo-install-action@v3
with:
tool: cargo-ndk@4
# v3.0.1: Temurin 25, the newest LTS (was 17). This is the JDK that RUNS
# Gradle; the bytecode target stays JVM 17 (`jvmTarget` and
# `sourceCompatibility` in app/build.gradle.kts), which is AGP 9.4's
# floor. Gradle 9.8 supports running on Java 25 (9.1.0 and later).
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '25'
- name: Resolve NDK
run: |
set -euo pipefail
sdk="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
# `yes` answers the licence prompt. Under `pipefail` its own exit
# status (EPIPE / SIGPIPE once sdkmanager closes the pipe, 141 here)
# failed the step even when the install succeeded, so it is absorbed;
# sdkmanager's status, and the clang check below, still decide.
(yes || true) | "$sdk/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" > /dev/null
ndk="$sdk/ndk/$NDK_VERSION"
test -x "$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" \
|| { echo "::error::NDK $NDK_VERSION did not install at $ndk"; exit 1; }
echo "ANDROID_NDK_HOME=$ndk" >> "$GITHUB_ENV"
echo "ANDROID_NDK_ROOT=$ndk" >> "$GITHUB_ENV"
- uses: gradle/actions/setup-gradle@v6.4.0
with:
cache-provider: basic
- name: Run the JVM unit tests
working-directory: android
run: ./gradlew :app:testFossDebugUnitTest --no-daemon --warning-mode all
- name: Upload the test report on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: kotlin-unit-test-report
path: android/app/build/reports/tests/testFossDebugUnitTest/
if-no-files-found: warn
gradle-bundle:
name: Gradle bundle foss+play release (best-effort packaging)
runs-on: ubuntu-26.04
timeout-minutes: 45
# Packaging is informational: the cross-build job above is the real link
# gate. R8/Compose packaging pulls the full Android SDK, so a transient
# tooling hiccup here must not fail the merge.
continue-on-error: true
needs: cross-build
# The slowest job in the repository (median 15.5 min over the 16 PR runs
# measured on 2026-09-23), and one that could never fail a PR anyway
# (`continue-on-error`). It runs on every `main` push and on dispatch, and
# on a PR only when the PR changes the Android app itself -- a PR that
# touches only shared Rust is covered by the cross-build's link gate, and
# the packaging is re-proven the moment it merges.
if: >-
${{ github.event_name != 'pull_request'
|| needs.cross-build.outputs.app == 'true' }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ./.github/actions/rust-setup
with:
targets: aarch64-linux-android,x86_64-linux-android
apt: "false"
cache-key: android-ndk
# Cached compiled binary instead of `cargo install` on every run (it
# compiled cargo-ndk from source, in both jobs, every time). `--locked` is
# the action's default. cargo-ndk publishes no prebuilt binary that
# `taiki-e/install-action` knows, which is why this is the caching form.
- uses: taiki-e/cache-cargo-install-action@v3
with:
tool: cargo-ndk@4
# v3.0.1: Temurin 25, the newest LTS (was 17). This is the JDK that RUNS
# Gradle; the bytecode target stays JVM 17 (`jvmTarget` and
# `sourceCompatibility` in app/build.gradle.kts), which is AGP 9.4's
# floor. Gradle 9.8 supports running on Java 25 (9.1.0 and later).
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '25'
# Both variables, to the same NDK. The runner image sets ANDROID_NDK_ROOT
# to its default NDK (27.x) while this job selects its own (r30, above) via
# ANDROID_NDK_HOME, and cargo-ndk warned on every build that the two
# disagree -- a real ambiguity about which toolchain linked the library.
- name: Resolve NDK
run: |
set -euo pipefail
sdk="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
# `yes` answers the licence prompt. Under `pipefail` its own exit
# status (EPIPE / SIGPIPE once sdkmanager closes the pipe, 141 here)
# failed the step even when the install succeeded, so it is absorbed;
# sdkmanager's status, and the clang check below, still decide.
(yes || true) | "$sdk/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" > /dev/null
ndk="$sdk/ndk/$NDK_VERSION"
test -x "$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" \
|| { echo "::error::NDK $NDK_VERSION did not install at $ndk"; exit 1; }
echo "ANDROID_NDK_HOME=$ndk" >> "$GITHUB_ENV"
echo "ANDROID_NDK_ROOT=$ndk" >> "$GITHUB_ENV"
- uses: gradle/actions/setup-gradle@v6.4.0
with:
# v6 extracted the default ("enhanced") Gradle User Home cache into the
# closed-source `gradle-actions-caching` library (separate Terms of Use).
# RustyNES is OSS, so pin the fully open-source (MIT) provider built on
# the standard GitHub Actions cache instead of the proprietary default.
cache-provider: basic
# Publish a Gradle Build Scan for every run (the free scans.gradle.com
# service) so CI Gradle failures have a shareable diagnostic URL in the
# log. ToS auto-accepted for the public service.
build-scan-publish: true
build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
build-scan-terms-of-use-agree: "yes"
# v2.0.1 (ADR 0025): the `distribution` flavor split builds BOTH channel AABs.
# `bundleFossRelease` is the F-Droid / sideload artifact (no Google SDKs);
# `bundlePlayRelease` is the Google Play artifact. Building both here proves the
# split compiles + packages for each flavor. The aggregate `bundleRelease` anchor
# would also fan out to both, but the explicit tasks make the intent legible.
- name: Bundle release AABs (foss + play, unsigned)
working-directory: android
# `--warning-mode all` because the summary line -- "Deprecated Gradle
# features were used in this build, making it incompatible with Gradle
# 10" -- names nothing. Gradle says so itself: "You can use
# '--warning-mode all' to show the individual deprecation warnings and
# determine if they come from your own scripts or plugins."
#
# A warning that cannot be attributed is a warning nobody acts on, and
# this one has a deadline attached to it.
run: ./gradlew :app:bundleFossRelease :app:bundlePlayRelease --no-daemon --warning-mode all
- name: Upload AABs
uses: actions/upload-artifact@v7
with:
name: rustynes-aab
# Flavored output dirs: outputs/bundle/fossRelease/*.aab + .../playRelease/*.aab
path: android/app/build/outputs/bundle/*/*.aab
if-no-files-found: warn