Android CI #19
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 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 |