Repository navigation
v3.0.1 "Mortar": the open items, Rust 1.99 everywhere, the review sweep, the roadmap to v4.0.0 #984
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |