~13 min · testflight, app-store-connect, ios-delivery, reporting, evidence
Level 0빈 캔버스
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
업로드 한 줄, 사실은 넷
가족 파이프라인이 끝나면 ARIANDEL_MOBILE_UPLOAD_OK 줄을 찍고 빌드 커밋을 써. 대부분은 거기서 "나갔다"고 말해. 가족의 배포 원칙은 다르게 말해. cwk피파에 불변식으로 적혀 있어. "Four facts, and only the last is delivery. Never collapse them in a report." 사실은 넷이고, 배포라고 부를 수 있는 건 마지막 하나뿐이야. 보고에서 절대 하나로 뭉개지 마.
#
사실
어떻게 아나
1
업로드됨
파이프라인의 *_UPLOAD_OK 줄
2
처리됨
App Store Connect의 processingState가 VALID
3
내부 테스트에 배정됨
App Store Connect의 internalBuildState가 IN_BETA_TESTING
4
설치됨
기기 자체, 또는 App Store Connect의 설치 수
앞의 사실이 참이어도 뒤의 사실은 거짓일 수 있고, 걸리는 시간도 다 달라. 업로드는 몇 초. 처리는 Apple 쪽에서 몇 분. 그룹 배정은 처리 다음. 설치는 누가 iPad를 집어 들 때까지 기다려. 사실 1에서 "나갔다"고 하는 보고는 확인 안 한 주장을 세 개 더 하고 있는 거야.
가족은 사실 2부터 4까지 읽는 비용을 싸게 만들어 놨어. 그래야 아무도 짐작 안 하니까. cwkSiblingKit에서 업로드 게이트 옆에 같이 벤더링되는 testflight-facts.sh가 그 일을 해. 같은 팀 키로 App Store Connect API에서 넷을 다 읽고, 마지막에 기계용 줄 하나를 남겨. 종료 코드는 사실 1부터 3까지 성립할 때만 0이야. --wait는 30초마다 다시 물어봐. 스크립트 말을 빌리면, "폰에 벌써 깔렸어?"라고 묻는 사람한텐 다시 물어보라는 지시 말고 답을 줘야 하니까.
Ariandel 기록은 어떻게 적었나
Ariandel의 Coop 기록에서 "Shipped" 칸만 빌드 순서대로 읽어 봐. 규율이 버티는 게 보여.
빌드 6 (업로드 직후에 쓴 행): Apple 처리와 내부 그룹은 이 행을 쓸 때 아직 대기 중이었고, 아빠 iPad 설치는 별개의 사실이라고 적혀 있어. 사실 1만 있고, 나머지는 정직한 빈칸이야.
빌드 8부터 17까지는 행마다 똑같아. Apple 처리는 VALID, 내부 그룹은 IN_BETA_TESTING, 아빠 iPad 설치는 아직 집계 안 됨, 그건 별개의 사실. 사실 1~3은 참, 사실 4는 열려 있어.
빌드 18: 자기 행엔 "아직 설치 안 됨"이라고 적혀 있어. 몇 시간 뒤 그날 아침, App Store Connect가 설치됨으로 셌고 기기 로그에도 도는 게 찍혔어. 이 질주에서 설치 수가 0보다 컸던 유일한 빌드야.
빌드 19, 21, 22, 23: 업로드됨, 처리됨, 그룹에 들어감. 행을 쓸 땐 다 "아직 설치 안 됨"이었어. 빌드 22는 답이 한 행 뒤에 왔어. 빌드 23 행이 그날 오후 아빠가 iPad에서 빌드 22를 써 보고 Generate 문제를 알려 준 얘기로 시작하거든. 설치 수가 아니라 기기를 쥔 사람한테서 온 사실 4고, 그것도 다른 빌드 얘기를 하는 행에 들어 있어. 어떤 빌드의 사실 4가 열려 있다고 하기 전에, 그 빌드 행 뒤에 오는 행들도 읽어.
빌드 20과 24: 기록 행 자체가 없어. 빌드 커밋은 파이프라인이 업로드했다는 걸 증명해. 그 뒤 일은 Coop 어디에도 없어. 그래서 기록만 놓고 보면 빌드 24에 대해 말할 수 있는 건 "사실 1"까지야. 빌드 24의 사실 4는 아예 다른 데서 와. 이 트랙 대표 사진이야. 이 레슨 끝에서 얘기할게. 아빠가 TestFlight에서 연 빌드라면, 가는 길에 사실 2와 3도 이미 지났고.
사실 4가 제일 미끄러워
설치 수는 사용량 지표고, 실제 설치보다 늦게 따라와. facts 스크립트 머리말에 9월 25일에 잰 결과가 적혀 있어. 수치가 실시간이 아니라는 거야. 그래서 이 도구는 0보다 큰 수만 installed=yes로 보고하고, 0은 not_counted로 적어. "설치 안 됨"으로는 절대 안 적어. 증인은 끝까지 폰의 TestFlight 앱이야.
게다가 양수라도 어느 시점에 설치됐었다는 것만 말해 줘. 지금 돌고 있는 빌드가 뭔지는 말 안 해. 10월 4일, 아빠 붓들이 사라졌어. 케이블로 읽은 기기 로그를 보니, 빌드 18이 09:59에 붓 라이브러리를 새 폴더로 옮겼어. 그런데 1분도 안 돼 iPad엔 옛 폴더밖에 모르는 빌드 14가 떠 있었어. 10:18엔 빌드 18이 돌아왔고 붓도 돌아왔어. 그동안 App Store Connect에선 아무 이상이 없었어. 18이 최신 빌드였고 설치됨으로 세어져 있었거든. 14가 왜 돌아왔는지는 아직 몰라.
해법은 더 나은 쿼리가 아니었어. 기기가 자기가 뭔지 말하게 만드는 거였지. 빌드 19부터 갤러리 제목에 빌드 번호가 "Ariandel · Build N"으로, About엔 "0.1.0 (N)"으로 떠. 둘 다 번들에서 읽어. 이 트랙 대표 사진이 아빠 iPad에 뜬 바로 그 제목이야. 기기가 사실 4를 직접 증언하는 거야.
이 모든 것 뒤의 규칙
CWK iOS 앱은 첫 빌드부터 전부 TestFlight로 아빠 기기에 가. 케이블 설치(Xcode Run, devicectl, 개발 서명)는 빌드 확인이야. 케이블이랑 잠금 풀린 기기가 있어야 하고, 기기 하나에만 닿고, 개발 프로비저닝 프로파일이 살아 있는 동안만 버텨. 무료 개인 팀이면 7일, 가족처럼 유료 팀이면 1년이야. TestFlight는 등록된 기기 전부에 무선으로 내려가고, 거기 올린 빌드는 업로드 90일 뒤에 만료돼. 그래서 오래된 빌드엔 업로드를 한 번 더 하는 게 답이지, 케이블이 아냐. 이 원칙이 생긴 건 어느 세션이 잠긴 폰에 앱을 사이드로드해 놓고 TestFlight를 나중 단계로 적었기 때문이야. 파이프라인이 생긴 지 12일 뒤였어. 위의 붓 이야기에서 경계를 봐 둬. 케이블은 로그를 읽는 데 썼어. 그건 진단이지, 배포가 아냐.
사실이 만들어질 수 있는 장소를 정하는 제약도 하나 더 있어. 업로드는 아빠 office Mac에서, GUI 로그인 세션 안에서만 돌아. ssh로 붙으면 codesign이 errSecInternalComponent로 죽어. ssh 세션은 로그인 키체인의 개인 키 확인 창에 답할 수가 없어서, 프로비저닝이 다 됐는데도 아카이브가 서명 단계에서 멈춰. 프로비저닝 문제처럼 보이는데 아냐.
파이프라인의 성공 줄은 사실 딱 하나만 말해. "도구가 끝났다"와 "필요한 사람 손에 있다" 사이의 사실을 전부 이름 붙여. 하나하나 그걸 가진 시스템에서 읽고, 따로따로 적어. 아직 빈칸인 것까지.
Code
읽는 도구의 계약: scripts/testflight-facts.sh 머리말 원문 (4–23행, cwkSiblingKit에서 생성된 파일)·bash
# 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.
최소 스케치 (swiftc로 컴파일해서 돌려 봄): 사실 넷을 뭉갤 수 없는 보고. 입력 줄은 스크립트 형식대로 손으로 쓴 거고, 실제 출력이 아냐·swift
// A minimal sketch: turn testflight-facts.sh's machine line into a report that
// never collapses the four facts. The three input lines are written by hand in
// the script's documented format; they are not captured output.
enum Installed: String { case yes, not_counted, unknown }
struct Facts {
var build = "?", uploaded = false, processed = "no", assigned = false
var installed = Installed.unknown
init?(line: String) {
let fields = line.split(separator: " ")
guard fields.first == "TESTFLIGHT_FACTS" else { return nil }
for field in fields.dropFirst() {
let kv = field.split(separator: "=", maxSplits: 1).map(String.init)
guard kv.count == 2 else { continue }
switch kv[0] {
case "build": build = kv[1]
case "uploaded": uploaded = kv[1] == "yes"
case "processed": processed = kv[1]
case "assigned": assigned = kv[1] == "yes"
case "installed": installed = Installed(rawValue: kv[1]) ?? .unknown
default: break
}
}
}
var report: String {
guard uploaded else { return "Build \(build): not uploaded." }
var said = ["Build \(build): uploaded"]
said.append(processed == "yes" ? "processed (VALID)"
: processed == "failed" ? "processing FAILED" : "still processing")
said.append(assigned ? "in the internal group" : "not in the internal group yet")
switch installed {
case .yes: said.append("counted as installed")
case .not_counted: said.append("install not counted yet (zero is not 'not installed')")
case .unknown: said.append("install unknown")
}
return said.joined(separator: "; ") + "."
}
}
let lines = [
"TESTFLIGHT_FACTS build=6 uploaded=yes processed=no assigned=no installed=not_counted",
"TESTFLIGHT_FACTS build=8 uploaded=yes processed=yes assigned=yes installed=not_counted",
"TESTFLIGHT_FACTS build=18 uploaded=yes processed=yes assigned=yes installed=yes",
]
for line in lines { if let facts = Facts(line: line) { print(facts.report) } }
// Prints:
// Build 6: uploaded; still processing; not in the internal group yet; install not counted yet (zero is not 'not installed').
// Build 8: uploaded; processed (VALID); in the internal group; install not counted yet (zero is not 'not installed').
// Build 18: uploaded; processed (VALID); in the internal group; counted as installed.
증인이 된 기기: 앱의 AppBuild.swift 원문, 그리고 그걸 읽는 갤러리 제목 (GalleryView.swift 한 줄)·swift
import Foundation
/// The installed build, read from the bundle, so Dad sees at a glance which
/// TestFlight build is open (2026-10-04: a build 14 sat on his iPad while he
/// believed he was on 18, and his brushes seemed gone).
enum AppBuild {
static var version: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" }
static var number: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "" }
}
// GalleryView.swift
.navigationTitle(stack?.name ?? "Ariandel · Build \(AppBuild.number)")
네가 맡은 배포 경로 하나를 골라 (앱 릴리스, 서버 배포, 패키지 게시, 메일로 보내는 보고서도 돼). "도구가 성공을 찍었다"와 "그게 필요한 사람이 쓰고 있다" 사이의 사실을 하나씩 다 적어. 각각 어느 시스템이 갖고 있는지, 아무한테도 안 묻고 어떻게 읽을지 적어. 그다음 그 경로에 대해 마지막으로 보낸 상태 메시지 세 개를 꺼내서, 각각이 실제로 어떤 사실을 주장했는지 표시해 봐.
Hint
웹 배포도 보통 최소 넷이야. 산출물이 올라갔다, 새 버전이 응답한다, 캐시가 더는 옛 버전을 안 준다, 실제 사용자 세션이 그걸 불러왔다. 보고에서 제일 자주 빠지는 게 마지막이야. 제일 싼 증인은 대개 돌아가는 쪽이 사람 눈에 띄는 곳에 자기 버전을 말하게 하는 거야.
Progress
Progress is local-only — sign in to sync across devices.