DEV Community

Tarang patel
Tarang patel

Posted on

A Complete CI CD Pipeline for React Native

 > A production-grade setup covering pre-commit checks, automated builds, and store deployment via GitHub Actions and Fastlane.

Pre-commit Hooks

yarn add -D husky lint-staged prettier
yarn husky init
Enter fullscreen mode Exit fullscreen mode

Before every commit, lint-staged runs three quick jobs on the changed files:

  1. removeConsoleLogs.js removes stray console.log calls.
  2. Prettier formats the code.
  3. ESLint fixes what it can automatically.

package.json

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "node scripts/removeConsoleLogs.js",
      "prettier --write",
      "eslint --fix"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

scripts/removeConsoleLogs.js

const fs = require('fs');

const file = process.argv[2];
const original = fs.readFileSync(file, 'utf8');

const cleaned = original.replace(
  /^\s*console\.(log|debug|info)\([^)]*\);?\s*$/gm,
  ''
);

if (cleaned !== original) {
  fs.writeFileSync(file, cleaned);
}
Enter fullscreen mode Exit fullscreen mode

.husky/pre-commit

npx lint-staged
Enter fullscreen mode Exit fullscreen mode

Fastlane

Fastlane wraps your build commands into lanes, so local and CI runs stay identical.

Set it up per platform (android/fastlane and ios/fastlane):

echo "gem 'fastlane'" >> Gemfile
bundle install

cd android && bundle exec fastlane init && cd ..
cd ios && bundle exec fastlane init && cd ..
Enter fullscreen mode Exit fullscreen mode

iOS Appfile

Appfile keeps your identifiers out of the Fastfile.

Variable Description
DEVELOPER_APP_IDENTIFIER Your app's Bundle ID — in Xcode, select your project and open the General tab.
FASTLANE_APPLE_ID The email used to access your Apple ID.
APP_STORE_CONNECT_TEAM_ID Shown next to your name under Users and Access in App Store Connect.
DEVELOPER_PORTAL_TEAM_ID Your Team ID from the Apple Developer portal under Membership.

ios/fastlane/Appfile

app_identifier(ENV["DEVELOPER_APP_IDENTIFIER"])
apple_id(ENV["FASTLANE_APPLE_ID"])
itc_team_id(ENV["APP_STORE_CONNECT_TEAM_ID"])
team_id(ENV["DEVELOPER_PORTAL_TEAM_ID"])
Enter fullscreen mode Exit fullscreen mode

Android Appfile

For Android, json_key_file is your Play Console service account key.

Play Console setup

  1. Go to Play Console → API access.
  2. Create a service account in Google Cloud Console.
  3. Download its JSON key.
  4. Grant it Release apps to testing tracks back in Play Console.

android/fastlane/Appfile

json_key_file("<path-to-play-console-service-account>.json")
package_name("com.yourcompany.app")
Enter fullscreen mode Exit fullscreen mode

iOS Certificates with match

Certificates with match store your distribution certificate and provisioning profile encrypted in a private Git repository, so CI can pull them instead of relying on your local keychain.

Variable Description
MATCH_PASSWORD Password used to retrieve certificates from GitHub. You choose this yourself; match init will prompt you for it along with your repository URL.
GIT_AUTHORIZATION Also referred to as the Git authorization token. Create one using the appropriate guide, then format it as username:token.
cd ios

bundle exec fastlane match init
# Creates ios/fastlane/Matchfile

bundle exec fastlane match appstore
# Syncs App Store certificate + profile
Enter fullscreen mode Exit fullscreen mode

Check the official documentation for more details.


Android Fastfile

The following environment variables are used for Android signing:

Variable Description
ANDROID_KEYSTORE_FILE Path to your existing release .jks/.keystore file. If you need a new one, run the command below.
ANDROID_KEYSTORE_ALIAS The -alias used when generating the keystore.
ANDROID_KEYSTORE_PASSWORD The password you set when generating the keystore.

To generate a new keystore if needed:

keytool -genkeypair -v \
  -keystore your_keystore.jks \
  -keyalg RSA \
  -keysize 2048 \
  -validity 10000 \
  -alias your_alias
Enter fullscreen mode Exit fullscreen mode

android/fastlane/Fastfile

default_platform(:android)

platform :android do
  desc 'Submit a new build to Play Store'

  lane :deploy_to_play_store do
    # Bump versionCode/versionName in build.gradle
    # so every release is versioned automatically
    increment_version_code(gradle_file_path: "app/build.gradle")
    increment_version_name(
      gradle_file_path: "app/build.gradle",
      bump_type: "patch"
    )

    # Build a signed release .aab using the keystore secrets
    gradle(
      task: 'clean bundleRelease',
      project_dir: './android',
      properties: {
        "android.injected.signing.store.file" => ENV['ANDROID_KEYSTORE_FILE'],
        "android.injected.signing.store.password" => ENV['ANDROID_KEYSTORE_PASSWORD'],
        "android.injected.signing.key.alias" => ENV['ANDROID_KEYSTORE_ALIAS'],
        "android.injected.signing.key.password" => ENV['ANDROID_KEYSTORE_PASSWORD']
      }
    )

    # Upload the .aab to Play Store as a draft on the production track
    upload_to_play_store(
      track: 'production',
      release_status: "draft",
      aab: File.absolute_path(
        '../android/app/build/outputs/bundle/release/app-release.aab'
      )
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

iOS Fastfile

The iOS Fastfile:

  • Sets up a temporary CI keychain.
  • Pulls certificates via match.
  • Builds the app.
  • Ships it to TestFlight.

Environment variables

Variable Description
TEMP_KEYCHAIN_USER Any name you pick — only used to create a throwaway keychain in CI.
TEMP_KEYCHAIN_PASSWORD Any password you pick — only used to create a throwaway keychain in CI.
APPLE_KEY_ID From App Store Connect → Users and Access → Integrations → Keys.
APPLE_ISSUER_ID The Issuer ID shown at the top of the App Store Connect API Keys page.
APPLE_KEY_CONTENT The private key file (.p8) downloaded when creating the API key. Open it in a text editor and copy the full -----BEGIN/END PRIVATE KEY----- content.
DEVELOPER_APP_ID Your app's numeric Apple ID — in App Store Connect under App Information.
PROVISIONING_PROFILE_SPECIFIER Match AppStore <DEVELOPER_APP_IDENTIFIER> — the profile name match creates for you.

ios/fastlane/Fastfile

default_platform(:ios)

def ensure_temp_keychain(name, password)
  delete_keychain(name: name) if File.exist?(
    File.expand_path("~/Library/Keychains/#{name}-db")
  )

  create_keychain(
    name: name,
    password: password,
    unlock: false,
    timeout: 0
  )
end

platform :ios do
  desc "Submit a new build to App Store"

  lane :deploy_to_app_store do
    # Bump the version number in Info.plist
    # so every release is versioned automatically
    increment_version_number(bump_type: "patch")

    # Create a clean, temporary keychain for CI to store certs in
    ensure_temp_keychain(
      ENV["TEMP_KEYCHAIN_USER"],
      ENV["TEMP_KEYCHAIN_PASSWORD"]
    )

    # Authenticate with App Store Connect using an API key
    # (no Apple ID prompt in CI)
    api_key = app_store_connect_api_key(
      key_id: ENV["APPLE_KEY_ID"],
      issuer_id: ENV["APPLE_ISSUER_ID"],
      key_content: ENV["APPLE_KEY_CONTENT"],
      duration: 1200,
      in_house: false
    )

    # Pull the distribution cert + provisioning profile
    # from the match git repo
    match(
      type: 'appstore',
      app_identifier: ENV["DEVELOPER_APP_IDENTIFIER"],
      git_basic_authorization: Base64.strict_encode64(
        ENV["GIT_AUTHORIZATION"]
      ),
      readonly: true,
      keychain_name: ENV["TEMP_KEYCHAIN_USER"],
      keychain_password: ENV["TEMP_KEYCHAIN_PASSWORD"],
      api_key: api_key
    )

    # Build the signed .ipa
    ipa = gym(
      configuration: "Release",
      export_xcargs: "-allowProvisioningUpdates",
      workspace: 'YourApp.xcworkspace',
      scheme: "YourApp Release",
      export_method: "app-store",
      export_options: {
        provisioningProfiles: {
          ENV["DEVELOPER_APP_ID"] => ENV["PROVISIONING_PROFILE_SPECIFIER"]
        }
      }
    )

    # Ship the build to TestFlight for internal testers
    pilot(
      apple_id: ENV["DEVELOPER_APP_ID"],
      app_identifier: ENV["DEVELOPER_APP_IDENTIFIER"],
      distribute_external: false,
      notify_external_testers: true,
      uses_non_exempt_encryption: false,
      ipa: ipa,
      skip_waiting_for_build_processing: true
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

Test Locally

Android

cd android && bundle exec fastlane deploy_to_play_store
Enter fullscreen mode Exit fullscreen mode

iOS

cd ios && bundle exec fastlane deploy_to_app_store
Enter fullscreen mode Exit fullscreen mode

GitHub Actions

One workflow, one file.

Lint and test first; deploy jobs only fire on master, once those checks pass.

Add all the environment variables from the Fastlane setup as secrets under:

Settings → Secrets and variables → Actions

.github/workflows/mobile-pipeline.yml

name: Mobile Build Pipeline

# Runs on every PR targeting master
on:
  pull_request:
    branches: [master]

jobs:
  # Lints and tests the JS/TS code — gatekeeper for both deploy jobs below
  lint-and-test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'yarn'

      - run: yarn install --frozen-lockfile
      - run: yarn lint
      - run: yarn test --watchAll=false

  # Builds and ships the Android app to Play Store
  # — only runs if lint-and-test passes
  deploy-android:
    needs: lint-and-test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'yarn'

      - run: yarn install --frozen-lockfile

      - uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17' # needed to build with Gradle

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.3'
          bundler-cache: true # Fastlane runs on Ruby

      - run: cd android && bundle exec fastlane deploy_to_play_store
        # bumps version, builds, uploads

    env:
      ANDROID_KEYSTORE_FILE: ${{ secrets.ANDROID_KEYSTORE_FILE }}
      ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
      ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }}

  # Builds and ships the iOS app to TestFlight
  # — only runs if lint-and-test passes
  deploy-ios:
    needs: lint-and-test
    runs-on: macos-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'yarn'

      - run: yarn install --frozen-lockfile

      - run: cd ios && pod install
        # installs native iOS dependencies

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.3'
          bundler-cache: true # Fastlane runs on Ruby

      - run: cd ios && bundle exec fastlane deploy_to_app_store
        # bumps version, signs, builds, ships

    env:
      APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
      APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
      APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
      MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
      GIT_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }}
      TEMP_KEYCHAIN_USER: ${{ secrets.TEMP_KEYCHAIN_USER }}
      TEMP_KEYCHAIN_PASSWORD: ${{ secrets.TEMP_KEYCHAIN_PASSWORD }}
Enter fullscreen mode Exit fullscreen mode

Conclusion

With pre-commit hooks, Fastlane, and GitHub Actions wired together, every merge to master is linted, tested, versioned, and shipped — no manual steps, no missed builds.

Top comments (0)