Back to Journal
Mobile/Frontend

How to Build Mobile CI/CD Pipelines Using React

Step-by-step tutorial for building Mobile CI/CD Pipelines with React, from project setup through deployment.

Muneer Puthiya Purayil 21 min read

This tutorial builds a complete mobile CI/CD pipeline for a React Native application, from project setup through automated App Store and Play Store deployment. We use React Native CLI (not Expo) with Fastlane and GitHub Actions.

Project Setup

bash
npx react-native init MobileApp --template react-native-template-typescript cd MobileApp

Install Build Dependencies

bash
1# Fastlane for build automation
2gem install fastlane
3 
4# Initialize Fastlane for both platforms
5cd ios && fastlane init && cd ..
6cd android && fastlane init && cd ..
7 

React Native Build Configuration

Environment-Specific Configuration

typescript
1// src/config/environment.ts
2import Config from "react-native-config";
3 
4interface Environment {
5 apiUrl: string;
6 environment: "development" | "staging" | "production";
7 enableAnalytics: boolean;
8 sentryDsn: string;
9}
10 
11const environment: Environment = {
12 apiUrl: Config.API_URL || "http://localhost:3000",
13 environment: (Config.APP_ENV as Environment["environment"]) || "development",
14 enableAnalytics: Config.ENABLE_ANALYTICS === "true",
15 sentryDsn: Config.SENTRY_DSN || "",
16};
17 
18export default environment;
19 
bash
1# .env.production
2API_URL=https://api.example.com
3APP_ENV=production
4ENABLE_ANALYTICS=true
5SENTRY_DSN=https://[email protected]/123
6 

Native Module Configuration

ruby
1# ios/Podfile
2platform :ios, '15.0'
3 
4target 'MobileApp' do
5 config = use_native_modules!
6 use_react_native!(path: config[:reactNativePath])
7 
8 target 'MobileAppTests' do
9 inherit! :complete
10 end
11end
12 
13post_install do |installer|
14 react_native_post_install(installer)
15 installer.pods_project.targets.each do |target|
16 target.build_configurations.each do |config|
17 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
18 end
19 end
20end
21 

Testing Setup

Jest Configuration

typescript
1// jest.config.ts
2import type { Config } from "jest";
3 
4const config: Config = {
5 preset: "react-native",
6 moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
7 setupFilesAfterSetup: ["<rootDir>/jest.setup.ts"],
8 transformIgnorePatterns: [
9 "node_modules/(?!(react-native|@react-native|react-native-config)/)",
10 ],
11 collectCoverageFrom: [
12 "src/**/*.{ts,tsx}",
13 "!src/**/*.d.ts",
14 "!src/**/*.test.{ts,tsx}",
15 ],
16 coverageThreshold: {
17 global: {
18 branches: 60,
19 functions: 70,
20 lines: 70,
21 statements: 70,
22 },
23 },
24};
25 
26export default config;
27 

Component Test Example

tsx
1// src/components/__tests__/LoginForm.test.tsx
2import React from "react";
3import { render, fireEvent, waitFor } from "@testing-library/react-native";
4import { LoginForm } from "../LoginForm";
5 
6describe("LoginForm", () => {
7 it("submits with valid credentials", async () => {
8 const onSubmit = jest.fn();
9 const { getByPlaceholderText, getByText } = render(
10 <LoginForm onSubmit={onSubmit} />,
11 );
12 
13 fireEvent.changeText(getByPlaceholderText("Email"), "[email protected]");
14 fireEvent.changeText(getByPlaceholderText("Password"), "password123");
15 fireEvent.press(getByText("Login"));
16 
17 await waitFor(() => {
18 expect(onSubmit).toHaveBeenCalledWith({
19 email: "[email protected]",
20 password: "password123",
21 });
22 });
23 });
24 
25 it("shows validation error for empty email", async () => {
26 const { getByText } = render(<LoginForm onSubmit={jest.fn()} />);
27 fireEvent.press(getByText("Login"));
28 
29 await waitFor(() => {
30 expect(getByText("Email is required")).toBeTruthy();
31 });
32 });
33});
34 

Detox E2E Tests

typescript
1// e2e/login.test.ts
2import { device, element, by, expect } from "detox";
3 
4describe("Login Flow", () => {
5 beforeAll(async () => {
6 await device.launchApp();
7 });
8 
9 beforeEach(async () => {
10 await device.reloadReactNative();
11 });
12 
13 it("should login with valid credentials", async () => {
14 await element(by.id("email-input")).typeText("[email protected]");
15 await element(by.id("password-input")).typeText("password123");
16 await element(by.id("login-button")).tap();
17 
18 await expect(element(by.id("home-screen"))).toBeVisible();
19 });
20 
21 it("should show error with invalid credentials", async () => {
22 await element(by.id("email-input")).typeText("[email protected]");
23 await element(by.id("password-input")).typeText("wrong");
24 await element(by.id("login-button")).tap();
25 
26 await expect(element(by.text("Invalid credentials"))).toBeVisible();
27 });
28});
29 

Need a second opinion on your mobile/frontend architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Fastlane Configuration

ruby
1# fastlane/Fastfile
2default_platform(:ios)
3 
4platform :ios do
5 desc "Run tests"
6 lane :test do
7 scan(
8 workspace: "ios/MobileApp.xcworkspace",
9 scheme: "MobileApp",
10 devices: ["iPhone 15 Pro"],
11 clean: true,
12 code_coverage: true,
13 )
14 end
15 
16 desc "Build beta for TestFlight"
17 lane :beta do
18 match(type: "appstore", readonly: true)
19 
20 increment_build_number(
21 xcodeproj: "ios/MobileApp.xcodeproj",
22 build_number: latest_testflight_build_number + 1,
23 )
24 
25 build_app(
26 workspace: "ios/MobileApp.xcworkspace",
27 scheme: "MobileApp",
28 export_method: "app-store",
29 clean: true,
30 )
31 
32 upload_to_testflight(
33 api_key: app_store_connect_api_key,
34 skip_waiting_for_build_processing: true,
35 )
36 end
37 
38 desc "Production release"
39 lane :release do
40 match(type: "appstore", readonly: true)
41 
42 build_app(
43 workspace: "ios/MobileApp.xcworkspace",
44 scheme: "MobileApp-Production",
45 export_method: "app-store",
46 )
47 
48 upload_to_app_store(
49 api_key: app_store_connect_api_key,
50 submit_for_review: false,
51 )
52 end
53end
54 
55platform :android do
56 desc "Run tests"
57 lane :test do
58 gradle(task: "test", project_dir: "android/")
59 end
60 
61 desc "Build beta for Play Store internal"
62 lane :beta do
63 gradle(
64 task: "bundle",
65 build_type: "Release",
66 project_dir: "android/",
67 )
68 
69 upload_to_play_store(
70 track: "internal",
71 aab: "android/app/build/outputs/bundle/release/app-release.aab",
72 json_key: ENV["PLAY_STORE_JSON_KEY"],
73 )
74 end
75end
76 

GitHub Actions Workflow

yaml
1name: React Native CI/CD
2 
3on:
4 pull_request:
5 branches: [main, develop]
6 push:
7 branches: [main]
8 
9jobs:
10 lint-and-test:
11 runs-on: ubuntu-latest
12 steps:
13 - uses: actions/checkout@v4
14 - uses: actions/setup-node@v4
15 with:
16 node-version: 20
17 cache: 'npm'
18 
19 - run: npm ci
20 
21 - name: Lint
22 run: npm run lint
23 
24 - name: Type check
25 run: npx tsc --noEmit
26 
27 - name: Unit tests
28 run: npm test -- --coverage --ci
29 
30 - name: Upload coverage
31 uses: codecov/codecov-action@v4
32 with:
33 token: ${{ secrets.CODECOV_TOKEN }}
34 
35 build-ios:
36 needs: lint-and-test
37 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
38 runs-on: macos-latest
39 steps:
40 - uses: actions/checkout@v4
41 
42 - uses: actions/setup-node@v4
43 with:
44 node-version: 20
45 cache: 'npm'
46 
47 - run: npm ci
48 
49 - uses: ruby/setup-ruby@v1
50 with:
51 ruby-version: '3.2'
52 bundler-cache: true
53 
54 - name: Install CocoaPods
55 run: cd ios && pod install
56 
57 - name: Deploy to TestFlight
58 env:
59 MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
60 MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
61 APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
62 APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
63 APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
64 run: bundle exec fastlane ios beta
65 
66 build-android:
67 needs: lint-and-test
68 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
69 runs-on: ubuntu-latest
70 steps:
71 - uses: actions/checkout@v4
72 
73 - uses: actions/setup-node@v4
74 with:
75 node-version: 20
76 cache: 'npm'
77 
78 - run: npm ci
79 
80 - uses: actions/setup-java@v4
81 with:
82 distribution: 'temurin'
83 java-version: '17'
84 
85 - uses: ruby/setup-ruby@v1
86 with:
87 ruby-version: '3.2'
88 bundler-cache: true
89 
90 - name: Decode keystore
91 run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/release-keystore.jks
92 
93 - name: Deploy to Play Store
94 env:
95 PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }}
96 KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
97 KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
98 KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
99 run: bundle exec fastlane android beta
100 

Sentry Error Reporting Integration

typescript
1// src/config/sentry.ts
2import * as Sentry from "@sentry/react-native";
3import environment from "./environment";
4 
5export function initSentry(): void {
6 if (environment.environment === "development") return;
7 
8 Sentry.init({
9 dsn: environment.sentryDsn,
10 environment: environment.environment,
11 tracesSampleRate: environment.environment === "production" ? 0.2 : 1.0,
12 enableAutoSessionTracking: true,
13 attachStacktrace: true,
14 });
15}
16 
yaml
1# Add to build workflow for source map upload
2- name: Upload source maps to Sentry
3 run: |
4 npx sentry-cli releases new ${{ github.sha }}
5 npx sentry-cli releases set-commits ${{ github.sha }} --auto
6 npx sentry-cli releases files ${{ github.sha }} upload-sourcemaps \
7 --dist $BUILD_NUMBER \
8 --rewrite \
9 ios/build/sourcemaps/
10 npx sentry-cli releases finalize ${{ github.sha }}
11 env:
12 SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
13 SENTRY_ORG: your-org
14 SENTRY_PROJECT: mobile-app
15 

CodePush for OTA Updates

typescript
1// src/config/codepush.ts
2import CodePush from "react-native-code-push";
3 
4const codePushOptions = {
5 checkFrequency: CodePush.CheckFrequency.ON_APP_RESUME,
6 installMode: CodePush.InstallMode.ON_NEXT_RESUME,
7 minimumBackgroundDuration: 60 * 5,
8};
9 
10export function withCodePush<P extends object>(
11 Component: React.ComponentType<P>,
12): React.ComponentType<P> {
13 return CodePush(codePushOptions)(Component);
14}
15 
yaml
1# Add CodePush deployment to workflow
2- name: Deploy OTA update
3 if: contains(github.event.head_commit.message, '[ota]')
4 run: |
5 npx appcenter codepush release-react \
6 -a owner/MobileApp-iOS \
7 -d Production \
8 --description "${{ github.event.head_commit.message }}"
9 npx appcenter codepush release-react \
10 -a owner/MobileApp-Android \
11 -d Production \
12 --description "${{ github.event.head_commit.message }}"
13 env:
14 APPCENTER_ACCESS_TOKEN: ${{ secrets.APPCENTER_TOKEN }}
15 

Conclusion

This pipeline provides fully automated testing and deployment for a React Native application. PRs get lint, type checking, and unit tests. Merges to main trigger TestFlight and Play Store internal deployments automatically. The entire setup takes approximately one day for an experienced developer, with GitHub Actions costs of $100-200/month for typical startup build volumes.

The critical path items to get right first are code signing (Fastlane Match), environment configuration (react-native-config), and the test suite. Everything else — Sentry, CodePush, E2E tests — can be added incrementally after the core pipeline is stable.

FAQ

Need expert help?

Building with mobile/frontend?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026