androidengineers.Book a session

CI/CD Pipeline

Exercise: CI Pipeline YAML

exercise55 minHard

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

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: CI Pipeline YAML | Android System Design | Android Engineers