# 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.

Canonical: https://bundledrop.app/resources/react-native-ota-ci-cd

Published: 2026-11-07

Updated: 2026-11-07

Category: Guides

Topics: OTA Operations, Delivery

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](/docs/ci-cd) 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-drop` and its CLI.
- Android's `android/app/build.gradle` supplies 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 `lint` and Jest-based `test` scripts. 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](/docs/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`.

**scripts/publish-ota.sh**

```bash
#!/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);
NODE
```

The 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.

### GitHub Actions

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.

**.github/workflows/ota.yml**

```yaml
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: 30
```

[Provider documentation](https://docs.github.com/en/actions/tutorials/build-and-test-code/nodejs)

### Azure Pipelines

Create 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.

**azure-pipelines.yml**

```yaml
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)'
```

[Provider documentation](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/set-secret-variables?view=azure-devops)

### CircleCI

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.

**.circleci/config.yml**

```yaml
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: main
```

[Provider documentation](https://circleci.com/docs/guides/security/contexts/)

Use 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](https://github.com/actions/checkout), [Node setup](https://github.com/actions/setup-node), and [artifact upload](https://github.com/actions/upload-artifact) actions. Pin reviewed action revisions according to your team's dependency policy. Azure's [Node task](https://learn.microsoft.com/en-us/azure/devops/pipelines/ecosystems/customize-javascript?view=azure-devops) and [pipeline artifacts](https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/publish-pipeline-artifact-v1?view=azure-pipelines) example targets Azure DevOps Services; on-premises Azure DevOps Server needs its supported build-artifact task. CircleCI's [configuration reference](https://circleci.com/docs/reference/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](/docs/uploading#expo-uploads), 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](/resources/staged-rollouts-targeting-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](/resources/react-native-ota-sentry-source-maps) 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

### Can I publish React Native OTA updates from GitHub Actions, Azure Pipelines, or CircleCI?

Yes. Run the installed Bundle Drop CLI in your pipeline with committed project configuration, a publishing token stored as a CI secret, and an existing destination channel. Retain the exact bundle, matching source map, and upload result together as release evidence.

### Does a successful CI upload mean the update is installed?

No. An upload publishes a release. Devices still need to pass runtime and release eligibility checks, download and verify it, and activate it under the app’s selected update policy. Test on a compatible release build before expanding production exposure.
