DeployPulse

Moving off EAS Update

An Expo app on EAS Update already uses expo-updates, and DeployPulse speaks the same Expo Updates v1 protocol. So the move is a config change, not an SDK change: point updates.url at DeployPulse, name the channel each build reads, ship a new build, and release with dpctl release-expo instead of eas update.

EAS Build keeps working as before. Only EAS Update is replaced.

Create the app

Create one app for both platforms, with the expo-v1 platform:

dpctl app add MyApp --platform expo-v1

Expo Updates releases are only accepted by an app created this way, and an app's platform can't be changed later. The app starts with three channels, development, preview and production, matching the profiles eas build:configure generates. If your eas.json uses other channel names, add them:

dpctl deployment add MyApp staging

Each app has a project ID, a UUID shown on the app's page in the dashboard. It goes in the update URL below. Like the project ID EAS gives you, it is public: it identifies the app and grants nothing on its own.

Point updates.url at DeployPulse

Replace the u.expo.dev URL in your app config with DeployPulse's manifest URL for the project ID:

{
  "expo": {
    "updates": {
      "url": "https://apps.deploypulse.io/expo/v1/YOUR_PROJECT_ID/manifest"
    }
  }
}

Leave extra.eas.projectId where it is. EAS Build uses it, and so does getExpoPushTokenAsync for push notifications. It has no effect on where updates come from.

Set the channel in your app config

On EAS Update, each build reads the channel from its eas.json build profile. That stops working once the URL changes: EAS Build only writes the profile's channel into a build when updates.url points at u.expo.dev. After the move, a build asks for the channel named by the expo-channel-name request header in your app config, and for production when there is none.

For one channel, set the header in app.json:

{
  "expo": {
    "updates": {
      "url": "https://apps.deploypulse.io/expo/v1/YOUR_PROJECT_ID/manifest",
      "requestHeaders": { "expo-channel-name": "production" }
    }
  }
}

For a channel per build profile, read the profile EAS Build is running in an app.config.js:

// app.config.js
const CHANNELS = { development: 'development', preview: 'preview', production: 'production' }

module.exports = ({ config }) => ({
  ...config,
  updates: {
    ...config.updates,
    requestHeaders: {
      'expo-channel-name': CHANNELS[process.env.EAS_BUILD_PROFILE] ?? 'production',
    },
  },
})

Map each eas.json profile to the channel its builds should read, and make sure the app has every channel you map to. EAS_BUILD_PROFILE is only set inside an EAS Build job, so a local build reads production. Compatibility: OTA protocol has the full explanation.

Runtime version

DeployPulse serves an update only to builds with the same runtime version, exactly as EAS Update does. Keep the runtimeVersion your app config already has, policy and all. If it has none, add one:

{
  "expo": {
    "runtimeVersion": { "policy": "appVersion" }
  }
}

dpctl release-expo works out each platform's runtime version from your app config the same way expo-updates does. With the fingerprint policy iOS and Android usually differ, so release each platform separately with --platform, as Compatibility describes.

Projects with committed ios and android folders

If ios/ and android/ are committed to your repository (a bare app, or an Expo app you prebuilt once and kept), expo-updates reads its settings from the native files at runtime, not from the app config:

SettingiOS: ios/<App>/Supporting/Expo.plistAndroid: android/app/src/main/AndroidManifest.xml
Update URLEXUpdatesURLmeta-data expo.modules.updates.EXPO_UPDATE_URL
Request headers (the channel)EXUpdatesRequestHeadersmeta-data expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY
Runtime versionEXUpdatesRuntimeVersionmeta-data expo.modules.updates.EXPO_RUNTIME_VERSION
Updates on or offEXUpdatesEnabledmeta-data expo.modules.updates.ENABLED

Change the update URL and the request headers there too. Edit the app config as well, not only the native files: EAS Build and npx expo prebuild re-sync these values from the app config, so a native-only edit is overwritten by the next build.

Some prebuilt projects carry EXUpdatesEnabled set to false or expo.modules.updates.ENABLED set to "false". A build with either never checks for updates. Set it to true, unless your app config sets updates.enabled: false on purpose.

If ios/ and android/ are git-ignored, they are generated from the app config, so there is nothing to edit. Run npx expo prebuild --clean before any local build, so your local folders stop carrying the EAS URL.

Code signing

DeployPulse serves Expo updates unsigned today. If your app config sets updates.codeSigningCertificate (or your native files set EXUpdatesCodeSigningCertificate or expo.modules.updates.CODE_SIGNING_CERTIFICATE), a build with that certificate rejects every update DeployPulse serves. Remove code signing from the build before you ship it. Code Signing covers signing on the CodePush SDK.

Never run eas update in the migrated project

If it did run, put the files back before you build:

git checkout -- app.json ios android

List only the paths your project has. If your app config is an app.config.js, check it as well.

Builds already in users' hands

Builds you shipped before the move still point at EAS Update. They keep updating from EAS until their users install a build that points at DeployPulse, and DeployPulse cannot reach them.

To fix something for those users in the meantime, release to EAS from a checkout of the last commit before the move, never from the migrated tree. A separate worktree keeps the two apart:

git worktree add ../myapp-eas <commit-before-the-move>
cd ../myapp-eas
npm install
eas update --channel production --message "Fix for builds still on EAS"

Once enough users are on the new build, stop releasing to EAS.

CI: replace eas update with dpctl release-expo

Every eas update step in your pipelines becomes dpctl release-expo. Install dpctl (on GitHub Actions, with deploypulseio/setup-dpctl), log in with an access key, then release from the project folder:

dpctl login --key "$DEPLOYPULSE_ACCESS_KEY"
dpctl release-expo MyApp -d production --description "Fix checkout crash"

release-expo needs dpctl 1.2.2 or later. The flags translate like this:

eas updatedpctl release-expo
--channel production-d production (or --deploymentName production)
--message "..." or -m "..."--description "..."
--platform ios or -p ios--platform ios (leave it off for all)
--rollout-percentage 10--rollout 10
--branch <name> or --auto-d <channel>: choose the channel explicitly. Without -d, release-expo releases to preview
--private-key-path <file>No equivalent: DeployPulse serves Expo updates unsigned today
--non-interactive, --json, --clear-cacheNot needed: remove them

If your pipeline already exports in an earlier step, release that folder with --exportDir. Partial rollouts, rollbacks and the other options are covered in Compatibility: OTA protocol. The GitHub Actions, GitLab CI/CD, CircleCI, Bitrise and Jenkins guides show where the dpctl steps go.

Checklist

  1. Create the app with --platform expo-v1 and add any channels your builds use.
  2. Set updates.url, the expo-channel-name header and runtimeVersion in your app config.
  3. With committed native folders, make the same changes in Expo.plist and AndroidManifest.xml, and turn updates on if they are off.
  4. Remove update code signing from the build.
  5. Build with EAS Build as usual and ship to the stores.
  6. Replace eas update with dpctl release-expo in CI, and never run eas update in this project again.