Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

CI/CD Pipelines

Module Overview

Estimated Time: 5 hours | Difficulty: Advanced | Prerequisites: Testing, Deployment modules
Continuous Integration and Continuous Deployment (CI/CD) are essential for maintaining code quality and delivering updates efficiently. For React Native specifically, CI/CD is more complex than typical web projects because you are building for two platforms (iOS and Android), each with its own build system, code signing requirements, and store submission process. The payoff is enormous: a well-configured pipeline means every pull request is automatically linted, type-checked, and tested; builds are reproducible across team members; and releasing an update goes from a 2-hour manual process to a single button click. This module covers setting up automated pipelines using GitHub Actions and EAS Build. What You’ll Learn:
  • GitHub Actions workflows
  • EAS Build integration
  • Automated testing pipelines
  • Code signing automation
  • Release management
  • Multi-environment deployments

CI/CD Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                    React Native CI/CD Pipeline                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   Code Push          Build & Test           Deploy                          │
│   ─────────          ───────────           ──────                           │
│                                                                              │
│   ┌─────────┐       ┌─────────────┐       ┌─────────────┐                  │
│   │  Push   │──────▶│   Lint      │──────▶│  TestFlight │                  │
│   │  to PR  │       │   TypeCheck │       │  (iOS)      │                  │
│   └─────────┘       └─────────────┘       └─────────────┘                  │
│        │                   │                     │                          │
│        │            ┌─────────────┐              │                          │
│        │            │  Unit Tests │              │                          │
│        │            │  E2E Tests  │              │                          │
│        │            └─────────────┘              │                          │
│        │                   │                     │                          │
│        │            ┌─────────────┐       ┌─────────────┐                  │
│        │            │  iOS Build  │──────▶│  App Store  │                  │
│        │            │  Android    │       │  Play Store │                  │
│        │            └─────────────┘       └─────────────┘                  │
│        │                   │                     │                          │
│   ┌─────────┐       ┌─────────────┐       ┌─────────────┐                  │
│   │  Merge  │──────▶│  Release    │──────▶│  OTA Update │                  │
│   │  to Main│       │  Build      │       │  (EAS)      │                  │
│   └─────────┘       └─────────────┘       └─────────────┘                  │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

GitHub Actions Setup

GitHub Actions is the most popular CI/CD platform for React Native projects because it offers macOS runners (required for iOS builds), generous free-tier minutes, and tight integration with your repository. The workflow files live in .github/workflows/ and run automatically on push, pull request, or release events.

Basic Workflow Structure

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

env:
  NODE_VERSION: '18'
  EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}

jobs:
  lint-and-typecheck:
    name: Lint & TypeCheck
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: Run TypeScript check
        run: npm run typecheck

  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test -- --coverage --ci

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/lcov.info
          fail_ci_if_error: true

Complete CI/CD Workflow

# .github/workflows/ci-cd.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
  release:
    types: [published]

env:
  NODE_VERSION: '18'
  JAVA_VERSION: '17'
  RUBY_VERSION: '3.2'

jobs:
  # ============================================
  # Quality Checks
  # ============================================
  quality:
    name: Quality Checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: TypeCheck
        run: npm run typecheck

      - name: Format Check
        run: npm run format:check

  # ============================================
  # Unit Tests
  # ============================================
  unit-tests:
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: quality
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test -- --coverage --ci --maxWorkers=2

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  # ============================================
  # E2E Tests (Detox)
  # ============================================
  e2e-ios:
    name: E2E Tests (iOS)
    runs-on: macos-latest
    needs: unit-tests
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Detox CLI
        run: npm install -g detox-cli

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

      - name: Build for Detox
        run: detox build --configuration ios.sim.release

      - name: Run Detox tests
        run: detox test --configuration ios.sim.release --cleanup

      - name: Upload test artifacts
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: detox-artifacts-ios
          path: artifacts/

  e2e-android:
    name: E2E Tests (Android)
    runs-on: ubuntu-latest
    needs: unit-tests
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: ${{ env.JAVA_VERSION }}

      - name: Install dependencies
        run: npm ci

      - name: Install Detox CLI
        run: npm install -g detox-cli

      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: AVD cache
        uses: actions/cache@v3
        id: avd-cache
        with:
          path: |
            ~/.android/avd/*
            ~/.android/adb*
          key: avd-api-31

      - name: Create AVD and generate snapshot
        if: steps.avd-cache.outputs.cache-hit != 'true'
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 31
          force-avd-creation: false
          emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
          disable-animations: true
          script: echo "Generated AVD snapshot"

      - name: Build for Detox
        run: detox build --configuration android.emu.release

      - name: Run Detox tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 31
          force-avd-creation: false
          emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
          disable-animations: true
          script: detox test --configuration android.emu.release --cleanup

  # ============================================
  # Build iOS
  # ============================================
  build-ios:
    name: Build iOS
    runs-on: macos-latest
    needs: [unit-tests]
    if: github.event_name == 'release' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: ${{ env.RUBY_VERSION }}
          bundler-cache: true

      - name: Install dependencies
        run: npm ci

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

      - name: Setup certificates
        env:
          CERTIFICATES_P12: ${{ secrets.CERTIFICATES_P12 }}
          CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }}
          PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          # Create keychain
          security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
          security default-keychain -s build.keychain
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
          security set-keychain-settings -t 3600 -u build.keychain

          # Import certificate
          echo "$CERTIFICATES_P12" | base64 --decode > certificate.p12
          security import certificate.p12 -k build.keychain -P "$CERTIFICATES_PASSWORD" -T /usr/bin/codesign
          security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain

          # Install provisioning profile
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          echo "$PROVISIONING_PROFILE" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/profile.mobileprovision

      - name: Build iOS app
        run: |
          cd ios
          xcodebuild -workspace MyApp.xcworkspace \
            -scheme MyApp \
            -configuration Release \
            -archivePath $PWD/build/MyApp.xcarchive \
            archive

      - name: Export IPA
        run: |
          cd ios
          xcodebuild -exportArchive \
            -archivePath $PWD/build/MyApp.xcarchive \
            -exportOptionsPlist ExportOptions.plist \
            -exportPath $PWD/build

      - name: Upload IPA artifact
        uses: actions/upload-artifact@v3
        with:
          name: ios-build
          path: ios/build/*.ipa

  # ============================================
  # Build Android
  # ============================================
  build-android:
    name: Build Android
    runs-on: ubuntu-latest
    needs: [unit-tests]
    if: github.event_name == 'release' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: ${{ env.JAVA_VERSION }}

      - name: Setup Android SDK
        uses: android-actions/setup-android@v2

      - name: Install dependencies
        run: npm ci

      - name: Setup signing
        env:
          ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: |
          echo "$ANDROID_KEYSTORE" | base64 --decode > android/app/release.keystore
          echo "MYAPP_UPLOAD_STORE_FILE=release.keystore" >> android/gradle.properties
          echo "MYAPP_UPLOAD_KEY_ALIAS=$KEY_ALIAS" >> android/gradle.properties
          echo "MYAPP_UPLOAD_STORE_PASSWORD=$KEYSTORE_PASSWORD" >> android/gradle.properties
          echo "MYAPP_UPLOAD_KEY_PASSWORD=$KEY_PASSWORD" >> android/gradle.properties

      - name: Build Android AAB
        run: |
          cd android
          ./gradlew bundleRelease

      - name: Build Android APK
        run: |
          cd android
          ./gradlew assembleRelease

      - name: Upload AAB artifact
        uses: actions/upload-artifact@v3
        with:
          name: android-build-aab
          path: android/app/build/outputs/bundle/release/*.aab

      - name: Upload APK artifact
        uses: actions/upload-artifact@v3
        with:
          name: android-build-apk
          path: android/app/build/outputs/apk/release/*.apk

  # ============================================
  # Deploy to TestFlight
  # ============================================
  deploy-testflight:
    name: Deploy to TestFlight
    runs-on: macos-latest
    needs: [build-ios, e2e-ios]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Download iOS build
        uses: actions/download-artifact@v3
        with:
          name: ios-build
          path: build/

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: ${{ env.RUBY_VERSION }}
          bundler-cache: true

      - name: Upload to TestFlight
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
        run: |
          bundle exec fastlane pilot upload \
            --ipa build/*.ipa \
            --skip_waiting_for_build_processing

  # ============================================
  # Deploy to Play Store
  # ============================================
  deploy-playstore:
    name: Deploy to Play Store
    runs-on: ubuntu-latest
    needs: [build-android, e2e-android]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Download Android build
        uses: actions/download-artifact@v3
        with:
          name: android-build-aab
          path: build/

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: ${{ env.RUBY_VERSION }}
          bundler-cache: true

      - name: Deploy to Play Store
        env:
          GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
        run: |
          echo "$GOOGLE_PLAY_JSON_KEY" > google-play-key.json
          bundle exec fastlane supply \
            --aab build/*.aab \
            --track internal \
            --json_key google-play-key.json

  # ============================================
  # Production Release
  # ============================================
  production-release:
    name: Production Release
    runs-on: ubuntu-latest
    needs: [deploy-testflight, deploy-playstore]
    if: github.event_name == 'release'
    steps:
      - uses: actions/checkout@v4

      - name: Promote iOS to Production
        run: |
          # Use fastlane to promote from TestFlight to App Store
          bundle exec fastlane deliver --submit_for_review

      - name: Promote Android to Production
        run: |
          # Use fastlane to promote from internal to production
          bundle exec fastlane supply --track production --track_promote_to production

EAS Build Integration

EAS (Expo Application Services) Build is Expo’s cloud build service. The key advantage: it handles native build complexity (Xcode, Gradle, code signing, provisioning profiles) on remote servers, so your team does not need macOS machines or Android SDK installations to produce production builds. Think of it as “Vercel for mobile apps.” The trade-off is cost (cloud build minutes are not free at scale) and less control over the native build environment. For most teams, especially those using Expo, EAS Build dramatically reduces the operational burden of mobile CI/CD.

EAS Configuration

// eas.json
{
  "cli": {
    "version": ">= 5.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": {
        "simulator": true
      },
      "env": {
        "APP_ENV": "development"
      }
    },
    "preview": {
      "distribution": "internal",
      "ios": {
        "simulator": false
      },
      "android": {
        "buildType": "apk"
      },
      "env": {
        "APP_ENV": "staging"
      }
    },
    "production": {
      "distribution": "store",
      "ios": {
        "resourceClass": "m-medium"
      },
      "android": {
        "buildType": "app-bundle"
      },
      "env": {
        "APP_ENV": "production"
      }
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "your@email.com",
        "ascAppId": "1234567890",
        "appleTeamId": "XXXXXXXXXX"
      },
      "android": {
        "serviceAccountKeyPath": "./google-services.json",
        "track": "internal"
      }
    }
  }
}

EAS Build Workflow

# .github/workflows/eas-build.yml
name: EAS Build

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      platform:
        description: 'Platform to build'
        required: true
        default: 'all'
        type: choice
        options:
          - all
          - ios
          - android
      profile:
        description: 'Build profile'
        required: true
        default: 'preview'
        type: choice
        options:
          - development
          - preview
          - production

jobs:
  build:
    name: EAS Build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Setup Expo
        uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test -- --ci

      - name: Build iOS
        if: github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios'
        run: eas build --platform ios --profile ${{ github.event.inputs.profile || 'preview' }} --non-interactive

      - name: Build Android
        if: github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android'
        run: eas build --platform android --profile ${{ github.event.inputs.profile || 'preview' }} --non-interactive

  submit:
    name: Submit to Stores
    runs-on: ubuntu-latest
    needs: build
    if: github.event.inputs.profile == 'production'
    steps:
      - uses: actions/checkout@v4

      - name: Setup Expo
        uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      - name: Submit iOS
        run: eas submit --platform ios --latest --non-interactive

      - name: Submit Android
        run: eas submit --platform android --latest --non-interactive

Fastlane Integration

Fastlane is a Ruby-based automation tool that wraps the painful parts of iOS and Android deployment: code signing, screenshot generation, metadata management, and store submission. Even if you use EAS Build for the build step, Fastlane is often still useful for the submission step, especially for teams with complex App Store Connect or Google Play Console workflows.

Fastfile Configuration

# ios/fastlane/Fastfile
default_platform(:ios)

platform :ios do
  desc "Build and upload to TestFlight"
  lane :beta do
    setup_ci if ENV['CI']
    
    # Increment build number
    increment_build_number(
      build_number: ENV['GITHUB_RUN_NUMBER'] || latest_testflight_build_number + 1
    )
    
    # Build the app
    build_app(
      workspace: "MyApp.xcworkspace",
      scheme: "MyApp",
      configuration: "Release",
      export_method: "app-store"
    )
    
    # Upload to TestFlight
    upload_to_testflight(
      skip_waiting_for_build_processing: true
    )
    
    # Notify Slack
    slack(
      message: "iOS beta build uploaded to TestFlight!",
      success: true
    )
  end

  desc "Deploy to App Store"
  lane :release do
    setup_ci if ENV['CI']
    
    # Download latest build from TestFlight
    download_dsyms
    
    # Upload to App Store
    deliver(
      submit_for_review: true,
      automatic_release: false,
      force: true,
      precheck_include_in_app_purchases: false
    )
    
    # Upload dSYMs to crash reporting
    upload_symbols_to_crashlytics
  end

  desc "Match certificates"
  lane :certificates do
    match(
      type: "appstore",
      readonly: is_ci
    )
  end
end
# android/fastlane/Fastfile
default_platform(:android)

platform :android do
  desc "Build and upload to Play Store internal track"
  lane :beta do
    # Increment version code
    increment_version_code(
      gradle_file_path: "app/build.gradle",
      version_code: ENV['GITHUB_RUN_NUMBER'].to_i
    )
    
    # Build the app
    gradle(
      task: "bundle",
      build_type: "Release"
    )
    
    # Upload to Play Store
    upload_to_play_store(
      track: "internal",
      aab: "app/build/outputs/bundle/release/app-release.aab",
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
    
    # Notify Slack
    slack(
      message: "Android beta build uploaded to Play Store!",
      success: true
    )
  end

  desc "Promote to production"
  lane :release do
    upload_to_play_store(
      track: "internal",
      track_promote_to: "production",
      skip_upload_aab: true,
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end
end

Environment Management

Production apps need at least three environments: development (local testing), staging (QA testing against a staging API), and production (real users). Each environment uses different API URLs, analytics keys, feature flags, and possibly different app icons and names (so testers can have both the staging and production builds installed simultaneously). The challenge in React Native is that environment variables need to flow into both the JavaScript bundle and the native build configuration. The workflow below shows how to create environment-specific .env files and pass them through GitHub Actions environment contexts.

Environment-Specific Builds

# .github/workflows/environment-builds.yml
name: Environment Builds

on:
  push:
    branches:
      - develop    # Triggers staging build
      - main       # Triggers production build
  pull_request:
    branches: [develop, main]

jobs:
  determine-environment:
    runs-on: ubuntu-latest
    outputs:
      environment: ${{ steps.set-env.outputs.environment }}
    steps:
      - id: set-env
        run: |
          if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
            echo "environment=production" >> $GITHUB_OUTPUT
          elif [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then
            echo "environment=staging" >> $GITHUB_OUTPUT
          else
            echo "environment=development" >> $GITHUB_OUTPUT
          fi

  build:
    needs: determine-environment
    runs-on: ubuntu-latest
    environment: ${{ needs.determine-environment.outputs.environment }}
    steps:
      - uses: actions/checkout@v4

      - name: Create .env file
        run: |
          cat << EOF > .env
          API_URL=${{ vars.API_URL }}
          SENTRY_DSN=${{ secrets.SENTRY_DSN }}
          ANALYTICS_KEY=${{ secrets.ANALYTICS_KEY }}
          APP_ENV=${{ needs.determine-environment.outputs.environment }}
          EOF

      - name: Build app
        run: |
          npm ci
          npm run build:${{ needs.determine-environment.outputs.environment }}

Secrets Management

# Required GitHub Secrets:
# 
# iOS:
# - CERTIFICATES_P12: Base64 encoded .p12 file
# - CERTIFICATES_PASSWORD: Password for .p12
# - PROVISIONING_PROFILE: Base64 encoded provisioning profile
# - APP_STORE_CONNECT_API_KEY_ID: App Store Connect API Key ID
# - APP_STORE_CONNECT_API_ISSUER_ID: App Store Connect Issuer ID
# - APP_STORE_CONNECT_API_KEY: Base64 encoded .p8 key
#
# Android:
# - ANDROID_KEYSTORE: Base64 encoded keystore
# - KEYSTORE_PASSWORD: Keystore password
# - KEY_ALIAS: Key alias
# - KEY_PASSWORD: Key password
# - GOOGLE_PLAY_JSON_KEY: Service account JSON
#
# Common:
# - EXPO_TOKEN: Expo access token
# - SENTRY_DSN: Sentry DSN
# - CODECOV_TOKEN: Codecov token

Automated Version Management

# .github/workflows/version-bump.yml
name: Version Bump

on:
  workflow_dispatch:
    inputs:
      bump_type:
        description: 'Version bump type'
        required: true
        type: choice
        options:
          - patch
          - minor
          - major

jobs:
  bump-version:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'

      - name: Configure Git
        run: |
          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"

      - name: Bump version
        run: |
          npm version ${{ github.event.inputs.bump_type }} -m "chore: bump version to %s"

      - name: Update native versions
        run: |
          VERSION=$(node -p "require('./package.json').version")
          
          # Update iOS
          /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" ios/MyApp/Info.plist
          
          # Update Android
          sed -i "s/versionName \".*\"/versionName \"$VERSION\"/" android/app/build.gradle

      - name: Commit and push
        run: |
          git add .
          git commit --amend --no-edit
          git push --follow-tags

Monitoring & Notifications

Slack Notifications

# Add to workflow
- name: Notify Slack on Success
  if: success()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "✅ Build Successful",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*Build Successful* :white_check_mark:\n*Repository:* ${{ github.repository }}\n*Branch:* ${{ github.ref_name }}\n*Commit:* ${{ github.sha }}"
            }
          }
        ]
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

- name: Notify Slack on Failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "❌ Build Failed",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*Build Failed* :x:\n*Repository:* ${{ github.repository }}\n*Branch:* ${{ github.ref_name }}\n*Commit:* ${{ github.sha }}\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"
            }
          }
        ]
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

CI/CD Platform Comparison

Choosing a CI/CD platform for React Native involves a constraint that web teams do not face: iOS builds require macOS runners. This single requirement eliminates many cheap or self-hosted options and makes cost planning more important.
PlatformmacOS RunnersFree Tier (Mobile)Build Time (Typical)Best For
GitHub ActionsYes (macos-latest)2,000 min/month (but macOS uses 10x multiplier = 200 effective min)15-25 min per platformTeams already on GitHub; flexible workflow authoring
EAS BuildYes (managed)30 builds/month (free plan)10-20 min per platformExpo projects; teams wanting zero native build config
BitriseYes (dedicated)90 min/month12-20 min per platformMobile-first teams; extensive mobile step library
CircleCIYes (resource class)30,000 credits/month (~60 min macOS)15-25 min per platformComplex workflows; strong caching layer
CodemagicYes (managed)500 min/month10-18 min per platformFlutter and React Native; generous free tier
Azure DevOpsYes (hosted)1,800 min/month (but limited macOS)15-25 min per platformEnterprise teams already on Azure
Decision framework:
  1. Using Expo and want simplicity? EAS Build. It handles code signing, provisioning profiles, and native build toolchains. You trade flexibility for dramatically reduced configuration.
  2. Need full native control? GitHub Actions + Fastlane. You manage everything but can customize every step. Best for bare React Native or heavily customized Expo projects.
  3. Budget-constrained? Codemagic’s free tier is the most generous for mobile. Alternatively, self-host Linux runners for Android (free) and use cloud macOS only for iOS.
  4. Enterprise with compliance requirements? Azure DevOps or self-hosted GitHub runners. You keep build artifacts and secrets within your network.

EAS Build vs. Self-Managed Builds

This is the most consequential CI/CD decision for Expo teams. Here is a detailed comparison:
AspectEAS BuildSelf-Managed (GitHub Actions + Fastlane)
Setup time30 minutes4-8 hours (including code signing)
Code signingManaged automatically (or use local credentials)Manual: create certs, export .p12, store as secrets, script keychain setup
iOS builds without MacYes — builds run on EAS serversNo — you must use macOS runners
Build cachingAutomaticManual: cache node_modules, Pods, Gradle
Cost at scale$99/month (production plan: 1,000 builds)GitHub Actions macOS minutes (0.08/min= 0.08/min = ~100/month for 1,250 min)
Debugging build failuresLimited: logs only, no SSH into build machineFull control: can SSH, add debug steps, inspect artifacts
Custom native codeSupported (config plugins, custom dev client)Full flexibility
OTA updatesIntegrated via eas updateSeparate setup required
Vendor lock-inMedium: EAS-specific config, but can ejectLow: standard tooling
My recommendation: Start with EAS Build. When (and if) you hit its limitations — needing custom native build steps, running builds inside your VPN, or exceeding cost thresholds — migrate to self-managed. Most teams never hit those limits.

Edge Cases and Common Failures

iOS Code Signing in CI

This is the single most common CI/CD failure for React Native teams. Code signing involves four pieces that must all align: the signing certificate (.p12), the provisioning profile, the Apple Team ID, and the bundle identifier. If any one is expired, mismatched, or missing, the build fails with a cryptic Xcode error. Common failure scenarios:
SymptomCauseFix
”No signing certificate found”.p12 not imported into CI keychainVerify base64 encoding: `base64 -i cert.p12pbcopy`, re-store in GitHub secrets
”Provisioning profile does not match”Profile was generated for a different bundle ID or teamRegenerate in Apple Developer Portal matching the exact bundle ID
”Certificate has expired”Signing cert expired (1-year Apple dev certs)Generate new cert, update .p12 in CI secrets, regenerate provisioning profile
Build succeeds but IPA rejected by TestFlightDistribution cert used instead of development, or vice versaUse “Apple Distribution” cert for App Store/TestFlight, “Apple Development” for ad-hoc
Prevention strategy: Use Fastlane Match, which stores certs and profiles in a private Git repo or cloud storage (S3, GCS). When certs rotate, update once in Match, and all CI pipelines pick up the change automatically.

Android Keystore Loss

If you lose your Android upload keystore, you cannot publish updates to your existing Play Store listing. Google introduced Play App Signing to mitigate this (Google holds the actual signing key; you hold an upload key), but many teams set up their app before this existed or opted out. Prevention: Store your keystore file in a secure, versioned location (encrypted S3 bucket, 1Password vault). Never rely solely on a CI/CD secret — if the CI provider has an outage, you need to be able to sign locally.

Build Flakiness from Dependency Caching

Stale caches cause the most insidious CI failures: a build that worked yesterday fails today with a “module not found” error, even though no code changed. This happens when a transitive dependency publishes a new version that is incompatible with your lockfile’s resolution, and the cache serves a mix of old and new packages.
# Defensive caching: include lockfile hash in cache key
- name: Cache node_modules
  uses: actions/cache@v4
  with:
    path: node_modules
    # If package-lock.json changes, cache is busted
    key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    # Do NOT use restore-keys with partial match -- this causes stale deps
    # restore-keys: node-modules-${{ runner.os }}-  # <-- DON'T do this

- name: Cache CocoaPods
  uses: actions/cache@v4
  with:
    path: ios/Pods
    key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}

GitHub Actions macOS Runner Cost Surprise

GitHub charges macOS minutes at a 10x multiplier. A 20-minute iOS build consumes 200 minutes of your 2,000-minute free allocation. Teams building iOS on every PR exhaust their free tier in a week. Mitigation strategies:
  • Run iOS builds only on pushes to main or develop, not on every PR
  • Use paths filters to skip builds when only docs or config changed
  • Use EAS Build for iOS (runs on Expo’s infrastructure, not your GitHub minutes)
  • Cache aggressively to reduce build time (every minute saved is 10x more valuable on macOS)
# Only build iOS when relevant files change
on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'ios/**'
      - 'package.json'
      - 'package-lock.json'
      - '!**.md'        # Skip documentation changes
      - '!.github/**'   # Skip workflow changes (use workflow_dispatch to test)

Best Practices

Cache Dependencies

Cache node_modules, CocoaPods, and Gradle to speed up builds

Parallel Jobs

Run independent jobs in parallel to reduce total pipeline time

Fail Fast

Run quick checks (lint, typecheck) before expensive builds

Secure Secrets

Use environment-specific secrets and rotate regularly

Pipeline Optimization Checklist

OptimizationImpactEffort
Cache node_modules with lockfile hash-2 to -5 min per buildLow
Cache CocoaPods with Podfile.lock hash-3 to -8 min per iOS buildLow
Cache Gradle dependencies-2 to -4 min per Android buildLow
Run lint/typecheck before build jobsFail 60% of bad PRs in under 2 minLow
Parallelize iOS and Android builds-50% wall clock timeMedium
Use npm ci instead of npm install-30s to -2 min (skips resolution)Trivial
Skip builds for docs-only changesEliminates wasted builds entirelyLow
Use matrix builds for multiple Node versionsTests compatibility without serial runsMedium
Archive and restore build artifactsAvoids rebuilding for deploy jobsMedium
Self-host Android runners on LinuxEliminates cloud runner costs for AndroidHigh

Next Steps

Module 41: App Store Deployment

Learn the complete process of deploying to iOS App Store and Google Play Store