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

TestFlight 사실 네 개, 보이는 버전 하나

~14 min · testflight, ios, delivery, product-version, app-store-connect

Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

아카이브 전에 서는 관문

scripts/test-mobile.sh는 iOS 스위트의 정문이야. 파이프라인은 빌드 번호를 건드리기 전에 이걸 먼저 돌려. SIZZLE_MOBILE_SIMULATOR_ID가 없으면 시작을 거부하고, 쓸 수 있는 기기 목록을 보여줘. 한 Mac의 시뮬레이터를 여러 앱 세션이 같이 쓰거든. 마지막에 열려 있던 아무 시뮬레이터에 올라탄 실행은 다른 앱의 화면을 뺏어. 그래서 스위트는 전용 Sizzle QA iPhone에서 돌아. 먼저 xcodegen으로 Xcode 프로젝트를 다시 만들어. 원본은 project.yml이고, 재생성 없이 추가한 테스트 파일은 스위트가 조용히 안 돌리거든. 이어서 xcodebuild test를 돌리고 출력을 로그로 남겨. 판정은 그 로그의 ** TEST SUCCEEDED ** 배너로 해. 필터를 건 파이프의 종료 코드는 필터의 결과일 뿐이니까. 단위 테스트는 앱 안에서 돌고, 거기 나오는 클라이언트는 전부 미리 짠 전송 계층 위에 있어. 그래도 UI 타깃은 엔진에 닿아. 거기 테스트는 진짜 앱을 띄우고, 진짜 앱은 평소 쓰는 전송 경로로 설정된 엔진에 연결해. 사진 지우기 테스트도 앱을 띄우는 것만으로 접시 목록을 읽어. 그 통신은 아무것도 확인 안 하니까 답이 오든 말든 통과하긴 해. 일부러 엔진을 겨누는 건 업로드 벤치마크야. SIZZLE_BENCH_ENGINE을 주면 킷의 속도 제한 프록시 뒤에 있는 진짜 엔진에 진짜 앱을 띄우고, Save 타임라인을 찍어. 그 변수가 없으면 건너뛰고, 평소엔 그렇게 돌아. 스크립트 머리 주석은 아직도 여기선 어떤 엔진에도 안 닿는다고 말해.

프로그램 하나, 앱의 사실은 설정 파일에

scripts/mobile-testflight.sh는 킷에서 벤더링한 파일이고, 머리말에 가족의 모든 iOS 앱을 위한 프로그램 하나라고 적혀 있어. 2026-09-14에 아빠가 그렇게 정했어. 손으로 키운 사본 여덟 개를 조사해 보니, 저마다 다른 안전장치가 하나씩 빠져 있었거든. Sizzle이 보태는 건 scripts/mobile-testflight.config.zsh가 전부야. 대입문은 13개야. 앱 이름, 결과 줄의 표식, 경로들, 번들 id com.cwk.sizzle.mobile, 아이콘, 시뮬레이터 변수가 들어가. 커밋돼 있어야 할 입력, 빌드 번호가 사는 곳, 워치 앱이 없다는 사실, 번호 올림 커밋의 제목, 마케팅 버전의 출처도 있어. 사본들이 어떻게 프로그램 하나가 됐는지는 sibling-kit-quest의 how-a-duplicate-dies 트랙에 있어.

가족의 첫 아이폰 앱은 Firebrand였고, 아빠가 그 모양을 뒤따르는 모든 앱의 기준으로 정했어. 그 폰 이야기는 firebrand-quest의 remote-vessel 트랙이 다뤄. Sizzle은 나중에 와서 사본 대신 합쳐진 파이프라인을 받았어.

단계 하나보다 순서가 더 중요해. 인자와 설정, App Store Connect 키, 전용 시뮬레이터, 커밋된 입력, 깨끗한 킷, 아이콘, 스위트, 폰 폭의 시각 관문. 이걸 다 지나야 빌드 번호, 아카이브, 빌드 중에 HEAD와 입력이 안 움직였는지 확인, 아카이브 관문, 내보내기나 업로드가 오고, 맨 끝에 번호 올림 커밋과 결과 줄이 와.

2번부터 19번까지

빌드 번호는 project.yml의 CURRENT_PROJECT_VERSION에 살아. 돌 때마다 하나씩 올리고, 프로젝트를 다시 만들고, 끝에서 그 변경을 커밋해. 커밋 본문엔 원본 커밋과 목적지가 적혀. 그래서 git이 장부가 돼. 2번 빌드는 내보내기만 했고, 그걸로 번들 id가 등록됐어. 그 뒤에 아빠가 App Store Connect 레코드를 만들었고. 3번부터 19번까지 17개는 업로드였어. 마지막이 2026-10-22고, 지금 project.yml엔 19가 적혀 있어. 폰 쪽에서 19번 뒤의 커밋들은 글만 바꿨어. 주석이랑 Settings 화면 문구. 엔진은 달라졌어. 13c12f8은 스테이징 문이 파트를 읽는 동안 크기 상한을 지키게 바꿨고, 킷 동기화 커밋 여럿이 벤더링한 레지스트리를 고치고 파이프라인 스크립트도 건드렸어. 그중 폰 코드는 하나도 없고, 그 뒤로 나간 빌드도 없어.

번호 올림 커밋이 스크립트 어디에 있는지 봐. 업로드 관문이 내보내기의 종료 코드와 로그 배너를 둘 다 받아들인 뒤에야 써져. 업로드가 실패하면 그 전에 빠져나가면서 번호 올림을 되돌리라고 알려줘. 그러니까 본문에 Destination: upload가 적힌 커밋은 사실 1의 증거야. 그 뒤의 사실에 대해선 아무 말도 안 해.

사실 넷, 증인 넷

사실무엇이 증명해어디서 읽어
1. 업로드됨SIZZLE_MOBILE_UPLOAD_OK 줄, 그리고 번호 올림 커밋파이프라인 자기 출력
2. 처리됨processingState가 VALIDApp Store Connect에서 읽는 scripts/testflight-facts.sh
3. 배정됨internalBuildState가 IN_BETA_TESTING같은 스크립트
4. 설치됨0보다 큰 설치 수, 아니면 기기 자체설치 수는 실제 설치보다 늦어. 폰의 TestFlight 앱이 증인이야

testflight-facts.sh는 사실 2부터 4까지를 GET 요청으로만 읽어. 팀 키는 업로드 관문과 같은, 하나뿐인 키 읽기 경로로 받아. --wait를 주면 사실 1부터 3까지가 설 때까지, 아니면 시간이 다 될 때까지 30초마다 다시 물어. 마지막 기계용 줄은 설치를 yes, not_counted, unknown 중 하나로 적고, 설치 안 됨이라는 값은 절대 안 써. 실제 설치보다 늦게 따라오는 사용량 지표의 0은 아무것도 증명 못 하니까.

Sizzle 자기 기록이 이 조심이 왜 필요한지 보여줘. 3번 빌드 때 이 스크립트는 업로드, 처리, 내부 그룹까지 확인했는데, 설치 수는 아직 안 셌다고 했어. 사실 4의 증인은 아빠였어. TestFlight로 3번 빌드를 폰에 깔았거든. 4번과 6번은 사실 1부터 3까지 기록돼 있고, 설치는 확인되지 않았어. 나머지 빌드는 git이 업로드까지만 증명해. 딱 이런 보고를 쓰는 법은 coop-quest의 reporting-back 트랙에 있어.

보이는 버전 하나

cwk-product.json엔 사실이 세 개 들어 있어. 스키마 버전 1, 제품 id cwkSizzle, 버전 1.0.0. 쓰는 쪽은 Firelink 하나뿐이야. 2026-09-25에 1.0.0을 적은 릴리스 커밋에 v1.0.0 태그가 붙어 있어. 모든 화면이 이 파일을 읽어.

  • 엔진의 /api/health는 킷의 product_version을 거쳐서 이 값을 내줘. 스키마, 제품 id, SemVer 모양을 확인하고, 파일이 틀렸으면 경고 한 번과 함께 0.0.0으로 물러나.
  • 웹 Settings 페이지는 그 헬스 응답을 받아 Sizzle v1.0.0을 보여줘.
  • 설정 파일에 mtf_marketing_version=cwk-product가 있어서, 파이프라인은 plutil로 version을 읽어 아카이브에 MARKETING_VERSION으로 넘겨. project.yml에도 1.0.0이 있는데 로컬 빌드용일 뿐이고, Firelink의 릴리스 카드가 둘을 같게 맞춰.
  • 폰의 Settings는 CFBundleShortVersionString에서 Version을, CFBundleVersion에서 Build를 보여줘. 엔진을 확인할 때는 엔진이 알려준 버전도 같이 띄워.

다른 숫자들은 자기 영역에 머물러. frontend/package.json의 0.0.0은 패키지 버전이고, 빌드 번호는 파이프라인 빌드를 세고, RUBRIC_VERSION 2는 읽기 스키마의 버전이야. 어느 것도 제품 버전과 다투지 않아.

원칙: 보고할 땐 확인된 사실 중 가장 높은 단계를 콕 집어 말해. 19번 빌드가 업로드됐다는 말은 커밋 하나로 참이 돼. 19번 빌드가 폰에 있다는 말은 다른 증인이 필요해.

Code

cwkSizzle, scripts/mobile-testflight.config.zsh: 가족 파이프라인에 넘기는 앱의 사실 (원문 그대로, 1-20행)·shell
# cwkSizzle's facts for the family TestFlight pipeline (scripts/mobile-testflight.sh, vendored from
# cwkSiblingKit — edit the pipeline there, and this file here). The contract is the header of
# scripts/mobile-testflight.sh.

mtf_app=SizzleMobile
mtf_marker=SIZZLE_MOBILE
mtf_mobile=clients/apple/SizzleMobile
mtf_project=SizzleMobile.xcodeproj
mtf_scheme=SizzleMobile
mtf_bundle_id=com.cwk.sizzle.mobile
mtf_icon=clients/apple/SizzleMobile/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png
mtf_simulator_env=SIZZLE_MOBILE_SIMULATOR_ID
mtf_inputs=(clients/apple/SizzleMobile scripts/test-mobile.sh)
mtf_build_number=project-yml
mtf_watch=no
mtf_commit_subject=sizzle-mobile

# The visible version on the phone is the product's: cwk-product.json is its one writer, and
# the archive receives it as MARKETING_VERSION. project.yml's value serves local builds only.
mtf_marketing_version=cwk-product
cwkSizzle, scripts/mobile-testflight.sh: 업로드 관문 뒤의 번호 올림 커밋과 결과 줄 (원문 그대로, 353-373행)·shell
if (( ${#bump_files} )); then
  git -C "$repo_root" add -- "${bump_files[@]}"
  git -C "$repo_root" commit -q -m "$mtf_commit_subject: TestFlight build $build_number

Source commit before the bump: $head_before. Destination: $destination.
Kit: cwkSiblingKit @ $kit_head ($kit_state), consumed as a path dependency.

Model: ${PIPPA_COOP_MODEL:-unknown} (${PIPPA_COOP_REASONING:-unknown})" -- "${bump_files[@]}"
  if ! git -C "$repo_root" push -q; then
    print -u2 "warning: the bump commit was not pushed — push by hand"
    # On the result line too: a build number that exists only here is the one the next
    # build on another Mac reuses, and Apple refuses (kit review 2026-09-23, E6).
    unpushed_note=" — the build-number bump is NOT pushed: push it before another Mac builds"
  fi
fi

if [[ "$destination" == upload ]]; then
  print "${mtf_marker}_UPLOAD_OK build $build_number source $head_after — uploaded is fact 1 of 4: Apple processing, the internal group and the INSTALLED build are still separate facts${unpushed_note:-}"
else
  print "${mtf_marker}_EXPORT_OK build $build_number source $head_after — not uploaded (pass --upload). Fact 0 of 4.${unpushed_note:-}"
fi
cwkSizzle, scripts/testflight-facts.sh: 사실 네 개, 그리고 설치 수 0의 뜻 (원문 그대로, 4-23행)·shell
# The four delivery facts after a TestFlight upload, read from App Store Connect —
# cwkPippa/docs/IOS-NATIVE-DELIVERY.md §1 keeps them apart, and the pipeline's
# `*_UPLOAD_OK` line asserts only the first:
#
#   1 uploaded       App Store Connect has the build
#   2 processed      Apple finished processing it (processingState VALID)
#   3 assigned       it is in internal testing (internalBuildState IN_BETA_TESTING)
#   4 installed      a tester installed it (the build's install count)
#
#   testflight-facts.sh <bundle-id> <build-number> [--wait <seconds>]
#
# One line per fact, then a machine line:
#   TESTFLIGHT_FACTS build=<n> uploaded=yes|no processed=yes|no|failed assigned=yes|no installed=yes|not_counted|unknown
# Fact 4 is App Store Connect's usage metric, which trails installs (measured 2026-09-25: a
# year-long data point, counts not live). A count above zero is a fact; zero is "not counted
# yet", never "not installed" — the phone's TestFlight app stays the witness.
# Exit 0 when facts 1-3 hold, 1 when one does not (yet), 2 when App Store Connect could not
# be read. `--wait` polls every 30 s until facts 1-3 hold or the seconds run out — Apple's
# processing takes minutes, and a model or a person asking "is it on the phone yet" should get
# the answer, not an instruction to ask again.

External links

Exercise

TestFlight로 내보내는 앱, 아니면 내보낼 앱 하나를 골라서 업로드 뒤의 보고를 코드로 써 봐. App Store Connect API에 빌드의 처리 상태와 내부 베타 상태를 묻고, 사실마다 한 줄씩, 맨 끝엔 기계가 읽을 줄 하나를 찍어. 설치 사실은 세 값으로 만들어. 설치됨, 아직 안 셈, 모름. 끝으로 앱의 마케팅 버전을 도구 하나만 쓰는 파일 하나로 옮기고, 아카이브 단계가 프로젝트에 박힌 값 대신 그 파일을 읽게 해.
Hint
App Store Connect API는 팀 키로 서명한 짧은 수명의 ES256 JWT를 받아. 스크립트 안에서 만들고 로그엔 절대 남기지 마. Apple 처리는 몇 분 걸리니까 한 번 묻고 끝내지 말고 마감을 둔 채 반복해서 물어.

Progress

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

댓글 0

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

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