Skip to main content

GitHub Actions Deep Dive

GitHub Actions is the default CI for Android teams on GitHub — cheap for public repos, generous for private, tight GitHub integration, great macOS runners for emulator tests. This chapter covers the full production pipeline: PR validation, matrix builds, emulator testing, artifact caching, Fastlane integration, and secrets.

The PR pipeline

# .github/workflows/pr.yml
name: PR Validation

on:
pull_request:
branches: [main, release/*]

concurrency:
group: pr-${{ github.ref }}
cancel-in-progress: true # cancel in-flight runs on new push

env:
GRADLE_OPTS: -Dorg.gradle.configureondemand=true -Dorg.gradle.daemon=false
JAVA_VERSION: 17

jobs:
static-checks:
name: Static analysis
runs-on: ubuntu-latest-4core
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}

- uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: true
develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}

- name: Spotless
run: ./gradlew spotlessCheck

- name: Detekt
run: ./gradlew detekt

- name: Lint
run: ./gradlew lint

- name: Konsist architectural tests
run: ./gradlew :core:testing:test --tests '*ArchitectureTest'

- name: Upload reports
if: failure()
uses: actions/upload-artifact@v4
with:
name: static-reports
path: '**/build/reports/**'
retention-days: 7

unit-tests:
name: Unit tests (shard ${{ matrix.shard }}/4)
runs-on: ubuntu-latest-4core
timeout-minutes: 30
strategy:
fail-fast: false # don't cancel sibling shards on first failure
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }

- uses: gradle/actions/setup-gradle@v4
with: { cache-read-only: true }

- name: Test (shard ${{ matrix.shard }}/4)
run: ./gradlew testDebugUnitTest -Pshard=${{ matrix.shard }} -PshardCount=4

- name: Kover report
if: matrix.shard == 1
run: ./gradlew koverXmlReport koverVerify

- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-tests-shard-${{ matrix.shard }}
path: '**/build/test-results/**/*.xml'

android-tests:
name: Instrumentation tests
runs-on: macos-14-large # Apple Silicon, hardware-accelerated emulator
timeout-minutes: 45
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }

- uses: gradle/actions/setup-gradle@v4

- name: AVD cache
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-api34-${{ runner.os }}

- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: arm64-v8a
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "Generated AVD snapshot for caching"

- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: arm64-v8a
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew connectedDebugAndroidTest --stacktrace

- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: instrumentation-reports
path: '**/build/reports/androidTests/**'

assemble-preview:
name: Preview APK
runs-on: ubuntu-latest
needs: [static-checks, unit-tests]
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }

- uses: gradle/actions/setup-gradle@v4

- name: Build staging APK
run: ./gradlew :app:assembleStagingRelease

- name: Upload to Firebase App Distribution
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_APP_ID }}
serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
groups: qa-team,pm-group
file: app/build/outputs/apk/staging/release/app-staging-release.apk
releaseNotes: |
PR #${{ github.event.pull_request.number }}
${{ github.event.pull_request.title }}

- name: Comment PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '📦 Preview APK uploaded to Firebase App Distribution'
});

Test sharding

With 500+ unit tests, sharding cuts wall-clock time to ~1/N:

// build.gradle.kts (app or test module)
tasks.withType<Test> {
val shard = (project.findProperty("shard") as String?)?.toInt() ?: 1
val shardCount = (project.findProperty("shardCount") as String?)?.toInt() ?: 1

filter {
includeTestsMatching("*")
}

if (shardCount > 1) {
// Gradle test filter doesn't natively shard — use JUnit 5 + a filter
systemProperty("junit.jupiter.testinstance.lifecycle.default", "per_class")
systemProperty("shard.index", shard)
systemProperty("shard.count", shardCount)
}
}
// JUnit 5 extension that filters tests by hash
class ShardFilter : ExecutionCondition {
override fun evaluateExecutionCondition(ctx: ExtensionContext): ConditionEvaluationResult {
val index = System.getProperty("shard.index", "1").toInt()
val count = System.getProperty("shard.count", "1").toInt()
if (count <= 1) return ConditionEvaluationResult.enabled("no sharding")

val hash = (ctx.testClass.get().name + ctx.displayName).hashCode()
val target = (hash % count + count) % count + 1
return if (target == index)
ConditionEvaluationResult.enabled("in shard")
else
ConditionEvaluationResult.disabled("not in shard")
}
}

// In src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
// com.myapp.test.ShardFilter

Or use Gradle Enterprise Test Distribution — automatic sharding across remote agents.


Emulator strategy

Using reactivecircus/android-emulator-runner

Hardware-accelerated emulator on macOS runners. Cache the AVD snapshot between runs:

- name: AVD cache
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-api${{ matrix.api }}-${{ matrix.target }}

- name: Run tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api }}
target: ${{ matrix.target }}
arch: arm64-v8a
profile: pixel_7
emulator-options: -no-window -gpu swiftshader_indirect -noaudio
disable-animations: true
script: ./gradlew connectedDebugAndroidTest

Device matrix

strategy:
matrix:
include:
- api: 29
target: default # API 29 pre-dates Google APIs image
- api: 33
target: google_apis
- api: 34
target: google_apis

Test across minSdk, common SDK, latest. Running every API level is expensive — pick 3.

Firebase Test Lab alternative

- name: Upload to Firebase Test Lab
run: |
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=pixel6,version=33,locale=en,orientation=portrait \
--device model=oriole,version=31 \
--device model=blueline,version=28 \
--timeout 10m

Cross-device coverage runs in minutes instead of hours. Costs per device- minute but saves emulator runtime.


Caching

Gradle cache (automatic via setup-gradle)

- uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}

Saves ~/.gradle/caches and the Gradle wrapper. Re-use across jobs within the workflow.

Custom caches

- uses: actions/cache@v4
with:
path: |
~/.android/build-cache
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', '**/gradle.properties', 'gradle/libs.versions.toml') }}
restore-keys: gradle-${{ runner.os }}-

Develocity remote cache

See Module 14: Convention Plugins for remote build cache setup. In CI:

env:
DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}

- uses: gradle/actions/setup-gradle@v4
with:
develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}

Hits to the remote cache mean builds reuse compilation from other developers / CI runs. Saves 30-60% on clean builds.


Secrets management

Keystore

# Locally, encode once
base64 -i release.keystore -o release.keystore.b64
# Copy contents into GitHub Actions secret KEYSTORE_BASE64
- name: Decode keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
run: echo "$KEYSTORE_BASE64" | base64 -d > release.keystore

- name: Build signed AAB
env:
KEYSTORE_PATH: ${{ github.workspace }}/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleRelease

Play Console service account

- name: Upload to Play
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.myapp
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal

google-services.json

Store the file base64 encoded:

- name: Decode google-services.json
env:
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: echo "$GOOGLE_SERVICES_JSON" | base64 -d > app/google-services.json

Or commit a stub google-services.json (debug variant only); the real one comes from secrets.


Environments and manual approvals

jobs:
release:
environment: production # requires approval before run
runs-on: ubuntu-latest
steps: ...

In repo settings → Environments → production → required reviewers. The workflow pauses until an authorized person approves.

Use for:

  • Production releases (manual gate)
  • Infra-touching jobs (key rotation, cert updates)
  • Deploys with blast radius

Release workflow (tag-triggered)

# .github/workflows/release.yml
name: Release

on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
track:
type: choice
options: [internal, alpha, beta, production]
default: internal
rollout:
type: string
default: '0.05'

jobs:
release:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }

- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }

- uses: ruby/setup-ruby@v1
with: { bundler-cache: true }

- name: Decode secrets
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > release.keystore
echo "${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}" > play-service-account.json

- name: Fastlane internal
if: github.event.inputs.track == 'internal' || github.event_name == 'push'
env:
KEYSTORE_PATH: ${{ github.workspace }}/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
SUPPLY_JSON_KEY: ${{ github.workspace }}/play-service-account.json
run: bundle exec fastlane internal

- name: Fastlane promote_to_production
if: github.event.inputs.track == 'production'
env:
SUPPLY_JSON_KEY: ${{ github.workspace }}/play-service-account.json
run: bundle exec fastlane promote_to_production rollout:${{ github.event.inputs.rollout }}

- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v1.27
with:
payload: |
{"text": "Release ${{ job.status }}: ${{ github.ref_name }}"}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Rollout watchdog

# .github/workflows/rollout-watchdog.yml
name: Rollout Watchdog

on:
schedule:
- cron: '*/30 * * * *' # every 30 min

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Query Crashlytics
id: crash
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
run: |
RATE=$(curl -s -H "Authorization: Bearer $FIREBASE_TOKEN" \
"https://firebase.googleapis.com/v1beta2/projects/$PROJECT_ID/crashlytics/apps/$APP_ID/metrics/crashFreeUsers?period=24h" | jq '.metric.value')
echo "rate=$RATE" >> $GITHUB_OUTPUT

- name: Halt rollout if regressed
if: ${{ fromJSON(steps.crash.outputs.rate) < 99.5 }}
env:
SUPPLY_JSON_KEY: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
bundle exec fastlane halt
curl -X POST -H 'Content-type: application/json' \
--data '{"text":"🚨 Rollout halted — crash-free dropped to ${{ steps.crash.outputs.rate }}%"}' \
${{ secrets.SLACK_WEBHOOK }}

Automates what a 24/7 release manager would do — halt bad releases in minutes, not hours.


Job-level concurrency and cancellation

concurrency:
group: pr-${{ github.ref }}
cancel-in-progress: true # new push to same PR cancels running job

For release jobs, set cancel-in-progress: false — you don't want to cancel mid-upload.


Matrix strategies

strategy:
fail-fast: false
matrix:
api: [29, 33, 34]
flavor: [free, premium]
exclude:
- api: 29
flavor: premium # skip unsupported combo

Runs 2 × 3 - 1 = 5 jobs. fail-fast: false ensures one failing shard doesn't cancel others.


Self-hosted runners

For privacy / speed / cost:

runs-on: [self-hosted, linux, x64, android-ci]

Self-hosted runners on your infrastructure run all builds. Useful for:

  • Keeping source code off GitHub's network
  • Leveraging beefy hardware (32-core macOS for emulators)
  • Reducing CI cost at scale

Trade-offs: you maintain the infrastructure, security patches, scaling.


Artifact management

- name: Upload AAB
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/upload-artifact@v4
with:
name: app-release
path: app/build/outputs/bundle/release/app-release.aab
retention-days: 90

- name: Attach to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: app/build/outputs/bundle/release/app-release.aab
draft: true
generate_release_notes: true

GitHub Releases become your audit trail — every AAB ever shipped, with release notes.


Dependency updates

# .github/renovate.json5 or dependabot.yml
{
"extends": ["config:base"],
"labels": ["dependencies"],
"schedule": ["before 3am on Monday"],
"packageRules": [
{
"matchUpdateTypes": ["patch", "minor"],
"automerge": true,
"matchPackagePatterns": ["^com.google.", "^androidx."]
}
]
}

Renovate / Dependabot opens PRs for every dependency update. Auto-merge patches and minors for Google / AndroidX libraries after CI passes.


Running workflows locally

# Install act (GitHub Actions runner)
brew install act

# Run PR workflow
act pull_request -j static-checks --secret-file .secrets

Useful for debugging workflow YAML before pushing.


Common anti-patterns

Anti-patterns

CI problems

  • One monolithic job doing everything (slow, painful)
  • No sharding (tests take 30 min)
  • Keystore committed to git
  • Production deploys without manual approval
  • No cache — every build runs from scratch
  • fetch-depth: 1 when you need git history
Best practices

Production CI

  • Parallel jobs (static-checks, unit, instrumentation, preview)
  • Sharded unit tests (4 parallel shards)
  • Base64-encoded keystore in secrets
  • Environments with required reviewers for prod
  • setup-gradle + remote cache; AVD cache
  • fetch-depth: 0 for versionCode from git

Key takeaways

Practice exercises

  1. 01

    PR pipeline

    Build the parallel static-checks + unit-tests + instrumentation workflow shown above. Verify all jobs run in parallel on a PR.

  2. 02

    Sharded unit tests

    Add a matrix shard to testDebugUnitTest. Wire the ShardFilter extension. Measure before/after wall-clock time.

  3. 03

    Emulator with caching

    Set up reactivecircus/android-emulator-runner with actions/cache on ~/.android/avd. Verify emulator boot takes < 60s on second run.

  4. 04

    Tag-triggered release

    Build the release.yml workflow. Tag a commit (git tag v1.0.0; git push --tags). Confirm Fastlane uploads to Play internal track.

  5. 05

    Rollout watchdog

    Implement the crash-free rate watchdog. Test by temporarily lowering the threshold to 99.99% and watching it halt a rollout.

Next

Return to Module 17 Overview or continue to Module 18 — Observability.