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:
- Go to
Settings → Branches → Add rule - Set the branch pattern to
main - Enable Require status checks to pass before merging
- 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.