Every iOS app extension signs with its own profile
A widget or notification extension is a second app with its own bundle id, and it needs its own provisioning profile. Why one global profile setting breaks the build, the two places the mapping has to agree, and the entitlement trap that catches new extensions.
A common way for an iOS release to fall over in CI goes like this. The app has built and shipped from the same pipeline for months. Someone adds a home screen widget, or a notification service extension so pushes can carry images. It runs fine from Xcode on a laptop. The first release build on the build machine fails at signing, with an error saying a target requires a provisioning profile, or that the profile it was given does not match its bundle identifier.
Nothing is wrong with the extension. The pipeline was built around a fact that stopped being true the day the extension was added: that the app has one bundle id and therefore one profile. This week we changed how Ubriot's iOS builds sign apps for exactly this case, and the reasoning is worth setting out for anyone running their own signing.
An extension is a second app
Inside the .ipa an extension is its own bundle, nested in the app's PlugIns folder, with its own Info.plist, its own bundle id and its own signature. The bundle id is usually the app's id with a suffix, such as com.example.shop.widget, but to Apple it is a separate identifier that has to be registered in the developer account in its own right.
A provisioning profile is tied to exactly one bundle id. Unless you use a wildcard App ID, which rules out App Groups, push and most of the capabilities extensions rely on, the profile for com.example.shop cannot sign com.example.shop.widget. So an app with a widget and a notification extension needs three App Store profiles, all made with the same distribution certificate. Ad hoc builds need three ad hoc profiles, each listing the same test devices.
Xcode hides this on a laptop. With automatic signing turned on and a signed in account, it quietly creates and manages a profile for every target. A build machine usually has neither, so whatever Xcode was doing for you now has to be done on purpose.
Why the global setting breaks
The quickest way to sign in CI is to pass the profile on the xcodebuild command line, as PROVISIONING_PROFILE_SPECIFIER or PROVISIONING_PROFILE. It works for a single target app, and many build scripts, ours included until this week, were written that way.
Build settings passed on the command line apply to every target in the build. The app's profile is pushed onto the widget too. The widget's bundle id does not match, and signing fails. Leaving the setting off does not help either, because then the extension has no profile at all.
The fix is to set the profile per target, in each target's own build settings in the project file, and pass nothing global. Ubriot's build worker now does this: it reads the project, finds every app extension target and its bundle id, and sets each target's own profile before archiving.
The mapping has to agree in two places
Signing happens twice in an iOS release build. The archive step signs each target with the profile in its build settings. The export step then re-signs everything for distribution, and it takes its instructions from the ExportOptions.plist passed to xcodebuild -exportArchive. That file has a provisioningProfiles dictionary that maps each bundle id to a profile name.
<key>provisioningProfiles</key> <dict> <key>com.example.shop</key> <string>Shop App Store</string> <key>com.example.shop.widget</key> <string>Shop Widget App Store</string> <key>com.example.shop.notifications</key> <string>Shop Notifications App Store</string> </dict>
If the archive is configured correctly but the export file lists only the main app, the archive succeeds and the export fails, often with a message that does not name the missing extension. Our rule is that both are generated from the same list, so they cannot drift apart.
The entitlement trap
Most extensions need to share something with the main app. A widget reads data the app saved, which usually means an App Group. A notification extension may read a token from a shared keychain. Those features are entitlements, and an entitlement only works if the bundle id has the matching capability enabled in the developer account and the profile was created after that.
That catches people in two ways. A newly registered extension bundle id has no capabilities, so a fresh profile will not include the App Group the extension asks for, and signing fails with an error about a missing entitlement. And if you turn the capability on later, existing profiles do not update themselves. They have to be regenerated.
Our worker registers the extension's bundle id if it is missing and creates or reuses a profile for it, using the team's App Store Connect API key and the same distribution certificate as the app. It does not switch on capabilities for you. If your extension uses an App Group or keychain sharing, enable that capability on the extension's bundle id in the developer account once, and the next build will pick up a profile that includes it. We left that step out on purpose, because changing what an identifier is allowed to do is a decision for the account owner, not a side effect of a build.
A quick check before you add an extension
Before the first release build with a new extension, it is worth five minutes to answer three questions. Is the extension's bundle id registered, and does it have every capability its entitlements file asks for? Does your build pass any profile setting globally, on the command line or in an xcconfig shared by all targets? Does the export options file name a profile for every bundle id in the app, not just the main one?
Then check the result rather than the green tick. After an export, codesign -d --entitlements - on the extension inside the .ipa shows what it was really signed with. If the App Group is missing there, the widget will build, install and show nothing, which is a harder bug to trace than a failed build.
Apps without extensions build exactly as before. Extension signing on Ubriot needs an App Store Connect API key uploaded for the app or the account, because that is what lets the worker manage profiles, and the build log names each extension and the profile it was signed with so you can see what happened.