Signing, builds and TestFlight
Signing proves the app comes from you and hasn’t been changed. It involves:
- a certificate (your identity as a developer or team),
- a provisioning profile (which app, which certificate, and for development builds, which devices),
- the app’s bundle identifier, like
com.example.journal.
For most teams, Xcode’s Automatically manage signing handles all of it.
Every upload has two numbers:
- Version (
CFBundleShortVersionString), like2.4.0: what users see. - Build (
CFBundleVersion), like412: must be higher than every build already uploaded for that version. Most teams let CI set it.
To ship a build: Product → Archive, then Distribute App → App Store Connect. After processing, it appears in TestFlight:
- Internal testers (up to 100 people on your team) get it right away.
- External testers (up to 10,000, by email or a public link) get it after a short beta review.
- TestFlight builds expire after 90 days.
Distribution signing uses an Apple Distribution certificate and an App Store provisioning profile per bundle identifier (and per extension: widgets, notification service extensions and App Clips each need their own). Automatic signing works locally and on Xcode Cloud; other CI typically uses an App Store Connect API key with xcodebuild -allowProvisioningUpdates, or fastlane match storing certificates in an encrypted repository. Build numbers must increase within a version (Apple compares them as dot-separated integers); derive them from CI. Environments belong in build configurations with .xcconfig files: values such as API_BASE_URL go into Info.plist via $(API_BASE_URL) and are read at runtime, and a separate Beta configuration (archived by a Beta scheme) points TestFlight builds at staging. #if DEBUG only distinguishes “run from Xcode” from “archived”, and every TestFlight and App Store build is archived. TestFlight: internal testing needs no review and is limited to App Store Connect users; external groups need Beta App Review for the first build of a version, then subsequent builds usually go through faster; builds expire after 90 days; export compliance (encryption) can be declared once in Info.plist with ITSAppUsesNonExemptEncryption.
enum AppConfig {
static let apiBaseURL: URL = {
guard let value = Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String,
let url = URL(string: value) else {
fatalError("APIBaseURL missing from Info.plist") // fails on the first launch in testing, not in production
}
return url
}()
}// Config/Beta.xcconfig (used by the Beta build configuration and the "Journal Beta" scheme)
API_BASE_URL = https:/$()/staging-api.example.com
APP_DISPLAY_NAME = Journal β
PRODUCT_BUNDLE_IDENTIFIER = com.example.journal
// Config/Release.xcconfig
API_BASE_URL = https:/$()/api.example.com
APP_DISPLAY_NAME = Journal
PRODUCT_BUNDLE_IDENTIFIER = com.example.journal
// Info.plist
// <key>APIBaseURL</key> <string>$(API_BASE_URL)</string>
// <key>CFBundleDisplayName</key> <string>$(APP_DISPLAY_NAME)</string># CI: archive the Beta configuration with a build number from the pipeline, then upload
xcodebuild -scheme "Journal Beta" -configuration Beta \
-archivePath build/Journal.xcarchive archive \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER"
xcodebuild -exportArchive -archivePath build/Journal.xcarchive \
-exportOptionsPlist ExportOptions.plist -exportPath build/export \
-allowProvisioningUpdates \
-authenticationKeyPath "$ASC_KEY_PATH" -authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
# ExportOptions.plist: method = app-store-connect, destination = upload- Each configuration has its own server URL, set in an
.xcconfigfile and copied into Info.plist at build time. The app reads it at runtime, so there’s no#ifin Swift code. - The Beta app has a different display name (“Journal β”), so testers can see which one they’re using.
https:/$()/is an.xcconfigquirk://starts a comment there, so the empty$()splits it.- CI sets the build number (
CURRENT_PROJECT_VERSION), so it always goes up and nobody edits it by hand.
Tap the line with the bug.
enum Environment {
static var apiBaseURL: URL {
#if DEBUG
return URL(string: "https://staging-api.example.com")!
#else
return URL(string: "https://api.example.com")!
#endif
}
}
// The team's plan: Run from Xcode → staging, TestFlight → staging, App Store → productionHint: Which build configuration does Product → Archive use, and so which branch does a TestFlight build take?
#if DEBUG is decided at compile time by the build configuration. Archive uses Release, and every TestFlight build is an archive, so TestFlight builds take the #else branch: production. Testers, believing they’re on a test version, create real orders, real charges and real emails to real restaurants.
Make the environment an explicit build setting, with a configuration per environment:
enum Environment {
// Set per build configuration in .xcconfig: Debug and Beta → staging, Release → production
static let apiBaseURL = AppConfig.apiBaseURL
}Then archive TestFlight builds with the Beta scheme and App Store builds with Release. Add a visible marker in non-production builds (a “STAGING” badge or a different app icon), so nobody has to guess.
Predict the output
Version 2.4.0 build 57 is on TestFlight. A fix is ready, and CI's build counter was reset to 1 after migrating to a new CI system. The new upload is 2.4.0 build 1. What happens?
Build numbers must increase for each version, so upload validation fails with a message saying the bundle version must be higher than the previously uploaded version. Keep build numbers monotonic across CI migrations (start the new counter above the old one, or derive it from something that only grows, such as a timestamp-based number like 202611101530).
- Distribution certificate + App Store profile per bundle id, including every extension; automatic signing, Xcode Cloud, or API-key-based CI (
-allowProvisioningUpdates, fastlane match). CFBundleVersionstrictly increasing per version; from CI; survive CI migrations.- Environments via build configurations and
.xcconfig→ Info.plist;#if DEBUGcan’t express “TestFlight vs App Store” because both are archives. - TestFlight: internal (≤100 App Store Connect users, no review), external (≤10,000, Beta App Review), 90-day expiry; declare
ITSAppUsesNonExemptEncryption.
Check this part
Which number must increase with every upload of the same version?
Which testers get a TestFlight build without a beta review?
Why does #if DEBUG send TestFlight users to production?