One program for every app
The phone app reaches Dad's iPhone and iPad the way every family iOS app does: through TestFlight, never by cable. A cable install is a build check. And the program that takes a build to TestFlight is not Voyager's. It is the family's, written once in Sibling Kit and vendored into each app as scripts/mobile-testflight.sh, under a header that says it is generated from the kit and must not be edited here. The app keeps only its facts beside it, in scripts/mobile-testflight.config.zsh (first block). Voyager vendored the trio, the pipeline, its upload gate and a facts reader, in the commit that brought the phone app.
The reason for one program is written into its header as Dad's ruling of 2026-09-14. Until that day the family carried eight hand-grown copies. Two hid xcodebuild's status behind a filtering pipe, one had no clean-tree guard, and one never moved its build number, so its second upload would have been refused. Each was a guard one app had learned and the others had copied short. Firebrand Quest tells where the pipeline was born, with the first family app on iOS. Sibling Kit Quest teaches how vendored files stay honest.
The contract is a list of facts
The config is short because the pipeline asks for facts, not steps. Four of them deserve a word.
mtf_inputsnames what the binary is built from: the phone client, the package's parent and engine child,Package.swift, the gate script and the mark script. The pipeline refuses to start while any of them is uncommitted, so a build number always names a commit.mtf_simulator_envnames the variable that must hold a dedicated simulator id. Several sessions share one Mac's simulators, and a test run that landed on whatever was open would take another app's foreground.mtf_build_number=project-ymlmakesproject.ymlthe source of the build number. The pipeline bumps it there, regenerates the project and commits the bump under the subject prefixvoyager-mobile.mtf_marketing_version=project-ymlkeeps the phone's version line inproject.yml(third block), becausecwk-product.jsonstays at 0.0.0 until the product claims a release. Both numbers are set project-wide: App Store Connect refuses an extension whose numbers differ from its host's, and the widget is an extension.
The order of guards
Every guard runs for every app, in one order, cheapest refusals first. The config. The App Store Connect key, and without one, a refusal to upload from a locked screen, which breaks Xcode's keychain session. The dedicated simulator. Committed inputs. A clean kit. The icon. Then the suite, which for Voyager is scripts/test-mobile.sh from the last lesson, and a visual gate that photographs the first screens at phone width. Only then does the build number move and the archive build. The pipeline checks that HEAD did not move while xcodebuild ran, inspects the archive, judges the export or upload by its status and its banner separately, and commits and pushes the bump.
Four facts, never one
The pipeline's success line, VOYAGER_MOBILE_UPLOAD_OK, is true and it is small. Its own header says so: upload is not delivery.
| Fact | What it means | Who can say |
|---|---|---|
| 1 uploaded | App Store Connect has the build | the pipeline's upload line |
| 2 processed | Apple finished processing it | App Store Connect |
| 3 assigned | the internal group has it | App Store Connect |
| 4 installed | it is on the device | the device's TestFlight app |
scripts/testflight-facts.sh reads facts 1 to 3 from App Store Connect and can wait out Apple's processing (second block). Fact 4 it reports with care. The install count trails real installs, so a count above zero is a fact and a zero means "not counted yet", never "not installed". The phone stays the witness. Voyager's git history shows the limit plainly: each pipeline commit records "Destination: upload", its source commit and the kit's state, which is fact 1 and nothing more. What this lesson can say is that the phone app is delivered through TestFlight's internal group.
Internal only, and the entitlement it cost
Every family app goes to the internal group only: the team's own testers, Dad among them. No external group, no Beta App Review, no public link. App Store Connect records and requests for Apple capabilities are Codex Chief Pippa's work, never a coding session's.
Voyager tested that rule. A default browser on iOS needs Apple's web-browser entitlement, and the phone app was briefly set up to qualify: an entitlements file claimed it, and http and https were declared as its URL schemes. It was wanted only for service workers inside WKWebView. Apple grants it only after a public TestFlight link and external Beta App Review. Dad stopped it the same day:
아니, 근데 이거 다 디폴트 브라우져 자격 얻는데 필요한 깐깐한 심사 아니야? 디폴트로 안 쓰면 그만이잖아? 이걸 외부 베타 테스트 심사까지 받는다고? 그건 하면 안 돼.
"Wait, isn't this all the strict review you need to qualify as a default browser? If we don't use it as the default, that's the end of it. We'd go through external beta review for this? We must not do that."
Commit 0517387 removed the entitlement file and the web schemes. The cost is written down as current fact in the parity contract: family web apps' service workers do not run on the phone, and pages get no Touch ID passkey, so the family PIN signs in. The phone app declares only cwkvoyager, for the widget's family links. The hosted suite checks that this scheme is declared and nothing more: a web scheme added back beside it would still pass, so what keeps http and https out today is the Info.plist itself and the comment that says why.