Every time you push code, wouldn't it be great if your Android app was automatically built, tested, and verified — without you lifting a finger? That's exactly what CI/CD with GitHub Actions gives you.
CI/CD stands for Continuous Integration / Continuous Delivery. With GitHub Actions, you define workflows in YAML files that run automatically on events like a push or pull request — catching bugs early, enforcing code quality, and keeping your main branch always in a deployable state.
In this guide you'll set up a complete Android CI/CD pipeline with GitHub Actions covering builds, tests, Gradle caching, secrets management, and artifact uploads.
What is GitHub Actions?
GitHub Actions is GitHub's built-in automation platform. You define workflows — sequences of automated steps — in YAML files stored in .github/workflows/ in your repository. Workflows are triggered by events like pushes, pull requests, or scheduled times.
| Concept | What it is | Example |
|---|---|---|
| Workflow | A YAML file defining automation | android-ci.yml |
| Trigger (on) | Event that starts the workflow | push, pull_request, schedule |
| Job | A set of steps that run on one machine | build, test, deploy |
| Step | A single task within a job | Checkout code, run Gradle |
| Action | Reusable, pre-written step | actions/checkout@v4 |
Setting Up Your First Workflow
Step 1: Create the workflow file
In your Android project, create the directory .github/workflows/ and add a new file called android-ci.yml. GitHub automatically detects and runs all .yml files in this directory.
# Create the directory structure in your project root: mkdir -p .github/workflows touch .github/workflows/android-ci.yml
Then paste the workflow YAML below into android-ci.yml and commit it to your repository. That's all — GitHub will start running it automatically on the next push.
Step 2: Monitor the workflow and download artifacts
After pushing your code, here's where to find everything:
- Go to your GitHub repository → click the Actions tab
- You'll see all workflow runs listed with their status (✅ passed, ❌ failed, 🟡 in progress)
- Click any run to see individual step logs — useful for debugging failures
- Scroll to the bottom of a completed run to find the Artifacts section — click to download your APK or reports
Basic CI Workflow — Build and Test
This workflow triggers on every push to main and every pull request. It checks out the code, sets up JDK 17, caches Gradle dependencies, builds the debug APK, and runs unit tests:
# .github/workflows/android-ci.yml
name: Android CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
# Step 1: Check out the repository code
- name: Checkout code
uses: actions/checkout@v4
# Step 2: Set up JDK 17 (required for AGP 8+)
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'gradle' # Caches Gradle dependencies automatically
# Step 3: Make gradlew executable
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# Step 4: Build the debug APK
- name: Build debug APK
run: ./gradlew assembleDebug
# Step 5: Run unit tests
- name: Run unit tests
run: ./gradlew test
# Step 6: Upload the APK as a downloadable artifact
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: app/build/outputs/apk/debug/*.apk
retention-days: 7
Gradle Dependency Caching
Without caching, every workflow run downloads all Gradle dependencies from scratch — adding 2–5 minutes to every run.
Use
cache: 'gradle' (automatic) — the simplest option. Already included in the basic workflow above via setup-java@v4. Works for most projects with no extra configuration.Use manual
actions/cache@v4 — only needed if you have a monorepo, custom Gradle home location, or need fine-grained control over the cache key.
Here's the manual approach if you need it:
# Manual Gradle cache — use only if automatic cache: 'gradle' doesn't work for your setup
- name: Cache Gradle packages
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
Managing Secrets — API Keys and Keystore
Never hardcode API keys, keystore passwords, or any sensitive data in your workflow files. Use GitHub Secrets — encrypted environment variables stored securely in your repository settings.
Add a secret in GitHub:
- Go to your repository → Settings → Secrets and variables → Actions
- Click New repository secret
- Add your secret name and value (e.g.
KEYSTORE_PASSWORD)
Use secrets in your workflow:
# Access secrets via ${{ secrets.SECRET_NAME }}
- name: Build release APK
run: ./gradlew assembleRelease
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
Building a Signed Release APK
To build a signed release APK in CI, store your keystore as a base64-encoded secret and decode it at runtime:
name: Android Release
on:
push:
tags:
- 'v*' # Triggers on version tags like v1.0.0
jobs:
release:
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'
cache: 'gradle'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# Decode base64 keystore from secret and write to file
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/keystore.jks
# Build signed release APK
- name: Build signed release APK
run: ./gradlew assembleRelease
env:
SIGNING_STORE_FILE: keystore.jks
SIGNING_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
# Upload signed APK as artifact
- name: Upload release APK
uses: actions/upload-artifact@v4
with:
name: release-apk
path: app/build/outputs/apk/release/*.apk
# Clean up keystore file
- name: Clean up keystore
if: always()
run: rm -f app/keystore.jks
KEYSTORE_BASE64:macOS:
base64 -i your-keystore.jks | pbcopyLinux:
base64 your-keystore.jksWindows (PowerShell):
[Convert]::ToBase64String([IO.File]::ReadAllBytes("your-keystore.jks")) | clip
Configure signing in build.gradle
The workflow passes the keystore details as environment variables — but you also need to tell Gradle how to read them. Add this signing config to your app/build.gradle:
// app/build.gradle
android {
signingConfigs {
release {
storeFile file(System.getenv("SIGNING_STORE_FILE") ?: "keystore.jks")
storePassword System.getenv("SIGNING_STORE_PASSWORD")
keyAlias System.getenv("SIGNING_KEY_ALIAS")
keyPassword System.getenv("SIGNING_KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
This reads the signing credentials from environment variables set by the workflow. Locally, your own keystore config handles signing — these env vars only exist in the CI environment.
Pull Request Checks Workflow
Add a separate workflow specifically for PRs to run linting and tests before merging. Pair this with a branch protection rule that requires these checks to pass:
name: PR Checks
on:
pull_request:
branches: [ main, develop ]
jobs:
checks:
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'
cache: 'gradle'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# Run Android Lint
- name: Run Lint
run: ./gradlew lint
# Upload lint report
- name: Upload lint results
uses: actions/upload-artifact@v4
if: always()
with:
name: lint-results
path: app/build/reports/lint-results*.html
# Run unit tests
- name: Run unit tests
run: ./gradlew test
# Upload test report
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: app/build/reports/tests/
Best Practices
- Always cache Gradle dependencies — use
cache: 'gradle'insetup-java@v4. This alone can cut your build time by 50–70% by reusing previously downloaded dependencies. - Use latest action versions — always pin to a major version like
@v4not@v1. Outdated actions may use deprecated Node.js versions and cause warnings or failures. - Never hardcode secrets — use GitHub Secrets for API keys, keystore passwords, and tokens. Secrets are masked in logs and never exposed in workflow output.
- Run checks on PRs, not just main — catch bugs before they reach main by triggering lint and tests on every pull request. Combine with branch protection rules to enforce this.
- Use
if: always()for upload steps — always upload test and lint reports even when the step fails. This lets you diagnose failures without re-running the workflow. - Clean up sensitive files — if you decode a keystore to disk, always add a cleanup step with
if: always()to remove it, even if the build fails.
Frequently Asked Questions
How much does GitHub Actions cost for Android CI/CD?
GitHub Actions is free for public repositories. For private repositories, the free tier includes 2,000 minutes/month on Linux runners. A typical Android debug build takes 3–5 minutes without caching, so you get roughly 400–600 free builds per month. With Gradle caching, builds drop to 1–2 minutes significantly increasing that count.
What JDK version should I use for Android builds?
Use JDK 17 with the temurin distribution for Android projects targeting AGP 8.0+. Set java-version: '17' and distribution: 'temurin' in the setup-java@v4 action. JDK 11 is needed for AGP 7.x.
How do I store my Android keystore securely?
Encode your keystore to base64 and store it as a GitHub Secret. In your workflow, decode it back to a file using the base64 command. Store the keystore password, key alias, and key password as separate secrets. Always delete the decoded file after the build using a cleanup step with if: always().
How do I speed up my Android GitHub Actions build?
The biggest gain comes from caching Gradle dependencies via cache: 'gradle' in setup-java@v4. Other improvements: use --build-cache in Gradle commands, split build and test into parallel jobs, and run only lint/unit tests on PRs while full builds run on main.
- Place workflow YAML files in .github/workflows/ — GitHub detects them automatically
- Use actions/checkout@v4 and actions/setup-java@v4 — always use latest major versions
- Enable Gradle caching with
cache: 'gradle'— cuts build time by 50–70% - Store sensitive data in GitHub Secrets — never hardcode API keys or passwords
- Encode keystores to base64 and decode at runtime for signed release builds
- Use upload-artifact to save APKs and test reports as downloadable files
- Trigger PR checks separately from main branch builds for faster feedback
Informative, nicely explained. Thank you for sharing.
ReplyDeleteThank you for explaining GitHub Actions to me.
ReplyDeleteGreat Post
ReplyDelete