Skip to main content

CI integration

CI integration

Patch updates can be published from a developer machine or automated in CI. Releasing from CI keeps OTA delivery aligned with your build and test pipeline.

Typical flow:

commit → CI build → tests pass → cmpatch release-react → update deployed

Prerequisites​

Your pipeline needs:

  1. Node.js >=20 and the cmpatch CLI installed (see below)
  2. A Patch API token (cm_pat_…) with permission to publish
  3. Your server URL reachable from the CI runner (not localhost unless the runner is on the same host)

Install cmpatch in CI​

Install from npm:

npm install -g @codemagic/patch-cli

Create a token:

cmpatch token create --name ci

Store the cm_pat_… value as a secret. It is shown only once.

Environment variables​

VariablePurpose
CODEMAGIC_PATCH_TOKENAPI token for the CLI (or pass --token)
CODEMAGIC_PATCH_SERVER_URLYour Patch API URL, e.g. https://updates.example.com

Auth precedence: --token → CODEMAGIC_PATCH_TOKEN → the credential stored by login.

Keep bundle environment in sync with the native build​

Values read at bundling time, such as Expo's EXPO_PUBLIC_* variables, are inlined into the JavaScript bundle. cmpatch release-react runs the Metro or Expo bundler with the environment of the shell it is invoked from. If that environment differs from the one the store binary was bundled with, the update ships different values than the binary did. The CLI does not read EAS build profiles or the env blocks in eas.json, so you keep the two in sync yourself.

With remote EAS Build, the native binary takes its variables from the build profile in eas.json: its env block, or the EAS environment named by its environment field. Your CI runner's shell is not involved, so export the same values explicitly before release-react:

eas.json
{
"build": {
"production": {
"env": { "EXPO_PUBLIC_API_URL": "https://api.example.com" }
}
}
}
OTA step
- name: Release OTA update
env:
EXPO_PUBLIC_API_URL: https://api.example.com
CODEMAGIC_PATCH_TOKEN: ${{ secrets.CODEMAGIC_PATCH_TOKEN }}
run: cmpatch release-react --platform ios --deployment Production --yes

A committed dotenv file also works: release-react bundles with --dev false, so Expo's bundler loads .env.production.local, .env.local, .env.production, and .env (highest priority first), and variables already set in the shell win over file values. Remote EAS Build only sees dotenv files that are committed and not gitignored.

Anything that changes the native binary itself (deployment keys, native config) still needs a new store build; see Build for different deployments.

Codemagic​

Run the same commands you would locally after a successful build:

scripts:
- name: Install cmpatch
script: |
npm install -g @codemagic/patch-cli

- name: Release OTA update
script: |
cmpatch release-react \
--server-url "$CODEMAGIC_PATCH_SERVER_URL" \
--token "$CODEMAGIC_PATCH_TOKEN" \
--platform android \
--deployment Staging \
--yes

Store CODEMAGIC_PATCH_TOKEN and CODEMAGIC_PATCH_SERVER_URL as secure environment variables.

If the project has codemagic-patch.config.json from cmpatch init, you can omit --server-url and app flags when defaults are set.

GitHub Actions​

- name: Install cmpatch
run: npm install -g @codemagic/patch-cli

- name: Release OTA update
env:
CODEMAGIC_PATCH_TOKEN: ${{ secrets.CODEMAGIC_PATCH_TOKEN }}
CODEMAGIC_PATCH_SERVER_URL: ${{ secrets.CODEMAGIC_PATCH_SERVER_URL }}
run: |
cmpatch release-react \
--server-url "$CODEMAGIC_PATCH_SERVER_URL" \
--token "$CODEMAGIC_PATCH_TOKEN" \
--platform ios \
--deployment Staging \
--yes

GitLab CI​

stages:
- release

variables:
PATCH_APP: "my-app"
PATCH_DEPLOYMENT: "Staging"
PATCH_PLATFORM: "android"

publish_ota:
stage: release
image: node:22
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
- if: '$CI_COMMIT_TAG'
when: manual
- when: never
script:
- npm install --global @codemagic/patch-cli
- |
cmpatch release-react \
--app "$PATCH_APP" \
--deployment "$PATCH_DEPLOYMENT" \
--platform "$PATCH_PLATFORM" \
--server-url "$CODEMAGIC_PATCH_SERVER_URL" \
--yes \
--non-interactive

Configure these in GitLab project Settings > CI/CD > Variables:

  • CODEMAGIC_PATCH_TOKEN — Patch API token (cm_pat_…), masked and protected
  • CODEMAGIC_PATCH_SERVER_URL — e.g. https://updates.example.com
  • PATCH_APP, PATCH_DEPLOYMENT, PATCH_PLATFORM — regular variables

The CLI reads the token from the environment, so no --token flag is needed. --non-interactive keeps the job from hanging on a prompt. For a monorepo, run the job from the React Native project directory or pass --project-root. On tag pipelines, pass --target-binary-version explicitly.

Target the version from a published iOS binary​

For projects with SDK-dependent plist paths or version settings, read the version from the IPA you shipped. This avoids guessing which source build settings produced the installed app. Keep that IPA, or its extracted version, with your release artifacts and use it in later OTA jobs.

In an existing Fastlane pipeline, read the built app's plist and pass its version explicitly:

lane :publish_patch do
version = get_ipa_info_plist_value(
ipa: ENV.fetch("PUBLISHED_IPA_PATH"),
key: "CFBundleShortVersionString"
)
UI.user_error!("The published IPA has no app version") if version.to_s.strip.empty?

sh("cmpatch", "release-react", "--platform", "ios",
"--project-root", ENV.fetch("PATCH_PROJECT_ROOT"),
"--deployment", "Staging", "--target-binary-version", version, "--yes")
end

Set PATCH_PROJECT_ROOT to the absolute path of your configured React Native project and PUBLISHED_IPA_PATH to the absolute IPA path.

Use the Patch CI credentials above. PUBLISHED_IPA_PATH must point to the store binary you intend to update; reading a newly built, unreleased IPA would target that new version instead. See Fastlane's get_ipa_info_plist_value action.

Target the version from a published Android binary​

For flavor-specific versions, dynamic Gradle expressions, or CI property overrides, extract versionName from the APK or AAB you shipped and retain it for OTA jobs. Use the published binary you intend to update, not a newly built, unreleased binary. versionCode is the build number and is not the Patch target version.

With the Android SDK's apkanalyzer:

set -euo pipefail
version="$(apkanalyzer manifest version-name "$PUBLISHED_APK_PATH")"
[[ -n "${version//[[:space:]]/}" ]] || { echo "Published APK has no versionName" >&2; exit 1; }
cmpatch release-react --platform android \
--project-root "$PATCH_PROJECT_ROOT" \
--deployment Staging --target-binary-version "$version" --yes

For an AAB, replace the extraction command with bundletool:

version="$(java -jar "$BUNDLETOOL_JAR" dump manifest \
--bundle="$PUBLISHED_AAB_PATH" --module=base \
--xpath='/manifest/@android:versionName')"

Set the artifact, tool, and project paths to absolute paths, and use the Patch CI credentials above. Keep the empty-version check and release command from the APK example when using an AAB.

Sequential release changes​

A release is processed by a worker after the upload returns, and a deployment accepts one queued or running release job at a time. A pipeline that changes the same deployment twice in a row (publish, then mark mandatory, promote, or roll back) should wait for the first job before the second command. --format json prints the new release's id, so a script can capture it and wait on it:

release_id="$(cmpatch release-react --platform ios --deployment Staging --yes --format json | jq -r '.release.id')"
cmpatch release inspect --release-id "$release_id" --wait
cmpatch release patch --release-id "$release_id" --mandatory --yes

If the first job is still queued or running when the second command runs, it fails with an active-release-job conflict that names the release to wait on:

Active release job already exists (409)
deployment already has an active queued or running release job
Hint: Release rel_01J… still has a running release job on this deployment. Wait for it to finish with `cmpatch release inspect --release-id rel_01J… --wait`, then retry this command.

A partial rollout is a separate limit, one per deployment, and waiting never finishes it: complete or disable the rollout first. See Rollout constraints.

Release strategies​

On every merge to main

merge → CI build → tests pass → release to Staging (or Production)

On tags only

tag v1.2.0 → CI build → release with --target-binary-version 1.2.0

Manual pipeline trigger

Developer runs the workflow when ready to ship an OTA update.

Best practices​

  • Store tokens as CI secrets; never commit them
  • Restrict who can trigger release pipelines
  • Release to Staging first; promote to Production after validation
  • On each store build, register that binary's fingerprint with a disabled release
  • Run cmpatch doctor in CI before publishing when debugging integration issues
  • Monitor metrics after deployment

See also: CLI reference · Security