Module Overview
Estimated Time: 5 hours | Difficulty: Advanced | Prerequisites: Testing, Deployment modules
- GitHub Actions workflows
- EAS Build integration
- Automated testing pipelines
- Code signing automation
- Release management
- Multi-environment deployments
CI/CD Architecture
Copy
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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
Basic Workflow Structure
Copy
# .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
Copy
# .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 Configuration
Copy
// 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
Copy
# .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
Fastfile Configuration
Copy
# 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
Copy
# 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
Environment-Specific Builds
Copy
# .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
Copy
# 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
Copy
# .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
Copy
# 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 }}
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
Next Steps
Module 41: App Store Deployment
Learn the complete process of deploying to iOS App Store and Google Play Store