Tutorial: Set up an Android CI pipeline with GitHub Actions, secure the keystore and publish to internal testing — step by step
Apps mobile · By Kevin Marcondes · Published on · Updated on · 9 min read
This is the tutorial to set up an Android CI pipeline with GitHub Actions, secure the keystore, and publish an internal rollout. You will prepare secrets, adjust Gradle, write the workflow YAML and validate publishing. It's a straight, practical guide to start from zero, sign your app securely and automate sending to the internal testing track.
Prerequisites
You need a few items prepared before automating the build and publishing. These ensure the pipeline runs without interruptions and remains secure.
If your project doesn't yet publish manually, review the local signing and versioning flow before automating. To learn the manual publishing steps, see the guide how to publish an Android app to Google Play.
- Android project with the Android Gradle plugin working locally.
- Google Play Console account with the app already created (packageName set).
- Service account in the Play Console with the JSON key downloaded and publisher permission.
- App signing keystore (JKS or PKCS12) and associated passwords.
- GitHub repository with GitHub Actions enabled.
- Java 17 for the build (recent AGP versions require it).
CI/CD pipeline overview
We'll build a pipeline that signs the app, generates the bundle (.aab) and publishes to the internal track. You'll have a single workflow with chained steps. Note: this can publish a build to testers, so use protected branches.
If you want to see a generic deploy pipeline with Actions, also check this automatic deploy pipeline with GitHub Actions and complement your reading.
- Checkout code and Gradle cache.
- Install Java and the required Android SDK.
- Restore the keystore from secrets.
- Generate the app bundle with Gradle (bundleRelease).
- Automatic publish with Gradle Play Publisher to the internal track.
- Upload the artifact as build evidence.
Step 1 — Configure signing and Play in Gradle
Prepare your app module to read environment variables in signingConfig and enable publishing via the Play plugin. Replace the plugin version with the current one and adjust minify/proguard according to your project.
The service account JSON file will be created in the Actions workspace during the job. We keep a simple path (service-account.json).
// app/build.gradle (Groovy DSL)
plugins {
id 'com.android.application'
id 'com.github.triplet.play' version 'x.y.z' // substitua pela versão atual
}
android {
namespace "com.seu.pacote" // ajuste para o seu package
compileSdk 34 // ajuste para seu projeto
defaultConfig {
applicationId "com.seu.pacote"
minSdk 24
targetSdk 34
versionCode 1 // incremente a cada release
versionName "1.0.0"
}
signingConfigs {
release {
storeFile file(System.getenv("ANDROID_KEYSTORE_PATH") ?: "keystore.jks")
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias System.getenv("ANDROID_KEY_ALIAS")
keyPassword System.getenv("ANDROID_KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
play {
// O arquivo será escrito pelo workflow na raiz do repo
serviceAccountCredentials.set(file("service-account.json"))
track.set("internal")
defaultToAppBundles.set(true) // publica .aab
// releaseStatus.set("completed") // opcional: completed/draft
}
Step 2 — Create a service account in the Play Console
Create a service account linked to the app in the Play Console and generate the JSON key. Grant only the permissions required to publish to the internal track.
Store the JSON carefully. If it leaks, revoke it and create a new key immediately.
Note the app's exact packageName. The plugin will use it to locate the application.
- Go to API access in the Play Console and link to Google Cloud.
- Create a service account and generate the JSON key.
- Assign publishing permissions to the target app.
- Download the JSON file and keep it in a secure place.
Step 3 — Add GitHub secrets securely
We'll store everything as secrets. For the keystore, use base64 to avoid binary issues. In the workflow we'll decode it back to a .jks file.
In the repository, go to Settings > Secrets and variables > Actions and create the following secrets.
| Secret name | Content | Tip |
|---|---|---|
| ANDROID_KEYSTORE_BASE64 | Keystore (.jks or .p12) in base64 | Use your OS's base64 tool; avoid copying binary files directly. |
| ANDROID_KEYSTORE_PASSWORD | Keystore password | Do not reuse passwords across environments. |
| ANDROID_KEY_ALIAS | Signing key alias | Confirm the alias with keytool if unsure. |
| ANDROID_KEY_PASSWORD | Alias password | May be the same as the keystore password, but doesn't have to be. |
| PLAY_SERVICE_ACCOUNT_JSON | The service account JSON content | Paste the full JSON; the workflow will create the file. |
| PLAY_PACKAGE_NAME | The app package name (e.g.: com.your.package) | Keep the explicit value for validations. |
Step 4 — GitHub Actions workflow YAML
Below is a working workflow. It installs Java 17, prepares the Android SDK, restores the keystore, builds the .aab and publishes to the internal track. This performs a publish, so restrict the trigger according to your policy (for example: tags only).
name: android-ci-internal
on:
push:
branches: [ main ]
# Para maior segurança, você pode publicar só em tags:
# tags: [ 'v*' ]
jobs:
build-and-publish:
runs-on: ubuntu-latest
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
PLAY_PACKAGE_NAME: ${{ secrets.PLAY_PACKAGE_NAME }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: |
platform-tools
platforms;android-34
build-tools;34.0.0
- name: Gradle cache
uses: gradle/gradle-build-action@v3
- name: Restaurar keystore e credenciais do Play
run: |
echo "Decodificando keystore"
echo "$ANDROID_KEYSTORE_PASSWORD" > /dev/null
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > keystore.jks
echo "Gravando service-account.json"
echo "$PLAY_SERVICE_ACCOUNT_JSON" > service-account.json
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
- name: Build AAB (release)
run: |
export ANDROID_KEYSTORE_PATH=$GITHUB_WORKSPACE/keystore.jks
./gradlew --no-daemon clean bundleRelease
- name: Publicar na track internal
run: |
export ANDROID_KEYSTORE_PATH=$GITHUB_WORKSPACE/keystore.jks
./gradlew --no-daemon publishRelease
- name: Upload do artefato (.aab)
uses: actions/upload-artifact@v4
with:
name: app-release-aab
path: app/build/outputs/bundle/release/*.aab
Step 5 — Publish to internal rollout and best practices
The internal track releases a build quickly to testers without public exposure. Create groups and email lists in the Play Console to receive internal builds.
You can keep the release status as draft for manual review and then promote to completed. That gives control and reduces risk.
If your project has other automated deploys, also see CI/CD patterns in the CI/CD guide for PHP/Laravel to adapt ideas to Android.
- Define internal test groups before the first publication.
- Start with draft status and automate promotion as your confidence grows.
- Avoid publishing from forks' PRs; limit the workflow to protected branches.
- Keep the keystore out of the repo; use only secrets.
- Adjust targetSdk and build-tools in the SDK setup to match your project.
How to confirm it worked and common troubleshooting
Validate the publish and investigate failures quickly. Below is a verification checklist and common errors with likely causes.
To standardize runner infrastructure at scale, see practices for auto-scaling self-hosted runners on GitHub Actions and adapt them to your context.
- In GitHub Actions, check the job "Publicar na track internal" for errors.
- Confirm the .aab artifact in the upload step.
- In the Play Console, verify the internal testing version has the correct versionCode.
- Test installation via the internal test link sent to groups.
- Keystore errors: wrong alias or corrupted file (incorrect base64).
- Insufficient service account permissions: adjust the role in the Play Console.
- versionCode conflict: increment versionCode in Gradle.
- Missing SDK: align platforms/build-tools in the setup with the project's compileSdk.
Frequently asked questions
Do I need self-hosted runners for Android builds?
No. ubuntu-latest covers most cases when you install the Android SDK in the job. Self-hosted runners make sense only if you need faster builds, heavy caching, or access to internal network resources.
How can I generate the keystore base64 safely?
Use your system's native tool. On Linux and macOS: base64 < your-keystore.jks > keystore.b64. On Windows PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes('your-keystore.jks')). Keep the .b64 file out of the repository.
Is Gradle Play Publisher required to publish?
No. You can use other tools like Fastlane or call the API directly. The plugin simplifies the Gradle flow by integrating assemble and publish into a single pipeline. The secrets and keystore handling remains similar.
Can I publish only on tags to avoid accidental releases?
Yes. Adjust the workflow trigger to on: push: tags: ['v*']. Another option is to require code review and protect the main branch to prevent unintended publishes.
How long until testers see an internal release?
Usually it's fast—anywhere from a few minutes up to a short delay depending on automated checks. Ensure testers are correctly added to groups and the app meets Play policy and minimum requirements.
How do I roll back a version published by mistake?
On the internal track you can quickly publish a new corrected version with a higher versionCode. To prevent further accidental publishes, set the status to draft in Gradle or restrict the workflow trigger.
Want to adapt this pipeline to your Android project?
If you need to discuss fine-tuning (module matrix, multiple flavors, different tracks), contact me and we'll build a safe plan.
Talk about Android CISee also
- Tutorial: Implement Canary releases with Traefik in Docker Compose using headers and weighted routing for gradual PHP deployments
- Tutorial: Configure HAProxy with Let's Encrypt and health checks to load balance traffic between Docker containers on a VPS (step-by-step)
- Hands‑On Tutorial: Implement mTLS Between Nginx and Microservices with smallstep CA in Docker Compose
- Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian