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
| Fact | What proves it | Where it is read |
|---|---|---|
| 1. Uploaded | the SIZZLE_MOBILE_UPLOAD_OK line, then the bump commit | the pipeline's own output |
| 2. Processed | processingState is VALID | scripts/testflight-facts.sh, from App Store Connect |
| 3. Assigned | internalBuildState is IN_BETA_TESTING | the same reader |
| 4. Installed | an install count above zero, or the device itself | the 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/healthserves it through the kit'sproduct_version, which checks the schema, the product id and the SemVer shape, and falls back to0.0.0with one warning when the file is wrong. - The web Settings page shows
Sizzle v1.0.0from that health reply. - The pipeline reads
versionwithplutiland passes it to the archive asMARKETING_VERSION, because the config saysmtf_marketing_version=cwk-product.project.ymlalso 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 fromCFBundleVersion, 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.