🚀 CI/CD for React Native Android: Build, Sign, Version, and Upload to Google Play with GitHub Actions

⏱ 10 min read
#React Native#GitHub Actions#Android#CI/CD#Google Play#Mobile Development#DevOps#Automation

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++ projects
  • ndk: 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.b64 into 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-aab job: checks out code, bumps versionCode, updates versionName, installs tools, decodes keystore, builds .aab, uploads artifact.

  • upload-to-play job: only runs if release == "true", downloads the .aab, then uses r0adkll/upload-google-play to 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