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:
- Node.js
>=20and thecmpatchCLI installed (see below) - A Patch API token (
cm_pat_…) with permission to publish - Your server URL reachable from the CI runner (not
localhostunless 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
| Variable | Purpose |
|---|---|
CODEMAGIC_PATCH_TOKEN | API token for cmpatch (or pass --token) |
CODEMAGIC_PATCH_SERVER_URL | Your Patch API URL, e.g. https://updates.example.com |
Auth precedence: --token → CODEMAGIC_PATCH_TOKEN → credential from cmpatch 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:
{
"build": {
"production": {
"env": { "EXPO_PUBLIC_API_URL": "https://api.example.com" }
}
}
}
- 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 protectedCODEMAGIC_PATCH_SERVER_URL— e.g.https://updates.example.comPATCH_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
Stagingfirst; promote to Production after validation - On each store build, register that binary's fingerprint with a disabled release
- Run
cmpatch doctorin CI before publishing when debugging integration issues - Monitor metrics after deployment
See also: CLI reference · Security