Skip to content
Commitwise
Menu
iOS Development

TestFlight and App Store release

Ship an iOS app from Xcode to users, with signing certificates and provisioning profiles, build numbers, build configurations for staging and production, TestFlight internal and external testing, App Store review and its common rejections, privacy labels and manifests, and phased releases you can pause but not roll back.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The team sent the first beta to 40 testers through TestFlight on Monday. By Tuesday, the testers had placed 23 real orders with real cards, because the beta talked to the production server: the staging URL was behind #if DEBUG, and TestFlight builds aren’t Debug builds. On Thursday, the App Store submission was rejected because the reviewer couldn’t log in. On Friday, the next upload failed with “The bundle version must be higher than the previously uploaded version.”

Releasing an iOS app has more moving parts than building it: certificates and profiles, build configurations, TestFlight, review, and a rollout you can pause but not undo. None of it is hard once you know where each piece lives.

New to some of these? Open what you need, or skip ahead.

Basics:What is App Store Connect?

App Store Connect is Apple’s website (and API) for managing your apps: creating the app record, uploading builds, inviting TestFlight testers, writing the store listing, submitting for review and releasing. You need a paid Apple Developer Program membership.

Basics:What is a build configuration?

A build configuration is a named set of build settings. New projects have Debug (used when you press Run) and Release (used when you archive for distribution). You can add more, like Beta, each with its own settings, such as which server to use.

Part Not started

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), like 2.4.0: what users see.
  • Build (CFBundleVersion), like 412: 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 .xcconfig file and copied into Info.plist at build time. The app reads it at runtime, so there’s no #if in Swift code.
  • The Beta app has a different display name (“Journal β”), so testers can see which one they’re using.
  • https:/$()/ is an .xcconfig quirk: // 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.
Spot the bugBeta testers place real orders

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 → production

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?

  • Distribution certificate + App Store profile per bundle id, including every extension; automatic signing, Xcode Cloud, or API-key-based CI (-allowProvisioningUpdates, fastlane match).
  • CFBundleVersion strictly increasing per version; from CI; survive CI migrations.
  • Environments via build configurations and .xcconfig → Info.plist; #if DEBUG can’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

  1. Which number must increase with every upload of the same version?

  2. Which testers get a TestFlight build without a beta review?

  3. Why does #if DEBUG send TestFlight users to production?

Apps are signed with a distribution certificate and provisioning profile (automatic signing or a CI-managed setup). Each upload needs a new build number for its version. Use build configurations, not #if DEBUG, to pick environments, since archives are Release builds. TestFlight internal testing is instant for up to 100 team members; external testing needs a beta review and reaches up to 10,000 testers; builds expire after 90 days.

Part Not started

Review, privacy and releasing

To submit a version for review, App Store Connect needs:

  • Store listing: name, subtitle, description, keywords, screenshots for the required device sizes, support URL.
  • Privacy: the “nutrition label” (what data the app and its SDKs collect and why) and a privacy policy URL.
  • Review information: a demo account if the app needs login, and notes explaining anything non-obvious.

Common reasons apps get rejected:

  • The app crashes or has obvious bugs on the reviewer’s device.
  • The reviewer can’t log in (no demo account, or it expired).
  • Apps with account creation don’t offer account deletion inside the app.
  • Digital content or features are sold without in-app purchase.
  • Privacy information doesn’t match what the app actually does.

After approval, you choose how to release: immediately, on a date, or as a phased release over 7 days, reaching more users each day for automatic updates. You can pause a phased release if problems appear, but you can’t take an update back from people who installed it; the fix is a new version.

Submission requirements beyond metadata: privacy nutrition labels must cover third-party SDKs; a privacy manifest (PrivacyInfo.xcprivacy) declares collected data types, tracking domains and reasons for required-reason APIs (UserDefaults, file timestamps, system boot time, disk space, active keyboards), and commonly used third-party SDKs must ship their own signed manifests; App Tracking Transparency is required before cross-app tracking. Guideline hot spots: 2.1 (completeness: crashes, placeholder content, broken demo accounts), 4.8 (an equivalent privacy-focused login option when offering third-party login), 5.1.1(v) (in-app account deletion), 3.1.1 (in-app purchase for digital goods, with regional exceptions for external purchase links that change over time, so check the current rules), and 4.2 (minimum functionality). Review usually completes within a day or two; expedited review exists for critical fixes. Phased release applies to automatic updates over 7 days (1%, 2%, 5%, 10%, 20%, 50%, 100%) and can be paused for up to 30 days in total; users can always update manually from the store. Rollback isn’t possible: mitigation is server-side (feature flags, kill switches, minimum-version enforcement with a forced-update screen) plus a quick fix submission.

// Account deletion must be reachable inside the app (Guideline 5.1.1(v))
struct AccountSettingsView: View {
    @State private var confirmDeletion = false
    let account: AccountService

    var body: some View {
        Form {
            Section {
                Button("Delete account", role: .destructive) { confirmDeletion = true }
            } footer: {
                Text("Deletes your journal, photos and account from our servers. This can't be undone.")
            }
        }
        .confirmationDialog("Delete your account?", isPresented: $confirmDeletion, titleVisibility: .visible) {
            Button("Delete account", role: .destructive) {
                Task { try? await account.deleteAccount() }   // server deletes data, then sign out
            }
        }
    }
}

// A minimum-version check: the only way to push users off a broken release
func enforceMinimumVersion(_ remote: RemoteConfig) -> Bool {
    let current = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
    return current.compare(remote.minimumSupportedVersion, options: .numeric) != .orderedAscending
}
<!-- PrivacyInfo.xcprivacy (excerpt) -->
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>CA92.1</string></array>   <!-- app's own preferences -->
    </dict>
  </array>
  <key>NSPrivacyCollectedDataTypes</key>
  <array>
    <dict>
      <key>NSPrivacyCollectedDataType</key>
      <string>NSPrivacyCollectedDataTypeEmailAddress</string>
      <key>NSPrivacyCollectedDataTypeLinked</key>
      <true/>
      <key>NSPrivacyCollectedDataTypeTracking</key>
      <false/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
    </dict>
  </array>
</dict>

Production puzzles

Predict the output

Version 2.5 is in a phased release on day 3 (5%) when a crash in the new editor appears. The team pauses the phased release. Who can still get 2.5?

Predict the output

An app adds an analytics SDK that reads the device's boot time and disk space for fingerprinting, without declaring reasons in a privacy manifest. What happens on upload?

Swift note

Xcode Cloud can run the whole pipeline (build, test, archive with the right configuration, upload, distribute to TestFlight groups) from workflows defined in App Store Connect, with automatic signing and build numbers handled for you. It’s often the simplest path for small teams; larger ones with existing CI typically use the App Store Connect API with xcodebuild or fastlane.

A release checklist

  • Version and build numbers set by CI; the right configuration for each channel.
  • TestFlight: internal for a day, external testers for the risky parts, crash reports checked in the Organizer.
  • Privacy labels and manifest updated for any new SDK; ATT prompt only if tracking.
  • Review notes with a working demo account that doesn’t expire; any special hardware or setup explained.
  • Phased release on, feature flags for risky features, a minimum-version kill switch, and a plan for an expedited fix.
Exerciseeasy

Prepare the review notes

Your app requires login with a phone number and SMS code, and the main feature only works near a partner restaurant (it uses location). Write down what you’d put in the App Review notes so the reviewer can test it.

›

The reviewer can’t receive your SMS and isn’t near your restaurants. What do they need?

SolutionTry it yourself first
  • A demo account that skips SMS: a test phone number with a fixed code (for example +1 555 0100, code 123456) that works only for that number, never expires, and has sample data.
  • Location: explain that ordering is limited to areas with partner restaurants, and provide a way to test anyway: a demo restaurant available everywhere for the demo account, or steps to set a simulated location.
  • Anything else unusual: in-app purchases and how to reach them, features behind settings, hardware the app needs.

Without this, the likely result is a rejection under Guideline 2.1 (the reviewer couldn’t access the app’s features), which costs a day or more.

  • Submission: metadata, screenshots, privacy nutrition labels covering SDKs, PrivacyInfo.xcprivacy with required-reason APIs (rejections since May 2024), ATT before tracking; no fingerprinting.
  • Rejection hot spots: 2.1 completeness/demo accounts, 4.8 login options, 5.1.1(v) in-app account deletion, 3.1.1 in-app purchase for digital goods (regional rules change), 4.2 minimum functionality.
  • Phased release: 7 days for automatic updates, pausable up to 30 days total; manual updates still possible; no rollback.
  • Mitigation: remote feature flags, minimum-version forced update, expedited review for the fix.
  • Xcode Cloud or App Store Connect API + xcodebuild/fastlane for automation; Organizer privacy report per archive.

Check this part

  1. What must an app that lets users create accounts also offer?

  2. A released version has a bad bug. Can you roll users back to the previous version?

  3. What does pausing a phased release stop?

A submission needs metadata, screenshots, privacy nutrition labels, a privacy manifest covering required-reason APIs, and review notes with a demo account. Common rejections: crashes, broken login for the reviewer, missing account deletion, digital goods sold without in-app purchase, and inaccurate privacy information. Release manually or with a 7-day phased release that you can pause; you can't roll back, so fixes ship as new versions, possibly with expedited review.

Key takeaways

  • Archive builds use the Release configuration; pick environments with build configurations, not #if DEBUG.
  • Every upload needs a higher build number.
  • TestFlight internal testing is instant; external testing needs a short review.
  • Give reviewers a working demo account, offer account deletion, and release in phases.
  • Signing per bundle id and extension, automated with API keys or Xcode Cloud.
  • .xcconfig-driven environments surfaced through Info.plist.
  • Privacy manifests and labels are enforced at upload and review.
  • Phased releases pause but never roll back: flags, minimum versions and expedited fixes are the real safety net.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.