React Native OTA CI/CD: Publish, Test, and Promote
Publish React Native OTA updates from CI with publishing tokens, matching source maps, compatible runtimes, and a controlled production rollout.
Written by George FeanFounder and maintainer of Bundle Drop
A green upload job proves that an OTA bundle reached the service. A useful React Native OTA pipeline also retains the exact bundle and source map, identifies what QA tested, and leaves production exposure under your control.
The examples below publish a bare Android app from GitHub Actions, Azure Pipelines, or CircleCI. Each uses the same small publishing script. Commit that script and the YAML for your provider; you do not need all three configurations. The CI/CD documentation owns the full command reference.
Prepare the project once
Complete and review Bundle Drop integration locally before adding unattended publication. Commit bundle.drop.config.js, the intended native configuration, package.json, and package-lock.json. Keep credentials out of those files. Create the develop channel in the configured project first: uploading does not create channels.
These examples assume:
- The locked project dependencies include
@gfean/react-native-bundle-dropand its CLI. - Android's
android/app/build.gradlesupplies the app version matching the installed native build. - Node 24.15.0 is suitable for your app; change the runner version if your application requires another supported version.
- Your app has
lintand Jest-basedtestscripts. Replace the check step with your real checks if its test runner uses different options. - A Bundle Drop Personal Access Token can publish to this organization and project.
npm ci installs the committed dependency graph; npx --no-install uses its CLI rather than downloading a different release. Keep the native runtime compatible with the installed binary. Native fingerprinting also needs consistent build inputs; strict Expo build authority needs its matching receipt. A successful job cannot make a native dependency change work in an older binary.
Keep publication and artifact checks together
Save this as scripts/publish-ota.sh in your app repository. It generates release notes from the source revision, requests a matching source map, and retains the result under artifacts/bundledrop/android.
#!/usr/bin/env bash
set -euo pipefail
: "${BUNDLE_DROP_PAT:?Set BUNDLE_DROP_PAT in the CI secret store}"
artifact_dir=./artifacts/bundledrop/android
mkdir -p "$artifact_dir"
git rev-parse HEAD > "$artifact_dir/source-revision.txt"
git log -1 --format='Release %h' > "$artifact_dir/release-notes.txt"
npx --no-install bundle-drop upload android \
--buildgradle-path android/app/build.gradle \
--channel develop \
--token "$BUNDLE_DROP_PAT" \
--author "CI Pipeline" \
--release-notes-file "$artifact_dir/release-notes.txt" \
--sourcemap \
--artifact-dir "$artifact_dir"
# Upload success alone does not guarantee retained local artifacts.
node <<'NODE'
const fs = require('node:fs');
const result = JSON.parse(fs.readFileSync(
'./artifacts/bundledrop/android/bundle-drop-result.json', 'utf8'
));
if (result.success === false || result.status === 'failed' ||
!/^[a-f0-9]{64}$/i.test(result.hash ?? '') ||
!Number.isInteger(result.bundleVersion) || result.bundleVersion < 1) {
throw new Error('Missing successful publication identity');
}
for (const field of ['bundlePath', 'sourceMapPath']) {
const file = result[field];
if (typeof file !== 'string' || !file) {
throw new Error('Missing retained artifact: ' + field);
}
const stat = fs.statSync(file);
if (!stat.isFile() || stat.size === 0) {
throw new Error('Empty or invalid retained artifact: ' + field);
}
}
console.log('Retained bundle v' + result.bundleVersion + ': ' + result.hash);
NODEThe script stops on upload failure. It then requires a published hash, bundle version, and nonempty retained bundle and source-map files. That second check matters: the CLI can warn about a local artifact-copy or result-writing failure after the server accepted the upload. An exit code alone does not prove that debugging artifacts were saved.
If artifact validation fails after publication, inspect the dashboard before rerunning the upload. Repeating a successful publication can create another release. Preserve the available diagnostics and repair the artifact handoff first.
Choose your CI provider
Copy the matching YAML into the filename shown. GitHub and Azure examples are manually queued on main; CircleCI runs after commits to main. Adapt the branch and trigger to your team's release process. Restrict publishing secrets to trusted jobs, and keep pull-request checks separate from publication.
The provider configurations check out the repository, install locked dependencies, run application checks, publish through the shared script, and save release artifacts. Artifact collection also runs after an ordinary step failure so available diagnostics can be retained; cancellation or runner loss can still prevent collection.
Create the BUNDLE_DROP_PAT repository secret. Dispatch this workflow from the reviewed main branch. The GitHub token only reads your repository; the Bundle Drop token publishes OTA.
name: Publish Android OTA
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: bundledrop-develop-android
cancel-in-progress: false
jobs:
publish:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: '24.15.0'
cache: npm
- run: npm ci
- run: npm run lint && npm test -- --runInBand
- name: Publish and verify retained artifacts
run: bash scripts/publish-ota.sh
env:
BUNDLE_DROP_PAT: ${{ secrets.BUNDLE_DROP_PAT }}
- name: Save release artifacts and diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: bundledrop-android-${{ github.run_id }}
path: artifacts/bundledrop/android/
if-no-files-found: error
retention-days: 30Create BUNDLE_DROP_PAT as a secret pipeline variable in Azure DevOps Services. Queue the reviewed main branch. Secret variables are explicitly mapped into the publishing step's environment.
trigger: none
pr: none
pool:
vmImage: ubuntu-latest
jobs:
- job: PublishAndroidOTA
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
steps:
- checkout: self
persistCredentials: false
- task: UseNode@1
inputs:
version: '24.15.0'
- bash: npm ci
displayName: Install locked dependencies
- bash: npm run lint && npm test -- --runInBand
displayName: Check the application
- bash: bash scripts/publish-ota.sh
displayName: Publish and verify retained artifacts
env:
BUNDLE_DROP_PAT: $(BUNDLE_DROP_PAT)
- task: PublishPipelineArtifact@1
condition: always()
inputs:
targetPath: '$(Build.SourcesDirectory)/artifacts/bundledrop/android'
artifact: 'bundledrop-android-$(Build.BuildId)'Create a restricted context named bundledrop-publish containing BUNDLE_DROP_PAT. The workflow runs only on main; restrict who can use the context and publish to that branch.
version: 2.1
jobs:
publish_android_ota:
docker:
- image: cimg/node:24.15.0
steps:
- checkout
- run:
name: Install locked dependencies
command: npm ci
- run:
name: Check the application
command: npm run lint && npm test -- --runInBand
- run:
name: Publish and verify retained artifacts
command: bash scripts/publish-ota.sh
- store_artifacts:
path: artifacts/bundledrop/android
destination: bundledrop-android
workflows:
publish:
jobs:
- publish_android_ota:
context: bundledrop-publish
filters:
branches:
only: mainUse a secret named BUNDLE_DROP_PAT in the selected provider. Azure requires explicit environment mapping; CircleCI's named context must exist and be authorized for the project. Do not run interactive login or AI setup inside routine publication jobs. Never enable shell tracing around the token or archive credential files.
The GitHub example uses the current checkout, Node setup, and artifact upload actions. Pin reviewed action revisions according to your team's dependency policy. Azure's Node task and pipeline artifacts example targets Azure DevOps Services; on-premises Azure DevOps Server needs its supported build-artifact task. CircleCI's configuration reference documents branch filters and artifact storage. Check each provider's retention and artifact-access settings for your source maps.
Adapt the platform without changing the release contract
For bare iOS, use upload ios with the appropriate --plist-file, a separate artifact directory, and the same validation. For Expo, use the option-free platform upload in Uploading, preserving token, channel, notes, source-map, and artifact options. Validate those variants against your own app before enabling publication.
These are JavaScript-and-asset publication jobs, not native binary builds. Keep store builds and any required compatibility receipts in your existing native build workflow.
Test the identified bundle before widening delivery
Record the retained hash, platform, runtime, source revision, and CI run. QA should validate that exact candidate on a compatible release build. “The develop channel passed” becomes ambiguous if another job publishes there during testing.
After acceptance, use the dashboard's channel promotion flow to move the tested bundle into the intended release track. Promotion preserves its identity without rebuilding JavaScript. Confirm the destination's history, managed publishing, audience rules, and rollout settings: promotion alone does not guarantee selection. Staged rollouts and rollback explains that decision.
Upload completion is neither device installation nor patch readiness. A compatible device can receive the complete bundle while a suitable patch is unavailable. Activation follows the app's configured policy.
Send the retained map to your monitoring tool and identify the active OTA hash in runtime events. The source-map guide explains how to keep those identities aligned. A complete CI handoff ends with evidence for the next release decision, not just a green upload step.
Common questions
About the author
George Fean
Founder and maintainer of Bundle Drop
George builds Bundle Drop and writes about the compatibility, delivery, rollout, and recovery boundaries behind React Native OTA updates.
GitHub profile