Skip to content
C.W.K.
Stream
Lesson 02 of 03 · published

Four TestFlight Facts

~13 min · testflight, app-store-connect, ios-delivery, reporting, evidence

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

One Upload Line, Four Facts

When the family pipeline finishes, it prints an ARIANDEL_MOBILE_UPLOAD_OK line and writes the build commit. Most of us would say "it shipped" right there. The family's delivery doctrine, written into cwkPippa as an invariant, says: "Four facts, and only the last is delivery. Never collapse them in a report."

#FactHow you know
1UploadedThe pipeline's *_UPLOAD_OK line
2ProcessedApp Store Connect: processingState is VALID
3Assigned to internal testingApp Store Connect: internalBuildState is IN_BETA_TESTING
4InstalledThe device, or App Store Connect's install count

Each fact can be true while the next is false, and each takes a different amount of time. Upload is seconds. Processing takes Apple minutes. Group assignment follows processing. Installation waits on a person picking up an iPad. A report that says "shipped" at fact 1 is making three claims it hasn't checked.

The family made reading facts 2 to 4 cheap so nobody has to guess. testflight-facts.sh, vendored from cwkSiblingKit beside the upload gate, reads all four from the App Store Connect API with the same team key, ends with one machine line, and exits 0 only when facts 1 to 3 hold. --wait polls every 30 seconds, because, in the script's own words, someone asking "is it on the phone yet" should get the answer, not an instruction to ask again.

How Ariandel's History Reports It

Read the "Shipped" sections of Ariandel's Coop history in build order and you can watch the discipline hold:

  • Build 6 (written right after upload): "Apple processing and the internal group were still pending when this row was written, and installation on Dad's iPad is a separate fact." Fact 1, and an honest blank for the rest.
  • Builds 8 to 17, every row: "Apple processing is VALID and the internal group is IN_BETA_TESTING. Installation on Dad's iPad is not counted yet; it is a separate fact." Facts 1 to 3 true, fact 4 open.
  • Build 18: its own row says "Not yet installed"; hours later, that morning, App Store Connect counted it as installed and a device log saw it run. The one build in the run with an install count above zero.
  • Builds 19, 21, 22 and 23: uploaded, processed, in the group, and "not yet installed" when each row was written. For build 22 the answer arrived one row later: build 23's row opens with Dad trying build 22 on the iPad that afternoon and reporting what was wrong with Generate. That's fact 4 from the person holding the device, not from a count, and it sits in a row about a different build. Read the rows after a build's own row before you call its fact 4 open.
  • Builds 20 and 24: no history row at all. Their build commits prove the pipeline uploaded them. Nothing in Coop says what happened next, so the record alone supports "fact 1" for build 24. Its fact 4 comes from somewhere else entirely: this track's hero picture, as the end of this lesson explains. And a build Dad opened from TestFlight had passed facts 2 and 3 on its way to him.

Fact 4 Is the Slippery One

Install count is a usage metric, and it trails real installs. The facts script's header records a measurement from 25 September: the count is not live. So the reader reports installed=yes only for a count above zero, and a zero as not_counted, never "not installed". The phone's TestFlight app stays the witness.

And even a positive count only tells you a build was installed at some point. It does not tell you which build is running. On 4 October Dad's brushes vanished. A device log read over the cable showed build 18 moving the brush library to a new folder at 09:59, and within the minute the iPad was running build 14, which only knew the old folder. At 10:18 build 18 was back and so were the brushes. Meanwhile App Store Connect showed nothing wrong: 18 was the newest build and counted as installed. Why 14 came back is still unknown.

The fix was not a better query. It was making the device say what it is. Build 19 put the build number into the gallery's title, "Ariandel · Build N", and into About as "0.1.0 (N)", both read from the bundle. The hero picture of this track is that title on Dad's iPad: the device testifying to fact 4 directly.

The Rule Behind All of This

Every CWK iOS app reaches Dad's devices through TestFlight, first build included. A cable install (Xcode Run, devicectl, development signing) is a build check: it needs the cable and an unlocked device, reaches one machine, and lasts only as long as its development provisioning profile (seven days on a free personal team, a year on a paid team like the family's). TestFlight lands over the air on every enrolled device, and each build there expires 90 days after upload, so an old build is answered with another upload, never with a cable. The doctrine exists because a session once side-installed an app on a locked phone and logged TestFlight as a future step, twelve days after the pipeline existed. Note the boundary in the brush story above: the cable was used to read a log, which is diagnosis, never delivery.

One more constraint shapes where facts can be produced at all. Uploads run only on Dad's office Mac, in a GUI login session. Over ssh, codesign fails with errSecInternalComponent: an ssh session cannot answer the login keychain's private-key prompt, so the archive dies at signing even though provisioning succeeded. It looks like a provisioning problem and is not one.

A pipeline's success line asserts exactly one fact. Name every fact between "the tool finished" and "the person has it", read each one from the system that owns it, and write them down separately, including the ones that are still blank.

Code

The reader's contract: scripts/testflight-facts.sh header, verbatim (lines 4–23; the script is generated from 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.
A minimal sketch (compiled with swiftc and run): a report that cannot collapse the four facts; the input lines are hand-written in the script's format, not captured output·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.
The device as witness: AppBuild.swift in the app, verbatim, and the gallery title that reads it (GalleryView.swift, one line)·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)")

External links

Exercise

Take one delivery path you own (an app release, a deploy, a package publish, even an emailed report). List every distinct fact between "my tool printed success" and "the person who needs it is using it". For each, write which system owns it and how you would read it without asking anyone. Then look at your last three status messages about that path and mark which facts each one actually asserted.
Hint
A web deploy usually has at least four: the artifact uploaded, the new version is serving, caches no longer serve the old one, and a real user's session loaded it. The last is the one most reports skip. The cheapest witness is often to make the running thing state its own version where a human will see it.

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.