Shipping It
Three EAS build profiles, managed credentials, a TestFlight and internal-track release, then production with a staged rollout.
eas build --profile production --platform all && eas submit --profile production --platform all --latestShipping is three profiles and two commands. The profiles live in eas.json. development builds a dev client for local work. preview builds a release-configured app distributed to your own devices and testers. production builds the artefact a store will accept. eas build makes one; eas submit sends it.
Credentials
EAS can generate and hold the iOS distribution certificate and provisioning profile, and the Android upload keystore. eas credentials inspects or replaces them. Keep the Android keystore: Google will not accept an app signed with a different upload key, and there is no local copy if you never asked for one.
Versions
Set cli.appVersionSource to remote, recommended since EAS CLI 12.0.0, and put autoIncrement: true on the profiles that submit. The build number then comes from EAS instead of a file two people edit at once.
The first release
Build production for both platforms. Submit iOS, which lands in App Store Connect and from there in TestFlight. Submit Android to the internal track. When the testers are happy, submit to production with releaseStatus: "inProgress" and rollout: 0.1, which releases to a tenth of users and lets you halt it. iOS has its own phased release, switched on in App Store Connect.
What trips people up
A build profile needs a channel. The channel is compiled into the binary and decides which updates that build is allowed to receive.
Example
// app.config.ts
// The Expo config as code, so one file describes three environments.
// EAS_BUILD_PROFILE is set by EAS while a build runs.
import type { ConfigContext, ExpoConfig } from "expo/config";
// ?? is shorthand for: the value, unless it is null or undefined.
// Longhand: const p = process.env.EAS_BUILD_PROFILE;
// const profile = p === undefined || p === null ? "development" : p;
const profile = process.env.EAS_BUILD_PROFILE ?? "development";
const isProduction = profile === "production";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: isProduction ? "Fieldlog" : `Fieldlog (${profile})`,
slug: "fieldlog",
version: "1.0.0",
// Separate ids let a preview build sit next to the store build on one
// device. Changing either one requires a new native build.
ios: {
bundleIdentifier: isProduction
? "com.example.fieldlog"
: `com.example.fieldlog.${profile}`,
supportsTablet: false,
},
android: {
package: isProduction
? "com.example.fieldlog"
: `com.example.fieldlog.${profile}`,
},
// Decides which builds an update may be served to. With appVersion, an
// update only reaches builds whose version matches.
runtimeVersion: { policy: "appVersion" },
updates: { url: "https://u.expo.dev/00000000-0000-0000-0000-000000000000" },
plugins: [
"expo-router",
// The permission strings a reviewer reads. Wrong or missing text is a
// rejection on iOS, not a warning.
["expo-camera", { cameraPermission: "Fieldlog uses the camera to attach a photo to an entry." }],
"expo-secure-store",
],
extra: { eas: { projectId: "00000000-0000-0000-0000-000000000000" } },
});When to use it
- Setting up staging and production builds that can be installed side by side on one phone.
- Getting the first TestFlight and internal-track build into testers' hands.
- Releasing to a tenth of Android users so a bad version can be halted before it spreads.
More examples
eas.json, the whole file
Three build profiles and two submit profiles. distribution: internal is what makes a build installable on registered devices without a store. The Android rollout field is a fraction from 0 to 1 and only works with releaseStatus inProgress, which is the pair that gives you a staged release you can halt from the Play Console.
{
"cli": {
"version": ">= 16.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview",
"autoIncrement": true,
"env": { "EXPO_PUBLIC_API_URL": "https://staging.example.com" }
},
"production": {
"channel": "production",
"autoIncrement": true,
"env": { "EXPO_PUBLIC_API_URL": "https://api.example.com" }
}
},
"submit": {
"preview": {
"android": { "track": "internal" }
},
"production": {
"android": {
"track": "production",
"releaseStatus": "inProgress",
"rollout": 0.1
},
"ios": { "ascAppId": "1234567890" }
}
}
}The first release, command by command
--latest submits the most recent finished build for that platform and profile, which is almost always what you want right after a build. Run the preview build as well on day one: having an installable non-store build is what lets you reproduce a bug report without waiting on review.
# Credentials first. EAS generates and stores the iOS distribution
# certificate and the Android upload keystore, and shows them here.
eas credentials
# One build per platform. --platform all runs both in parallel.
eas build --profile production --platform all
# iOS goes to App Store Connect, which routes it to TestFlight.
eas submit --profile production --platform ios --latest
# Android goes to the track named in the submit profile.
eas submit --profile production --platform android --latest
# A tester build, for the people who are not in the store flow at all.
eas build --profile preview --platform androidPromoting to production and halting it
The two stores stage releases differently and neither is reversible: halting stops distribution but does not take the build off the devices that have it. That asymmetry is the reason the next lesson leans on over-the-air updates for a fix, and on a new build only for native changes.
# Raise the Android rollout in eas.json and resubmit, or change it in the
# Play Console. 0.1 means a tenth of users are eligible.
# "rollout": 0.1 -> 0.5 -> 1
# A halted release stops reaching new users. Those who already have it
# keep it, which is why a staged rollout is not a rollback.
# Play Console -> Releases -> Halt rollout
# iOS phased release is a seven-day ramp, switched on per version in
# App Store Connect. It can be paused, and it can be made immediate.
# Check what is actually live before you change anything.
eas build:list --platform all --limit 5
eas channel:view production
Discussion