One autumn I installed a beta version of Xcode purely to see how the new OS would treat my layouts.
I run a small wallpaper app, and I wanted to know in advance whether the new display effects would break anything. The build compiled, went up to TestFlight, and landed on my testers' devices. So far, so good.
The wall appeared when I tried to send that same build to review. The upload had succeeded, but the build simply wasn't there in the submission picker.
The cause wasn't my code. It was the SDK version already baked into the build.
TestFlight and review accept different SDKs
Here is the short version. Apple allows builds made with beta SDKs to be distributed for internal and external testing on TestFlight, but App Store submission requires a released SDK — Release Candidate or later.
In other words, "it uploaded" does not mean "it can be submitted." App Store Connect applies two separate checks: one at upload, one at submission. A beta-SDK build clears only the first.
As of August 20, 2026, the picture looks like this.
| Purpose | SDK required | Current example |
|---|---|---|
| TestFlight internal and external testing | Beta SDKs allowed | Xcode 27 beta 5 / iOS 27.0 beta 5 SDK is accepted |
| App Store review submission | Released SDK (RC or later) | Beta-SDK builds never appear in the submission list |
| Any upload to App Store Connect | iOS 26 / iPadOS 26 SDK or newer | In effect since April 28, 2026 |
That third row is the one people miss. If your reaction to the first two rows is "then I'll just stay on an older Xcode," you run into the minimum requirement instead. There is a ceiling and a floor, and you need to sit between them.
Worth noting: Apple has not announced a date requiring the iOS 27 SDK. There is no reason to rush the upgrade right now. Requirements like these shift every few months, so open the App Store Connect release notes and the Xcode SDK requirements right before you actually submit.
Check which SDK produced the build in front of you
Walking into submission on the assumption that "it was probably the release build" is how you lose an afternoon. Checking takes a few seconds, so it's worth making it a reflex.
Everything you need lives in the Info.plist inside the .ipa. Xcode writes these keys automatically at build time — they are not something you edit by hand.
#!/usr/bin/env bash
# ipa-sdk.sh — show which SDK and Xcode produced an .ipa
set -euo pipefail
IPA="${1:?usage: ipa-sdk.sh <path-to-ipa>}"
# An .ipa is a zip, so we can read Payload/*.app/Info.plist without extracting it
PLIST_PATH="$(unzip -Z1 "$IPA" 'Payload/*.app/Info.plist' | head -1)"
unzip -p "$IPA" "$PLIST_PATH" \
| plutil -convert xml1 -o - - \
| grep -A1 -E '<key>(DTSDKName|DTXcode|DTPlatformBuild|MinimumOSVersion)</key>' \
| grep -v -- '--'A typical run prints something like this.
<key>DTPlatformBuild</key>
<string>23A5308c</string>
<key>DTSDKName</key>
<string>iphoneos27.0</string>
<key>DTXcode</key>
<string>2700</string>
<key>MinimumOSVersion</key>
<string>16.0</string>
Reading it: DTSDKName is the SDK the build used, and DTXcode is the Xcode version (2700 means 27.0). The deciding field is DTPlatformBuild. A platform build number ending in a lowercase letter — 23A5308c above — comes from a beta release. Released versions drop that trailing letter.
The reason for piping unzip -p into plutil rather than unzipping the archive is that you don't want to expand a few hundred megabytes every time you check. And because the plist is stored in binary form, grep alone returns nothing useful, which is why plutil -convert xml1 -o - sits in the middle.
Your Xcode version is chosen by eas.json, not by Rork
If you exported a project from Rork and build it on EAS, the Xcode version comes from the image field in eas.json. If you have never touched it, the default auto is in play.
The values you can pass include several aliases, and they don't mean the same thing.
| Value | Meaning | Behavior for release builds |
|---|---|---|
auto (default when unset) |
EAS picks an image suited to your Expo SDK | Generally the safe choice |
latest |
Points at the most recently added image | Follows along automatically when a beta Xcode image lands |
SDK aliases such as sdk-57 |
The image for a specific Expo SDK | Intent is explicit, which makes it good for pinning |
latest is the one to watch. The name suggests "newest stable," but it actually means "most recently added image," and it will happily point at a beta Xcode image the day one is published. The failure arrives long after you set it — which is exactly why it catches people.
The shape I use in my own solo projects separates the release lane from the beta-inspection lane up front.
{
"build": {
"production": {
"distribution": "store",
"ios": {
"image": "sdk-57"
}
},
"beta-preview": {
"distribution": "internal",
"channel": "beta-preview",
"ios": {
"image": "latest"
}
}
}
}Pinning production to an SDK alias means a newly published beta image can't move it. When I do want to look at a new OS, I run eas build --profile beta-preview and keep that lane on internal distribution.
The benefit isn't only that it prevents mistakes. Because the profile name is recorded with the build, you can tell weeks later which lane a given binary came from without guessing.
You can confirm the image that was actually used near the top of the build log. Checking it once, the first time you set this up, is enough to know your configuration took effect.
Put one hard stop in front of submission
Anything that depends on remembering will eventually be forgotten. I fold the pre-submission check into a single command that exits non-zero when the build isn't eligible.
#!/usr/bin/env bash
# guard-release-sdk.sh — stop beta-SDK builds before they enter the submission flow
set -euo pipefail
IPA="${1:?usage: guard-release-sdk.sh <path-to-ipa>}"
PLIST_PATH="$(unzip -Z1 "$IPA" 'Payload/*.app/Info.plist' | head -1)"
XML="$(unzip -p "$IPA" "$PLIST_PATH" | plutil -convert xml1 -o - -)"
read_key() {
echo "$XML" | grep -A1 "<key>$1</key>" | tail -1 | sed -E 's/.*<string>(.*)<\/string>.*/\1/'
}
SDK_NAME="$(read_key DTSDKName)"
PLATFORM_BUILD="$(read_key DTPlatformBuild)"
echo "SDK: ${SDK_NAME} / platform build: ${PLATFORM_BUILD}"
# Beta platform build numbers end in a lowercase letter, e.g. 23A5308c
if [[ "$PLATFORM_BUILD" =~ [a-z]$ ]]; then
echo "This build cannot be submitted for review. Rebuild with a released SDK." >&2
exit 1
fi
echo "Built with a submittable SDK."The check hangs on the last character of DTPlatformBuild rather than the SDK name because the SDK name isn't discriminating enough. The string iphoneos27.0 appears for both the beta and the release SDK. Only the platform build number tells them apart.
Putting this at the top of your release steps changes the check from something you recall to something you run. When you ship alone, verification steps are the first thing to get skipped, so it helps to have the process stop on its own.
If a build reaches TestFlight cleanly but behaves differently after release, the cause is usually build configuration rather than the SDK. I walk through that separation in Four reasons your app crashes after App Store release when TestFlight was fine.
One thing to check today
Open eas.json and look at whether ios.image in your release profile is set to latest. If it is, the next time a beta image is published, an unsubmittable build will be produced quietly.
It's a one-line setting, but it's the line that matters during the autumn OS cycle. Since the afternoon I described at the top, I keep the release lane pinned and let only the preview lane float.
Thank you for reading. I hope this saves someone the detour I took.