Growing concerns surround over-the-air (OTA) updates due to the belief that they may include code that hasn’t undergone the rigorous quality assurance (QA) process typical of store releases. In contrast, a store build is thoroughly linted, tested, and compiled from a known commit. However, OTA updates often originate from an individual’s laptop, raising questions about their reliability.
Addressing this issue doesn’t involve eliminating OTA updates. Instead, the solution lies in implementing a similar pipeline for OTA updates as used for store releases, ensuring that failed tests halt the update process, just as they would for a store build.
The data discussed herein is derived from a practical implementation involving React Native version 0.87.1, SDK version 0.6.0, CLI version 0.9.0, and a self-hosted Patch server.
The Pipeline
name: OTA release to Staging
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
env:
CODEMAGIC_PATCH_SERVER_URL: https://patch.example.com
CODEMAGIC_PATCH_TOKEN: ${{ secrets.CODEMAGIC_PATCH_TOKEN }}
PATCH_APP: MyApp-iOS
PATCH_DEPLOYMENT: Staging
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Unit tests
run: npm test
- name: Install the Patch CLI
run: npm install -g @codemagic/patch-cli
- name: Preflight
run: |
cmpatch doctor --app "$PATCH_APP" --deployment "$PATCH_DEPLOYMENT"
--platform ios --token "$CODEMAGIC_PATCH_TOKEN" --non-interactive
- name: What would ship
run: |
cmpatch release-react --app "$PATCH_APP" --deployment "$PATCH_DEPLOYMENT"
--platform ios --token "$CODEMAGIC_PATCH_TOKEN" --dry-run --non-interactive
- name: Publish the OTA update
run: |
cmpatch release-react --app "$PATCH_APP" --deployment "$PATCH_DEPLOYMENT"
--platform ios --token "$CODEMAGIC_PATCH_TOKEN"
--release-notes "$(git log -1 --pretty=%s)"
--yes --non-interactive
In this pipeline, a failure at any step results in the job being halted, ensuring updates are only published if all preceding steps pass. The same commands can be executed on Codemagic, making them versatile across different CI platforms.
Use a token, not a browser. The command cmpatch login triggers a browser. For CI purposes, generate a token via cmpatch token create --name ci-pipeline, store it as a secret, and use the --token option instead.
Avoid waiting for input. The parameter --yes confirms the release automatically, while --non-interactive ensures that any prompts lead to job failure instead of stalling execution indefinitely.
Extract release notes from the commit. Initial releases often shipped with void release notes due to an empty CM_COMMIT_MESSAGE. This workflow now extracts the commit subject, which is often the pull request title in a squash merge.
Preflight validations are crucial. Early runs encountered failures due to references in the app’s Info.plist pointing to outdated servers and deployment keys, making it impossible for devices built from it to obtain updates.
The dry run function provides a preview of what would be shipped without uploading the actual update:
{
"dryRun": true,
"fingerprint": "d03e95481074159d7e6f8d6c007ae5b35bb98431",
"platform": "ios",
"publicationSafety": {
"duplicateRelease": "block",
"fingerprintMismatch": "block"
},
"targetBinaryVersion": "1.0",
"uploadSkipped": true
}
Here, targetBinaryVersion indicates the store version applicable to the update, obtained from CFBundleShortVersionString or versionName. If a project does not resolve to a single version, ensure to specify --target-binary-version manually, as an incorrect value may direct the update to the wrong installations.
A Test That Blocked a Real Release
An out-of-the-box Jest setup exhibits failure before executing any tests because the SDK includes TypeScript and loads a native module upon import. It’s advisable to permit @codemagic access and utilize the corresponding setup file provided by the package:
module.exports = {
preset: '@react-native/jest-preset',
setupFiles: ['@codemagic/react-native-patch/jest-setup'],
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?|@codemagic)/)'
],
};
An essential test verifies a device where v8 previously failed installation while it still appears available on the server. Populate the device using installPatchFake, then load the SDK with loadSdk. The loading order is significant, as the SDK evaluates device state at the time of loading:
import { installPatchFake, loadSdk } from '@codemagic/react-native-patch/jest';
it('declines to reinstall a release that already failed here', async () => {
installPatchFake({
running: { label: 'v7' },
failedInstall: { label: 'v8' },
server: { release: { label: 'v8' } },
});
const Patch = await loadSdk();
const check = await Patch.checkForUpdate();
if (check.action !== 'ota-update') {
throw new Error(expected ota-update, got ${check.action});
}
expect(check.remotePackage.previouslyFailed).toBe(true);
await expect(Patch.sync()).resolves.toBe('up-to-date');
await expect(Patch.getRunningBundleUpdateMetadata()).resolves.toMatchObject({
label: 'v7',
});
});
While checkForUpdate() indicates an update is available, sync() fails to install it as expected. This default behavior is advantageous; however, informing the user regarding an update when it will not be delivered creates a disconnect. Verify the previouslyFailed state before displaying an update prompt. Additional device states are discussed in the testing guide.
An assertion was intentionally broken on main, expecting an update-installed response, allowing the pipeline to execute:
[success] Install dependencies
[success] Lint
[failed] Unit tests Expected: "update-installed" Received: "up-to-date"
[skipped] Install the Patch CLI
[skipped] Preflight
[skipped] What would ship
[skipped] Publish the OTA update
The results indicate what devices would download. The stored manifest remained unchanged from v1, and the server recorded no request from that build. By restoring the test and implementing a minor update in App.tsx, v2 was successfully published.
Note: Authenticating end-to-end tests on a genuine binary occurs within the same step, keeping the execution order consistent. Setting one up is beyond the scope of this article.
What the Server Enforces Anyway
Tests cannot predict a mismatch between a JavaScript bundle and the native binary it targets. Patch maintains a fingerprint record for each binary version, denying the release of any with a differing fingerprint:
Fingerprint disagreement (409)
release fingerprint a5180c4651cc… differs from the fingerprint d03e95481074…
recorded for binary version 1.0 in this deployment; devices on this binary
version may be native-incompatible with this update
Hint: Re-run with --allow-fingerprint-mismatch after verifying the installed
and release fingerprints.
The checking occurs post-bundling and prior to the upload committing. If discrepancies exist, nothing gets stored, and the manifest remains intact. The --yes option will not bypass this verification; only --allow-fingerprint-mismatch does, and its usage should be deliberate.
Podfile.lock contributes to the fingerprint, whereas Pods/ does not:
with Podfile.lock: d03e95481074159d7e6f8d6c007ae5b35bb98431
without Podfile.lock: 31bb27373b6da31255435cb1631faf378b6d20c5
Ensure to commit Podfile.lock and prevent CI from regenerating it. This consistency ensures CI and local environments produce identical fingerprints.
How Big an Update Is
The singular change in App.tsx is what formed v2. A device running v1 downloads a binary patch rather than the entire bundle:
| Artifact | Bytes |
|---|---|
Full bundle (bundle.tar.zst) |
704,235 |
| Binary patch, v1 to v2 | 1,982 |
meta.json |
31 |
The binary patch comprises a mere 0.28% of the bundle size. Its efficiency is limited as Hermes bytecode; altering a single string shifts bytes throughout the file. Additional adjustments, such as new imports or asset changes, can amplify the diff significantly. Devices using the store binary will download the entire bundle as there is no previous version to compare against.
The Last Gate Should Be a Person
Continuous Integration (CI) manages publishing to Staging, while transitioning to Production represents a separate, intentional action:
cmpatch release promote --app MyApp-iOS
--source-deployment Staging --dest-deployment Production --label v2 --yes
Release labels function as per-deployment counters. Promoting v2 from Staging generates Production v1, retaining the same package hash. To verify that two deployments utilize the same build, compare the target_package_hash, rather than relying on the label. The procedures for controlled rollouts within React Native cover the implications of promotion.
After It Ships
Every release maintains several counters, including ACTIVE, DOWNLOADED, READY, FAILED, and APPLIED. These can be viewed through the dashboard or from the command line interface (CLI):
cmpatch deployment metrics --app MyApp-iOS --deployment Production
cmpatch deployment failures --app MyApp-iOS --deployment Production --reason network
In this instance, the metrics reported zero, indicating that no devices had communicated with this server.
The bundle is minified. Without a source map, tools like Sentry or Datadog may display an overwhelming number of names, lacking specificity regarding which OTA release a device has installed. It’s advisable to instruct the bundler to create a map upon publishing and then upload it to the monitoring tool.
cmpatch release-react
--app MyApp-iOS
--deployment Staging
--platform ios
--sourcemap-output ./build/main.jsbundle.map
--yes
Separate step: your crash tool's CLI, not cmpatch
npx @sentry/cli sourcemaps upload ./build/main.jsbundle.map
For Datadog, its unique upload protocol applies in the same step. The monitoring documentation addresses both platforms.
What This Settles
OTA updates, on their own, may pose risks. This pipeline aims to bridge the reliability gap between OTA updates and store releases, incorporating identical commit, tests, and clean machines. Additionally, it features server checks that the pipeline cannot bypass, alongside human verification before Production release. Thus, if “OTA is lower quality” is underscored, it signals a pipeline deficiency—one that you can readily address.
Want to Try Patch?
Patch is self-hostable. Visit patch.codemagic.io for documentation, a local evaluation stack, and an installation wizard that can establish a server in less than twenty minutes.
Frequently Asked Questions
Does Publishing an OTA Update Need a Mac?
No, the publishing process does not require Xcode. The workflow outlined executes on ubuntu-latest. The run mentioned also used a Mac, as Codemagic’s free plan only offers macOS machines.
Can I Show Which Update Is Waiting for a Restart?
The function isNextVersionReady() returns a boolean value, but no API currently provides the staged label. There is no equivalent to CodePush’s getUpdateMetadata(PENDING). If you require the label, extract it from checkForUpdate() prior to installation.
Why Do My CI and Local Fingerprints Differ?
The varying Podfile.lock input contributes to the fingerprint, while Pods/ does not. Ensure that Podfile.lock is committed, and avoid allowing CI to regenerate it.

















Discussion about this post