đ CI/CD for React Native Android: Build, Sign, Version, and Upload to Google Play with GitHub Actions
Struggling with manual builds for your React Native Android app? In this step-by-step guide, we cover how to implement CI/CD using GitHub Actions. Learn to automate your Android builds, run tests, sign release APKs, and publish directly to Google Play, saving time and reducing errors.
Shipping a React Native Android app manually feels like doing laundry without a washing machine: repetitive, slow, and you always forget something (probably socks or your versionCode).
So, I decided to automate the whole thing: build â sign â increment version â upload to Google Play. It wasnât smooth sailing. I hit errors, roadblocks, and the occasional âwhy me?â moments. But by the end, I had a pipeline that actually worked.
This article is the story of that pipeline ( Access the complete workflow directly on GitHub Gist) , written step by step in a way thatâs easy to follow.
For every step, Iâll explain:
Description: why this step exists
What weâre gonna do: the plan
Implementation: how to set it
Code walkthrough: explain the snippet
Letâs dive in đ€
1. Triggering the Workflow
Description
We donât want builds to run automatically on every push. Imagine uploading a half-broken commit to Google Play by mistake. Yikes.
What weâre gonna do
Weâll use a manual trigger (workflow_dispatch) so we can:
- Choose the version name (e.g. 1.0.0)
- Decide if we want to release to Play Store
- Pick the track (internal, beta, production)
Implementation
Add inputs in .github/workflows/android-release.yml.
Code walkthrough
on:
workflow_dispatch:
inputs:
versionName:
description: "Version name (e.g. 1.0.0)"
required: true
default: "1.0.0"
release:
description: "Upload to Google Play?"
required: true
default: "false"
type: choice
options: ["true", "false"]
track:
description: "Play Store track"
required: true
default: "internal"
type: choice
options: ["internal", "alpha", "beta", "production"]
-
workflow_dispatch: lets you trigger the action manually. -
inputs: these are the âknobsâ you set when you run the workflow.
Think of it like ordering pizza: size (versionName), delivery or pickup (release), and which sauce you want (track). đ
2. Handling the Keystore
Description
Android wonât let you build release APKs/AABs without signing them. Itâs like trying to board a plane without a passport.
What weâre gonna do
- Convert keystore into base64
- Store it and passwords in GitHub Secrets
- Decode it in the workflow for Gradle to use
Implementation
On your machine:
base64 -i my-release-key.keystore > keystore.b64
Put the contents into ANDROID_KEYSTORE_BASE64 secret, and store other values (ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD).
Code walkthrough
- name: Decode and save keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/release.keystore
- echo ... | base64 --decode: takes the secret string and turns it back into a keystore file.
- android/app/release.keystore: this is the file Gradle will use for signing.
Boom đ„ â your CI runner now magically has the keystore without you emailing it around like itâs a family recipe.
3. Fixing Native Builds (CMake, Ninja, NDK)
Description
React Native loves native dependencies (react-native-gesture-handler, react-native-reanimated etc.). They need CMake/NDK to compile. GitHubâs runner doesnât always have these tools.
What weâre gonna do
Install Ninja, CMake, and NDK explicitly before building.
Implementation
- name: Install CMake, Ninja & NDK
run: |
sudo apt-get update
sudo apt-get install -y ninja-build cmake
sdkmanager "cmake;3.22.1" "ndk;25.2.9519653"
Code walkthrough
ninja-build: a super-fast build system, not a stealth warrior đ„·cmake: generates build files for C++ projectsndk: Native Development Kit, needed for React Native native modules
Without these, your workflow will cry with an error about not finding ninja.
4. Auto-Incrementing versionCode
Description
Google Play enforces that every new release has a higher versionCode. Forgetting to update it is like trying to enter a nightclub with the same stamp from last week â the bouncer (Play Store) wonât let you in.
What weâre gonna do
Make GitHub Actions increment versionCode automatically.
Implementation
- name: Auto-increment versionCode
id: bump
run: |
file="android/app/build.gradle"
current=$(grep versionCode $file | awk '{print $2}')
next=$((current + 1))
sed -i "s/versionCode [0-9]\+/versionCode $next/" $file
echo "versionCode=$next" >> $GITHUB_ENV
echo "versionCode=$next" >> $GITHUB_OUTPUT
Code walkthrough
grep versionCode: finds the current versionCode line in build.gradle.next=$((current + 1)): adds 1.sed -i: replaces the old versionCode with the new one.GITHUB_ENV: makes the new value available for other steps.
Now youâll never forget. The workflow always bumps the build number.
5. Release Naming
Description
Plain versionCodes are boring. v0.1.4 - b347 is way more fun and human-friendly.
What weâre gonna do
Combine versionName and versionCode into a releaseName.
Implementation
releaseName: v${{ github.event.inputs.versionName }} - b${{ steps.bump.outputs.versionCode }}
Code walkthrough
github.event.inputs.versionName: the manual input you gave.steps.bump.outputs.versionCode: the auto-bumped build number.
Together â a nice release name. Itâs like giving your dog both a name and a microchip number. đ¶
6. Creating the Service Account & Configuring Access
This is the trickiest but critical step. The upload-google-play action uses a service account to authenticate with Google Play. Below is the process adapted from the official documentation (the "Configure access via service account" section).
Description
You need to authorize your CI (via service account) to call the Google Play Developer API on behalf of your app.
What weâre gonna do
In Google Cloud: create a project (if not already), enable the Play Developer API, create a service account and JSON key
In Play Console: grant that service account permission via API Access or via Users & Permissions invite
Save the JSON key in GitHub secrets
Implementation
In Google Cloud Console
1. Create or pick a project (if you donât already have one).
2. Enable the Play Developer API (also known as androidpublisher.googleapis.com).
3. Go to Go to IAM & Admin â Service Accounts â & click Create Service Account.
- Give a name and description
- Optionally assign roles (can defer)
4. After creation, manage keys â Add Key â Create new key â JSON
- This downloads a
service_account.jsonâ this is confidential and critical.
In Play Console
There are two possible ways depending on your account UI:
Method A: API Access
- Go to Play Console â Setup â API access
- Link your Google Cloud project (if not already)
- Grant your service account Release Manager permissions
Method B: Invite as User
-
If you donât see API Access in Play Console: go to Users & Permissions â Invite new user
-
Use the service account email you got while creating the Service Account (e.g.
my-sa@myproject.iam.gserviceaccount.com) -
Grant Release Manager (or "Manage releases") rights
-
Once invited, Play Console allows that account to act on your behalf
This Method B is often needed in organization-level Play accounts.
Storing in GitHub
On your local machine:
base64 -i service_account.json > service_account.json.b64
- Add the content of
service_account.json.b64into GitHub Secrets âPLAY_SERVICE_ACCOUNT_JSON. - Add it to GitHub Secrets as
PLAY_SERVICE_ACCOUNT_JSON
In your workflow, you will use it like:
- name: Decode service account key
run: echo "${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}" | base64 --decode > service_account.json
Code walkthrough
In GitHub Actions:
with:
serviceAccountJson: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.mycompany.myapp
releaseFiles: ./release/app-release.aab
track: ${{ github.event.inputs.track }}
status: completed
releaseName: v${{ github.event.inputs.versionName }} - b${{ steps.bump.outputs.versionCode }}
-
serviceAccountJson: the raw JSON key from your secret -
packageName: your appâs ID (like com.example.app) -
releaseFiles: path to your AAB -
track: where to release it (internal, beta, etc.) -
releaseName: the fun human-friendly label
7. Common Errors & Their Fixes
-
Error: Unexpected token 'e' ... not valid JSON
â You stored base64 JSON in secrets. Fix: use raw JSON.
-
Error: The caller does not have permission
â You didnât give your service account access in Play Console. Fix: API Access or Invite as User.
-
Error: Cannot rollout this release
â You reused versionCode. Fix: auto-increment.
8. Final Workflow
Now letâs put it all together.
name: Android Release
on:
workflow_dispatch:
inputs:
versionName:
description: "Version name (e.g. 1.0.0)"
required: true
default: "1.0.0"
release:
description: "Upload to Google Play?"
required: true
default: "false"
type: choice
options: ["true", "false"]
track:
description: "Play Store track"
required: true
default: "internal"
type: choice
options: ["internal", "alpha", "beta", "production"]
jobs:
build-aab:
name: Build Signed AAB
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
# Auto-increment versionCode
- name: Auto-increment versionCode
id: bump
run: |
file="android/app/build.gradle"
current=$(grep versionCode $file | awk '{print $2}')
next=$((current + 1))
sed -i "s/versionCode [0-9]\+/versionCode $next/" $file
echo "versionCode=$next" >> $GITHUB_ENV
echo "versionCode=$next" >> $GITHUB_OUTPUT
# Update versionName
- name: Update versionName
run: |
sed -i "s/versionName \".*\"/versionName \"${{ github.event.inputs.versionName }}\"/" android/app/build.gradle
# Setup Node.js, Java & Android SDK
- uses: actions/setup-node@v4
with:
node-version: 18
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
- uses: android-actions/setup-android@v3
# Install native build tools
- name: Install CMake, Ninja & NDK
run: |
sudo apt-get update
sudo apt-get install -y ninja-build cmake
sdkmanager "cmake;3.22.1" "ndk;25.2.9519653"
# Install JS dependencies
- name: Install dependencies
run: npm install --legacy-peer-deps
# Decode keystore for signing
- name: Decode and save keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/release.keystore
# Make gradlew executable
- name: Make gradlew executable
run: chmod +x android/gradlew
# Clean previous build artifacts
- name: Clean build
working-directory: android
run: ./gradlew clean
# Build the AAB
- name: Bundle Signed Release AAB
working-directory: android
env:
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android/app/release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/25.2.9519653
run: ./gradlew bundleRelease --no-daemon -x lint -x test
# Upload the generated AAB as artifact
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: app-release-aab
path: android/app/build/outputs/bundle/release/app-release.aab
upload-to-play:
name: Upload to Google Play
runs-on: ubuntu-latest
needs: build-aab
if: ${{ github.event.inputs.release == 'true' }}
steps:
- name: Download AAB artifact
uses: actions/download-artifact@v4
with:
name: app-release-aab
path: ./release
- name: Decode service account key
run: echo "${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}" | base64 --decode > service_account.json
- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: service_account.json
packageName: com.example.app # <-- replace with your app's ID
releaseFiles: ./release/app-release.aab
track: ${{ github.event.inputs.track }}
status: completed
releaseName: v${{ github.event.inputs.versionName }} - b${{ steps.bump.outputs.versionCode }}
Workflow walkthrough, line by line
-
The
on: section configures your manual inputs. -
build-aabjob: checks out code, bumps versionCode, updates versionName, installs tools, decodes keystore, builds.aab, uploads artifact. -
upload-to-playjob: only runs ifrelease == "true", downloads the.aab, then usesr0adkll/upload-google-playto upload it to the track you chose, giving it a nice release name.
â Final Thoughts
By now, you have a pipeline that:
-
Lets you trigger releases manually
-
Builds & signs Android App Bundles
-
Auto-increments versionCode so you never get version conflicts
-
Names releases cleanly
(e.g. v0.1.4 - b347) -
Uploads to Google Play Internal / Beta / Production
-
Works even if your Play Console doesnât show API Access (via inviting service account as a user)
-
Handles common errors (JSON parsing, permission, rollout)
Sit back while CI builds, signs, bumps version, and uploads đ
No more manual Gradle commands. No more Play Console drag-and-drop. Just pure automation bliss.
It was painful to set up â but like debugging React Native on a Friday night, once you get through it, you feel unstoppable.
Let's bring your app idea to life
I specialize in mobile and backend development.
Share this article
Related Articles
đ React Native iOS CI/CD with GitHub Actions â Build and Deploy to TestFlight Automatically
A complete step-by-step guide to automate React Native iOS builds using GitHub Actions: handle code signing, provisioning, Xcode 16 compatibility, and seamless TestFlight uploads â no manual Xcode archives needed.
Custom Fonts in React Native WebView: The Complete Fix
Struggling with custom fonts not rendering inside a React Native WebView? Learn why it happens and how to properly inject fonts using platform-specific asset paths and base64 embedding.
Building Offline-First React Native Apps: Complete Implementation Guide
Master offline-first architecture in React Native. Learn AsyncStorage, Realm, SQLite, sync strategies, and conflict resolution for seamless offline UX.