~ writing/index.md

The App ID graveyard: shipping an Apple Watch app from a headless CI runner

The watch app that already worked

The MyPepTracker Apple Watch app compiled months before it shipped. Logging doses from the wrist, pushing them to the iPhone, updating the complication — all of it worked on a real device in my office. That is the easy part. The part that took another four builds and a string of 4 a.m. CI failures was proving to Apple's servers that the watch target was allowed to exist.

I run my iOS builds on a headless Mac mini in the homelab, not a cloud runner. It is cheaper, quieter, and gives me full control over certificates and keychains. It also means when provisioning breaks, I am the person who fixes it, with no GitHub Actions abstraction to hide behind. This is the story of the six layers that broke in sequence, each one invisible until the previous one was fixed.

Layer 1: The burned App ID

The first failure was instant and permanent. I had created the watch complication's bundle identifier early in the project, deleted it during a refactor, and tried to recreate it later. Apple does not allow that. Once an App ID is deleted from the developer portal, it is gone forever — globally reserved, never reusable, with no recovery path. I registered a new complication identifier with a slightly different suffix instead. That sounds like a one-minute fix, but it meant every downstream profile, entitlement, and Info.plist reference had to be updated to match the new string. Burned identifiers are a trap because they feel recoverable until they are not.

Layer 2: Manual signing on a headless runner

With the App ID corrected, I turned on automatic signing locally and archived successfully. The CI runner did not. xcodebuild -allowProvisioningUpdates can download an existing matching profile, but it cannot create a new provisioning profile for a freshly registered App ID when nobody is logged into a GUI. The iPhone and widget targets were fine because their profiles already existed. The new watch targets had none. The fix was to abandon automatic signing entirely.

I switched all four targets — iPhone app, widget, watch app, watch complication — to manual signing, created an App Store distribution profile for each bundle ID, and taught the CI pipeline to download those profiles via the App Store Connect API using a stored API key. The runner now carries exactly the profiles it needs in its keychain, and Xcode is told not to ask the portal for anything.

Layer 3: App groups do not bind over the API

Manual signing exposed the next problem. The iPhone app and watch extension share an app group so dose data can move between them. The App Store Connect API can create bundle IDs, profiles, and capabilities, but it cannot bind an app group membership. That toggle only exists in the web developer portal. I had to open the portal, attach the shared group to each watch App ID, then regenerate the provisioning profiles. Any profile created before that capability change is stale, even if it downloads cleanly. I regenerated everything and the group entitlement finally matched on both sides.

Layer 4: Icons and idioms

The archive then passed signing and failed validation. The watch target had no asset catalog at all, which Apple rejected with the classic altool 90713/90391 pair. I added a full canonical watchOS icon set — sixteen sizes for the home screen, notification center, complication slots, and marketing banner. The 1024×1024 marketing image was the last gotcha: the correct idiom is watch-marketing, not watch with a marketing role. One wrong key and App Store Connect labels the icon unassigned. After the idiom fix, validation cleared.

Layer 5: The sync deadlock

Provisioning solved, the app ran on hardware but the watch showed "No doses" permanently. The bug was a dependency loop. The phone only pushed the next dose to the watch after a dose was logged from the watch, but the watch button to log a dose was gated on already having dose data. There was no initial seed. I fixed it by pushing context on WCSession activation and again whenever the iPhone app came to the foreground. The phone now owns the authoritative state and pushes it proactively; the watch logs back a delta. Wrist logging now lands in iPhone History within a second.

Pre-flight checklist for solo shippers

If I were adding a watch target again, I would do these things before writing the first line of watch UI:

  • Choose bundle identifiers once and never delete them. Treat App IDs as permanent.
  • Switch to manual signing immediately if the build will run headless. Pre-create every profile and pull them via API.
  • Bind app groups, iCloud containers, and any other capabilities in the web portal first, then regenerate profiles.
  • Add the full watchOS icon set up front, and use watch-marketing for the 1024 banner.
  • Seed shared state from the phone on every session activation; do not assume the watch will ask first.
  • Capture Apple Watch screenshots before submission. Adding a watch target flips a switch in App Store Connect that blocks review until they exist.

The watch app shipped in v1.35.0 build 44, waiting for review, auto-releasing on approval. The code had been ready for months. The lesson was that shipping is only partly about code. The rest is provisioning discipline: assume every Apple portal action is order-dependent, reversible only in the wrong direction, and happiest when decided in advance.

← back to writing