Rust + NDK on Android, Part 14 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Previous: Binary Size and the 16KB Page Requirement: Optimizing Rust .so Files for Release. Next: Rust in Kotlin Multiplatform: One Native Core for Android and iOS, and When to Skip Rust Entirely (coming soon).
A CI pipeline for a Rust + Android repo has five jobs: pin the compiler and the NDK so every machine links the same bits, install the Android targets, cache the cargo registry and the target directory with keys that actually hit, run the Rust tests on the host and the instrumented tests on an emulator, and assemble the release APK. This post builds that pipeline as one GitHub Actions workflow file, the one the companion repo will run on every push when it ships.
It assumes the toolchain from Part 1 and the Gradle wiring from Part 4, which matters more here than anywhere else in the series: because Gradle already drives cargo-ndk through an Exec task, the CI build entry point is ./gradlew :app:assembleRelease. CI adds pins, caches, and a device; the build system stays the one you already have. The release profile from Part 13 rides along for free: it lives in rust/Cargo.toml, so the size-optimized, 16KB-aligned libraries you tuned there are what CI assembles.
Key Takeaways
- The hosted Ubuntu runner ships rustup and an Android SDK, but versions drift as the image updates. Reproducibility comes from two committed pins:
rust-toolchain.tomland one NDK version string.- Cache two stores separately: the cargo registry keyed on
Cargo.lock, andrust/targetkeyed on the toolchain file plus the lock file. A repository gets 10 GB of cache before eviction (GitHub Docs), so keys that never hit are worse than no cache.cargo teston the host needs no NDK and no emulator, so it is the fastest failure signal you have. Only the JNI border needs the emulator, hardware-accelerated through KVM on free Linux runners.- The emulator job reuses Part 4’s
-PrustAbisproperty to compile a single ABI, because an x86_64 emulator has no use for three ARM libraries.
Pin the toolchain before touching CI
The runner image ships a Rust toolchain and an Android SDK. Trust neither: image updates move the preinstalled versions without anything in your history changing. Both pins live in your repository instead.
The Rust pin is rust-toolchain.toml at the repo root, which rustup discovers from any subdirectory (rustup book, Overrides):
[toolchain]
channel = "1.90.0"
components = ["clippy", "rustfmt"]
targets = [
"aarch64-linux-android",
"armv7-linux-androideabi",
"x86_64-linux-android",
"i686-linux-android",
]
profile = "minimal"
This file does three jobs. It pins the compiler for CI and for every laptop that clones the repo. It declares the Android targets, so Part 1’s rustup target add becomes self-serve: the first cargo command installs the pinned toolchain, targets included. And it stabilizes your caches, because cargo’s fingerprints embed the compiler version, so an unpinned compiler silently invalidates every cached artifact.
The NDK pin is the same string Part 1 exported as ANDROID_NDK_HOME, kept in one env entry at the top of the workflow. Each job installs that exact version through sdkmanager (Android Developers, sdkmanager) and points ANDROID_NDK_HOME at it, the only discovery mechanism cargo-ndk needs (cargo-ndk). AGP never touches the NDK here: Part 4’s Exec task compiles, AGP packages.
The GitHub Actions CI workflow for Rust on Android
Three jobs run in parallel: rust-checks proves the pure Rust core on the host, build assembles the release APK, and instrumented exercises the JNI border on an emulator. That is the hub’s debugging principle as a job graph: logic bugs fail on the host in minutes, before an NDK download even starts. Here is .github/workflows/android.yml, starting with the header and the host-side job:
name: android
on:
push:
branches: [main]
pull_request:
env:
NDK_VERSION: "28.2.13676358"
CARGO_TERM_COLOR: always
jobs:
rust-checks:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: cargo-registry-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: cargo-registry-
- name: Cache target directory
uses: actions/cache@v4
with:
path: rust/target
key: rust-host-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: rust-host-${{ hashFiles('rust-toolchain.toml') }}-
- name: Check formatting
run: cargo fmt --check
- name: Lint
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test on the host
run: cargo test --workspace
There is no Rust install step, on purpose: rustup sees rust-toolchain.toml and the first cargo invocation installs 1.90.0 before running. The tests compile for the host’s own Linux target, which is the point: the cores are pure Rust libraries, so their tests need nothing Android-shaped.
The build job continues in the same file:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install the pinned NDK
run: |
echo "y" | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$NDK_VERSION"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: cargo-registry-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: cargo-registry-
- name: Cache target directory
uses: actions/cache@v4
with:
path: rust/target
key: rust-android-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: rust-android-${{ hashFiles('rust-toolchain.toml') }}-
- name: Cache cargo-ndk
id: cargo-ndk
uses: actions/cache@v4
with:
path: ~/.cargo/bin/cargo-ndk
key: cargo-ndk-${{ hashFiles('rust-toolchain.toml') }}
- name: Install cargo-ndk
if: steps.cargo-ndk.outputs.cache-hit != 'true'
run: cargo install cargo-ndk --locked
- name: Assemble release
run: ./gradlew :app:assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: app-release
path: app/build/outputs/apk/release/
The interesting line is the least impressive one: ./gradlew :app:assembleRelease. Gradle runs cargoBuildRelease from Part 4, which runs cargo ndk for all four ABIs and drops the libraries into app/build/rustJniLibs/release. Local and CI builds are the same code path, so a CI failure reproduces on your laptop with one command.
The setup-gradle step brings its own caching for the Gradle side, so the workflow only hand-manages cargo’s caches. And cargo install cargo-ndk sits behind a small binary cache, because compiling it from source every run is a silly tax; the key ties it to the toolchain file so a compiler bump rebuilds it.
Cross-compilation caching that actually hits
Cargo wants two different things cached, and mixing them up is how Rust CI caching usually goes wrong.
The first store is the registry: the crates.io index, downloaded .crate archives, and git dependencies. It is identical across host and Android builds and changes only with Cargo.lock, so the key hashes the lock file, and the restore-keys prefix lets a lockfile change restore the previous cache and download only the delta. Deliberately absent: ~/.cargo/registry/src/, which cargo re-extracts faster than the cache restores it, and ~/.cargo/bin/, which would clobber the runner’s own cargo.
The second store is rust/target, the compiled artifacts. Its key has three parts. The lock file, because dependency artifacts are what make the cache worth having. The toolchain file, because fingerprints embed the compiler version, so artifacts from an old rustc restore, never match, and still count against your budget. And the job’s own prefix, rust-host- against rust-android-, because Linux host artifacts are ballast for a job compiling Android targets.
That budget is real: 10 GB per repository by default, entries unused for 7 days are removed, and past the limit GitHub evicts by last access date, oldest first (GitHub Docs, caching dependencies). Four ABIs in release mode fill that faster than you would guess, which is why keys that restore useless artifacts are worse than no cache: they evict the caches that were working.
If you would rather not hand-maintain the keys, the community action rust-cache automates the same scheme, compiler version and lock file hashed into the keys, target directory pruned before saving. I show the manual version because the day the cache misbehaves, you debug it by understanding this key structure.
A matrix over ABIs, and when it pays
The build job compiles all four ABIs sequentially inside one Gradle invocation. The alternative is a matrix, one runner per ABI, in the same workflow file. Add the registry and cargo-ndk cache steps you have already seen; only the per-target pieces are new:
rust-targets:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- abi: arm64-v8a
triple: aarch64-linux-android
- abi: armeabi-v7a
triple: armv7-linux-androideabi
- abi: x86_64
triple: x86_64-linux-android
- abi: x86
triple: i686-linux-android
steps:
- uses: actions/checkout@v4
- name: Install the pinned NDK
run: |
echo "y" | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$NDK_VERSION"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
- name: Cache target directory
uses: actions/cache@v4
with:
path: rust/target
key: rust-${{ matrix.triple }}-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: rust-${{ matrix.triple }}-${{ hashFiles('rust-toolchain.toml') }}-
- name: Install cargo-ndk
run: cargo install cargo-ndk --locked
- name: Build ${{ matrix.abi }}
working-directory: rust
run: cargo ndk -t ${{ matrix.abi }} build --release --locked
Be honest about the arithmetic. Four jobs each compile the entire dependency graph for their target, so total runner minutes go up even when wall clock comes down, and four per-target caches eat the 10 GB budget four times faster. For this workspace, the single Gradle job is the right default.
The matrix earns its keep twice: when the workspace grows enough that one job crowds the runner’s time or memory, and for its failure signal. armeabi-v7a is a 32-bit target, so code that quietly assumes usize is eight bytes, the trap flagged in Part 6, fails in a job named after the guilty ABI instead of deep in a twenty-minute log.
Instrumented tests on an emulator, with KVM
Free Linux runners run the Android emulator hardware-accelerated, since GitHub enabled KVM on them in 2023 (GitHub changelog, hardware accelerated Android virtualization, 2023). The device node needs its permissions opened first. Here is the last job, minus the registry, target and cargo-ndk cache steps you have seen twice already:
instrumented:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Install the pinned NDK
run: |
echo "y" | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$NDK_VERSION"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Install cargo-ndk
run: cargo install cargo-ndk --locked
- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
emulator-options: -no-window -no-audio -no-boot-anim
disable-animations: true
script: ./gradlew :app:connectedDebugAndroidTest -PrustAbis=x86_64
Two details carry this job. The udev rule makes /dev/kvm writable by the runner user; without it the emulator falls back to software emulation and your timeout budget evaporates. And -PrustAbis=x86_64 is Part 4’s iteration property doing CI duty: the emulator is an x86_64 device, so three ARM libraries would be pure waste.
Keep the suite small on purpose. Everything the host can prove belongs in rust-checks. What belongs here is the border: the library loads, the JNI names resolve instead of throwing UnsatisfiedLinkError, the bug class Part 2 dissected, and a Kotlin string survives the round trip.
Reproducible builds: what you can pin, and what you cannot
Everything above pins the toolchain. Reproducibility also needs pinned inputs, which starts with committing rust/Cargo.lock. The Cargo book says to commit the lock file so every build uses identical dependency versions (Cargo book, Cargo.toml vs Cargo.lock), and for an artifact you ship that is not negotiable.
The lock file only protects you if nothing rewrites it silently, which is what --locked enforces: cargo fails the build if Cargo.lock disagrees with the manifests instead of updating it. The matrix job already passes it. The Gradle-driven build gets it through one line in Part 4’s task registration in app/build.gradle.kts:
val cargoBuildRelease = registerCargoBuild("release", listOf("--release", "--locked"))
The workflow file is an input too. Tags like actions/cache@v4 are mutable; GitHub’s hardening guide notes that pinning an action to a full commit SHA is the only way to use it as an immutable release (GitHub Docs, security hardening). For a personal repo the major tags are a reasonable trade; when secrets or signing keys enter the workflow, switch to SHAs.
Be precise about what this buys. Same sources, same rustc, same NDK, same flags. That is the reproducibility most teams need, and the payoff this series cares about, because symbolicating a production tombstone with ndk-stack means rebuilding the exact .so that shipped. Byte-for-byte output is a deeper project, because debug info and panic messages embed absolute build paths that differ per machine; rustc’s --remap-path-prefix flag flattens them, and it is where you start if bit-exact becomes a requirement.
Measuring what the cache buys you
The methodology is built in. Every run’s summary reports wall time per job, and each cache step logs which key it restored, exact match or restore-keys fallback. So: delete the caches from the Actions caches view, push a commit, record the cold times; push a trivial change, record the warm times; change one line of Rust, record the incremental times.
The shape is predictable from how the keys compose. The cold run pays for everything: registry download, full dependency graph per target, cargo-ndk compile. The warm run with an unchanged lock file pays only for your workspace crates, and a Kotlin-only change should cost cargo nothing, because Part 4’s input declarations let Gradle skip the Exec task entirely. The honest numbers, cold against warm per job, land when the companion repo ships, and this section is where that chart will slot in.
Frequently Asked Questions
Does the GitHub Actions Ubuntu runner include the Android SDK and NDK?
Yes, the hosted Ubuntu image ships an Android SDK with an NDK, plus rustup. Treat them as bootstrap, not as your toolchain: preinstalled versions change whenever the image updates. Pin your own NDK with one sdkmanager install, and pin Rust with rust-toolchain.toml.
How do you cache Rust cross-compilation builds in GitHub Actions?
Cache the registry paths under ~/.cargo keyed on Cargo.lock, and cache rust/target keyed on the toolchain file, the lock file, and the target family. Mind the budget: 10 GB per repository, and entries unused for 7 days are removed (GitHub Docs). The rust-cache action automates the same key structure.
Can you run Android instrumented tests on free GitHub runners?
Yes. Linux runners have KVM, so the x86_64 emulator runs hardware-accelerated once a udev rule opens /dev/kvm. Boot it with the android-emulator-runner action and build only the x86_64 ABI for that job. ARM system images remain the gap; those need a physical device farm.
Where this leaves you
This workflow is deliberately boring, and that is its best property. Every command in it appeared earlier in the series; CI just runs them on a machine nobody has touched, with every version written down. When it breaks, it breaks for a readable reason: a cache key stopped matching, or an image update moved a version you never pinned. Boring is what you want from the thing that guards your releases.
One part remains. The series closes with Part 15, Rust in Kotlin Multiplatform: One Native Core for Android and iOS, and When to Skip Rust Entirely, where the same crate compiles for iOS too, and where I argue against Rust more often than you might expect. The full series map lives at the hub.
Sources
- GitHub Docs, “Caching dependencies to speed up workflows”, retrieved 2026-10-25, https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows
- GitHub Docs, “Security hardening for GitHub Actions”, retrieved 2026-10-25, https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions
- The rustup book, “Overrides”, retrieved 2026-10-25, https://rust-lang.github.io/rustup/overrides.html
- Cargo book, “Cargo.toml vs Cargo.lock”, retrieved 2026-10-25, https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
- Android Developers, “sdkmanager”, retrieved 2026-10-25, https://developer.android.com/tools/sdkmanager
- crates.io, cargo-ndk, retrieved 2026-10-25, https://crates.io/crates/cargo-ndk
Rust + NDK on Android, Part 14 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Previous: Binary Size and the 16KB Page Requirement: Optimizing Rust .so Files for Release. Next: Rust in Kotlin Multiplatform: One Native Core for Android and iOS, and When to Skip Rust Entirely (coming soon).
Un pipeline de CI para un repositorio Rust + Android tiene cinco trabajos: fijar el compilador y el NDK para que cada máquina enlace los mismos bits, instalar los targets de Android, cachear el registro de cargo y el directorio target con claves que de verdad aciertan, ejecutar las pruebas de Rust en el host y las pruebas instrumentadas en un emulador, y ensamblar el APK de release. Este artículo construye ese pipeline como un solo archivo de flujo de trabajo de GitHub Actions, el que el repositorio complementario ejecutará en cada push cuando se publique.
Da por hecha la cadena de herramientas de la parte 1 y el cableado de Gradle de la parte 4, que aquí importa más que en ningún otro punto de la serie: como Gradle ya dirige cargo-ndk a través de una tarea Exec, el punto de entrada de la compilación en CI es ./gradlew :app:assembleRelease. La CI añade fijaciones, cachés y un dispositivo; el sistema de compilación sigue siendo el que ya tienes. El perfil de release de la parte 13 viene incluido sin trabajo extra: vive en rust/Cargo.toml, así que las bibliotecas optimizadas en tamaño y alineadas a 16 KB que afinaste allí son las que ensambla la CI.
Ideas clave
- El ejecutor Ubuntu alojado trae rustup y un SDK de Android, pero las versiones se desvían cuando se actualiza la imagen. La reproducibilidad sale de dos fijaciones versionadas:
rust-toolchain.tomly una sola cadena con la versión del NDK.- Cachea dos almacenes por separado: el registro de cargo, con clave basada en
Cargo.lock, yrust/target, con clave basada en el archivo de la cadena de herramientas más el archivo de bloqueo. Un repositorio dispone de 10 GB de caché antes del desalojo (GitHub Docs), así que unas claves que nunca aciertan son peores que no tener caché.cargo testen el host no necesita NDK ni emulador, así que es la señal de fallo más rápida que tienes. Solo la frontera JNI necesita el emulador, acelerado por hardware mediante KVM en los ejecutores Linux gratuitos.- El trabajo del emulador reutiliza la propiedad
-PrustAbisde la parte 4 para compilar una sola ABI, porque un emulador x86_64 no tiene nada que hacer con tres bibliotecas ARM.
Fija la cadena de herramientas antes de tocar la CI
La imagen del ejecutor trae una cadena de herramientas de Rust y un SDK de Android. No te fíes de ninguno: las actualizaciones de la imagen mueven las versiones preinstaladas sin que cambie nada en tu historial. Las dos fijaciones viven en tu repositorio.
La fijación de Rust es rust-toolchain.toml en la raíz del repositorio, que rustup descubre desde cualquier subdirectorio (rustup book, Overrides):
[toolchain]
channel = "1.90.0"
components = ["clippy", "rustfmt"]
targets = [
"aarch64-linux-android",
"armv7-linux-androideabi",
"x86_64-linux-android",
"i686-linux-android",
]
profile = "minimal"
Este archivo hace tres trabajos. Fija el compilador para la CI y para cada portátil que clona el repositorio. Declara los targets de Android, así que el rustup target add de la parte 1 pasa a ser autoservicio: el primer comando de cargo instala la cadena fijada, targets incluidos. Y estabiliza tus cachés, porque las huellas de cargo incrustan la versión del compilador, de modo que un compilador sin fijar invalida en silencio cada artefacto cacheado.
La fijación del NDK es la misma cadena que la parte 1 exportaba como ANDROID_NDK_HOME, guardada en una sola entrada env al principio del flujo de trabajo. Cada trabajo instala esa versión exacta mediante sdkmanager (Android Developers, sdkmanager) y apunta ANDROID_NDK_HOME hacia ella, el único mecanismo de descubrimiento que necesita cargo-ndk (cargo-ndk). AGP no toca el NDK aquí: la tarea Exec de la parte 4 compila, y AGP empaqueta.
El flujo de CI de GitHub Actions para Rust en Android
Tres trabajos corren en paralelo: rust-checks demuestra el núcleo en Rust puro en el host, build ensambla el APK de release e instrumented ejercita la frontera JNI en un emulador. Eso es el principio de depuración del artículo central, convertido en grafo de trabajos: los errores de lógica fallan en el host en minutos, antes incluso de que empiece una descarga del NDK. Aquí está .github/workflows/android.yml, empezando por la cabecera y el trabajo del lado host:
name: android
on:
push:
branches: [main]
pull_request:
env:
NDK_VERSION: "28.2.13676358"
CARGO_TERM_COLOR: always
jobs:
rust-checks:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: cargo-registry-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: cargo-registry-
- name: Cache target directory
uses: actions/cache@v4
with:
path: rust/target
key: rust-host-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: rust-host-${{ hashFiles('rust-toolchain.toml') }}-
- name: Check formatting
run: cargo fmt --check
- name: Lint
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test on the host
run: cargo test --workspace
No hay un paso de instalación de Rust, a propósito: rustup ve rust-toolchain.toml y la primera invocación de cargo instala 1.90.0 antes de ejecutarse. Las pruebas compilan para el propio target Linux del host, y esa es la idea: los núcleos son bibliotecas en Rust puro, así que sus pruebas no necesitan nada con forma de Android.
El trabajo build sigue en el mismo archivo:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install the pinned NDK
run: |
echo "y" | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$NDK_VERSION"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: cargo-registry-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: cargo-registry-
- name: Cache target directory
uses: actions/cache@v4
with:
path: rust/target
key: rust-android-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: rust-android-${{ hashFiles('rust-toolchain.toml') }}-
- name: Cache cargo-ndk
id: cargo-ndk
uses: actions/cache@v4
with:
path: ~/.cargo/bin/cargo-ndk
key: cargo-ndk-${{ hashFiles('rust-toolchain.toml') }}
- name: Install cargo-ndk
if: steps.cargo-ndk.outputs.cache-hit != 'true'
run: cargo install cargo-ndk --locked
- name: Assemble release
run: ./gradlew :app:assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: app-release
path: app/build/outputs/apk/release/
La línea interesante es la menos impresionante: ./gradlew :app:assembleRelease. Gradle ejecuta cargoBuildRelease de la parte 4, que ejecuta cargo ndk para las cuatro ABI y deja las bibliotecas en app/build/rustJniLibs/release. La compilación local y la de CI son la misma ruta de código, así que un fallo de CI se reproduce en tu portátil con un solo comando.
El paso setup-gradle trae su propia caché para el lado Gradle, así que el flujo de trabajo solo gestiona a mano las cachés de cargo. Y cargo install cargo-ndk queda detrás de una caché pequeña del binario, porque compilarlo desde el código fuente en cada ejecución es un peaje absurdo; la clave lo ata al archivo de la cadena de herramientas, de modo que un salto de compilador lo recompila.
Caché de compilación cruzada que de verdad acierta
Cargo quiere dos cosas distintas en caché, y mezclarlas es como suele fallar la caché de CI en Rust.
El primer almacén es el registro: el índice de crates.io, los archivos .crate descargados y las dependencias de git. Es idéntico entre las compilaciones de host y de Android, y solo cambia con Cargo.lock, así que la clave hace hash del archivo de bloqueo, y el prefijo de restore-keys permite que un cambio del archivo de bloqueo restaure la caché anterior y descargue solo el delta. Ausente a propósito: ~/.cargo/registry/src/, que cargo vuelve a extraer más rápido de lo que la caché lo restaura, y ~/.cargo/bin/, que pisaría el propio cargo del ejecutor.
El segundo almacén es rust/target, los artefactos compilados. Su clave tiene tres partes. El archivo de bloqueo, porque los artefactos de las dependencias son lo que hace que la caché merezca la pena. El archivo de la cadena de herramientas, porque las huellas incrustan la versión del compilador, así que los artefactos de un rustc viejo se restauran, no coinciden nunca y aun así cuentan contra tu presupuesto. Y el propio prefijo del trabajo, rust-host- frente a rust-android-, porque los artefactos del host Linux son lastre para un trabajo que compila targets de Android.
Ese presupuesto es real: 10 GB por repositorio por defecto, las entradas sin usar durante 7 días se eliminan y, pasado el límite, GitHub desaloja por fecha de último acceso, las más antiguas primero (GitHub Docs, caching dependencies). Cuatro ABI en modo release lo llenan más rápido de lo que imaginas, y por eso unas claves que restauran artefactos inútiles son peores que no tener caché: desalojan las cachés que sí funcionaban.
Si prefieres no mantener las claves a mano, la action de la comunidad rust-cache automatiza el mismo esquema, con la versión del compilador y el archivo de bloqueo hasheados dentro de las claves, y el directorio target podado antes de guardarlo. Muestro la versión manual porque, el día que la caché se porte mal, la depuras entendiendo esta estructura de claves.
Una matriz sobre las ABI, y cuándo compensa
El trabajo build compila las cuatro ABI en secuencia, dentro de una sola invocación de Gradle. La alternativa es una matriz, un ejecutor por ABI, en el mismo archivo de flujo de trabajo. Añade los pasos de caché del registro y de cargo-ndk que ya has visto; solo son nuevas las piezas por target:
rust-targets:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- abi: arm64-v8a
triple: aarch64-linux-android
- abi: armeabi-v7a
triple: armv7-linux-androideabi
- abi: x86_64
triple: x86_64-linux-android
- abi: x86
triple: i686-linux-android
steps:
- uses: actions/checkout@v4
- name: Install the pinned NDK
run: |
echo "y" | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$NDK_VERSION"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
- name: Cache target directory
uses: actions/cache@v4
with:
path: rust/target
key: rust-${{ matrix.triple }}-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: rust-${{ matrix.triple }}-${{ hashFiles('rust-toolchain.toml') }}-
- name: Install cargo-ndk
run: cargo install cargo-ndk --locked
- name: Build ${{ matrix.abi }}
working-directory: rust
run: cargo ndk -t ${{ matrix.abi }} build --release --locked
Sé sincero con la aritmética. Cuatro trabajos compilan cada uno el grafo entero de dependencias para su target, así que los minutos totales de ejecutor suben aunque baje el tiempo de reloj, y cuatro cachés por target se comen el presupuesto de 10 GB cuatro veces más rápido. Para este workspace, el único trabajo de Gradle es la opción por defecto adecuada.
La matriz se justifica en dos casos: cuando el workspace crece lo bastante como para que un solo trabajo sature el tiempo o la memoria del ejecutor, y por su señal de fallo. armeabi-v7a es un target de 32 bits, así que el código que asume en silencio que usize ocupa ocho bytes, la trampa señalada en la parte 6, falla en un trabajo que lleva el nombre de la ABI culpable, en lugar de quedar enterrado en un registro de veinte minutos.
Pruebas instrumentadas en un emulador, con KVM
Los ejecutores Linux gratuitos ejecutan el emulador de Android con aceleración por hardware, desde que GitHub activó KVM en ellos en 2023 (changelog de GitHub, virtualización Android acelerada por hardware, 2023). Antes hay que abrir los permisos del nodo de dispositivo. Aquí está el último trabajo, menos los pasos de caché del registro, del target y de cargo-ndk que ya has visto dos veces:
instrumented:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Install the pinned NDK
run: |
echo "y" | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$NDK_VERSION"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Install cargo-ndk
run: cargo install cargo-ndk --locked
- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
emulator-options: -no-window -no-audio -no-boot-anim
disable-animations: true
script: ./gradlew :app:connectedDebugAndroidTest -PrustAbis=x86_64
Dos detalles sostienen este trabajo. La regla de udev hace que /dev/kvm sea escribible por el usuario del ejecutor; sin ella, el emulador cae en la emulación por software y tu presupuesto de tiempo de espera se evapora. Y -PrustAbis=x86_64 es la propiedad de iteración de la parte 4 cumpliendo su papel en CI: el emulador es un dispositivo x86_64, así que tres bibliotecas ARM serían puro desperdicio.
Mantén la suite pequeña a propósito. Todo lo que el host pueda demostrar pertenece a rust-checks. Lo que corresponde aquí es la frontera: la biblioteca se carga, los nombres JNI se resuelven en lugar de lanzar UnsatisfiedLinkError, la clase de error que diseccionó la parte 2, y una cadena de Kotlin sobrevive al viaje de ida y vuelta.
Compilaciones reproducibles: qué puedes fijar y qué no
Todo lo anterior fija la cadena de herramientas. La reproducibilidad también necesita entradas fijadas, y eso empieza por versionar rust/Cargo.lock. El libro de Cargo dice que hay que hacer commit del archivo de bloqueo para que cada compilación use versiones de dependencias idénticas (Cargo book, Cargo.toml vs Cargo.lock), y para un artefacto que publicas eso no es negociable.
El archivo de bloqueo solo te protege si nada lo reescribe en silencio, que es lo que impone --locked: cargo falla la compilación si Cargo.lock no coincide con los manifiestos, en lugar de actualizarlo. El trabajo de la matriz ya lo pasa. La compilación dirigida por Gradle lo recibe mediante una línea en el registro de la tarea de la parte 4, en app/build.gradle.kts:
val cargoBuildRelease = registerCargoBuild("release", listOf("--release", "--locked"))
El archivo del flujo de trabajo también es una entrada. Etiquetas como actions/cache@v4 son mutables; la guía de refuerzo de GitHub señala que fijar una action a un SHA de commit completo es la única forma de usarla como versión inmutable (GitHub Docs, security hardening). Para un repositorio personal, las etiquetas mayores son un compromiso razonable; cuando entren secretos o claves de firma en el flujo de trabajo, pasa a los SHA.
Sé preciso sobre lo que esto te aporta. Las mismas fuentes, el mismo rustc, el mismo NDK, las mismas flags. Esa es la reproducibilidad que necesitan la mayoría de los equipos, y el beneficio que le importa a esta serie, porque simbolizar un tombstone de producción con ndk-stack significa recompilar el .so exacto que se publicó. Una salida byte a byte es un proyecto más profundo, porque la información de depuración y los mensajes de panic incrustan rutas absolutas de compilación que cambian según la máquina; la flag --remap-path-prefix de rustc las aplana, y es por donde empiezas si la exactitud bit a bit pasa a ser un requisito.
Medir lo que te aporta la caché
La metodología ya viene integrada. El resumen de cada ejecución informa del tiempo de reloj por trabajo, y cada paso de caché registra qué clave restauró, coincidencia exacta o alternativa por restore-keys. Así que: borra las cachés desde la vista de cachés de Actions, haz push de un commit, anota los tiempos en frío; haz push de un cambio trivial, anota los tiempos en caliente; cambia una línea de Rust, anota los tiempos incrementales.
La forma es predecible por cómo se componen las claves. La ejecución en frío paga por todo: descarga del registro, grafo completo de dependencias por target, compilación de cargo-ndk. La ejecución en caliente, con el archivo de bloqueo intacto, solo paga por los crates de tu workspace, y un cambio solo de Kotlin no debería costarle nada a cargo, porque las declaraciones de entradas de la parte 4 permiten a Gradle saltarse por completo la tarea Exec. Las cifras honestas, en frío frente a en caliente por trabajo, llegan cuando se publique el repositorio complementario, y esta sección es donde encajará ese gráfico.
Preguntas frecuentes
¿El ejecutor Ubuntu de GitHub Actions incluye el SDK y el NDK de Android?
Sí, la imagen Ubuntu alojada trae un SDK de Android con un NDK, más rustup. Trátalos como arranque, no como tu cadena de herramientas: las versiones preinstaladas cambian cada vez que se actualiza la imagen. Fija tu propio NDK con una instalación de sdkmanager, y fija Rust con rust-toolchain.toml.
¿Cómo se cachean en GitHub Actions las compilaciones cruzadas de Rust?
Cachea las rutas del registro bajo ~/.cargo con clave basada en Cargo.lock, y cachea rust/target con clave basada en el archivo de la cadena de herramientas, el archivo de bloqueo y la familia de targets. Ten en cuenta el presupuesto: 10 GB por repositorio, y las entradas sin usar durante 7 días se eliminan (GitHub Docs). La action rust-cache automatiza la misma estructura de claves.
¿Se pueden ejecutar pruebas instrumentadas de Android en los ejecutores gratuitos de GitHub?
Sí. Los ejecutores Linux tienen KVM, así que el emulador x86_64 corre con aceleración por hardware en cuanto una regla de udev abre /dev/kvm. Arráncalo con la action android-emulator-runner y compila solo la ABI x86_64 para ese trabajo. Las imágenes de sistema ARM siguen siendo el hueco; esas necesitan una granja de dispositivos físicos.
Dónde te deja esto
Este flujo de trabajo es deliberadamente aburrido, y esa es su mejor cualidad. Cada comando que contiene apareció antes en la serie; la CI solo los ejecuta en una máquina que nadie ha tocado, con cada versión apuntada. Cuando se rompe, se rompe por un motivo legible: una clave de caché dejó de coincidir, o una actualización de la imagen movió una versión que nunca fijaste. Aburrido es lo que quieres del sistema que vigila tus publicaciones.
Queda una parte. La serie se cierra con la parte 15, Rust en Kotlin Multiplatform: un núcleo nativo para Android e iOS, y cuándo prescindir de Rust por completo, donde el mismo crate también compila para iOS, y donde argumento en contra de Rust más a menudo de lo que esperarías. El mapa completo de la serie está en el hub.
Fuentes
- GitHub Docs, «Caching dependencies to speed up workflows», consultado el 2026-10-25, https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows
- GitHub Docs, «Security hardening for GitHub Actions», consultado el 2026-10-25, https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions
- The rustup book, «Overrides», consultado el 2026-10-25, https://rust-lang.github.io/rustup/overrides.html
- Cargo book, «Cargo.toml vs Cargo.lock», consultado el 2026-10-25, https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
- Android Developers, «sdkmanager», consultado el 2026-10-25, https://developer.android.com/tools/sdkmanager
- crates.io, cargo-ndk, consultado el 2026-10-25, https://crates.io/crates/cargo-ndk

Loading comments…Cargando comentarios…