Orchestrating native releases in a white-label React Native monorepo

February 21, 2026

JavaScript changes can ship over the air. Native changes cannot. New SDKs, entitlement updates, extension targets, signing config, Android Gradle changes, and native dependency upgrades all require a native release: bump versions, build binaries, submit to stores, and make sure every active over-the-air update still targets the right native versions.

That sounds mechanical, but in a white-label React Native monorepo the hard part is rarely the build command. One codebase may produce public and private multi-customer apps, customer-specific production and test builds, internal demo variants, and QA-only automation binaries. The release is not one artifact; it is a small distribution system.

The biggest lesson for us was that native release work is coordination work disguised as build work.

The native binary is a contract

CodePush and Expo Updates are useful because they let teams ship JavaScript without waiting for app review. That convenience also creates a trap: it becomes easy to forget that every OTA bundle runs inside a specific native binary.

When the JS-to-native contract stays the same, a new binary can usually be introduced gradually. The current JS bundle works on the old store build and the new store build, so the OTA target range can widen from something like 2.3.0 - 2.3.2 to 2.3.0 - 2.3.3.

When the contract changes, the release becomes much more sensitive. If JavaScript calls a new native module, depends on a new entitlement, or expects a changed bridge API, old binaries cannot safely receive that bundle. The OTA target range must narrow, old users may need an upgrade prompt, and active release branches may need temporary dual-target deployments while stores roll out the new app.

This is the mental shift that made releases calmer: treat targetBinaryVersion as part of the native release, not as CodePush housekeeping.

One app is really many apps

The second lesson was that "the app" is an imprecise phrase. In a multi-flavor monorepo, a release can involve several different products that happen to share most of their code.

Flavor type Why it exists
Public multi-customer Main store app for most customers
Private multi-customer Same product distributed through private channels
Customer production Customer-specific signing, branding, or distribution
Customer test UAT or pre-production validation for one customer
Internal demo Sales, demo, or sandbox environments
QA automation E2E and visual regression builds with test-only hooks

The exact scheme names do not matter outside your codebase. What matters is that the release process names every flavor explicitly. If a build matrix says only "iOS" and "Android," people will assume different things: QA expects an installable IPA, customer success expects store release notes, automation expects a QA binary, and release engineering expects signed archives.

A good native release makes those assumptions visible before anyone starts uploading artifacts.

QA builds are operationally different

QA builds look like release builds, but they should not be treated as customer builds.

They often use dedicated bundle IDs or package names so they can sit beside production apps on the same device. They may include launch arguments, login bypass, test tokens, UI-testing flags, or extra observability tags. Those hooks are valuable for automation, but they must never leak into store binaries.

The separation needs more than convention:

  • QA-only native code should compile only into QA schemes or flavors.
  • Runtime bypass paths should check that the current app identifier is a QA app.
  • CI should fail production artifacts if test-only strings or hooks appear in the binary.

This boundary changes how you think about the release. QA binaries are not "pre-release production builds." They are a separate operational tool for validating production behavior quickly. Customer binaries and QA binaries may share a version number, but they have different distribution, signing, and security rules.

Backwards compatibility decides the release shape

Before starting a native release, the most important question is not "which workflow do we run?" It is:

Can the current JavaScript safely run on both the old and new native binary?

If yes, the release can be incremental. Merge the native change, widen the OTA range, let stores roll out, and keep hotfix branches aligned.

If no, the release needs choreography. You may have to hold dependent JS until the store build is available, publish one OTA bundle for old binaries and another for the new binary, and prompt users on old native versions to upgrade.

This compatibility decision also affects branch strategy. Main, hotfix, integration candidate, and release candidate lines often live at the same time. A native release change set is not just the version bump; it includes native config, dependency changes, bridge changes, JS that depends on the native work, and OTA range updates. Cherry-picking only part of that set is how one release train ends up with a binary that cannot receive the bundle it needs.

Version bumps are a release artifact

Version bumps seem boring until they diverge.

For a native release, iOS and Android should move together unless there is an intentional platform-specific rollout. The app config, iOS marketing version, iOS build number, Android versionName, Android versionCode, release workflow inputs, and OTA target defaults should all describe the same release wave.

We learned to keep version changes in one coherent commit where possible. Partial version bumps create confusing crash reports, failed uploads, mismatched release notes, and QA builds that nobody can confidently map back to a store binary.

Internal testing builds can use different build-number conventions if that helps distinguish candidates. Customer-facing versions need monotonic, explainable numbering.

The handoff matters as much as the build

Native releases fail socially before they fail technically.

Sending builds one by one as they finish creates ambiguity. QA validates one binary, automation runs against another, a customer-specific archive gets signed later, and release notes reference a version that is not actually in every track yet.

The better pattern is a single release handoff:

  • native version and build numbers
  • source branch and commit SHA
  • compatibility classification: backwards-compatible or breaking
  • expected OTA target range
  • iOS and Android artifact list
  • which artifacts are production, customer-signed, simulator-only, or QA-only
  • known issues and feature flags
  • explicit QA and release-owner approval

This handoff does not have to be bureaucratic. It just has to make the release state observable to everyone who depends on it.

What changed in how we think about releases

The process improved when we stopped treating native releases as an app-store task and started treating them as a distribution contract.

The contract has several parts:

  • Binaries — what native capability exists on the device
  • OTA ranges — which JS bundles are allowed to run on which binaries
  • Branches — which active release lines can still deploy updates
  • Flavors — which products need artifacts from the same codebase
  • QA builds — which binaries include automation hooks and must stay out of customer distribution
  • Approvals — who has validated the actual artifacts being shipped

Once those parts are explicit, the checklist becomes much less mysterious. Build matrices, version bumps, cherry-picks, and CodePush patches are just the mechanics of keeping the contract true.

Lessons

  • Treat flavors as first-class citizens. Every release note, workflow, and QA handoff should name the variant it refers to.
  • Separate QA binaries from customer binaries. QA-only hooks are useful, but production artifacts must prove those hooks are absent.
  • Decide compatibility early. Backwards-compatible and breaking native releases need different OTA and rollout plans.
  • Move version numbers together. A native release should have one coherent version story across app config, iOS, Android, CI, and OTA defaults.
  • Cherry-pick complete release sets. Native config, bridge changes, dependent JS, and OTA ranges belong together.
  • Do not end at store approval. A native release is not finished until OTA deployments target the right native versions.

If you maintain one React Native repo with multiple customer apps, the goal is not to make native releases lightweight. They are inherently heavier than JS-only updates. The goal is to make them legible: everyone should be able to answer which binary exists, which JavaScript can run on it, which customers receive it, and which branch owns the next update.


Profile picture

Hi, I'm Pavel Ivanov, a staff engineer building React Native apps and AI-powered products.

I write about frontend and mobile engineering, React and React Native, platform architecture, technical leadership, and the practical side of building reliable products and teams.

Say hi on LinkedIn.

* use of site content is strictly prohibited without prior written approval