CI/CD for Android with GitHub Actions
Set up automated builds, tests, signing, and Play Store deployment for your Android project using GitHub Actions.
Every time you push code, you want to know two things: does it build, and do the tests pass? GitHub Actions makes this free for public repos and easy for private ones. This guide walks you through setting up a complete CI/CD pipeline for an Android project — from basic builds to Play Store deployment.
Prerequisites
- An Android project with Gradle
- A GitHub repository
- Basic familiarity with YAML
Step 1: Basic Build and Test
Create .github/workflows/android.yml in your repository:
name: Android CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Grant execute permission for Gradle
run: chmod +x gradlew
- name: Build debug APK
run: ./gradlew assembleDebug
- name: Run unit tests
run: ./gradlew test
This runs on every push to main and on every pull request. It checks out the code, sets up JDK 21, builds the debug APK, and runs unit tests.
Push this file and check the Actions tab in your GitHub repository — you should see the workflow running.
Step 2: Gradle Caching
Android builds are slow. Caching the Gradle dependencies and build outputs cuts build time significantly — often by 50% or more.
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
Add this step after the JDK setup and before the build step. It caches Gradle dependencies and build outputs. The cache-read-only option ensures that only builds on main write to the cache — PR builds read from it but don’t pollute it.
Remove the “Grant execute permission” step — setup-gradle handles that automatically.
Updated workflow so far
name: Android CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Build debug APK
run: ./gradlew assembleDebug
- name: Run unit tests
run: ./gradlew test
Step 3: Build Variants and Product Flavors
If your project has multiple build variants (debug/release) or product flavors, you can build them selectively:
# Build specific variant
- name: Build release APK
run: ./gradlew assembleRelease
# Build all variants
- name: Build all variants
run: ./gradlew assemble
# Build specific flavor
- name: Build production release
run: ./gradlew assembleProductionRelease
For most CI setups, building debug on PRs and release on main is a good balance:
- name: Build
run: |
if [ "${{ github.event_name }}" == "pull_request" ]; then
./gradlew assembleDebug
else
./gradlew assembleRelease
fi
Step 4: Upload Artifacts
Save the built APK/AAB so you can download it from the GitHub Actions UI — useful for QA testing or sharing builds without a full Play Store deploy.
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: app/build/outputs/apk/debug/app-debug.apk
retention-days: 14
For AAB (Android App Bundle):
- name: Build release AAB
run: ./gradlew bundleRelease
- name: Upload AAB
uses: actions/upload-artifact@v4
with:
name: release-aab
path: app/build/outputs/bundle/release/app-release.aab
retention-days: 30
After the workflow runs, go to the workflow run page on GitHub and download the artifact from the Artifacts section at the bottom.
Step 5: Release Signing
Debug builds are signed automatically with a debug keystore. Release builds need your signing configuration. Never commit your keystore or passwords to the repository.
Store secrets in GitHub
Go to your repository → Settings → Secrets and variables → Actions and add these secrets:
| Secret Name | Value |
|---|---|
KEYSTORE_BASE64 | Base64-encoded keystore file |
KEYSTORE_PASSWORD | Keystore password |
KEY_ALIAS | Key alias |
KEY_PASSWORD | Key password |
To encode your keystore as Base64:
base64 -i your-keystore.jks | pbcopy # macOS — copies to clipboard
base64 -w 0 your-keystore.jks # Linux — prints to stdout
Decode keystore in the workflow
- name: Decode keystore
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/release-keystore.jks
- name: Build signed release APK
if: github.event_name != 'pull_request'
run: ./gradlew assembleRelease
env:
SIGNING_STORE_FILE: release-keystore.jks
SIGNING_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
Configure Gradle to read environment variables
In your app/build.gradle.kts:
android {
signingConfigs {
create("release") {
storeFile = file(System.getenv("SIGNING_STORE_FILE") ?: "release-keystore.jks")
storePassword = System.getenv("SIGNING_STORE_PASSWORD") ?: ""
keyAlias = System.getenv("SIGNING_KEY_ALIAS") ?: ""
keyPassword = System.getenv("SIGNING_KEY_PASSWORD") ?: ""
}
}
buildTypes {
release {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
This way, signing works in CI (reads from environment variables) and locally (you can set the same env vars or use a local keystore.properties file).
Step 6: Play Store Deployment
For automated Play Store uploads, use the r0adkll/upload-google-play action.
Prerequisites
- Create a Google Play service account in the Google Cloud Console
- Grant it access in Google Play Console → Setup → API access
- Download the service account JSON key
- Store it as a GitHub secret:
PLAY_STORE_SERVICE_ACCOUNT_JSON
Workflow step
- name: Deploy to Play Store (Internal Testing)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
packageName: com.example.yourapp
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
status: completed
Available tracks: internal, alpha, beta, production. Start with internal for testing, then promote manually in Play Console when ready.
Step 7: Build Status Badge
Add a build status badge to your README.md:

Replace YOUR_USERNAME and YOUR_REPO with your actual values. This shows a green/red badge based on the latest workflow run on the default branch.
Complete Workflow
Here’s the full workflow combining everything:
name: Android CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# Debug build + tests (always)
- name: Build debug APK
run: ./gradlew assembleDebug
- name: Run unit tests
run: ./gradlew test
- name: Upload debug APK
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: app/build/outputs/apk/debug/app-debug.apk
retention-days: 14
# Signed release build (main branch only)
- name: Decode keystore
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/release-keystore.jks
- name: Build signed release AAB
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./gradlew bundleRelease
env:
SIGNING_STORE_FILE: release-keystore.jks
SIGNING_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Upload release AAB
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: release-aab
path: app/build/outputs/bundle/release/app-release.aab
retention-days: 30
# Deploy to Play Store (optional)
- name: Deploy to Play Store
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
packageName: com.example.yourapp
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
status: completed
What this does
| Trigger | What runs |
|---|---|
| PR opened/updated | Debug build → Unit tests → Upload debug APK |
| Push to main | Debug build → Unit tests → Signed release AAB → Upload AAB → Deploy to Play Store |
Tips
- Start simple — begin with just the build and test steps. Add signing and deployment later when you’re ready.
- Use branch protection — require the CI workflow to pass before merging PRs. Go to Settings → Branches → Branch protection rules → check “Require status checks to pass.”
- Run instrumented tests — unit tests run on the JVM, but UI/integration tests need an emulator. Use reactivecircus/android-emulator-runner for that.
- Lint checks — add
./gradlew lintto catch code quality issues early. - Matrix builds — test against multiple API levels by using a strategy matrix. Useful for libraries, overkill for most apps.
- Keep secrets secret — never echo secrets in logs. GitHub masks them automatically, but be careful with custom scripts.