Why CI/CD for Android Matters
Manual testing and release processes don't scale. As your team grows, inconsistent builds, missed edge cases, and deployment delays become the norm rather than the exception. CI/CD for Android: a Android & Kotlin guide for developers starts with the recognition that automation isn't optional—it's the foundation of reliable, repeatable releases.
This guide walks you through setting up a production-ready CI/CD pipeline using tools your team likely already has access to: GitHub Actions, Firebase, and Google Play Console. We'll cover how to automate builds, run tests on every commit, sign APKs securely, distribute to testers, and push releases to Play Store tracks without touching a single local build command.
Build and Test with GitHub Actions
Every pull request should trigger automated checks. GitHub Actions gives you a free, integrated way to run unit tests, lint rules, and static analysis on every push.
Start with a workflow file in your repository at .github/workflows/ci.yml:
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- 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: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: app/build/test-results/This workflow runs every time you push to main or develop, and on every pull request. It sets up Java, runs your lint rules, executes unit tests, and uploads the results as artifacts so you can inspect them in the GitHub UI.
Add more granular checks as your project grows: Kotlin linter, code coverage thresholds, or integration tests against a mock API. The cost is minimal, and the feedback loop is immediate.
Signed Builds and Secure Key Storage
You cannot distribute or publish an unsigned APK. GitHub Actions must sign your build, but the signing key itself must never be committed to version control.
Generate your keystore locally (if you don't already have one):
keytool -genkey -v -keystore release.keystore -alias release \
-keyalg RSA -keysize 2048 -validity 10000Convert it to base64:
base64 release.keystore | tr -d '\n' | xclip -selection clipboardIn your GitHub repository settings, go to Secrets and Variables → Actions and create three secrets:
KEYSTORE_BASE64: the base64-encoded keystoreKEYSTORE_PASSWORD: the keystore passwordKEY_ALIAS: the key alias (e.g.,release)KEY_PASSWORD: the key password
Update your workflow to decode and use the keystore:
- name: Decode signing key
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > release.keystore
- name: Build signed APK
run: |
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file=release.keystore \
-Pandroid.injected.signing.store.password="${{ secrets.KEYSTORE_PASSWORD }}" \
-Pandroid.injected.signing.key.alias="${{ secrets.KEY_ALIAS }}" \
-Pandroid.injected.signing.key.password="${{ secrets.KEY_PASSWORD }}"Never log the secrets. GitHub masks them automatically in the workflow output, but be cautious with any echo statements that might expose them.
Distribute to Testers with Firebase App Distribution
Before pushing to Play Store, you need a staging gate. Firebase App Distribution lets you upload APKs and invite testers via email. They receive a link, install the app on their device, and report issues before production users see them.
Install the Firebase CLI and authenticate:
npm install -g firebase-tools
firebase loginAdd this step to your workflow after building the signed APK:
- name: Upload to Firebase App Distribution
run: |
firebase appdistribution:distribute app/build/outputs/apk/release/*.apk \
--app="${{ secrets.FIREBASE_APP_ID }}" \
--release-notes="Testing build from commit ${{ github.sha }}" \
--testers-file=testers.txt
env:
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}Store your Firebase App ID and token in GitHub secrets. Create a testers.txt file (or generate it dynamically) with email addresses:
qa-team@company.com
senior-qa@company.com
product-manager@company.comTesters will receive an email invitation within minutes of the APK upload. You can iterate rapidly without the overhead of managing Play Store tracks or internal testing groups.
Publish to Google Play Store
When you're ready to release to production, automate the final step: uploading to Google Play. Two main tools exist: Gradle Play Publisher and Fastlane. Both work well; Gradle Play Publisher integrates more tightly with your Gradle build.
Set up Gradle Play Publisher in your app-level build.gradle.kts:
plugins {
id("com.android.application")
id("com.github.triplet.play") version "3.8.0"
}
play {
serviceAccountCredentials.set(file("play-console-key.json"))
defaultToAppBundles.set(true)
}Download a service account key from Google Play Console (Settings → API access → Create service account). Store it securely and reference it in CI:
- name: Decode Play Console key
run: |
echo "${{ secrets.PLAY_CONSOLE_KEY_BASE64 }}" | base64 -d > play-console-key.json
- name: Build and publish to Play Store
run: ./gradlew bundleRelease publishReleaseBundle \
--artifact-type=bundle \
-Dorg.gradle.jvmargs=-Xmx2048mBy default, this publishes to the internal track, which only visible to internal testers. Move to alpha, beta, or production once you've validated:
play {
track.set("beta") // or "production" for general release
releaseStatus.set(ReleaseStatus.COMPLETED)
}If you prefer Fastlane, the setup is similar but offers more flexibility for complex release workflows:
- name: Upload to Play Store with Fastlane
run: |
gem install fastlane
fastlane supply --aab app/build/outputs/bundle/release/app-release.aab \
--track beta \
--json_key play-console-key.jsonCommon Mistakes
Committing the keystore file. Even in a private repository, version control is not a secure storage mechanism. Always use base64 encoding and GitHub secrets.
Hardcoding build variants and flavors. If your app has different product flavors (dev, staging, production), parameterize them in your workflow. Use environment variables or workflow inputs to avoid multiple nearly-identical workflow files.
Skipping versioning automation. Manually incrementing versionCode and versionName leads to conflicts and missed releases. Use a versioning strategy: increment versionCode with every build, and tie versionName to git tags.
No rollback strategy. Publishing directly to production without a staged rollout is risky. Always start with a small percentage in the Play Console's staged rollout feature, then increase to 100% once metrics look healthy.
Forgetting to test the CI workflow locally. Use act to simulate GitHub Actions locally before pushing:
act -j test # Runs the "test" job locallyKey Takeaways
- Automate unit tests and lint on every push and pull request using GitHub Actions.
- Store signing keys in secrets, never in version control; decode them at build time.
- Use Firebase App Distribution as a staging gate before Play Store releases.
- Publish to Google Play via Gradle Play Publisher or Fastlane, starting with internal or beta tracks.
- Validate your CI workflow locally with
actbefore committing. - Implement staged rollouts in Play Console to catch issues early in production.
- Automate versioning to avoid manual conflicts and missed releases.






Comments
Loading comments…