You push code. Tests run automatically. The App Bundle (AAB) is built and signed. It gets uploaded to the Play Store internal track. You never touch the build machine.
This is CI/CD — Continuous Integration and Continuous Deployment. It saves hours of manual work and catches bugs before they reach users.
In this tutorial, you will set up a complete CI/CD pipeline for your Android app using GitHub Actions and Fastlane.
Prerequisites: You should know Git basics and have an Android app with tests. See Android Tutorial #16: App Security for release build configuration.
Why CI/CD Matters
Without CI/CD:
- You run tests manually (or forget to run them)
- You build the APK on your laptop
- You sign it with a keystore stored somewhere on your desktop
- You upload to Play Console manually
- Something breaks on a different Android version because you didn’t test there
With CI/CD:
- Push code → tests run automatically
- Tests pass → signed AAB is built
- AAB is uploaded to Play Console → available for testing
- Everything is reproducible, traceable, and version-controlled
GitHub Actions Basics
GitHub Actions runs workflows defined in YAML files. Put them in .github/workflows/.
Key concepts:
- Workflow — a YAML file that defines automation
- Job — a set of steps that run on a virtual machine
- Step — a single command or action
- Trigger — when the workflow runs (push, PR, schedule)
CI Workflow: Build and Test
This workflow runs on every pull request:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 30
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: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Run lint checks
run: ./gradlew lintDebug
- name: Build debug APK
run: ./gradlew assembleDebug
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: app/build/reports/tests/
- name: Upload lint results
if: always()
uses: actions/upload-artifact@v4
with:
name: lint-results
path: app/build/reports/lint-results-debug.html
Gradle Caching
The gradle/actions/setup-gradle action handles Gradle caching automatically. This reduces build times from 10+ minutes to 3-5 minutes.
For multi-module projects, Gradle Build Cache and Configuration Cache provide additional savings:
# gradle.properties
org.gradle.caching=true
org.gradle.configuration-cache=true
Instrumented Tests with Gradle Managed Devices
Instead of using a separate emulator, use Gradle Managed Virtual Devices to run instrumented tests in CI:
// build.gradle.kts (app module)
android {
testOptions {
managedDevices {
localDevices {
create("pixel6Api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd"
}
}
}
}
}
Run in CI:
- name: Run instrumented tests
run: ./gradlew pixel6Api34DebugAndroidTest
This downloads and manages the emulator automatically. No manual setup needed.
CD Workflow: Build and Deploy
This workflow builds a signed AAB and uploads it to the Play Store:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
tags:
- 'v*'
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
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: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore
- name: Build signed AAB
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleRelease
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: release-aab
path: app/build/outputs/bundle/release/app-release.aab
- name: Deploy to Play Store
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.example.myapp
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
mappingFile: app/build/outputs/mapping/release/mapping.txt
Setting Up Secrets
In your GitHub repository, go to Settings → Secrets and variables → Actions:
| Secret | Description |
|---|---|
KEYSTORE_BASE64 | Your keystore encoded as base64 |
KEYSTORE_PASSWORD | Keystore password |
KEY_ALIAS | Key alias name |
KEY_PASSWORD | Key password |
PLAY_SERVICE_ACCOUNT_JSON | Google Play service account JSON |
Encode your keystore:
# macOS
base64 -i release.keystore -o keystore_base64.txt
# Linux / Ubuntu (GitHub Actions runners)
base64 -w 0 release.keystore > keystore_base64.txt
# Copy the contents of keystore_base64.txt to the secret
Signing Configuration
In your build.gradle.kts:
android {
signingConfigs {
create("release") {
storeFile = file("release.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
Automated Versioning
Use the CI build number for the version code:
// build.gradle.kts
android {
defaultConfig {
val buildNumber = System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull() ?: 1
versionCode = buildNumber
versionName = "1.0.$buildNumber"
}
}
This ensures every build has a unique, incrementing version code.
Fastlane for Android
Fastlane automates repetitive tasks — building, testing, deploying, taking screenshots.
Setup
# Install Fastlane
gem install fastlane
# Initialize in your project
cd your-android-project
fastlane init
Fastfile
Create fastlane/Fastfile:
default_platform(:android)
platform :android do
desc "Run unit tests"
lane :test do
gradle(task: "testDebugUnitTest")
end
desc "Build debug APK"
lane :build_debug do
gradle(task: "assembleDebug")
end
desc "Build release AAB"
lane :build_release do
gradle(
task: "bundleRelease",
properties: {
"android.injected.signing.store.file" => ENV["KEYSTORE_PATH"],
"android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["KEY_PASSWORD"]
}
)
end
desc "Deploy to internal track"
lane :deploy_internal do
build_release
upload_to_play_store(
track: "internal",
aab: "app/build/outputs/bundle/release/app-release.aab",
mapping: "app/build/outputs/mapping/release/mapping.txt",
json_key: ENV["PLAY_JSON_KEY_PATH"]
)
end
desc "Promote internal to production"
lane :promote_to_production do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
json_key: ENV["PLAY_JSON_KEY_PATH"],
rollout: "0.1" # 10% rollout
)
end
end
Using Fastlane in CI
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Decode keystore and Play key
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > ${{ github.workspace }}/app/release.keystore
echo "${{ secrets.PLAY_JSON_KEY_BASE64 }}" | base64 -d > ${{ github.workspace }}/play-service-account.json
- name: Deploy with Fastlane
env:
KEYSTORE_PATH: ${{ github.workspace }}/app/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
PLAY_JSON_KEY_PATH: ${{ github.workspace }}/play-service-account.json
run: fastlane deploy_internal
Gradle Play Publisher — Lighter Alternative
If you don’t need Fastlane’s full feature set, the Gradle Play Publisher plugin is a simpler option:
plugins {
id("com.github.triplet.play") version "3.11.0"
}
play {
serviceAccountCredentials.set(file("play-service-account.json"))
track.set("internal")
defaultToAppBundles.set(true)
}
Then deploy with:
./gradlew publishBundle
AAB Size Monitoring
Track APK size in CI to prevent bloat:
- name: Check AAB size
run: |
AAB_SIZE=$(stat -c%s app/build/outputs/bundle/release/app-release.aab)
MAX_SIZE=52428800 # 50 MB
if [ "$AAB_SIZE" -gt "$MAX_SIZE" ]; then
echo "AAB size ($AAB_SIZE bytes) exceeds limit ($MAX_SIZE bytes)"
exit 1
fi
echo "AAB size: $(($AAB_SIZE / 1048576)) MB"
Branch Protection
Set up branch protection rules for main:
- Go to repository Settings → Branches
- Add a rule for
main - Enable:
- Require status checks to pass before merging
- Require branches to be up to date
- Select your CI workflow as a required check
Now nobody can merge to main without passing tests.
Complete Pipeline Example
Here is a complete workflow that handles PR checks and release deployment:
# .github/workflows/android.yml
name: Android CI/CD
on:
pull_request:
branches: [main]
push:
branches: [main]
tags:
- 'v*'
jobs:
# Run on every PR and push to main
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew testDebugUnitTest lintDebug
- uses: actions/upload-artifact@v4
if: always()
with:
name: reports
path: |
app/build/reports/tests/
app/build/reports/lint-results-debug.html
# Run only on version tags
deploy:
if: startsWith(github.ref, 'refs/tags/v')
needs: test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- uses: gradle/actions/setup-gradle@v4
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore
- name: Build release AAB
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleRelease
- name: Deploy to Play Store
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.example.myapp
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
mappingFile: app/build/outputs/mapping/release/mapping.txt
Release Process
# Tag a release
git tag -a v1.0.42 -m "Release 1.0.42: bug fixes and performance"
git push origin v1.0.42
# CI runs → tests pass → AAB uploaded to internal track
Cost Comparison
| Platform | Free Minutes | Cost After Free |
|---|---|---|
| GitHub Actions | 2,000 min/month | $0.008/min (Linux) |
| CircleCI | 6,000 min/month | $0.006/min |
| Bitrise | 300 min/month | ~$0.02/min |
| GitLab CI | 400 min/month | $0.005/min |
GitHub Actions is the best choice for most projects. The free tier is generous, and integration with GitHub is seamless.
Common Mistakes
1. Not Caching Gradle Dependencies
Without caching, every CI run downloads all dependencies from scratch. Use the gradle/actions/setup-gradle action or manually cache ~/.gradle/caches.
2. Storing Keystores in the Repository
Never commit your keystore file. Use secrets to store it as a base64-encoded string and decode it during the build.
3. Running All Tests on Every PR
If your test suite takes 30 minutes, developers stop waiting for CI. Split into fast (unit tests) and slow (instrumented tests) jobs. Run instrumented tests only on main branch pushes.
4. Not Uploading Mapping Files
Without the mapping file, crash reports in Play Console and Crashlytics show obfuscated class names. Always upload mapping.txt alongside your AAB.
5. Hardcoding Version Codes
If two developers build with the same version code, the upload fails. Use the CI build number to ensure uniqueness.
What’s Next?
In this tutorial, you learned:
- Setting up GitHub Actions for Android CI
- Running tests and lint checks automatically
- Building signed AABs in CI
- Deploying to Play Store with Fastlane and GitHub Actions
- Automated versioning with build numbers
- Monitoring AAB size and enforcing branch protection
Next up: Android Tutorial #19: Play Store Publishing — Signing, Screenshots, ASO — where you will learn how to publish your app and optimize your store listing.
Related Articles
- Android Tutorial #16: App Security — R8 and release build configuration
- Android Tutorial #19: Play Store Publishing — store listing and signing
- Android Tutorial #5: Multi-Module Architecture — build caching for modules
- Android Tutorial #17: Firebase — Crashlytics mapping files
This is part 18 of the Android Development Tutorial series.