Apple-native Archive
Archive and TestFlight post-actions live next to App Store Connect, with env vars editable in Xcode.
EAS Build is the default path for most Expo teams, and it works well. You can also ship the same app through Xcode Cloud — Apple's CI next to App Store Connect — when you want Archive and TestFlight in one Apple-native workflow, a second production lane beside EAS, or builds that are not tied to whatever Xcode is installed on a developer Mac.
We set this up for Sisu, Codse Tech's Expo kids learning app (managed workflow, ios/ gitignored). This post is the playbook: regenerate native projects in ci_post_clone, keep build numbers sane, trigger only from release/*, Archive with App Store Connect deployment preparation (so builds are App Store–eligible), and upload to TestFlight from a post-action.
One constraint up front: do not archive for the App Store from a macOS beta host. Even with a "stable" Xcode.app, Apple can reject the IPA with ITMS-90111 (unsupported SDK / Xcode). Cloud builders on release images are fine. Local eas build --local on the same beta Mac hits the same wall.

Treat Cloud as an alternative or complement to EAS iOS Archive, not a forever fork.
Archive and TestFlight post-actions live next to App Store Connect, with env vars editable in Xcode.
Useful when you want EAS for day-to-day Expo work and Cloud for store archives, or the reverse.
Runs on Apple's release macOS/Xcode images, independent of beta OS on a laptop.
Pin EAS production to a stable image (for example "image": "sdk-57") so cloud EAS is not coupled to a local toolchain.
On a beta Mac: use EAS cloud or Xcode Cloud for store binaries. Skip local Archive and eas build --local until you are back on a release OS.
ios/ projectManaged Expo usually keeps ios/ out of git. Xcode Cloud needs a workspace after clone.
What works for Sisu:
ios/ci_scripts/ci_post_clone.sh (ignore the rest of ios/).xcode-cloud/ci_post_clone.sh.npm ci → expo prebuild --platform ios --clean → restore the launcher → pod install.prebuild --clean deletes ios/, including the launcher Cloud used to find your script. Copy that launcher back in the same job or the next run has nothing to exec.
Local one-time setup so you can create the workflow:
npm run prebuild:ios # prebuild + restore ci_scripts launcher
open ios/*.xcworkspace
# Product → Xcode Cloud → Create Workflow
# Archive → App Store Connect (distribute + TestFlight), then post-action upload
Pick the app product in the workflow — for Sisu that is the Sisu target, not an ExpoPods target.
You do not need production secrets in git for Cloud builds. Edit workflow env in:
Mark values as secret, scope them per workflow, and change them without a code push. Anything ci_post_clone, expo prebuild, or the JS bundle needs at build time should live there (plus Apple's own CI vars like CI_BUILD_NUMBER).
| Kind | Example names | Notes |
|---|---|---|
| Public client config | EXPO_PUBLIC_API_URL, EXPO_PUBLIC_ENV=production | Inlined into the JS bundle; treat as public |
| IAP / subscriptions | EXPO_PUBLIC_REVENUECAT_IOS_API_KEY, entitlement id | Use the store SDK key on Archive workflows; mark secret |
| Ads | EXPO_PUBLIC_ADMOB_USE_TEST_IDS=0 | Production workflows should not ship test ad units |
| Analytics / flags | EXPO_PUBLIC_POSTHOG_KEY, EXPO_PUBLIC_FLAGS_KEY | Client / read-only keys only |
| Native secrets | Sentry auth, upload tokens | Secret in Xcode; never EXPO_PUBLIC_* |
| Build control | IOS_BUILD_NUMBER, IOS_BUILD_NUMBER_BASE | Only if you stamp before prebuild (Option B below) |
Apple also injects CI metadata (CI, CI_BUILD_NUMBER, CI_PRIMARY_REPOSITORY_PATH, …). Export CI=true (lowercase) in ci_post_clone if your tooling only treats the boolean form as truthy — Apple often sets CI=TRUE.
On the same Environment tab, pick a release macOS / Xcode image. Creating the workflow from beta Xcode on your Mac is fine; building the store IPA on a beta image is not.
1If app.json has no expo.ios.buildNumber, expo prebuild writes CFBundleVersion as 1. Fine for a brand-new app. Wrong if ASC already has higher builds — duplicates and backward jumps get rejected.
Fix it one of two ways. Pick one. Do not run both.
In the Archive action, enable Managed Version and Build Number. Set the next build number above whatever is already in App Store Connect. Cloud stamps that value and increments on later runs.
This is the simplest path when Cloud is your primary Archive lane: no script math, fewer env vars, Apple owns the counter.
app.json before prebuildUseful when you want the number driven from env, or you are coordinating closely with EAS:
buildNumber = IOS_BUILD_NUMBER
or
buildNumber = IOS_BUILD_NUMBER_BASE + CI_BUILD_NUMBER
Example: base 40 + first Cloud CI_BUILD_NUMBER=1 → 41.
If you use Option B, turn Managed Version and Build Number off so Cloud does not overwrite your stamp.
After a Cloud ship, sync EAS if you still use it:
eas build:version:set -p ios <n>
We do not Archive from every main push. The workflow start condition is custom branches matching release/* only. Pushes to main, feature branches, or tags alone do not start this job.
# version = marketing (e.g. 1.0.7), build = intended CFBundleVersion label
git checkout -b release/1.0.7-41
git push -u origin release/1.0.7-41 # this push starts Xcode Cloud
git tag v1.0.7-41 # optional git label
git push origin v1.0.7-41
Branch pattern: release/{version}-{build}. The branch push is the trigger. The tag is for humans and history.
Also turn Auto-cancel Builds off on this Archive workflow. With it on, a second push while a run is queued cancels the earlier one — fine for PR spam, bad mid-release.
Configure the Archive action carefully. Deployment preparation must be App Store Connect — the option for distributing on the App Store and testing (TestFlight). Do not leave it on an internal-only / TestFlight-internal destination.
If you Archive for internal testing only, the build lands as buildAudienceType: INTERNAL_ONLY. It can show VALID in TestFlight and still refuse App Store version attach (The specified pre-release build could not be added). App Review needs APP_STORE_ELIGIBLE.
Recommended Archive + post-action setup:
Green Cloud + TestFlight is still not App Review. You attach a Valid, App Store–eligible build to the version and submit (IAP and metadata are separate).
ci_post_clone shapenpm ci
export CI=true
# Optional: only if you use Option B build numbers
# node ./xcode-cloud/set-ios-build-number.mjs
npx expo prebuild --platform ios --clean
# restore ios/ci_scripts/ci_post_clone.sh launcher
cd ios && pod install --repo-update
Write the durable bits into AGENTS.md so the next engineer or coding agent does not rediscover ITMS-90111 the hard way.
| Symptom | Likely cause | Fix |
|---|---|---|
ITMS-90111 | Archive from macOS beta host | Build on EAS cloud or Xcode Cloud release image |
| Build attaches in TF but not to App Store version | Archive destination was internal-only → INTERNAL_ONLY audience | Set Archive deployment preparation to App Store Connect; rebuild |
| Huge EAS upload / failed archive | Fat paths in the upload (.build/, node_modules, screenshot trees) | Tighten .easignore if you still use EAS |
Cloud build number 1 | No prior number, or Managed + script fighting | Option A or Option B, not both |
| Pending build disappears | Auto-cancel on | Disable for the release Archive workflow |
| Wrong target / odd compile | Workflow pointed at a pod | Select the Sisu app product |
| Wrong client config in binary | Workflow env missing in Xcode | Set Environment vars; rebuild |
| Launcher gone after clean | Forgot restore after prebuild --clean | Copy launcher back in the same script |
CI green does not prove the app boots. Separate Expo gotchas we have seen as blank TestFlight windows: react-i18next suspending with useSuspense: true before initI18n, a nested Stack under Native Tabs hanging in Release only, and Info.plist pointing at a SceneDelegate class that never landed in the binary. Smoke a Release build on device or TestFlight before you trust Archive alone.
No. Use it as an alternative or second lane. Keep one source of truth for CFBundleVersion — Cloud-managed or stamped in prebuild, not both — and sync the other system with eas build:version:set after a ship if you use both.
No. Track only ios/ci_scripts/ (launcher) plus your xcode-cloud/ scripts. Regenerate native projects on every Cloud run with expo prebuild.
Same app, different host OS/SDK. A release-image cloud builder (EAS cloud or Xcode Cloud) is what Apple expects. Local beta macOS archives are a common ITMS-90111 source even when Xcode.app looks stable.
The Xcode Cloud Archive action was set to an internal-only / TestFlight-internal destination. Switch Deployment Preparation to App Store Connect (distribute and test), run a new release branch build, and attach that App Store–eligible binary instead.
Start condition on release/* only, Auto-cancel off for that workflow, and no "every change to main." Intentional ships only.
If you want an Expo store archive that is not bound to a laptop Xcode install — or you are stuck behind the macOS beta wall — release-image Xcode Cloud plus prebuild-in-ci_post_clone is a clean alternative to EAS Archive.
Pair this with the screenshot pipeline when you are refreshing store creatives for the same release.