CI/CD for Android
GitHub Actions — complete workflow
# .github/workflows/ci.yml
name: Android CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # cancel in-progress runs on new push
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- name: Run lint
run: ./gradlew lint --continue
- name: Upload lint results
if: failure()
uses: actions/upload-artifact@v4
with:
name: lint-results
path: '**/build/reports/lint-results-*.html'
unit-test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- name: Run unit tests
run: ./gradlew testDebugUnitTest --continue
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: '**/build/test-results/**/*.xml'
build:
name: Build
needs: [lint, unit-test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- name: Build debug APK
run: ./gradlew assembleProdDebug
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: app/build/outputs/apk/prod/debug/*.apk
deploy-internal:
name: Deploy to Internal Testing
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v4
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > keystore.jks
- name: Build release AAB
env:
KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleProdRelease
- name: Upload to Play Store (Internal)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.company.app
releaseFiles: app/build/outputs/bundle/prodRelease/*.aab
track: internal
status: completed
Rule 2: Required secrets setup
GitHub Secrets needed:
KEYSTORE_BASE64 → base64 of your .jks file: base64 -i keystore.jks | pbcopy
KEYSTORE_PASSWORD → keystore password
KEY_ALIAS → key alias
KEY_PASSWORD → key password
PLAY_SERVICE_ACCOUNT_JSON → Google Play service account JSON with Editor permissions
Rule 3: Gradle caching for faster CI
# Add to every job that uses Gradle
- uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# Saves 3-5 minutes per run
Rule 4: Instrumented tests on Firebase Test Lab
- name: Run instrumented tests on Firebase Test Lab
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_JSON }}
- run: |
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/*.apk \
--test app/build/outputs/apk/androidTest/debug/*.apk \
--device model=Pixel6,version=33 \
--device model=Pixel_Tablet,version=33 \
--timeout 15m
Common Mistakes
❌ Committing keystore or passwords — always use GitHub Secrets
❌ No concurrency: group — multiple PRs queue up unnecessarily
❌ Running tests before lint — lint is faster, fail fast
❌ No Gradle caching — every CI run downloads all dependencies
❌ Building unsigned APK for Play Store — AAB must be signed
❌ Uploading APK instead of AAB — Play Store requires AAB since 2021