Skip to content

v2.9.2 "Candidate" -- the full audit acted on, and the release-candid… #890

v2.9.2 "Candidate" -- the full audit acted on, and the release-candid…

v2.9.2 "Candidate" -- the full audit acted on, and the release-candid… #890

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
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 }}
# 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:
filters: |
app:
- 'android/**'
# The project toolchain (rust-toolchain.toml = 1.96) 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@3
# 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 the latest (29.x) 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: |
echo "ANDROID_NDK_HOME=$ANDROID_NDK_LATEST_HOME" >> "$GITHUB_ENV"
echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> "$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
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@3
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '17'
# Both variables, to the same NDK. The runner image sets ANDROID_NDK_ROOT
# to its default NDK (27.x) while this job selects the latest (29.x) 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: |
echo "ANDROID_NDK_HOME=$ANDROID_NDK_LATEST_HOME" >> "$GITHUB_ENV"
echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> "$GITHUB_ENV"
- uses: gradle/actions/setup-gradle@v6.3.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