androidengineers.Book a session

Testing & Automation

Setting up CI/CD (GitHub Actions)

article50 minMedium

A CI/CD pipeline runs your build, lint checks, and tests automatically on every pull request and merge. It turns "works on my machine" into a verifiable, reproducible process. Senior developers are expected to own and maintain these pipelines, not just benefit from them.

How GitHub Actions Works

A workflow is a YAML file in .github/workflows/. It defines triggers (when to run) and jobs (what to run). Each job runs on a fresh virtual machine and executes steps in order.

name: Android CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Run lint
        run: ./gradlew lint

      - name: Run unit tests
        run: ./gradlew testDebugUnitTest

      - name: Build release APK
        run: ./gradlew assembleRelease

This workflow triggers on any push to main or any pull request targeting main.

Caching Gradle Dependencies

Without caching, every run downloads all Gradle dependencies from scratch — often 3–5 minutes of wasted time. The Gradle and wrapper caches change rarely, so they are ideal candidates for caching.

      - name: Cache Gradle packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: gradle-

The key is a hash of your Gradle files. If those files change (dependency version bump, plugin update), the cache is invalidated and rebuilt. Otherwise, the cache is restored and ./gradlew skips the download phase.

A well-configured cache cuts build times from 6–8 minutes to 2–3 minutes on a typical app.

Uploading Test Reports and APKs

Make artifacts available for download after the workflow finishes — useful when a test fails on CI but you cannot reproduce it locally:

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: app/build/reports/tests/

      - name: Upload APK
        uses: actions/upload-artifact@v4
        with:
          name: release-apk
          path: app/build/outputs/apk/release/

if: always() ensures the test report is uploaded even when the tests fail — which is exactly when you need it.

Managing Secrets

API keys, keystore passwords, and signing credentials must never be committed to the repository. Store them as GitHub Actions Secrets (Settings → Secrets and variables → Actions) and reference them in the workflow:

      - name: Sign release APK
        env:
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: |
          ./gradlew assembleRelease \
            -Pandroid.injected.signing.store.file=keystore.jks \
            -Pandroid.injected.signing.store.password=$KEYSTORE_PASSWORD \
            -Pandroid.injected.signing.key.alias=$KEY_ALIAS \
            -Pandroid.injected.signing.key.password=$KEY_PASSWORD

The keystore file itself can be stored as a base64-encoded secret and decoded in a step before signing.

Separate Jobs for Parallelism

Split lint and tests into separate jobs so they run in parallel and you get faster feedback:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '17', distribution: 'temurin' }
      - uses: actions/cache@v4
        with:
          path: ~/.gradle/caches
          key: gradle-${{ hashFiles('**/*.gradle*') }}
      - run: ./gradlew lint

  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '17', distribution: 'temurin' }
      - uses: actions/cache@v4
        with:
          path: ~/.gradle/caches
          key: gradle-${{ hashFiles('**/*.gradle*') }}
      - run: ./gradlew testDebugUnitTest

  build:
    needs: [lint, unit-test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '17', distribution: 'temurin' }
      - uses: actions/cache@v4
        with:
          path: ~/.gradle/caches
          key: gradle-${{ hashFiles('**/*.gradle*') }}
      - run: ./gradlew assembleRelease

The needs key makes build wait for both lint and unit-test to pass before running. Lint and tests run in parallel.

Branch Protection Rules

Enable branch protection on main so no code can be merged unless the CI workflow passes:

  1. Go to Settings → Branches → Add rule
  2. Set the branch pattern to main
  3. Enable Require status checks to pass before merging
  4. Add your job names (lint, unit-test, build)

This makes CI mandatory, not optional.

Deploying to Firebase App Distribution

Distribute debug builds to testers automatically after merging to main:

      - name: Deploy to Firebase App Distribution
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{ secrets.FIREBASE_APP_ID }}
          serviceCredentialsFileContent: ${{ secrets.FIREBASE_CREDENTIALS }}
          groups: testers
          file: app/build/outputs/apk/debug/app-debug.apk
          releaseNotes: "Build from ${{ github.sha }}"

Practice

Create a .github/workflows/ci.yml for an existing Android project. Add Gradle caching, run lint and unit tests in parallel jobs, upload test reports with if: always(), and require both jobs to pass before the build job runs. Enable branch protection on your main branch.

Summary

GitHub Actions workflows are YAML files that define triggers, jobs, and steps. Cache Gradle dependencies to cut build times in half. Split lint and tests into parallel jobs. Store secrets in GitHub Secrets, never in code. Use branch protection to enforce that CI passes before any merge.

YOUR LEARNING JOURNEY

0 of 17 available lessons completed

Progress saved in this browser. No account needed.
Setting up CI/CD (GitHub Actions) | Senior Android Developer | Android Engineers