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:
- 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.
- 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:
| Type | Name | Value |
|---|---|---|
| Secret | DISPATCH_API_KEY | API key from Dashboard → Settings |
2. Create the workflow file
Copy this to .github/workflows/ota-deploy.yml in your React Native app repo:
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 directoriesPodfile,Podfile.lock— CocoaPods dependenciesbuild.gradle,gradle.properties— Android build configapp.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, orcanary - 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.
10for 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