Skip to content
C.W.K.
Stream
Lesson 05 of 07 · published

Archive, Export, Upload: One Command That Cannot Lie

~17 min · testflight, xcodebuild, export, zsh, pipelines

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Nobody re-checks a green run."

Three Steps, Two Tools

Getting a build to App Store Connect from a terminal is two xcodebuild invocations. archive builds the Release configuration for generic/platform=iOS into an .xcarchive, signed for development, with -allowProvisioningUpdates so it can create what signing needs. -exportArchive then re-signs that archive according to an export-options property list. Its method is app-store-connect (the older app-store spelling is deprecated), and its destination is either export, which writes an .ipa to disk, or upload, which sends the build to App Store Connect. Since 28 April 2026, an iOS app uploaded to App Store Connect must be built with Xcode 26 or later and the iOS 26 SDK.

Two environment facts sit in front of it. Put Apple's binaries first on PATH: the export step shells out to rsync, and a Homebrew rsync 3.x kills it. And never name a zsh variable status, options, path, signals or commands. Those are zsh's own, and assigning options kills the script with an error that zsh -n cannot see.

UPLOAD_OK Under EXPORT FAILED

The training app's pipeline ran an upload. xcodebuild printed error: exportArchive Error Downloading App Information and ** EXPORT FAILED **, and the very next line the script printed was its upload-OK marker. set -euo pipefail was on. The export command was piped through grep to show only the interesting lines, with || true so a grep that matched nothing would not stop the run, and that made the upload step have no gate at all. The same shape was in the three pipelines that came before it.

This is the worst version of collapsing the four facts: a pipeline that prints fact one when it is false, in a line the next session and the owner will both believe. The fix takes two independent checks, because each misses a case the other catches. Tee the full output to a log and take the status of the first command from zsh's pipestatus[1] (lowercase and 1-indexed; bash's is PIPESTATUS[0]) between set +e and set -e. That catches a tool that fails without a banner. Then grep the kept log for ** EXPORT FAILED **, which catches a tool that exits 0 on a logical failure.

The archive step gets the simpler treatment: no pipe at all, the whole log redirected to a file, xcodebuild's own exit status, and a check that the archive directory actually exists. The script below was run against a stand-in for xcodebuild in all three export behaviours, beside the filtered version it replaces.

Code

Archive and upload with a kept log, the real status, and the failure banner·bash
#!/bin/zsh
set -euo pipefail
# Apple's binaries first: the export step shells out to rsync, and Homebrew's 3.x kills it.
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:$PATH"
xcodebuild=${XCODEBUILD:-xcodebuild}
asc_auth=(${(f)"$(./asc-auth.zsh)"})
build=42 dir=build
archive="$dir/SparkMobile-$build.xcarchive"
mkdir -p "$dir" && rm -rf "$archive"

# 1. Archive: the whole log in a file and xcodebuild's own status. Never a filtered pipe.
set +e
"$xcodebuild" -project SparkMobile.xcodeproj -scheme SparkMobile -configuration Release \
  -destination 'generic/platform=iOS' -archivePath "$archive" archive \
  -allowProvisioningUpdates "${asc_auth[@]}" > "$dir/archive-$build.log" 2>&1
archive_exit=$?
set -e
if (( archive_exit != 0 )) || [[ ! -d "$archive" ]]; then
  print -u2 "HARD_FAIL archive did not land (exit $archive_exit, see $dir/archive-$build.log)"
  exit 1
fi

# 2. Export options: App Store Connect distribution, uploaded, build number left alone.
cat > "$dir/export-options.plist" <<PLIST
<?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>destination</key><string>upload</string>
  <key>teamID</key><string>EXAMPLE_TEAM</string>
  <key>manageAppVersionAndBuildNumber</key><false/>
</dict></plist>
PLIST

# 3. Upload: show a filtered view, keep everything, and judge twice.
export_log="$dir/export-$build.log"
set +e
"$xcodebuild" -exportArchive -archivePath "$archive" -exportPath "$dir/export-$build" \
  -exportOptionsPlist "$dir/export-options.plist" -allowProvisioningUpdates "${asc_auth[@]}" \
  2>&1 | tee "$export_log" | grep -E 'error:|Upload|\*\* EXPORT'
export_status=${pipestatus[1]}   # xcodebuild's status, not grep's (zsh: lowercase, 1-indexed)
set -e
if (( export_status != 0 )) || grep -q '\*\* EXPORT FAILED \*\*' "$export_log"; then
  print -u2 "HARD_FAIL upload refused (status $export_status). Nothing was uploaded."
  exit 1
fi
print "SPARK_MOBILE_UPLOAD_OK build $build: uploaded is fact 1 of 4"
Measured: the filtered pipe against the two-check gate·text
A stand-in for xcodebuild, three ways, against two scripts:

  export behaviour                          | filtered pipe + "|| true"  | status + banner gate
  ------------------------------------------+----------------------------+---------------------
  exit 0, "** EXPORT SUCCEEDED **"          | prints UPLOAD_OK           | prints UPLOAD_OK
  exit 0, "** EXPORT FAILED **" in output   | prints UPLOAD_OK           | HARD_FAIL (status 0)
  exit 70, no banner                        | prints UPLOAD_OK           | HARD_FAIL (status 70)

External links

Exercise

Write a stand-in fake-xcodebuild with the three export behaviours from the table, selected by an environment variable, and run both the filtered one-liner and the gated pipeline against each. Record all six results. Then break the gated script on purpose in two ways, first by removing the banner grep and then by reading $? instead of pipestatus[1], and show which behaviour each broken version lets through.
Hint
The stand-in only needs to create the -archivePath directory for archive and print the right lines and exit code for -exportArchive. Pass it in with XCODEBUILD=./fake-xcodebuild so the pipeline itself stays unchanged.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.