Uploading to TestFlight from the Command Line

In Release Management with GitHub Actions I laid out a branching strategy and made the case for driving releases with xcodebuild rather than adding a Ruby toolchain to a Swift project. That post covered the workflow. This one covers the commands themselves, because the upload step is where people get stuck and the error messages are genuinely unhelpful.

Everything here runs on your machine as well as in CI. Being able to reproduce your pipeline locally, one command at a time, is most of the value of not hiding it behind a DSL.

The Three Steps

Getting a build to TestFlight is always the same three operations, whatever tool you use:

  1. Archive - compile and package into a .xcarchive
  2. Export - sign it and produce an .ipa
  3. Upload - send it to App Store Connect

Fastlane's upload_to_testflight wraps all three. So does Xcode's Organizer. Underneath, it's xcodebuild twice and one upload call.

First: An App Store Connect API Key

Stop using an Apple ID and app-specific password. API keys don't prompt for 2FA, don't expire when someone changes a password, and scope cleanly to CI.

In App Store Connect → Users and Access → Integrations → App Store Connect API, create a key with the App Manager role. You get three things:

  • Issuer ID - a UUID, shown once at the top of the page
  • Key ID - 10 characters
  • A .p8 file - downloadable exactly once

Now the part that costs people an afternoon: altool will not accept a path to the .p8 file. It searches these locations, in order:

./private_keys/
~/private_keys/
~/.private_keys/
~/.appstoreconnect/private_keys/

And the filename must be exactly AuthKey_<KEYID>.p8. Put it anywhere else, or rename it, and you get a "could not find the private key" error that doesn't tell you where it looked.

In CI, write it out before uploading:

mkdir -p ~/.appstoreconnect/private_keys
echo -n "$APP_STORE_CONNECT_KEY_BASE64" | base64 --decode \
  > ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8

Store the .p8 in your secrets base64-encoded. It's a PEM file with newlines, and newlines in CI secrets are a recurring source of pain.

Step 1: Archive

xcodebuild archive \
  -project Landmarks.xcodeproj \
  -scheme Landmarks \
  -configuration Release \
  -archivePath build/Landmarks.xcarchive \
  -destination "generic/platform=iOS" \
  -skipPackagePluginValidation \
  CODE_SIGN_STYLE=Manual \
  DEVELOPMENT_TEAM=TEAM123456

A few notes on those flags:

-destination "generic/platform=iOS" builds for the device family rather than a specific simulator. Omit it and you'll archive for whatever destination Xcode last used, which in CI is usually wrong.

-configuration Release is worth stating explicitly even though archive defaults to it. Schemes get edited, and a Debug archive uploads successfully and then behaves badly in ways that take a while to trace.

Use -workspace instead of -project if you have one. Mixing them up produces "scheme not found," which is the least informative error in the toolchain:

xcodebuild archive \
  -workspace Landmarks.xcworkspace \
  -scheme Landmarks \
  ...

-skipPackagePluginValidation is needed when you depend on packages with build plugins, otherwise CI hangs waiting for an interactive trust prompt nobody will ever answer.

To find the right scheme name when you're not sure:

xcodebuild -list -project Landmarks.xcodeproj

Step 2: Export

Export needs an ExportOptions.plist. Minimum viable version for App Store distribution:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>app-store-connect</string>

    <key>teamID</key>
    <string>TEAM123456</string>

    <key>uploadSymbols</key>
    <true/>

    <key>manageAppVersionAndBuildNumber</key>
    <false/>
</dict>
</plist>

Two of those keys deserve comment.

method was app-store in older Xcode versions and is app-store-connect in current ones. If you get "invalid method," this is why.

manageAppVersionAndBuildNumber defaults to true, which lets Xcode silently bump your build number during export. That sounds convenient but breaks your bookkeeping: your CI logs, your git tag, and the build in App Store Connect stop agreeing. Set it to false and manage the number yourself.

Then export:

xcodebuild -exportArchive \
  -archivePath build/Landmarks.xcarchive \
  -exportPath build/export \
  -exportOptionsPlist ExportOptions.plist \
  -allowProvisioningUpdates

-allowProvisioningUpdates lets Xcode fetch or create profiles as needed. It requires authentication, so in CI pass the API key directly:

xcodebuild -exportArchive \
  -archivePath build/Landmarks.xcarchive \
  -exportPath build/export \
  -exportOptionsPlist ExportOptions.plist \
  -allowProvisioningUpdates \
  -authenticationKeyPath ~/.appstoreconnect/private_keys/AuthKey_ABC123DEFG.p8 \
  -authenticationKeyID ABC123DEFG \
  -authenticationKeyIssuerID 1a2b3c4d-5e6f-7890-abcd-ef1234567890

You end up with build/export/Landmarks.ipa.

Step 3: Upload

xcrun altool --upload-app \
  --type ios \
  --file build/export/Landmarks.ipa \
  --apiKey ABC123DEFG \
  --apiIssuer 1a2b3c4d-5e6f-7890-abcd-ef1234567890

--apiKey takes the Key ID, not a path and not the key contents. That's why the file placement rules above matter.

Validate before uploading when you want a fast failure:

xcrun altool --validate-app \
  --type ios \
  --file build/export/Landmarks.ipa \
  --apiKey ABC123DEFG \
  --apiIssuer 1a2b3c4d-5e6f-7890-abcd-ef1234567890

Validation catches missing icons, bad entitlements, and Info.plist problems in under a minute. Upload catches the same issues after a ten-minute transfer. In CI, validating first is usually worth it.

The One-Step Alternative

You can skip altool entirely by telling the export step to upload directly. Add this to ExportOptions.plist:

<key>destination</key>
<string>upload</string>

Then xcodebuild -exportArchive signs and uploads in a single command. Fewer moving parts, and no .ipa left on disk.

The tradeoff is that you lose the artifact. I keep the two steps separate in CI so I can attach the .ipa to the workflow run - when a tester reports something odd, having the exact binary is worth the extra step.

Where notarytool Fits (And Where It Doesn't)

This confuses nearly everyone, so to be explicit:

xcrun notarytool is not part of the TestFlight path. Notarization is for macOS apps distributed outside the App Store, signed with a Developer ID certificate. Apps that go through App Store Connect - iOS or macOS - are notarized by Apple as part of App Store processing. You don't invoke it.

If you are shipping a Developer ID macOS app, the flow is different:

# Submit and wait for the result
xcrun notarytool submit Landmarks.zip \
  --key ~/.appstoreconnect/private_keys/AuthKey_ABC123DEFG.p8 \
  --key-id ABC123DEFG \
  --issuer 1a2b3c4d-5e6f-7890-abcd-ef1234567890 \
  --wait

# Staple the ticket so it validates offline
xcrun stapler staple Landmarks.app

Note that notarytool takes --key as an actual path, unlike altool's --apiKey. Same credentials, different interface, because they're different tools with different histories.

Worth knowing: altool's notarization support was retired - if you find a blog post using altool --notarize-app, it's out of date. altool remains the tool for App Store uploads; notarytool replaced it only for notarization.

When a notarization fails, the log is where the answer is:

xcrun notarytool log <submission-id> \
  --key ~/.appstoreconnect/private_keys/AuthKey_ABC123DEFG.p8 \
  --key-id ABC123DEFG \
  --issuer 1a2b3c4d-5e6f-7890-abcd-ef1234567890

It names the exact binary and the exact reason, which is far more than the submission status tells you.

Build Numbers

Every upload needs a build number higher than the last for that version. The cleanest CI source is the run number:

agvtool new-version -all "$GITHUB_RUN_NUMBER"

agvtool needs CURRENT_PROJECT_VERSION set in build settings and must run from the directory containing the .xcodeproj. If you'd rather not depend on it:

/usr/libexec/PlistBuddy -c \
  "Set :CFBundleVersion $GITHUB_RUN_NUMBER" \
  Landmarks/Info.plist

Whichever you pick, do it before archiving, and don't commit the result. The build number is a property of the CI run, not of the source.

Putting It Together

The whole thing as a script you can run locally or in CI:

#!/bin/bash
set -euo pipefail

SCHEME="Landmarks"
ARCHIVE="build/${SCHEME}.xcarchive"
EXPORT="build/export"

# Credentials come from the environment
: "${ASC_KEY_ID:?}" "${ASC_ISSUER_ID:?}" "${ASC_KEY_BASE64:?}"

mkdir -p ~/.appstoreconnect/private_keys
echo -n "$ASC_KEY_BASE64" | base64 --decode \
  > ~/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8

xcodebuild archive \
  -workspace "${SCHEME}.xcworkspace" \
  -scheme "$SCHEME" \
  -configuration Release \
  -archivePath "$ARCHIVE" \
  -destination "generic/platform=iOS" \
  -skipPackagePluginValidation

xcodebuild -exportArchive \
  -archivePath "$ARCHIVE" \
  -exportPath "$EXPORT" \
  -exportOptionsPlist ExportOptions.plist \
  -allowProvisioningUpdates \
  -authenticationKeyPath ~/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8 \
  -authenticationKeyID "$ASC_KEY_ID" \
  -authenticationKeyIssuerID "$ASC_ISSUER_ID"

xcrun altool --upload-app \
  --type ios \
  --file "${EXPORT}/${SCHEME}.ipa" \
  --apiKey "$ASC_KEY_ID" \
  --apiIssuer "$ASC_ISSUER_ID"

Around fifty lines including the signing setup from the release management post, and every line is a command you can run by hand when it breaks.

Errors You'll Hit

"No signing certificate iOS Distribution found" - the certificate isn't in the keychain, or the keychain isn't unlocked and in the search list. In CI, security list-keychain -d user -s $KEYCHAIN_PATH is the line people forget.

"Provisioning profile doesn't include signing certificate" - the profile was generated against a different certificate. Regenerate it after installing the cert, or use -allowProvisioningUpdates.

"The bundle version must be higher than the previously uploaded version" - build number didn't increment. If you're using $GITHUB_RUN_NUMBER, note that it resets if you rename the workflow file.

"Unable to authenticate with App Store Connect" - nine times out of ten, the .p8 is not in one of the four search directories, or the filename doesn't match AuthKey_<KEYID>.p8.

"altool has been deprecated" in a notarization context - you're following an old guide. Use notarytool for notarization; altool is still correct for App Store upload.

Upload succeeds but the build never appears - it's in processing, which takes anywhere from a few minutes to an hour. If it never shows up, check the email associated with your team account; Apple sends rejection reasons there rather than surfacing them in the CLI.

Wrapping Up

  • Archive, export, upload. Three commands, all inspectable.
  • Use an App Store Connect API key, and respect altool's search paths for the .p8.
  • Set manageAppVersionAndBuildNumber to false and own your build numbers.
  • --validate-app before --upload-app fails faster.
  • notarytool is for Developer ID macOS distribution, not TestFlight. Don't wire it into an iOS pipeline.
  • Keep it in a shell script so you can run it locally when CI misbehaves.

None of this is shorter than fastlane beta. It is, however, entirely made of commands Apple ships and documents, which means when something breaks you're reading xcodebuild output rather than a Ruby stack trace three gems deep.