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:
- React Native
- Capacitor
commit → CI build → tests pass → cmpatch release-react → update deployed
commit → CI web build → tests pass → cmpatch-capacitor release create → update deployed
Capacitor apps publish with cmpatch-capacitor, which uploads the webDir built by your CI. Pass --target-binary-version on every publish.
Prerequisites
- React Native
- Capacitor
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.
Your pipeline needs:
- Node.js
20.19+or22.12+andcmpatch-capacitor - 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)
The examples below install the CLI with npm install -g @codemagic/capacitor-patch-cli. The package is a 0.x release; its flags may still change.
Create a token from the CLI or from the dashboard's API tokens page:
cmpatch-capacitor token create --name ci --expires-in-days 365
Store the cm_pat_… value as a secret. It is shown only once.
Environment variables
| Variable | Purpose |
|---|---|
CODEMAGIC_PATCH_TOKEN | API token for the CLI (or pass --token) |
CODEMAGIC_PATCH_SERVER_URL | Your 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
- React Native
- Capacitor
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.
Build-time values, such as Vite VITE_* variables or Angular environment files, are compiled into the web assets when CI builds webDir. Build OTA bundles with the same environment and configuration as the web assets in the store binary. Otherwise the update ships different values than the binary.
Changes to the native binary (deployment keys, native config, plugins) require a new store build; see Build for different deployments.
Codemagic
Run the same commands you would locally after a successful build:
- React Native
- Capacitor
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
scripts:
- name: Install cmpatch-capacitor
script: |
npm install -g @codemagic/capacitor-patch-cli
- name: Build web assets
script: |
npm ci
npm run build
- name: Release OTA update
script: |
cmpatch-capacitor release create \
--bundle-path www \
--app MyApp-Android \
--deployment Staging \
--target-binary-version "$APP_VERSION" \
--yes
Store CODEMAGIC_PATCH_TOKEN and CODEMAGIC_PATCH_SERVER_URL as secure environment variables.
- React Native
- Capacitor
If the project has codemagic-patch.config.json from cmpatch init, you can omit --server-url and app flags when defaults are set.
cmpatch-capacitor has no project config file. Set the server with CODEMAGIC_PATCH_SERVER_URL or --server-url, and pass the app and deployment as flags.
GitHub Actions
- React Native
- Capacitor
- 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
- name: Install cmpatch-capacitor
run: npm install -g @codemagic/capacitor-patch-cli
- name: Build web assets
run: |
npm ci
npm run build
- name: Release OTA update
env:
CODEMAGIC_PATCH_TOKEN: ${{ secrets.CODEMAGIC_PATCH_TOKEN }}
CODEMAGIC_PATCH_SERVER_URL: ${{ secrets.CODEMAGIC_PATCH_SERVER_URL }}
run: |
cmpatch-capacitor release create \
--bundle-path www \
--deployment-id "${{ vars.PATCH_IOS_STAGING_DEPLOYMENT_ID }}" \
--target-binary-version "$APP_VERSION" \
--yes --format json > release.json
cmpatch-capacitor release inspect --release-id "$(jq -r .release.id release.json)" --wait
--deployment-id avoids name lookups and is not affected by renames, so it is recommended in CI. --app with --deployment also works. release inspect --wait fails the job if processing fails on the server.
GitLab CI
- React Native
- Capacitor
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.
stages:
- release
variables:
PATCH_APP: "MyApp-Android"
PATCH_DEPLOYMENT: "Staging"
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/capacitor-patch-cli
- npm ci && npm run build
- |
cmpatch-capacitor release create \
--bundle-path www \
--app "$PATCH_APP" \
--deployment "$PATCH_DEPLOYMENT" \
--target-binary-version "$APP_VERSION" \
--yes
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,APP_VERSION— regular variables
The CLI reads the token and server URL from the environment, so --token and --server-url are not needed. In CI, commands that need confirmation fail without --yes. There is no --platform flag; each app is a single platform.
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:
- React Native
- Capacitor
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.
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-capacitor", "release", "create",
"--bundle-path", ENV.fetch("WEB_DIR"),
"--app", "MyApp-iOS", "--deployment", "Staging",
"--target-binary-version", version, "--yes")
end
Set WEB_DIR to the built Capacitor webDir (usually www) 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:
- React Native
- Capacitor
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
set -euo pipefail
version="$(apkanalyzer manifest version-name "$PUBLISHED_APK_PATH")"
[[ -n "${version//[[:space:]]/}" ]] || { echo "Published APK has no versionName" >&2; exit 1; }
cmpatch-capacitor release create --bundle-path www \
--app MyApp-Android --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:
- React Native
- Capacitor
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
release_id="$(cmpatch-capacitor release create --bundle-path www \
--app MyApp-iOS --deployment Staging --target-binary-version "$APP_VERSION" \
--yes --format json | jq -r '.release.id')"
cmpatch-capacitor release inspect --release-id "$release_id" --wait
cmpatch-capacitor 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
- React Native
- Capacitor
- 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
- Store tokens as CI secrets; never commit them
- Restrict who can trigger release pipelines
- Release to
Stagingfirst; promote to Production after validation - Target the binary version of the store build you are updating, and bump the native version whenever native code or configuration changes. See Binary version compatibility
- Run
npx codemagic-patch-check-configin the native build job to detect a deployment key shared by iOS and Android. See Native setup - Monitor metrics after deployment
See also: CLI reference · Security