Skip to content

Android CI

Android CI #19

Workflow file for this run

name: Android CI
# W3 Android wrapper app — see android/README.md. Runs on every PR + every
# push to main that touches the Android workspace or its CI config.
#
# What this validates that the rest of CI doesn't:
# * Gradle + AGP can resolve the dependency graph
# * Kotlin compiles cleanly (the four Wave-3 parallel agents shipped
# ~5,000 lines of Android code; no JVM was available in their
# sandbox so this is the first real compile check)
# * Unit tests pass (Robolectric + Turbine + the Poseidon vectors)
# * Lint stays clean on release variant
# * verifyProverAssets succeeds (ADR-0010's SHA-256 gate)
#
# Three jobs:
# 1. build — JVM-only: compile, unit tests, lint, debug APK
# 2. instrumented — emulator boot + MainActivitySmokeTest (API 30, x86_64)
# 3. release — signed AAB + APK on tag pushes or workflow_dispatch
# with `release=true`. Reads the keystore from a
# base64-encoded GH secret. See android/RELEASE.md.
#
# Play Console internal-track upload is intentionally still manual for
# the W3 demo — the operator downloads the signed AAB from the workflow
# artifacts and uploads through the console. A future iteration wires
# r0adkll/upload-google-play once the service-account JSON is filed.
on:
push:
branches: [main]
paths:
- 'android/**'
- '.github/workflows/android.yml'
tags:
- 'android-v*' # signed-release trigger
pull_request:
paths:
- 'android/**'
- '.github/workflows/android.yml'
workflow_dispatch:
inputs:
release:
description: 'Also build the signed release AAB/APK'
required: false
default: 'false'
type: choice
options: ['false', 'true']
concurrency:
group: android-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build + lint + unit tests
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
working-directory: android
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
# sdkmanager wants packages as a single space-separated string,
# NOT a YAML multi-line block (the block joins lines with \n,
# which sdkmanager parses as one giant package name and dies).
# AGP only ever compiles against compileSdk = 34; the API 30
# platform install is for Robolectric's runtime classpath.
# build-tools 34.0.0 covers both AGP 8.5 + Robolectric needs.
packages: 'platforms;android-34 platforms;android-30 build-tools;34.0.0 platform-tools'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
# AGP 8.5 pins Gradle 8.7 (see gradle-wrapper.properties).
gradle-version: '8.7'
# Aggressively cache Gradle artifacts. The dependency graph is
# large (Compose BOM + Camera + ML Kit + snarkjs prover assets)
# but stable PR-over-PR.
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Bootstrap Gradle wrapper
# The wrapper jar isn't committed (yet — TODO bring it in-tree once
# we've done a real `gradle wrapper --gradle-version 8.7` locally).
# The `gradle` binary installed by setup-gradle@v4 generates a
# fresh wrapper that matches the gradle-wrapper.properties version
# pin. Idempotent.
run: gradle wrapper --gradle-version 8.7 --distribution-type bin
- name: Show toolchain
run: |
java -version
./gradlew --version
echo "ANDROID_HOME=$ANDROID_HOME"
- name: Verify prover-asset integrity (ADR-0010)
# Hash-gate ahead of the build so a tampered asset never even
# reaches kotlinc. Mirrors the preBuild hook but runs explicitly
# so the CI log carries a "PASS: prover assets pinned" record.
run: ./gradlew :app:verifyProverAssets --info
- name: Compile (debug variant)
run: ./gradlew :app:assembleDebug --stacktrace
- name: Unit tests
run: ./gradlew :app:testDebugUnitTest --stacktrace
- name: Lint (release variant)
# Release lint is stricter than debug; catches things like
# missing-required permissions, leaking PendingIntents, and
# unscoped exported components.
run: ./gradlew :app:lintRelease --stacktrace
continue-on-error: true # W3 deliberately doesn't error on
# lint findings yet — we want the
# baseline report. Flip to false in W4.
- name: Upload lint report
if: always()
uses: actions/upload-artifact@v4
with:
name: lint-report
path: android/app/build/reports/lint-results-release.html
retention-days: 14
if-no-files-found: ignore
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: android/app/build/reports/tests/
retention-days: 14
if-no-files-found: ignore
- name: Upload debug APK
if: success()
uses: actions/upload-artifact@v4
with:
name: zeroauth-android-debug-apk
path: android/app/build/outputs/apk/debug/*.apk
retention-days: 14
if-no-files-found: error
instrumented:
name: Instrumented smoke (API 30 emulator)
runs-on: ubuntu-latest
timeout-minutes: 45
needs: build
# The emulator job is flaky-by-construction: x86_64 system images on
# GitHub-hosted runners go through software acceleration (no KVM in
# the kernel boundary), so boot can take 3-8 minutes and the next
# framework crash is one Android image roll away. Don't gate PRs on
# it — surface the failure as a status check the human can read.
continue-on-error: true
defaults:
run:
working-directory: android
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Enable KVM
# The runner image has /dev/kvm available; the udev rule below
# makes it world-rw so the emulator can claim it.
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: Set up Android SDK
uses: android-actions/setup-android@v3
with:
packages: 'platforms;android-34 platforms;android-30 build-tools;34.0.0 platform-tools'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.7'
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Bootstrap Gradle wrapper
run: gradle wrapper --gradle-version 8.7 --distribution-type bin
- name: AVD cache
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
# Tie the cache to the AVD knobs below — bumping any of them
# invalidates the cache so we don't boot stale snapshots.
key: avd-api30-x86_64-aosp-atd
- name: Create AVD + warm boot snapshot
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
arch: x86_64
target: aosp_atd # automated-test-device image — smaller, faster
profile: pixel_6
ram-size: 2048M
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: echo "snapshot warmed"
- name: Run instrumented smoke test
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
arch: x86_64
target: aosp_atd
profile: pixel_6
ram-size: 2048M
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
working-directory: android
script: ./gradlew :app:connectedDebugAndroidTest --stacktrace
- name: Upload instrumented test report
if: always()
uses: actions/upload-artifact@v4
with:
name: instrumented-test-reports
path: android/app/build/reports/androidTests/
retention-days: 14
if-no-files-found: ignore
release:
name: Signed release (AAB + APK)
runs-on: ubuntu-latest
timeout-minutes: 30
needs: build
if: |
startsWith(github.ref, 'refs/tags/android-v')
|| (github.event_name == 'workflow_dispatch' && inputs.release == 'true')
defaults:
run:
working-directory: android
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
packages: 'platforms;android-34 platforms;android-30 build-tools;34.0.0 platform-tools'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.7'
- name: Bootstrap Gradle wrapper
run: gradle wrapper --gradle-version 8.7 --distribution-type bin
- name: Restore release keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
run: |
if [ -z "${KEYSTORE_BASE64}" ]; then
echo "::error::ANDROID_RELEASE_KEYSTORE_BASE64 secret is not set. See android/RELEASE.md."
exit 1
fi
# Decode into a tmp file outside the working tree so a stray
# `git add .` can't commit it.
mkdir -p "${RUNNER_TEMP}/signing"
echo "${KEYSTORE_BASE64}" | base64 -d > "${RUNNER_TEMP}/signing/release.jks"
chmod 600 "${RUNNER_TEMP}/signing/release.jks"
echo "ZEROAUTH_RELEASE_KEYSTORE=${RUNNER_TEMP}/signing/release.jks" >> "$GITHUB_ENV"
- name: Bundle release (AAB)
env:
ZEROAUTH_RELEASE_KEYSTORE_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
ZEROAUTH_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
ZEROAUTH_RELEASE_KEY_PASS: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
run: ./gradlew :app:bundleRelease --stacktrace
- name: Assemble release (APK fallback for direct sideload)
env:
ZEROAUTH_RELEASE_KEYSTORE_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
ZEROAUTH_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
ZEROAUTH_RELEASE_KEY_PASS: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
run: ./gradlew :app:assembleRelease --stacktrace
- name: Wipe keystore from runner
if: always()
run: rm -f "${RUNNER_TEMP}/signing/release.jks"
- name: Upload signed AAB
uses: actions/upload-artifact@v4
with:
name: zeroauth-android-release-aab
path: android/app/build/outputs/bundle/release/*.aab
retention-days: 90
if-no-files-found: error
- name: Upload signed APK
uses: actions/upload-artifact@v4
with:
name: zeroauth-android-release-apk
path: android/app/build/outputs/apk/release/*.apk
retention-days: 90
if-no-files-found: error