Concerns Surrounding Over-the-Air Updates
There exists a significant concern regarding over-the-air (OTA) updates: they may deploy code that lacks the thorough quality assurance (QA) evaluation that is standard for store releases. A store build undergoes comprehensive linting, testing, and construction on a clean machine using a verified commit. Conversely, an OTA push could originate from an individual’s local environment.
The solution is not to eschew OTA updates entirely but rather to subject them to the same rigorous pipeline and gates as store builds. This ensures that any failed test halts an update just as it would with a store build.
The following information is derived from an actual execution using React Native 0.87.1, SDK 0.6.0, CLI 0.9.0, alongside a self-hosted Patch server.
<h2>The Pipeline</h2>
<pre><code>
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 structure, a failed step results in a halted job, ensuring that publishing only occurs if all prior steps pass successfully. The commands presented are not exclusive to any particular Continuous Integration (CI) framework.
<p><strong>Note on Authentication:</strong> Instead of relying on a browser for access, create a token using <code>cmpatch token create --name ci-pipeline</code>, store it securely, and utilize <code>--token</code> in your configurations.</p>
<p><strong>Non-Interactive Process:</strong> The option <code>--yes</code> confirms the release automatically, while <code>--non-interactive</code> ensures that any prompt failure does not result in a hanging job.</p>
<p><strong>Release Notes from Commits:</strong> It is advisable to extract release notes from the commit subject via <code>git log</code> to maintain consistency, especially with squash merges.</p>
<p><strong>Utilizing Preflight Checks:</strong> The importance of the preflight step was demonstrated in a real scenario where the application's <code>Info.plist</code> referenced an outdated server and deployment key. A build produced from this configuration would be ineligible for updates.</p>
<h2>A Test That Blocked a Real Release</h2>
<p>The application’s basic Jest setup encounters failures prior to executing any tests. The SDK, which includes TypeScript and an imported native module, necessitates that <code>@codemagic</code> be permitted through the transform, using the setup file provided by the package:</p>
<pre><code>
module.exports = {
preset: ‘@react-native/jest-preset’,
setupFiles: [‘@codemagic/react-native-patch/jest-setup’],
transformIgnorePatterns: [
‘node_modules/(?!((jest-)?react-native|@react-native(-community)?|@codemagic)/)’,
],
};
The pertinent test scenario evaluates a device where the version 8 installation previously failed. By using installPatchFake, the device can then load the SDK through loadSdk. It’s crucial that this order is maintained since the SDK captures the device state during 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',
});
});
The checkForUpdate() function identifies an available update, yet sync() rightly refrains from installing it. This default is appropriate; however, if the status banner is solely reliant on checkForUpdate(), it may mislead the user into expecting an update that will not occur.
<h2>Enforcement Rules from the Server</h2>
<p>It’s important to note that tests cannot detect a JavaScript bundle that corresponds incorrectly with the native binary it is destined for. The Patch service records fingerprints of the native project for each binary version and will reject a release if there is a mismatch:</p>
<pre><code>
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.
This check occurs post-bundling and prior to committing the upload. If no rules are satisfied, no data will be stored, and the manifest will remain unchanged. The command --yes does not override this verification; only --allow-fingerprint-mismatch can do so, and its use should be intentional.
Additionally, Podfile.lock influences the fingerprint, while Pods/ does not:
with Podfile.lock: d03e95481074159d7e6f8d6c007ae5b35bb98431
without Podfile.lock: 31bb27373b6da31255435cb1631faf378b6d20c5
To ensure consistency, always commit Podfile.lock and prevent regeneration by CI. This guarantees both CI and your local setup yield identical fingerprints.
<h2>Understanding Update Sizes</h2>
<p>A single line change in <code>App.tsx</code> illustrates that a device currently running version 1 will only need to download a binary patch rather than the complete bundle:</p>
<table>
<thead>
<tr>
<th>Artifact</th>
<th>Bytes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Full bundle (<code>bundle.tar.zst</code>)</td>
<td>704,235</td>
</tr>
<tr>
<td>Binary patch, v1 to v2</td>
<td>1,982</td>
</tr>
<tr>
<td><code>meta.json</code></td>
<td>31</td>
</tr>
</tbody>
</table>
<p>This means that the update size is merely 0.28% of the full bundle. The minimal increase is due to the fact that the bundle uses Hermes bytecode, and subtle changes could shift bytes throughout the file. Changes in imports, assets, or module identifiers could significantly increase the difference, while devices that have yet to download from the store will require the entire bundle.</p>
<h2>The Importance of Human Oversight</h2>
<p>Continuous Integration (CI) manages publication to the Staging environment. Transitioning to Production necessitates a deliberate, separate step:</p>
<pre><code>
cmpatch release promote –app MyApp-iOS
–source-deployment Staging –dest-deployment Production –label v2 –yes
Release labels operate as per-deployment counters. When Staging v2 is promoted, it results in the creation of Production v1, which shares the same package hash. To verify that two deployments utilize the same build, compare target_package_hash rather than the label. For more information on controlled rollouts in React Native, refer to the documentation available.
<h2>Post-Release Procedures</h2>
<p>Each release incorporates metrics such as <code>ACTIVE</code>, <code>DOWNLOADED</code>, <code>READY</code>, <code>FAILED</code>, and <code>APPLIED</code>. These metrics can be monitored via the dashboard or CLI:</p>
<pre><code>
cmpatch deployment metrics –app MyApp-iOS –deployment Production
cmpatch deployment failures –app MyApp-iOS –deployment Production –reason network
Currently, these metrics are zero as no real devices have communicated with the server.
To ensure visibility into bundles, which are typically minified, it’s essential to generate a source map. Without this, monitoring tools like Sentry or Datadog may present an overwhelming array of names without properly identifying the OTA release installed on the device. To remedy this, configure the bundler to generate a map during the publishing phase, and upload it to the relevant 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
Datadog will require its own upload mechanism in the same step. Comprehensive monitoring documentation is available.
<h2>Conclusions Reached</h2>
<p>OTA updates do not inherently guarantee safety. This pipeline effectively bridges the gap in rigor between OTA updates and store releases by enforcing identical commits, tests, and environments as well as implementing a server-side check the pipeline cannot override. The presence of an individual overseeing Production further enhances quality control. As a result, the perception that “OTA is lower quality” is indicative of a pipeline issue, which can be rectified.</p>
<h2>Exploring Patch</h2>
<p>Patch is a self-hostable solution. Documentation, evaluation stacks, and an installation wizard that sets up a server within twenty minutes are available at <a href="https://patch.codemagic.io">patch.codemagic.io</a>.</p>
<h2>Frequently Asked Questions</h2>
<h3>Does publishing an OTA update need a Mac?</h3>
<p>No, the publishing process does not require Xcode. The workflow provided operates on <code>ubuntu-latest</code>.</p>
<h3>Can I show which update is waiting for a restart?</h3>
<p>While <code>isNextVersionReady()</code> returns a boolean, there is no API to report the staged label. There’s no equivalent to CodePush’s <code>getUpdateMetadata(PENDING)</code>. If access to the label is essential, it should be captured from <code>checkForUpdate()</code> prior to installation.</p>
<h3>Why do my CI and local fingerprints differ?</h3>
<p>Fingerprint discrepancies arise from <code>Podfile.lock</code> as it is an input for the fingerprint computation, while <code>Pods/</code> is not. Ensure that <code>Podfile.lock</code> is committed, and do not allow CI to regenerate it.</p>
Chief Editor of Quincy City Beat — Quincy, MA's independent investigative newspaper covering Greater Boston and the entire Commonwealth of Massachusetts with fearless public accountability and free access for all readers.










Discussion about this post