Skip to content
C.W.K.
Lesson 04 of 04 · published

TestFlight, Internal Only

~14 min · TestFlight, iOS, release pipeline, delivery

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

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_inputs names 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_env names 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-yml makes project.yml the source of the build number. The pipeline bumps it there, regenerates the project and commits the bump under the subject prefix voyager-mobile.
  • mtf_marketing_version=project-yml keeps the phone's version line in project.yml (third block), because cwk-product.json stays 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.

FactWhat it meansWho can say
1 uploadedApp Store Connect has the buildthe pipeline's upload line
2 processedApple finished processing itApp Store Connect
3 assignedthe internal group has itApp Store Connect
4 installedit is on the devicethe 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.

Principle: A delivery rule is real when it costs a feature and the feature loses.

Code

cwkVoyager, scripts/mobile-testflight.config.zsh: the app's facts for the family pipeline (verbatim, lines 1-23)·shell
# cwkVoyager'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=Voyager
mtf_marker=VOYAGER_MOBILE
mtf_mobile=clients/apple/VoyagerMobile
mtf_project=VoyagerMobile.xcodeproj
mtf_scheme=VoyagerMobile
mtf_bundle_id=com.cwk.voyager.mobile
mtf_icon=clients/apple/VoyagerMobile/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png
mtf_simulator_env=VOYAGER_MOBILE_SIMULATOR_ID
# The app is built from its client, the package's parent and engine child, and Pippa's mark
# script; the gate script is part of what was checked.
mtf_inputs=(clients/apple/VoyagerMobile Sources/VoyagerCore Sources/VoyagerWebKit Package.swift
  scripts/test-mobile.sh scripts/copy-pippa-mark.swift)
mtf_build_number=project-yml
mtf_watch=no
mtf_commit_subject=voyager-mobile

# cwk-product.json stays 0.0.0 until the product claims a release, so the phone app carries
# its own bundle line in project.yml (cwkPathfinder's shape).
mtf_marketing_version=project-yml
cwkVoyager, scripts/testflight-facts.sh: the four facts, kept apart (verbatim, lines 4-19)·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.
cwkVoyager, clients/apple/VoyagerMobile/project.yml: one version pair for the app and its widget (verbatim, lines 12-26)·text
name: VoyagerMobile
options:
  bundleIdPrefix: com.cwk
  deploymentTarget:
    iOS: "18.4"
  createIntermediateGroups: true

# Project-wide so every target carries the SAME version pair; App Store
# Connect refuses an extension whose numbers differ from its host.
settings:
  base:
    CURRENT_PROJECT_VERSION: 3
    # The phone app's own bundle line; cwk-product.json stays 0.0.0 until
    # the product claims a release (mtf_marketing_version=project-yml).
    MARKETING_VERSION: 0.1.0

External links

Exercise

For an iOS app you ship, write the release report as four lines, one per fact, each naming the instrument that establishes it. Then automate facts 2 and 3: with an App Store Connect API key, look up the build's processing state and its internal beta state, and make your script exit non-zero until both hold, with an optional wait that polls for a bounded time.
Hint
The fields Voyager's reader checks are processingState VALID and internalBuildState IN_BETA_TESTING. Keep fact 4 apart: a usage count that reads zero is not evidence of anything yet, so print "not counted yet" and look at the device. And make your upload script's success line name only what it knows, the upload, so nobody reads it as delivery.

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 in — Please sign in to comment.

No comments yet — be the first.