Case Study
6 mins

React Native CI/CD and BrowserStack E2E QA Pipeline

Built two production-focused mobile DevOps pipelines: one for controlled React Native iOS builds, OTA updates, and App Store/TestFlight releases, and another for automated Appium E2E testing on BrowserStack real devices.

Core Role

DevOps Engineer / Mobile CI/CD

Tech Stack

GitLab CI, React Native, Expo EAS, Appium, BrowserStack, TestFlight, App Store Connect, Cloud Run, Bash, Python

Architecture diagram of two React Native pipelines in GitLab CI: a release pipeline that validates code, detects native-sensitive changes, and routes each release to either an Expo EAS native build or an OTA update before TestFlight and App Store Connect; and a QA pipeline that uploads builds to BrowserStack, runs Appium smoke or regression suites on real devices, and stores reports as GitLab artifacts.
media_gallery // interactive_walkthrough

Visual Walkthrough

Selected production views and user interface states from React Native CI/CD and BrowserStack E2E QA Pipeline.

FRAME:01/01
[RENDER_MODE: RAW_IMAGE_SCALE]
React Native CI/CD and BrowserStack E2E QA Pipeline view 1

Two connected GitLab pipelines for a React Native app: one that decides per-change whether a release needs a native build or an OTA update before it reaches TestFlight, and one that validates every build on real devices via Appium on BrowserStack.

2 pipelines
Release + QA, connected: build → decide → test → report
Real devices
Appium E2E on BrowserStack, not just simulators
Native vs OTA
Auto-routed per change; no manual guessing

Shipping a React Native app is not like deploying a web app. On the web, a release is usually "build the latest commit and push it." On React Native, the correct path depends on the change: some changes need a full native binary, some can ship over-the-air, and some should only run validation and not release at all. Guess wrong in either direction and you either burn CI time on needless native builds or ship an OTA update that actually required a new binary.

The other half was QA. Local testing and simulators miss what real hardware, OS versions, and screen sizes expose. The team needed real-device E2E that ran as part of the release, not as a side task someone remembered to do.

Before
After

From guesswork to a gatekeeper

The two pipelines

A release pipeline that validates a change and routes it to the right path, feeding a QA pipeline that runs the resulting build on real devices and files the reports.

1Release

The release pipeline

The release pipeline runs three stages: quality validation, build decision, distribution. Linting, tests, and validation checks run first; if they fail, the pipeline stops there. Once a change passes, a decision step inspects the changed files and answers one question before anything ships: what kind of change is this?

Changes touching package.json, eas.json, app.json, ios/, or android/ are native-sensitive and need a new binary. JavaScript-only changes can ship over-the-air. The routing itself is deliberately blunt:

bash
if native_sensitive_files_changed; then
  trigger_native_build
else
  trigger_ota_update
fi

Native-sensitive changes go to an Expo EAS build; JavaScript-only changes go OTA; feature branches can stay at validation; production follows a controlled path from main. Developers stop hand-deciding the release type, and the pipeline decides it the same way every time.

Rather than cram every path into one .gitlab-ci.yml, the parent pipeline handles validation and the decision, then generates the right child pipeline for the release type. Mobile CI gets messy fast when preview builds, production builds, OTA updates, TestFlight submissions, and QA triggers share one file; the parent-child split keeps each path to only the jobs it needs.

txt
Native config change
→ Run validation
→ Trigger Expo EAS native build
→ Submit to TestFlight / internal testing
→ Trigger E2E QA pipeline
txt
JavaScript-only change
→ Run validation
→ Ship OTA update
→ Complete release flow

Choosing the device farm

Real-device E2E needed a cloud device farm. I compared four before building anything.

Engineering decision
Chose

BrowserStack

Over

Sauce Labs, AWS Device Farm, Firebase Test Lab

Why: Sauce Labs was expensive for this use case; Firebase Test Lab didn't fit the Appium workflow cleanly; AWS Device Farm's zip-and-upload, black-box run model was less developer-friendly; BrowserStack was the most plug-and-play with Appium and GitLab, and was chosen after stakeholder discussion

The deciding factor was operability, not a feature checklist: BrowserStack was the one the team could actually run day-to-day without adding release friction, worked cleanly with Appium, and connected to GitLab CI without a wrapper around packaged test bundles.

2QA

The QA pipeline

The QA pipeline runs Appium suites against the build on BrowserStack real devices. It can be triggered manually from GitLab or automatically once a preview build is ready, which is what makes QA part of the release lifecycle instead of a separate manual step: the release pipeline produces the build, the QA pipeline tests it, GitLab stores the result.

txt
Trigger pipeline
→ Check if build already exists on BrowserStack
→ Upload build only if needed
→ Run Appium tests (smoke or regression)
→ Wait for suite completion
→ Generate HTML and PDF reports
→ Email reports
→ Upload artifacts to GitLab

Build reuse

BrowserStack needs the .apk or .ipa uploaded before Appium can run. Uploading it on every run wastes time when the team is re-running tests against a build they already tested. So the pipeline checks whether the build already exists on BrowserStack using a unique signature: if it does, it reuses the existing app reference; if not, it uploads and stores the returned app URL for the run. A small optimization, but it makes reruns fast and keeps the pipeline from doing redundant work.

Serializing the single BrowserStack session

Android and iOS run as separate matrix jobs, which is useful for giving each platform its own job context. The catch: the BrowserStack plan allowed only one parallel session, so if both jobs started at once, one could fail on the session limit.

Engineering decision
Chose

A shared GitLab resource_group to serialize BrowserStack sessions

Over

Running Android + iOS matrix jobs in parallel

Why: the plan allowed only one parallel BrowserStack session; the resource_group queues jobs so one runs at a time, and concurrency can be raised later without redesign

yaml
resource_group: "browserstack_global_limit"

GitLab queues the jobs and runs one BrowserStack session at a time. If the plan is upgraded later, concurrency can be raised without reworking the pipeline.

Reports and artifacts

When the Appium suite finishes, the pipeline generates an HTML report for quick review and a PDF for sharing or keeping as a QA record. A Python script emails the reports to the team, and the same reports are uploaded to GitLab artifacts so they stay attached to the pipeline run. Every E2E run leaves a clear audit trail: which build, which platform, which suite, what passed or failed, and where the reports live. QA results belong on the delivery system, not in someone's local folder or a Slack thread.

For preview builds, the two pipelines chain end to end:

txt
Code change
→ GitLab validation
→ Build decision
→ Expo EAS preview build
→ BrowserStack E2E pipeline
→ Appium tests
→ Reports and artifacts

Technical foundation

The stack stayed practical: GitLab CI drives both pipelines; Expo EAS handles native builds; Appium runs the E2E suites; BrowserStack provides the real devices and debugging visibility; TestFlight and App Store Connect cover the iOS release path; Cloud Run connects build-lifecycle events to downstream automation; Python and Bash handle the reporting, build checks, and glue.

What I'd watch in production

The honest risk list if this ran at scale:

  • Flaky-test rate on real devices — real hardware surfaces timing and environment flakiness that simulators hide; a rising flake rate erodes trust in the gate.
  • BrowserStack queue time — with sessions serialized through one resource_group, queue depth is the first thing to feel a slowdown as test volume grows.
  • OTA rollout health — OTA updates skip store review, so a bad JS-only ship reaches users fast; rollout health needs its own watch.
  • Native build failure rate — EAS build failures block the native path entirely and are worth tracking as a first-class signal.

Outcome

Two connected pipelines: one decides how a React Native change is built and distributed (native build vs OTA, preview vs production, TestFlight and App Store), and one validates the resulting build on real devices with Appium and files the reports back into GitLab. Together they make mobile delivery a repeatable flow that can build, decide, test, report, and release.