본문 바로가기
C.W.K.
Lesson 05 of 07 · published

아카이브, export, 업로드: 거짓말 못 하는 명령 하나

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

Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"초록색으로 끝난 실행은 아무도 다시 확인 안 해."

단계 셋, 도구 둘

터미널에서 App Store Connect까지 빌드를 보내는 데는 xcodebuild 호출 두 번이면 돼. archive는 Release 설정을 generic/platform=iOS 대상으로 빌드해서, 개발용으로 서명한 .xcarchive를 만들어. 서명에 필요한 걸 알아서 만들 수 있게 -allowProvisioningUpdates도 줘. 이어서 -exportArchive가 export 옵션 속성 목록대로 그 아카이브를 다시 서명해. method는 app-store-connect(옛 이름 app-store는 폐기 예정)고, destination은 디스크에 .ipa를 쓰는 export나, App Store Connect로 보내는 upload야. 2026년 4월 28일부터 App Store Connect에 올리는 iOS 앱은 Xcode 26 이상과 iOS 26 SDK로 빌드돼야 해.

그 전에 챙겨야 할 환경 사실이 두 가지 있어. PATH엔 애플 바이너리를 먼저 둬. export 단계가 내부에서 rsync를 부르는데, Homebrew rsync 3.x가 잡히면 그 단계가 죽거든. 그리고 zsh 변수 이름을 절대 status, options, path, signals, commands로 짓지 마. 전부 zsh가 이미 쓰는 특수 변수라서, 예를 들어 options에 값을 넣으면 zsh -n 문법 검사로는 안 잡히는 에러로 스크립트가 죽어.

EXPORT FAILED 바로 밑의 UPLOAD_OK

훈련 앱의 파이프라인이 업로드를 돌렸어. xcodebuild가 error: exportArchive Error Downloading App Information과 ** EXPORT FAILED **를 찍었는데, 스크립트가 바로 다음 줄에 찍은 건 업로드 성공 표시였어. set -euo pipefail도 켜져 있었어. export 명령은 볼 만한 줄만 추리려고 grep으로 파이프돼 있었고, grep이 아무것도 못 찾아도 실행이 멈추지 않도록 || true가 붙어 있었어. 그래서 업로드 단계엔 관문이 아예 없었던 거야. 앞서 만든 파이프라인 세 개에도 똑같은 구조가 있었고.

네 사실을 뭉개는 방식 중에서도 이게 최악이야. 사실 1이 거짓인데도 파이프라인이 그걸 찍어버리고, 다음 세션도 주인도 그 줄을 믿거든. 고치려면 서로 독립된 검사 두 개가 필요해. 한쪽이 잡는 경우를 다른 쪽은 놓치거든. 먼저 전체 출력을 tee로 로그에 남겨. 그리고 set +e와 set -e 사이에서 zsh의 pipestatus[1]로 첫 명령의 상태를 받아(소문자고, 1부터 세. bash라면 PIPESTATUS[0]). 이 검사는 실패 표시 없이 실패하는 도구를 잡아. 다음으로, 남긴 로그에서 ** EXPORT FAILED **를 grep해. 이 검사는 논리적으로 실패하고도 0으로 끝나는 도구를 잡아.

아카이브 단계는 더 단순하게 가. 파이프 없이 로그 전체를 파일로 보내고, xcodebuild 자신의 종료 상태를 보고, 아카이브 디렉터리가 진짜 생겼는지 확인해. 아래 스크립트는 xcodebuild 대역을 만들어서, 세 가지 export 동작마다 이 스크립트가 대체하는 필터 버전과 나란히 돌려봤어.

Code

남긴 로그, 진짜 상태, 실패 표시로 아카이브하고 업로드하기·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"
잰 결과: 필터 파이프와 검사 두 개짜리 관문 비교·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

표에 나온 세 가지 export 동작을 환경 변수로 고를 수 있는 대역 fake-xcodebuild를 써. 그리고 동작마다 필터 한 줄짜리와 관문 있는 파이프라인을 돌려서 결과 여섯 개를 적어. 이어서 관문 있는 스크립트를 일부러 두 방식으로 망가뜨려 봐. 한 번은 실패 표시 grep을 빼고, 또 한 번은 pipestatus[1] 대신 $?를 읽게 해. 망가진 버전마다 어떤 동작을 통과시키는지 보여줘.
Hint
대역은 archive일 땐 -archivePath 디렉터리만 만들고, -exportArchive일 땐 해당하는 줄과 종료 코드만 내면 돼. XCODEBUILD=./fake-xcodebuild로 넘기면 파이프라인 자체는 안 바꿔도 돼.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.