A change lands in a React Native app. If it needs a new native binary, it goes through an app-store build and review. If it's JavaScript only, it can go out over the air and be running on installed phones within minutes, with no external reviewer and no way to pull it back off a device. When I came in as the DevOps engineer, a person was deciding which of those two roads each change took, under release pressure, and QA sat outside the release altogether.
The situation
The app belongs to a nationwide US property-intelligence and outreach platform: web plus native iOS and Android, on the order of 150M+ US property records, and marketed as used by hundreds of thousands of users. Those are the client's published figures, not my scale; I built the release infrastructure for the mobile app. The data is free, but the action costs money: a prepaid in-app wallet is debited per skip-trace lookup, and those lookups return contact details for property owners who are not users of the platform.
Those two facts raise the stakes on the release path. A billing bug is a financial incident, not a UX one, and the app holds personal data about people who never signed up for anything. The over-the-air (OTA) channel can reach both, and nothing outside the team reviews it, so whatever the CI pipeline checks is the only review that path gets.
The pipeline work was mine, and I did it alone: the release routing, the parent-child pipeline generation, the device-farm evaluation and the QA pipeline around it, the build-reuse check, the session serialisation, and the reporting. The client's product and QA teams owned the app itself and the content of the test suites.
What I saw
The native-or-OTA decision can go wrong in both directions. Call a JavaScript change native and CI burns time on a build nobody needed. Call a native change JavaScript and a bundle ships that depends on native code the installed binary doesn't have. A person was making that call per change, under release pressure.
What I realised is that it isn't a judgement call at all. The changed file set doesn't just predict the release type; it decides it. A change that touches package.json, eas.json, app.json, ios/ or android/ needs a new binary. Everything else is JavaScript and can ship over the air.
The second thing was where QA lived: locally and by hand, beside the release rather than inside it.
What I did
Let the diff pick the release path
I kept the routing deliberately blunt:
if native_sensitive_files_changed; then
trigger_native_build
else
trigger_ota_update
fi
That's the whole rule, and it's meant to be boring. It decides the same way every time, which a person under release pressure doesn't.
Around it I split the pipeline into parent and child. The parent runs validation, makes the routing decision, and generates the right child pipeline for the release type. Preview builds, production builds, OTA updates, store submission and QA triggers all need different jobs, and cramming them into one .gitlab-ci.yml with rules everywhere is how mobile CI becomes unreadable. A small Cloud Run service passes build-lifecycle events downstream, which lets a finished EAS build (Expo's hosted build service) trigger the QA pipeline without someone watching a queue.
Choose the device farm before building against it
Real-device E2E inside the release meant a cloud device farm, and I compared four before writing any pipeline code: BrowserStack, Sauce Labs, AWS Device Farm and Firebase Test Lab. A device farm can't be swapped out later like a library. It sits in the release path, comes with a contract and a plan tier, and the test framework binds to it, so choosing one after the pipeline exists means rewriting the pipeline or living with whatever seemed fine first.
BrowserStack
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 harder for developers to work with. BrowserStack worked with Appium and GitLab without a wrapper around packaged test bundles. I made the choice with stakeholders, before any pipeline code existed.
Day-to-day operability decided it, not a feature comparison. A farm that wins on features but is annoying to run ends up as a gate people route around, and that's worse than no gate, because it still looks like coverage.
Fit the gate inside a one-session plan
Android and iOS run as separate matrix jobs, which keeps each platform's context clean. But the plan allowed only one concurrent BrowserStack session, so if both started at once, one would fail on the session limit. I could design for the plan, with one hard-coded serial path that gets rebuilt whenever the budget changes, or for the shape of the problem: keep the matrix jobs independent and let a shared resource group be the only thing that enforces the limit.
resource_group: "browserstack_global_limit"
A shared GitLab resource_group to serialise device sessions
Collapsing Android and iOS into one serial job
Why: The resource group costs nothing extra to build and keeps the jobs independent. Raising concurrency later is a plan change, not a redesign. Scope the group per platform, or drop it, and the matrix already fans out. The trade-off is queue depth. With one session, more tests show up first as longer waits.
One related optimisation: BrowserStack needs the .apk or .ipa uploaded before Appium can run, and re-testing a build shouldn't upload it again. The pipeline checks by signature whether the build already exists there, reuses the app reference if it does, and uploads only when it doesn't.
File every run where it can be found later
When a suite finishes, the pipeline generates an HTML report for review and a PDF for the record, emails both, and uploads them as GitLab artifacts on the pipeline that produced them. A report in someone's local folder or a Slack thread isn't a record, and six weeks later nobody can use it to answer "was this tested before it shipped?"
What I can and can't say
Nothing on this project was measured, so there is no before-and-after number here, and I'd rather say that than reach for an adjective. What I can describe is the mechanism: what the pipeline decides, what it runs, and what it files.
There's also an imbalance to name. This write-up describes the delivery system in detail and the test content not at all. The suites run Appium smoke or regression selections on real Android and iOS devices, but I can't describe which user flows they cover. This product's critical paths are authentication, property search, a lookup that debits the wallet, and an export. For a product like that, "we run Appium on real devices" describes the machinery, not the coverage, and a pipeline that runs a thin suite very reliably is a gate in name only.
The result
The release path is no longer a judgement call made under pressure; it's read off the diff the same way every time. QA moved from a local, manual step beside the release to a real-device gate inside it, and every run leaves a record of which build, which platform, which suite and what failed.
What's still open
- Flake rate on real devices. Real hardware exposes timing and environment flakiness that simulators hide. A rising flake rate quietly decides whether anyone still trusts the gate a year from now.
- Queue depth. With sessions serialised through one resource group, more test volume shows up as wait time before anything else.
- OTA rollout health. The path with no external reviewer deserves its own signal, and it doesn't have one here.
- Native build failure rate. EAS failures block the native path entirely.
- Bot mitigation. The platform runs a commercial anti-scraping service, and automated sessions on real devices can look like the traffic it exists to block. I can't say either way whether that ever interfered here.