The Starting Point
The project started with a common mobile engineering problem.
Shipping a React Native app is not as simple as deploying a web application.
For a web app, a deployment usually means building the latest commit and pushing it to an environment. For a React Native app, the release path depends on the type of change.
Some changes need a full native build.
Some changes can safely ship through an OTA update.
Some changes should only run validation and should not trigger a release at all.
At the same time, the app needed a reliable QA process. Local testing was not enough because mobile apps behave differently across devices, screen sizes, operating systems, and real hardware conditions.
So the goal became clear:
Build a mobile DevOps workflow that could handle both release automation and real-device testing.
Not just “run a build.”
Not just “run tests.”
But create a predictable release system for React Native.
"A good mobile pipeline does not just deploy code. It decides the safest path for that code to reach users."
The Release Pipeline Problem
The first challenge was release control.
The team needed a pipeline that could answer one question before shipping anything:
“What type of change is this?”
A small JavaScript fix should not trigger a full native build.
A change in package.json, eas.json, app.json, ios/, or android/ should not be shipped casually as an OTA update.
Without automation, mobile teams usually fall into one of two bad patterns.
They either run native builds too often, which wastes CI time and slows releases, or they rely too heavily on OTA updates and risk shipping changes that actually require a new binary.
I wanted the pipeline to act as a gatekeeper.
Before anything reached TestFlight, App Store review, internal testing, or QA, the pipeline should validate the code and choose the correct release path.
Building the GitLab CI/CD Release Flow
I built a GitLab CI/CD pipeline around three stages:
- Quality validation.
- Build decision.
- Distribution.
Every change started with linting, tests, and validation checks. If those checks failed, the pipeline stopped early.
Once the code passed the quality gates, a custom decision step inspected the changed files and determined whether the release needed a full native build or could go through OTA.
The logic was simple:
if native_sensitive_files_changed; then
trigger_native_build
else
trigger_ota_update
fi
That small decision became the core of the release system.
Native-related changes triggered Expo EAS builds.
JavaScript-only changes went through OTA updates.
Feature branch changes could stay limited to validation.
Production releases could follow a controlled path from main.
This made the pipeline predictable. Developers no longer had to manually decide whether a change needed a native build or an OTA update.
The pipeline made that decision consistently.
Parent and Child Pipelines
Instead of putting every release path into one large .gitlab-ci.yml, I used GitLab parent-child pipelines.
The parent pipeline handled validation and decision-making.
Then it generated the correct child pipeline based on the release type.
For example:
Native config change
→ Run validation
→ Trigger Expo EAS native build
→ Submit to TestFlight / internal testing
→ Trigger E2E QA pipeline
JavaScript-only change
→ Run validation
→ Ship OTA update
→ Complete release flow
This kept the pipeline easier to reason about.
The main pipeline stayed clean, and each child pipeline only contained the jobs required for that specific release path.
That mattered because mobile CI/CD can become messy quickly when preview builds, production builds, OTA updates, TestFlight submissions, and QA triggers all live in the same file.
The parent-child structure gave the release system better separation.
The QA Problem
Once the mobile deployment pipeline was in place, the next problem was quality assurance.
Shipping a build is only half of the release process.
The real question is whether that build actually works on real devices.
For a React Native mobile app, local testing is not enough. Different devices, OS versions, screen sizes, and platform-specific behavior can expose issues that do not appear during development.
The team needed a repeatable way to run E2E tests after a preview build was created.
The pipeline needed to support:
- Android and iOS testing.
- Real cloud devices.
- Appium test execution.
- Smoke and regression test modes.
- Reports after every test run.
- GitLab artifact storage.
- A clean connection between build and QA.
This was not just a testing task.
It was a DevOps workflow problem.
Choosing the Device Farm
Before building the E2E pipeline, I researched different cloud device farm platforms.
The shortlist included:
- Sauce Labs
- BrowserStack
- AWS Device Farm
- Firebase Test Lab
Each option had tradeoffs.
Sauce Labs was powerful, but expensive for the team’s use case.
Firebase Test Lab was useful for mobile testing, but it did not fit the Appium workflow cleanly out of the box.
AWS Device Farm had flexible pricing, but the setup was less developer-friendly. The test code needed to be packaged, zipped, uploaded, and executed in a more black-box style environment.
BrowserStack had the best developer experience. It was more plug-and-play, worked cleanly with Appium, had a polished dashboard, and was easier to connect with GitLab CI.
After discussing the tradeoffs with stakeholders, we chose BrowserStack.
The decision was not just about features.
It was about choosing the platform that the team could actually operate without creating extra release friction.
Building the BrowserStack E2E Pipeline
I built a separate GitLab CI pipeline for Appium-based E2E testing on BrowserStack.
The high-level flow looked like this:
Trigger pipeline
→ Check if build already exists on BrowserStack
→ Upload build only if needed
→ Run Appium tests
→ Wait for suite completion
→ Generate HTML and PDF reports
→ Email reports
→ Upload artifacts to GitLab
The pipeline could be triggered manually from GitLab or automatically after a preview build was ready.
This made QA part of the release lifecycle instead of a separate manual step.
The release pipeline produced the build.
The QA pipeline tested the build.
GitLab stored the result.
That connection gave the team a traceable mobile delivery workflow.
Build Reuse and Faster QA Runs
BrowserStack requires the .apk or .ipa file to be uploaded before Appium tests can run.
The simple approach would be to upload the build every time.
But that creates unnecessary delay when the team wants to rerun tests against the same build.
To solve this, I added build reuse logic.
The pipeline checked whether the build already existed on BrowserStack using a unique signature. If it existed, the pipeline reused the existing BrowserStack app reference. If it did not exist, the pipeline uploaded the build and stored the returned app URL for the test run.
This made reruns faster and avoided redundant uploads.
It was a small optimization, but it made the pipeline feel more production-ready.
Handling Real-Device Test Limits
The pipeline supported Android and iOS jobs using GitLab’s matrix strategy.
Structurally, that was useful because each platform could have its own job context.
But there was one practical limitation.
The BrowserStack plan allowed only one parallel test session.
If Android and iOS jobs started at the same time, one of them could fail because the device session limit was exceeded.
To fix this, I used a shared GitLab resource_group.
resource_group: "browserstack_global_limit"
This forced GitLab to queue the jobs and run only one BrowserStack session at a time.
The pipeline still stayed ready for future scaling. If the BrowserStack plan was upgraded later, concurrency could be adjusted without redesigning the whole system.
That is the kind of detail that matters in DevOps work.
The goal is not just to make a pipeline pass once.
The goal is to make it behave reliably under real constraints.
Reports and Artifacts
After the Appium test suite finished, the pipeline generated HTML and PDF reports.
The HTML report was useful for quick review.
The PDF report was useful for sharing with stakeholders or keeping as a QA record.
A Python script emailed the reports to the team after execution. The same reports were also uploaded to GitLab artifacts so they stayed attached to the pipeline run.
That meant every E2E execution produced a clear audit trail:
- Which build was tested.
- Which platform was tested.
- Which suite was executed.
- What passed or failed.
- Where the reports were stored.
This was important because QA results should not live only in someone’s local machine, Slack thread, or manual note.
They should be attached to the delivery system.
Connecting Build and QA
The strongest part of the setup was the connection between both pipelines.
The release pipeline controlled how the app was built and distributed.
The E2E pipeline validated the build on real devices.
For preview builds, the flow could continue into QA automatically:
Code change
→ GitLab validation
→ Build decision
→ Expo EAS preview build
→ BrowserStack E2E pipeline
→ Appium tests
→ Reports and artifacts
This turned mobile delivery into a more complete CI/CD system.
It was no longer just about generating an .ipa or .apk.
It was about creating a release path where every important build could be validated, tested, and traced.
Technical Foundation
The stack was intentionally practical.
*GitLab CI- powered the release and QA automation.
*Expo EAS- handled native mobile builds for React Native.
*Appium- powered E2E test execution.
*BrowserStack- provided real cloud devices and debugging visibility.
*TestFlight and App Store Connect- supported the iOS release path.
*Cloud Run- connected build lifecycle events with downstream automation.
*Python and Bash- handled scripting, reporting, build checks, and pipeline glue.
The architecture was not overcomplicated. It focused on making mobile delivery safer, faster, and easier to operate.
What I Learned
This project taught me that mobile DevOps is different from web DevOps.
A React Native pipeline needs more than a basic build-and-deploy flow.
It needs to understand release types.
It needs to protect native changes.
It needs to support OTA updates without abusing them.
It needs to connect builds with QA.
It needs to generate reports that stakeholders can actually read.
And it needs to work within real constraints like device farm pricing, concurrency limits, Appium support, and platform setup complexity.
The biggest win was not just building two pipelines.
The biggest win was creating a controlled mobile release system.
Outcome
The final setup gave the team two connected pipelines.
One pipeline handled React Native CI/CD, native build decisions, OTA updates, preview builds, and iOS release flow.
The second pipeline handled Appium E2E testing on BrowserStack, real-device validation, reporting, email notifications, and GitLab artifacts.
Together, they turned mobile release management into a repeatable DevOps workflow.
The final experience was much closer to what a production mobile team needs:
a pipeline that can build, decide, test, report, and release with confidence.

