// TRANSMISSION LOG /

Fourteen Fixes in Twenty Minutes, Then an Hour of Code Signing

October's TestFlight feedback turned into fourteen shipped changes in one pull request and about twenty minutes of commits. Then a provisioning profile three weeks out of step with a certificate ate another hour, nearly shipped a build with push notifications silently broken, and was fixed in ninety seconds by the GUI I'd spent the whole time avoiding.

#fourth-and-drink #ios #swift #testflight #code-signing #xcode #claude-code

Fourth & Drink has been in TestFlight with external testers since October. The feedback came back in the form feedback usually does: not bug reports, but fourteen small, specific complaints about how the game feels over a full sixty minutes. Bets locking too early. A shield that didn't punish anybody. A card that fired on kick returns when it shouldn't.

Turning all fourteen into shipped code took one pull request and about twenty minutes of commits. Getting the result onto App Store Connect took another hour, almost none of it spent on the app.

That ratio is the post.

The easy part

PR #7 is 35 files, +1,227/−111, merged at 06:07 UTC. Three commits, the first at 05:44. Highlights:

  • One open bet per prop. The old check matched on the prop's id, and a line refresh hands you a new id for the same prop — so you could bet "Mahomes over 1.5 TD" twice. It now matches on the prop's wording and game. The row reads "You're on this one" and goes disabled.
  • Fewer halftime locks. Touchdown props stay open to the final whistle. Yardage lines still close at halftime, because their prices are stale pre-game numbers and I'd rather close a market than quote a bad one.
  • A shield bounces. Blocking a give used to just void it. Now the giver takes the sips and the blocker still banks the points.
  • Six crunch-time cards land on the deck the first time a table reaches the fourth quarter, and overtime deals everyone an extra prediction card.
  • Join in progress. Late players get starting points and a fresh hand without disturbing anyone else's cards. Replays hold late seats back until their entry, so the original deal still reconstructs exactly.

The one I'd single out is the bet reversal. A tester reported that reversing a bet didn't stick. The symptom looked like a UI bug; the cause was that the next stats poll saw an unsettled bet and helpfully re-settled it. Bets whose settlement has been reversed are now excluded from auto-settle and left for the host to call. That's not a fix you find by staring at the view code — you find it by reading the feedback as a description of a sequence rather than a screen.

Twenty new tests came with it, in a file named for where the requests came from: TestFlightFeedbackTests.swift. 306 tests in the core package, all green.

Then I tried to ship it

The app's version lives in exactly one place, a build setting in project.yml, so cutting a build is a one-line change: CURRENT_PROJECT_VERSION: 6 → 7. Marketing version stays at 1.0, deliberately — I'll come back to that.

xcodegen generate, archive, ARCHIVE SUCCEEDED. Then export, and:

error: exportArchive Provisioning profile
"iOS Team Store Provisioning Profile: com.twoguyswithlaptops.fourthanddrink"
doesn't include signing certificate
"Apple Distribution: Christopher Reynolds (6JHN44582A)"

Above it, a second error, which is the one that cost me the most time:

IDEDistribution: Failed to log in with account "(null)"
Unable to log in with account '[email protected]'
Your session has expired. Please log in.

So: expired session. Sign back in and move on, right? Except the account named there isn't the account that owns the app. It's a personal Apple ID, left over on this Mac, that has nothing to do with the paid team. The account that does own com.twoguyswithlaptops.fourthanddrink was signed in and working the whole time.

I spent a while on the wrong problem before checking the distribution logs properly, where this was sitting:

APIClient got status code 200
filter[bundleId]=com.twoguyswithlaptops.fourthanddrink
name: "Fourth & Drink"   id: "6813697525"   versionString: "1.0"

That's a successful, authenticated call to App Store Connect. The session was never the problem. Xcode was simply reaching for the first developer account it found, failing, and reporting that failure loudly while the real issue stayed quiet.

The real issue, once I stopped chasing the login:

Date
Apple Distribution certificate issued 2026-10-13
Cached store provisioning profile created 2026-09-19

The profile is three weeks older than the certificate. A provisioning profile embeds the certificates it's valid for, so this one cannot possibly contain a cert that didn't exist when it was minted. Nothing was expired, nothing was revoked, and the error message — "doesn't include signing certificate" — was telling me the literal truth the entire time.

The part that actually bit

Two things I want to write down, because I'll hit both again.

xcodebuild cannot regenerate a provisioning profile. It consumes profiles; it doesn't mint them. Minting is a Developer Portal operation that the Xcode GUI performs on your behalf when it notices a mismatch. So "automate the whole release from the command line" has a hard floor: the first archive after a certificate changes has to go through the GUI, or through an App Store Connect API key, which is a different mechanism entirely. I tried pinning signingStyle: manual with an explicit profile name to force it, and got told — correctly — that the profile is Xcode-managed and can't be used manually.

A stale Apple ID silently corrupts your archive. This is the one worth the price of admission. Because Xcode couldn't resolve a distribution profile, it fell back to a development profile and archived happily. ARCHIVE SUCCEEDED. And the resulting app carried:

aps-environment: development

My FourthAndDrink.entitlements says production, as it should. The fallback quietly overrode it. That binary would have uploaded, installed, launched, and looked completely normal — and every push notification to every tester would have gone nowhere, because a production build talking to the sandbox APNs gateway gets silence. No crash, no error, just a feature that doesn't work.

The export failure is what caught it. I'd have shipped it otherwise, and spent a confused week wondering why nobody was getting notifications.

For the record, Xcode does the right thing once it can reach the portal. Distribution re-signs the archive, so the binary that actually went up carries aps-environment: production and the Apple Distribution identity, even though the archive on disk still shows the development one. The archive isn't the artifact; the export is.

Keeping it out of review

One deliberate decision in there. The build number went 6 → 7 while the marketing version stayed at 1.0, and that's not laziness.

Apple's TestFlight review is scoped to the version, not the app or the tester group. From their glossary: "A review is only required for the first build of a version. Subsequent builds may not need a full review." Bump 1.0 to 1.1 and the first build of 1.1 goes back through review, even for a group that cleared it weeks ago. Hold the version and bump only the build, and it goes straight to your existing testers.

Two caveats I'd rather state than discover later. Apple's wording is "may not" — never a guarantee. And Guideline 2.2 asks that "significant updates" go through review regardless of how you number them, without ever defining significant. Holding the version number is a legitimate way to get a fix to testers tonight. It isn't a trick for sneaking a rewrite past anybody.

Worth noting: it's called TestFlight App Review now. "Beta App Review" survives only in the API.

What I'd do differently

Read the second error first. The loud failure was a red herring; the quiet one was the answer. Both were in the same output.

Remove developer accounts you don't use. One leftover personal Apple ID on this machine produced a misleading error and a silently broken binary. There's no upside to leaving it there.

Use an App Store Connect API key. Apple ID sessions expire and the GUI owns profile regeneration. An API key plus an issuer ID has neither problem. I found two .p8 keys sitting in my Downloads folder from previous attempts at exactly this realization, and no record of the issuer ID for either, which tells its own story.

Try the GUI before the fourth CLI attempt. I burned the better part of an hour on xcodebuild invocations for a problem the Organizer solves by itself, because the CLI is where my muscle memory lives. Reaching for it was the right instinct and staying there was the wrong one.

Treat "ARCHIVE SUCCEEDED" as the beginning. Mine succeeded with the wrong signing identity and a broken push entitlement. Check aps-environment and the signing identity in the archive before uploading — it's two PlistBuddy calls and it would have saved me the detour entirely.

It did ship

The fix was the thing I'd been avoiding the whole time: do it in the GUI. Remove the stale Apple ID, Product → Archive, Distribute App → App Store Connect → Upload. Xcode noticed the certificate mismatch, minted a new provisioning profile against the current cert without being asked, re-signed, and uploaded.

Build 7 is on App Store Connect, processed, and installed on my phone from TestFlight — opened and verified. Because the marketing version stayed at 1.0, it went straight to the existing external group with no new review.

Fourteen pieces of tester feedback took twenty minutes. A calendar date three weeks out of step with another calendar date took an hour, and the thing that finally cleared it took ninety seconds of clicking. There's a lesson in there about knowing when to stop automating, and I'm not sure I've fully learned it.

Chris