DevOps2025-01-12|7 min read

Automate React Native Builds with GitHub Actions and Fastlane

Manual app releases are a tax on your team's time and a source of human error. Forgetting to bump a version number, uploading the wrong build to TestFlight, or shipping a debug build to production — I've seen all of these happen on teams without automation. A well-configured CI/CD pipeline eliminates the entire category. Here's the setup I use across multiple React Native projects.

Why CI/CD Matters for Mobile

Mobile CI/CD solves three distinct problems: consistency (every build is produced the same way), speed (builds run in parallel without tying up a developer's machine), and auditability (every production build traces back to a specific commit and pull request).

Project Structure

my-app/
├── .github/
│   └── workflows/
│       ├── ios-deploy.yml
│       └── android-deploy.yml
├── fastlane/
│   ├── Fastfile
│   ├── Appfile
│   └── Matchfile
├── ios/
└── android/

Setting Up Fastlane

gem install fastlane
cd ios && fastlane init
cd ../android && fastlane init

Configure fastlane/Appfile:

# fastlane/Appfile
app_identifier("com.mycompany.myapp")
apple_id("dev@mycompany.com")
team_id("XXXXXXXXXX")

iOS Fastlane Lane with Match

Use match to manage certificates and provisioning profiles in a private Git repo. This solves the certificate chaos that plagues iOS teams:

# fastlane/Matchfile
git_url("https://github.com/myorg/certificates")
storage_mode("git")
type("appstore")
app_identifier(["com.mycompany.myapp"])
# fastlane/Fastfile
platform :ios do
  desc "Build and upload to TestFlight"
  lane :beta do
    increment_build_number(xcodeproj: "ios/MyApp.xcodeproj")
    match(type: "appstore", readonly: is_ci)
    build_app(
      workspace: "ios/MyApp.xcworkspace",
      scheme: "MyApp",
      configuration: "Release",
      export_method: "app-store",
      output_directory: "./build",
      output_name: "MyApp.ipa",
    )
    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      changelog: last_git_commit[:message],
    )
  end
end

Android Fastlane Lane

platform :android do
  desc "Build and upload to Google Play internal track"
  lane :beta do
    gradle(
      task: "bundle",
      build_type: "Release",
      project_dir: "android/",
      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"],
      }
    )
    upload_to_play_store(
      track: "internal",
      aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
    )
  end
end

GitHub Actions — iOS Workflow

# .github/workflows/ios-deploy.yml
name: iOS Deploy to TestFlight

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true

      - name: Install CocoaPods
        run: cd ios && pod install

      - name: Run Fastlane
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.ASC_PRIVATE_KEY }}
        run: bundle exec fastlane ios beta

GitHub Actions — Android Workflow

# .github/workflows/android-deploy.yml
name: Android Deploy to Play Store

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true

      - name: Decode keystore
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/release.keystore

      - name: Run Fastlane
        env:
          KEYSTORE_PATH: android/app/release.keystore
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          SUPPLY_JSON_KEY_DATA: ${{ secrets.PLAY_STORE_JSON_KEY }}
        run: bundle exec fastlane android beta

Managing Secrets

Store all sensitive values in GitHub repository secrets (Settings > Secrets and variables > Actions). Never commit keystores, certificates, or API keys to your repository. For the Android keystore, Base64-encode it and store that string as a secret:

# Encode your keystore locally
base64 -i android/app/release.keystore | pbcopy
# Paste into GitHub secret: KEYSTORE_BASE64

Triggering Deployments

The workflow above runs on every push to main. For a more controlled flow, add a version tag trigger:

on:
  push:
    tags:
      - 'v*.*.*'   # triggers on v1.2.3, v2.0.0, etc.

A tag-based trigger means your team controls exactly when a release is sent to TestFlight or the Play Store — no accidental deploys from routine merges to main.