Write a complete GitHub Actions CI/CD pipeline for an Android app with multiple product flavors. The pipeline should run fast, use caching efficiently, and automatically distribute to testers on develop merges.
Goal
- PR branches: lint + unit tests (< 5 min)
- Merge to
develop: + instrumented tests + Firebase App Distribution (staging) - Tag push (
v*): + production bundle + Play Store upload (internal track) - All jobs use Gradle caching
- Signing via GitHub Secrets
Pipeline Overview
PR push:
├── lint (parallel)
└── unit-tests (parallel)
develop push:
├── lint
├── unit-tests
├── screenshot-tests
├── instrumented-tests
└── distribute-staging (depends on all tests passing)
v* tag push:
├── unit-tests
├── sign-and-bundle
└── upload-to-play (depends on sign-and-bundle)
Complete Workflow File
# .github/workflows/main.yml
name: CI/CD
on:
push:
branches: [main, develop]
tags: ['v*']
pull_request:
branches: [main, develop]
env:
JAVA_VERSION: '17'
JAVA_DISTRIBUTION: 'temurin'
jobs:
# ── Lint ──────────────────────────────────────────────────────────────
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}
cache: gradle
- uses: gradle/gradle-build-action@v2
- run: ./gradlew lintDebug
- uses: actions/upload-artifact@v3
if: always()
with:
name: lint-results
path: "**/build/reports/lint-results-debug.html"
# ── Unit Tests ────────────────────────────────────────────────────────
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}
cache: gradle
- uses: gradle/gradle-build-action@v2
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
- run: ./gradlew testDebugUnitTest --build-cache
- uses: dorny/test-reporter@v1
if: always()
with:
name: Unit Test Results
path: "**/build/test-results/testDebugUnitTest/*.xml"
reporter: java-junit
# ── Screenshot Tests ──────────────────────────────────────────────────
screenshot-tests:
name: Screenshot Tests
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with: { java-version: '${{ env.JAVA_VERSION }}', distribution: '${{ env.JAVA_DISTRIBUTION }}', cache: gradle }
- run: ./gradlew verifyPaparazziDebug
- uses: actions/upload-artifact@v3
if: failure()
with:
name: screenshot-diffs
path: "**/build/paparazzi/failures/"
# ── Instrumented Tests ────────────────────────────────────────────────
instrumented-tests:
name: Instrumented Tests
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with: { java-version: '${{ env.JAVA_VERSION }}', distribution: '${{ env.JAVA_DISTRIBUTION }}', cache: gradle }
- name: Enable KVM
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
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
arch: x86_64
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
disable-animations: true
script: ./gradlew connectedDebugAndroidTest
- uses: actions/upload-artifact@v3
if: always()
with:
name: instrumented-test-results
path: "**/build/reports/androidTests/connected/"
# ── Firebase App Distribution (develop only) ──────────────────────────
distribute-staging:
name: Distribute to Staging Testers
runs-on: ubuntu-latest
needs: [lint, unit-tests, screenshot-tests, instrumented-tests]
if: github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # needed for git log
- uses: actions/setup-java@v3
with: { java-version: '${{ env.JAVA_VERSION }}', distribution: '${{ env.JAVA_DISTRIBUTION }}', cache: gradle }
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/staging.keystore
- name: Generate release notes
run: |
echo "## Changes" > release-notes.txt
git log --oneline origin/main..HEAD | head -10 >> release-notes.txt
- name: Build staging release
env:
KEYSTORE_PATH: staging.keystore
KEYSTORE_PASSWORD: ${{ secrets.STAGING_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.STAGING_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.STAGING_KEY_PASSWORD }}
run: ./gradlew assembleStagingRelease --build-cache
- name: Distribute to Firebase
run: ./gradlew appDistributionUploadStagingRelease
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
# ── Production Release (tag push only) ───────────────────────────────
release-production:
name: Release to Play Store
runs-on: ubuntu-latest
needs: [lint, unit-tests]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with: { java-version: '${{ env.JAVA_VERSION }}', distribution: '${{ env.JAVA_DISTRIBUTION }}', cache: gradle }
- name: Set version
run: |
echo "VERSION_NAME=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
echo "VERSION_CODE=$(date +%Y%m%d)01" >> $GITHUB_ENV
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore
- name: Build production bundle
env:
KEYSTORE_PATH: release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleProductionRelease --build-cache
- uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: com.myapp
releaseFiles: app/build/outputs/bundle/productionRelease/*.aab
track: internal # internal → alpha → beta → production
status: completed
Required GitHub Secrets
KEYSTORE_BASE64 — base64-encoded release keystore
KEYSTORE_PASSWORD — keystore password
KEY_ALIAS — key alias
KEY_PASSWORD — key password
STAGING_KEYSTORE_BASE64 — staging keystore (may differ)
STAGING_KEYSTORE_PASSWORD
STAGING_KEY_ALIAS
STAGING_KEY_PASSWORD
FIREBASE_TOKEN — firebase login:ci token
SERVICE_ACCOUNT_JSON — GCP service account for Play Store API
Verification Checklist
[ ] Open a PR → only lint + unit-tests run (not instrumented, not distribute)
[ ] Merge to develop → all 5 jobs run; FAD email arrives for staging testers
[ ] Push v1.2.3 tag → release-production job runs; bundle appears in Play internal track
[ ] Break a unit test → PR is blocked; "unit-tests" job fails with test report
[ ] Add a new screen → screenshot test fails with diff artifact on failure
[ ] Correct the screenshot → ./gradlew recordPaparazziDebug locally; push; CI passes