Skip to Content
UpdatesCI/CD Pipeline

CI/CD Pipeline

Automate OTA deployments with GitHub Actions so every push to main publishes a release — unless native code changed.

Overview

The workflow below does two things:

  1. Detects native changes — If iOS/Android code, Podfile, Gradle files, or native dependencies changed, the OTA deploy is skipped with a warning. Those changes require a full app store build.
  2. Publishes an OTA release — If only JavaScript changed, it installs the Dispatch CLI and publishes the release to your chosen channel.

Setup

1. Add secrets and variables

In your GitHub repo, go to Settings → Secrets and variables → Actions and add:

TypeNameValue
SecretDISPATCH_API_KEYAPI key from Dashboard → Settings

2. Create the workflow file

Copy this to .github/workflows/ota-deploy.yml in your React Native app repo:

.github/workflows/ota-deploy.yml
name: OTA Release on: push: branches: [main] workflow_dispatch: inputs: channel: description: 'Channel to publish to' required: false default: 'production' type: choice options: - production - staging - canary is_critical: description: 'Mark as critical release (force immediate reload)' required: false default: false type: boolean rollout_percentage: description: 'Rollout percentage (1-100)' required: false default: '100' type: string env: DISPATCH_API_KEY: ${{ secrets.DISPATCH_API_KEY }} DISPATCH_VERSION: latest jobs: check-native-changes: name: Check for native changes runs-on: ubuntu-latest outputs: native_changed: ${{ steps.check.outputs.native_changed }} steps: - uses: actions/checkout@v4 with: fetch-depth: 2 - name: Detect native dependency changes id: check run: | NATIVE_CHANGED=false NATIVE_PATTERNS=( "ios/" "android/" "Podfile" "Podfile.lock" "build.gradle" "gradle.properties" "app.json" "app.config.js" "app.config.ts" "expo-env.d.ts" ) CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD) for pattern in "${NATIVE_PATTERNS[@]}"; do if echo "$CHANGED_FILES" | grep -q "^${pattern}"; then echo "Native change detected in: $pattern" NATIVE_CHANGED=true break fi done if echo "$CHANGED_FILES" | grep -q "^package.json"; then DEPS_BEFORE=$(git show HEAD~1:package.json | jq -r '.dependencies // {} | keys[]' 2>/dev/null | sort) DEPS_AFTER=$(git show HEAD:package.json | jq -r '.dependencies // {} | keys[]' 2>/dev/null | sort) ADDED=$(comm -13 <(echo "$DEPS_BEFORE") <(echo "$DEPS_AFTER")) REMOVED=$(comm -23 <(echo "$DEPS_BEFORE") <(echo "$DEPS_AFTER")) if [ -n "$ADDED" ] || [ -n "$REMOVED" ]; then echo "Package dependencies changed:" [ -n "$ADDED" ] && echo " Added: $ADDED" [ -n "$REMOVED" ] && echo " Removed: $REMOVED" NATIVE_CHANGED=true fi fi echo "native_changed=$NATIVE_CHANGED" >> "$GITHUB_OUTPUT" deploy-ota: name: Publish OTA release needs: check-native-changes if: needs.check-native-changes.outputs.native_changed == 'false' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - name: Install Dispatch CLI run: | if [ "$DISPATCH_VERSION" = "latest" ]; then DOWNLOAD_URL="https://github.com/AppDispatch/cli/releases/latest/download/dispatch-linux-x64" else DOWNLOAD_URL="https://github.com/AppDispatch/cli/releases/download/${DISPATCH_VERSION}/dispatch-linux-x64" fi curl -sL "$DOWNLOAD_URL" -o /usr/local/bin/dispatch chmod +x /usr/local/bin/dispatch - name: Login to Dispatch run: dispatch login --key "$DISPATCH_API_KEY" - name: Publish release run: | ARGS=( --channel "${{ inputs.channel || 'production' }}" --rollout "${{ inputs.rollout_percentage || '100' }}" -m "$(git log -1 --pretty=%s)" ) if [ "${{ inputs.is_critical }}" = "true" ]; then ARGS+=(--critical) fi dispatch publish "${ARGS[@]}" warn-native-change: name: Warn - native changes detected needs: check-native-changes if: needs.check-native-changes.outputs.native_changed == 'true' runs-on: ubuntu-latest steps: - name: Skip OTA - native changes require full build run: | echo "::warning::Native dependencies changed — OTA release skipped." echo "Changes to iOS/Android native code, Podfile, build.gradle, or package dependencies" echo "require a full app store build." echo "" echo "After submitting the new binary, future JS-only pushes will resume OTA releases."

How it works

Native change detection

The check-native-changes job inspects the diff between the current and previous commit. It flags native changes if any of these paths were modified:

  • ios/, android/ — Native project directories
  • Podfile, Podfile.lock — CocoaPods dependencies
  • build.gradle, gradle.properties — Android build config
  • app.json, app.config.js, app.config.ts — Expo config (may change native modules)
  • package.json — Checked for added or removed dependencies (version bumps are ignored)

If native changes are detected, the OTA release is skipped and a warning annotation appears on the workflow run.

Manual triggers

You can trigger the workflow manually from the Actions tab in GitHub to:

  • Choose a channel — Deploy to production, staging, or canary
  • Mark as critical — Forces an immediate reload on devices instead of waiting for the next app launch (isCritical)
  • Set rollout percentage — Gradually roll out to a subset of users (e.g. 10 for 10%)

Pinning the CLI version

By default the workflow downloads the latest CLI release. To pin to a specific version, change the DISPATCH_VERSION env var:

env: DISPATCH_VERSION: v0.1.12
Last updated on