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

Four TestFlight Facts and One Visible Version

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

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The gate before anything is archived

scripts/test-mobile.sh is the iOS suite's front door, and the pipeline runs it before any build number moves. It refuses to start without SIZZLE_MOBILE_SIMULATOR_ID and lists the available devices when the variable is missing. Several app sessions share one Mac's simulators, and a run that lands on whatever was open last takes over another app's screen, so the suite runs on a dedicated Sizzle QA iPhone. It regenerates the Xcode project with xcodegen first: project.yml is the source, and a test file added without regenerating is one the suite silently does not run. Then it runs xcodebuild test into a kept log and judges by the log's ** TEST SUCCEEDED ** banner. The exit status of a filtered pipe would report the filter. The unit tests are hosted by the app, and every client in them runs on a scripted transport. The UI target does reach an engine. Its tests launch the real app, which dials the engine it is set to through its ordinary transports: launching it for the photo-removal test already reads the plate list, though that test asserts nothing about the traffic and passes with or without an answer. The upload benchmark is the one that aims at an engine on purpose. Given SIZZLE_BENCH_ENGINE, it launches the real app against a real engine behind the kit's throttling proxy and prints the Save timeline. Without the variable it skips, which is the usual run. The script's own header still says nothing here reaches an engine.

One program, the app's facts in a config

scripts/mobile-testflight.sh is vendored from the kit, and its header calls it one program for every iOS app in the family. Dad ruled that on 2026-09-14, after a census found eight hand-grown copies, each missing a different guard. Sizzle's whole contribution is scripts/mobile-testflight.config.zsh: thirteen assignments that name the app, the marker for its result line, its paths, the bundle id com.cwk.sizzle.mobile, the icon, the simulator variable, the inputs that must be committed, where the build number lives, that there is no watch app, the bump commit's subject, and where the marketing version comes from. sibling-kit-quest's how-a-duplicate-dies track tells how the copies became one program.

The family's first iPhone app was Firebrand's, and Dad made its shape the reference for every app that followed. firebrand-quest's remote-vessel track covers that phone. Sizzle came later and took the merged pipeline instead of a copy.

The order matters more than any single step: arguments and config, the App Store Connect key, the dedicated simulator, committed inputs, a clean kit, the icon, the suite, a visual gate at phone width, then the build number, the archive, a check that HEAD and the inputs did not move during the build, the archive gate, the export or upload, and only then the bump commit and the result line.

Builds 2 through 19

The build number lives in project.yml as CURRENT_PROJECT_VERSION. Every run adds one, regenerates the project and, at the end, commits the bump with a body naming the source commit and the destination. So git holds a ledger. Build 2 was an export only, which registered the bundle id, and Dad then created the App Store Connect record. Builds 3 through 19 were uploads, seventeen of them, the last on 2026-10-22, and project.yml reads 19 today. On the phone, the commits after build 19 changed only text: comments and the Settings screen's wording. The engine did change. 13c12f8 made the staging door enforce its size cap while it reads the part, and a run of kit syncs reworked the vendored registry and touched the pipeline script. None of that is phone code, and no build has followed it.

Notice where the bump commit sits in the script. It is written only after the upload gate has accepted both the export's exit status and its log banner. A failed upload exits first and tells you to revert the bump. A commit whose body says Destination: upload is therefore evidence of fact 1, and of nothing after it.

Four facts, four witnesses

FactWhat proves itWhere it is read
1. Uploadedthe SIZZLE_MOBILE_UPLOAD_OK line, then the bump committhe pipeline's own output
2. ProcessedprocessingState is VALIDscripts/testflight-facts.sh, from App Store Connect
3. AssignedinternalBuildState is IN_BETA_TESTINGthe same reader
4. Installedan install count above zero, or the device itselfthe count trails installs; the phone's TestFlight app is the witness

testflight-facts.sh reads facts 2 to 4 with GET requests only, using the team key through the upload gate's one reader. --wait polls every thirty seconds until facts 1 to 3 hold or the seconds run out. Its machine line spells installed as yes, not_counted or unknown, never as no, because a zero from a usage metric that trails installs proves nothing.

Sizzle's own record shows why that care matters. For build 3 the reader showed uploaded, processed and in the internal group, while the install count still said not counted. Fact 4 was witnessed by Dad installing build 3 from TestFlight on his phone. Builds 4 and 6 have facts 1 to 3 recorded and no install confirmed. For every other build, git proves the upload and nothing more. coop-quest's reporting-back track is about writing exactly that kind of report.

One visible version

cwk-product.json holds three facts: schema version 1, product id cwkSizzle, version 1.0.0. Firelink is its one writer. The release commit that set 1.0.0 on 2026-09-25 carries the tag v1.0.0. Every surface reads that file:

  • The engine's /api/health serves it through the kit's product_version, which checks the schema, the product id and the SemVer shape, and falls back to 0.0.0 with one warning when the file is wrong.
  • The web Settings page shows Sizzle v1.0.0 from that health reply.
  • The pipeline reads version with plutil and passes it to the archive as MARKETING_VERSION, because the config says mtf_marketing_version=cwk-product. project.yml also says 1.0.0, for local builds only, and Firelink's release card keeps it equal.
  • The phone's Settings shows Version from CFBundleShortVersionString, Build from CFBundleVersion, and the version the engine reports when Settings checks it.

Other numbers stay in their own domains. frontend/package.json stays at 0.0.0 as a package version, the build number counts the pipeline's builds, and RUBRIC_VERSION 2 versions the reading schema. None of them competes with the product version.

Principle: Report the highest fact you verified, by name. "Build 19 uploaded" is a true sentence on the strength of a commit. "Build 19 is on the phone" needs a different witness.

Code

cwkSizzle, scripts/mobile-testflight.config.zsh: the app's facts for the family pipeline (verbatim, lines 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: the bump commit and the result line, after the upload gate (verbatim, lines 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: the four facts and what a zero install count means (verbatim, lines 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

Take an app you ship through TestFlight, or plan to, and write its after-upload report as code. Query the App Store Connect API for the build's processing state and its internal beta state, print one line per fact and a final machine-readable line, and make the installed fact three-valued: yes, not counted, unknown. Then move the app's marketing version into one file that a single tool writes, and have your archive step read it instead of a value typed into the project.
Hint
The App Store Connect API takes a short-lived ES256 JWT signed with your team's key; mint it inside the script and keep it out of logs. Poll with a deadline instead of asking once, because Apple's processing takes minutes.

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.