CI/CD for Android: GitHub Actions, Fastlane & Bitrise
Automate your entire Android delivery pipeline — the skill that makes you invaluable on any team
Open interactive version (quiz + challenge)Real-world analogy
What is it?
CI/CD (Continuous Integration / Continuous Delivery) for Android is the practice of automating code quality checks, building signed APKs/AABs, running tests, and deploying to the Play Store or distribution platforms on every code change. GitHub Actions workflows, Fastlane lanes, and platforms like Bitrise provide the infrastructure to implement this pipeline.
Real-world relevance
A SaaS school management platform runs a GitHub Actions pipeline that: on every PR lint + unit tests complete in 4 minutes; on every merge to main a prodRelease AAB is built, signed, and uploaded to the Play Console Internal track in 12 minutes; on git tag v*.*.* the release is promoted to the 1% staged rollout automatically. The team of 6 ships updates to 50,000+ school users daily without any manual upload steps.
Key points
- What CI/CD solves — Manual builds are slow, inconsistent, and error-prone. CI ensures every code change is automatically linted, tested, and built. CD automates deployment to test tracks so QA always has the latest build without waiting for a developer to upload manually.
- Pipeline stages in order — 1. Checkout code → 2. Cache Gradle → 3. Lint → 4. Unit tests → 5. Build debug APK → 6. Instrumented tests (optional) → 7. Build release AAB → 8. Sign → 9. Deploy to Play Console / Firebase App Distribution.
- GitHub Actions basics — Workflows defined in .github/workflows/*.yml. Triggered by push, pull_request, or schedule. Jobs run on ubuntu-latest runners with actions/checkout and actions/setup-java for Android builds.
- Gradle caching in GitHub Actions — actions/cache with key based on libs.versions.toml hash. A warm cache cuts 3-minute dependency download to under 10 seconds. Critical for keeping CI feedback under 5 minutes.
- Secrets management — Store keystore password, API keys, and service account JSON in GitHub repository Secrets (Settings → Secrets). Access via ${{ secrets.KEYSTORE_PASSWORD }} in the workflow YAML. Never log secrets.
- Fastlane supply for Play deployment — Fastlane's supply lane uploads AABs to a specified Play Console track using a Google Play service account JSON. One command: fastlane supply --aab app-release.aab --track internal.
- Fastlane screengrab for screenshots — Automates capturing Play Store screenshots on all device sizes using Espresso. Runs on emulators in CI, uploads via deliver lane. Eliminates a day of manual screenshot work per release.
- Bitrise as a CI alternative — Mobile-first CI with pre-built steps for Android build, sign, and deploy. Simpler YAML than GitHub Actions for mobile teams without DevOps expertise. Better emulator support out of the box.
- Branch protection rules — Require CI to pass before merging to main. Set required status checks: lint, unit-tests, build. This prevents broken code from reaching main and ensures every merge is deployable.
- Artifact management — Upload APKs and AABs as GitHub Actions artifacts for QA download. Use actions/upload-artifact. Set retention to 30 days. QA scans the QR code from the artifact URL, installs, and tests without touching Gradle.
- Emulator tests in CI — reactivecircus/android-emulator-runner action spins up an emulator for instrumented tests. Slow (~8 min) — run only on main branch merges, not every PR. Separate fast-path (unit tests) from slow-path (UI tests).
- Environment-specific pipelines — PR → run lint + unit tests + build debug. Merge to main → also build prod release + deploy to Internal track. Tag v* → promote Internal to Production with staged rollout. Pipeline stages match deployment risk.
Code example
# .github/workflows/android-ci.yml
name: Android CI/CD
on:
push:
branches: [ main ]
tags: [ 'v*.*.*' ]
pull_request:
branches: [ main ]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('gradle/libs.versions.toml') }}
restore-keys: gradle-
- name: Run lint
run: ./gradlew lintProdRelease
- name: Run unit tests
run: ./gradlew testProdReleaseUnitTest
build-and-deploy:
needs: lint-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '17', distribution: 'temurin' }
- name: Restore Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('gradle/libs.versions.toml') }}
- name: Decode keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > release.jks
- name: Build release AAB
env:
KEYSTORE_PATH: release.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
GITHUB_RUN_NUMBER: ${{ github.run_number }}
run: ./gradlew :app:bundleProdRelease
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: release-aab
path: app/build/outputs/bundle/prodRelease/app-prod-release.aab
retention-days: 30
- name: Deploy to Play Internal track (Fastlane)
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
bundle exec fastlane supply \
--aab app/build/outputs/bundle/prodRelease/app-prod-release.aab \
--track internal \
--json_key_data "$PLAY_SERVICE_ACCOUNT_JSON"Line-by-line walkthrough
- 1. on: push: branches: [main] and tags: [v*.*.*] — the workflow triggers on pushes to main and on version tags; pull_request trigger runs lighter jobs on every PR without deploying.
- 2. actions/setup-java@v4 with java-version: '17' — GitHub-hosted ubuntu-latest runners do not have a JDK pre-configured; this action installs Temurin JDK 17 which is required by AGP 8.x.
- 3. actions/cache@v4 path: ~/.gradle/caches key: gradle-${{ hashFiles('libs.versions.toml') }} — the cache key changes only when the version catalog changes; unchanged dependencies are restored from cache in seconds.
- 4. echo '${{ secrets.KEYSTORE_BASE64 }}' | base64 --decode > release.jks — the keystore binary is stored in GitHub Secrets as a base64 string (since Secrets support only text); this step decodes it back to a binary .jks file.
- 5. env: KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD — injected as environment variables read by build.gradle.kts via System.getenv(); they are masked in GitHub Actions logs automatically.
- 6. GITHUB_RUN_NUMBER: ${{ github.run_number }} — maps the GitHub Actions run number to the env var that Gradle reads for versionCode; guarantees unique, monotonically increasing versionCode per build.
- 7. ./gradlew :app:bundleProdRelease — the Gradle task builds a signed AAB for the prod flavor + release build type; the variant selection ensures only production code is bundled, not dev or staging.
- 8. actions/upload-artifact with retention-days: 30 — stores the AAB so QA can download it directly from the GitHub Actions run page via a browser; no separate distribution service needed for early QA builds.
- 9. bundle exec fastlane supply --track internal — uses the Fastlane supply action to upload the AAB to the Play Console Internal testing track; PLAY_SERVICE_ACCOUNT_JSON is a service account key with Play Developer API access.
- 10. needs: lint-and-test in the build-and-deploy job — enforces the dependency chain; if lint or unit tests fail, the build and deploy job never starts, preventing a broken build from reaching QA.
Spot the bug
# .github/workflows/android-ci.yml — school management SaaS
name: Android CI
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build release
run: ./gradlew :app:bundleProdRelease # Bug 1
- name: Deploy to Play Store
run: |
echo "$KEYSTORE_B64" | base64 --decode > release.jks # Bug 2
./gradlew :app:bundleProdRelease
env:
KEYSTORE_B64: ${{ secrets.KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: MyHardcodedPass # Bug 3
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release
path: app/build/outputs/bundle/prodRelease/
retention-days: 365 # Bug 4
# No lint or unit test job — Bug 5Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- GitHub Actions — Android Build Guide (GitHub Docs)
- Fastlane — Android Automation (Fastlane Docs)
- android-emulator-runner GitHub Action (ReactiveCircus / GitHub)
- Bitrise — Mobile CI/CD (Bitrise Docs)
- Automating Play Store Deployment with Fastlane Supply (Fastlane Docs)