Publishing to TestFlight
How to ship Minuta (iOS app + widget extension) to TestFlight when the build is signed and uploaded under another person’s Apple Developer membership.
The project is wired to the personal Team ID UKMJ95C96B (Minuta/project.yml, every target). The publishing account belongs to someone else and is written as the Account Holder below; their Team ID is written as HOLDER_TEAM_ID.
Why manual signing
The publishing membership is an Individual Apple Developer account, not an Organization. On Individual accounts only the Account Holder can access Certificates, Identifiers & Profiles; every other user, including App Store Connect Admins, gets App Store Connect only. Xcode shows this as a red “Certificates, Identifiers, & Profiles” mark under the team in Settings > Apple Accounts, and the team never appears in the developer.apple.com team switcher for anyone but the holder. There is no permission that changes this.
Consequences:
- Automatic signing (
-allowProvisioningUpdates, cloud-managed certificates, Xcode registering App IDs) does not work from any Mac but the holder’s. - The Account Holder has to create the certificate, identifiers and profiles once and hand them over; builds are then signed manually here.
- Uploading needs only App Store Connect access, so that part stays on this side.
0. One-time setup by the Account Holder
Everything in this section happens at developer.apple.com/account under the holder’s Apple ID. Send them this list.
App Store Connect user: Users and Access > People > invite the publisher’s Apple ID with role Admin (done already; Admin also allows creating App Store Connect API keys later).
Program License Agreement: accept the current one (Account Holder only); uploads fail otherwise.
App Group: Identifiers > + > App Groups >
group.tools.minuta.apps, descriptionMinuta.App IDs (Identifiers > + > App IDs > type App, Bundle ID Explicit):
tools.minuta.app, descriptionMinuta, capability App Groups > Configure > tickgroup.tools.minuta.apps. Leave every other capability off (In-App Purchase is always ticked and cannot be removed). Tick Mac Catalyst only if a Mac build is planned (section 8).tools.minuta.app.widgets, descriptionMinuta Widgets, capability App Groups >group.tools.minuta.apps. Type is App, not App Clip: a widget extension is an ordinary app extension.
The IDs can only be registered once they are free (section 1).
Apple Distribution certificate: Certificates > + > Apple Distribution. Needs a CSR: Keychain Access > Certificate Assistant > Request a Certificate From a Certificate Authority, saved to disk, on the holder’s Mac (the private key is created there). Download the
.cerand double-click it to install into the login keychain.Provisioning profiles (Profiles > + > Distribution > App Store Connect), platform iOS, both using the Apple Distribution certificate from step 5. Name them exactly like this; the build relies on the names:
- App ID
tools.minuta.app> nametools.minuta.app AppStore - App ID
tools.minuta.app.widgets> nametools.minuta.app.widgets AppStore
- App ID
Export the certificate with its private key: Keychain Access > My Certificates > right-click “Apple Distribution:
(HOLDER_TEAM_ID)” > Export > .p12with a password. Exporting only the.ceris useless; the private key is what signs.Hand over: the
.p12, its password (separate channel), both.mobileprovisionfiles, and the Team ID (Membership details, also visible in the certificate name).
The Team ID also lands in the profiles: security cms -D -i <profile> | plutil -extract TeamIdentifier.0 raw -o - -.
1. Free the bundle IDs (not needed, verified 2026-08-29)
UKMJ95C96B is a free Personal Team (Xcode: isFreeProvisioningTeam = 1). App IDs held by free teams do not block registration in paid teams: the holder registered tools.minuta.app and tools.minuta.app.widgets while the personal team still used them, and afterwards the personal team could still create its own iOS Team Provisioning Profile: tools.minuta.app.widgets. Both coexist; nothing to delete. The rest of this section applies only if the personal team were a paid membership.
Bundle IDs are unique across all paid Apple teams. tools.minuta.app (plus tools.minuta.app.tray, .tests, .uitests, possibly .widgets and the App Group) has almost certainly been auto-registered under the personal team UKMJ95C96B: Xcode’s automatic signing registers an App ID the first time the app is run on a physical device. The holder cannot register an ID that another team owns.
At developer.apple.com/account with the personal team selected > Identifiers: delete tools.minuta.app and tools.minuta.app.widgets (App IDs filter) and group.tools.minuta.apps (App Groups filter) if present. Deletion is refused while an App Store Connect app record uses the ID; delete that record first (only possible if it never had a build submitted for review). Then the holder registers them (section 0, steps 3-4).
Alternative: keep the personal IDs and give the holder’s build different ones (PRODUCT_BUNDLE_IDENTIFIER override at build time, matching App IDs, App Group and profile names). Nothing in the code depends on the bundle ID beyond two string constants, but a different ID means a different data container on the device, and the App Group constant appGroupIdentifier in StorageLocationManager.swift and WidgetData.swift would have to change too. Prefer moving the IDs.
2. Install the signing material here
- Double-click the
.p12(orsecurity import Minuta.p12 -k ~/Library/Keychains/login.keychain-db -P '<password>' -T /usr/bin/codesign). Check:security find-identity -v -p codesigninglistsApple Distribution: <holder> (HOLDER_TEAM_ID). - Double-click each
.mobileprovisionso Xcode installs it (~/Library/Developer/Xcode/UserData/Provisioning Profiles/on Xcode 16+). Check:ls ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/and decode one withsecurity cms -D -i <file> | plutil -extract Name raw -o - -to confirm the names from section 0. - Xcode > Settings > Apple Accounts must have the Apple ID that is an Admin in the holder’s App Store Connect (used for the upload, not for signing).
3. Project changes
All of this section is already applied in the repository (2026-08-29); it is kept as the record of what a TestFlight build depends on.
The MinutaArchive scheme
Minuta/project.yml defines a separate MinutaArchive scheme that builds only Minuta (plus MinutaWidgets when widgets are on), without the Catalyst-only MinutaTray bundle or the test targets. The global -xcconfig (TestFlight.xcconfig) forces manual signing on every target in the scheme, and MinutaTray (a macOS bundle) and the test bundles reject a manually specified profile. Archive this scheme, not Minuta.
Filter MinutaTray to Mac Catalyst
MinutaTray is a Catalyst-only bundle. Its dependency in Minuta must carry a Mac Catalyst filter, or the executable-less macOS bundle lands in pure-iOS builds and App Store Connect rejects the upload (“Unexpected CFBundleExecutable Key. The bundle at ‘MinutaTray.bundle’ does not contain a bundle executable”). Two XcodeGen quirks (verified on 2.45.4):
platformFilteronly acceptsall/iOS/macOS;macCatalystis silently ignored and treated asall.macOSmaps tomaccatalyst, somacOSis the value to use.- a
target:dependency on a bundle withoutcopy:lands in the plain Resources phase, where the filter is not honored.copy:forces a Copy Files (Embed Dependencies) phase that does honor it.
- target: MinutaTray
embed: true
codeSign: true
destinationFilters: [macCatalyst] # modern platformFilters = (maccatalyst,)
platformFilter: macOS # legacy; macCatalyst is invalid here
copy:
destination: plugins
subpath: "$(CONTENTS_FOLDER_PATH)/PlugIns" After regenerating, MinutaTray.bundle in project.pbxproj carries platformFilter = maccatalyst in an Embed Dependencies phase; the iOS archive ships without Tray while Catalyst builds still embed it.
Enable the widget extension
Minuta/project.yml: - target: MinutaWidgets with platformFilter: iOS under the app’s dependencies and MinutaWidgets: [archive] under schemes.MinutaArchive.build.targets. The filter matters: an iOS-only extension cannot be embedded in a Catalyst app (“Your target is built for macOS but contains embedded content built for the iOS platform”), and Catalyst builds keep shipping without widgets.
Temporarily disabled (2026-09-06): the holder’s widget profile was issued for App ID tools.minuta.app.widget (missing the trailing “s”), which does not match the bundle ID tools.minuta.app.widgets. The MinutaWidgets dependency and its scheme entry are commented out so the first TestFlight build ships app-only; re-enable both, and add tools.minuta.app.widgets back to the provisioningProfiles in section 5, once the corrected profile arrives.
App Group name in code
The holder registered the group as group.tools.minuta.apps (the name in the code, group.tools.minuta.app, was not available). Change the constant in both places:
Minuta/Sources/Services/StorageLocationManager.swift(appGroupIdentifier)Shared/Sources/MinutaShared/Models/WidgetData.swift(WidgetDataConstants.appGroupIdentifier)
Entitlements
The committed entitlements files (Minuta/Sources/Minuta.entitlements, Minuta/Widgets/MinutaWidgets.entitlements) stay empty. An App Group entitlement in them would break automatic-signed Debug builds under the personal team UKMJ95C96B, which cannot register a group owned by the holder’s team.
Instead, holder builds swap the entitlements through the xcconfig (section 4). Create Minuta/TestFlight/Minuta.entitlements and Minuta/TestFlight/MinutaWidgets.entitlements, both with:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.tools.minuta.apps</string>
</array>
</dict>
</plist> The file names match $(TARGET_NAME), which is how the single xcconfig line picks the right file per target. These files contain nothing secret and can be committed.
Entitlements must match the profiles exactly; a profile without App Groups, or an entitlement the profile lacks, fails code signing with “Provisioning profile doesn’t include the com.apple.security.application-groups entitlement”.
Without the entitlement (personal-team builds) the widget target still compiles and runs; containerURL(forSecurityApplicationGroupIdentifier:) returns nil and the widgets show their placeholder.
Info.plist
Add to Minuta/Sources/Info.plist:
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string> ITSAppUsesNonExemptEncryption = false answers the export-compliance question once instead of on every build (“Missing Compliance” blocks a TestFlight build until answered); Minuta makes no network requests and uses no custom crypto. LSApplicationCategoryType is required for a Mac upload (ITMS-90242) and harmless for iOS. No usage-description keys are needed: attachments use PHPickerViewController (no photo-library permission) and there is no camera use.
Drop MANUAL_DISTRIBUTION
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) MANUAL_DISTRIBUTION" compiles in the opt-in update check (Update check). Through TestFlight updates come from TestFlight, and after a week of usage the pill would send testers to the website download. The xcconfig below removes the flag for holder builds only; the Homebrew release path (scripts/release-brew.sh) keeps it.
Version and build number
Every upload needs a unique CURRENT_PROJECT_VERSION for a given MARKETING_VERSION. Bump it in project.yml (the three product targets share the values) and regenerate, or override CURRENT_PROJECT_VERSION=N on the command line.
4. Signing xcconfig
The app and the widget extension need different profiles, and command-line overrides apply to every target. Because the profiles are named after the bundle IDs, one setting covers both. Create Minuta/TestFlight.xcconfig (keep it out of git; it is holder-specific):
// Manual signing under the Account Holder's Individual team.
CODE_SIGN_STYLE = Manual
DEVELOPMENT_TEAM = HOLDER_TEAM_ID
CODE_SIGN_IDENTITY = Apple Distribution
PROVISIONING_PROFILE_SPECIFIER = $(PRODUCT_BUNDLE_IDENTIFIER) AppStore
// App Group entitlements, per target (Minuta, MinutaWidgets); see section 3.
// MinutaTray is not included: it is filtered to Mac Catalyst and stays out of
// the iOS archive, so TestFlight/MinutaTray.entitlements is not needed.
CODE_SIGN_ENTITLEMENTS = TestFlight/$(TARGET_NAME).entitlements
// TestFlight build: no website update check.
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(inherited) resolves to the project-level DEBUG in Debug and to nothing in Release. The MinutaArchive scheme (section 3) excludes MinutaTray and the test targets, and MinutaTray is additionally filtered to Mac Catalyst in project.yml, so it never reaches the iOS archive and its signing settings are moot. Earlier this file carried [sdk=macosx*] overrides to neutralize Tray’s signing; the filter fix makes them unnecessary.
5. Archive, export, upload: iOS
The App Store Connect app record (section 6) must exist before the first upload.
cd Minuta
BUILD=../build/testflight
rm -rf "$BUILD"
xcodebuild clean archive \
-project Minuta.xcodeproj -scheme MinutaArchive \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$BUILD/Minuta-iOS.xcarchive" \
-xcconfig TestFlight.xcconfig
cat > "$BUILD/ExportOptions.plist" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key> <string>app-store-connect</string>
<key>destination</key> <string>upload</string>
<key>teamID</key> <string>HOLDER_TEAM_ID</string>
<key>signingStyle</key> <string>manual</string>
<key>signingCertificate</key> <string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>tools.minuta.app</key> <string>tools.minuta.app AppStore</string>
<!-- add the widgets line back when they are re-enabled (section 3) -->
<key>tools.minuta.app.widgets</key> <string>tools.minuta.app.widgets AppStore</string>
</dict>
</dict>
</plist>
EOF
xcodebuild -exportArchive \
-archivePath "$BUILD/Minuta-iOS.xcarchive" \
-exportPath "$BUILD/export-ios" \
-exportOptionsPlist "$BUILD/ExportOptions.plist" Verify the archive before exporting if signing looks off:
codesign -dvv "$BUILD/Minuta-iOS.xcarchive/Products/Applications/Minuta.app" 2>&1 | grep -E 'Authority|TeamIdentifier'
codesign -d --entitlements - "$BUILD/Minuta-iOS.xcarchive/Products/Applications/Minuta.app" | grep -A2 application-groups The upload in -exportArchive authenticates with the Apple ID signed into Xcode. For unattended uploads create an App Store Connect API key (Users and Access > Integrations > App Store Connect API; Admin can generate a team key once the holder has enabled API access) and add -authenticationKeyPath ~/.appstoreconnect/private_keys/AuthKey_KEYID.p8 -authenticationKeyID KEYID -authenticationKeyIssuerID ISSUER to the export command.
Xcode GUI alternative: manual signing per target can also be set in Signing & Capabilities and archived via Product > Archive, but xcodegen generate reverts it; the xcconfig route is the one that survives regeneration.
6. App Store Connect app record
App Store Connect, with the holder’s team selected > Apps > + > New App:
- Platforms: iOS (add macOS later for a Catalyst build)
- Name:
Minuta(names are unique across the App Store; pick another if taken, it can be renamed later) - Bundle ID:
tools.minuta.appfrom the dropdown (appears only after the holder registered it) - SKU:
minuta(internal, never shown, immutable; any unique string) - User Access: Full Access
Nothing under the App Store tab (screenshots, description, rating) is needed for TestFlight. External testing needs a Privacy Policy URL and beta contact info under TestFlight > Test Information.
7. TestFlight
- Processing after upload takes 5-30 minutes; the build then appears under the app’s TestFlight tab.
- Internal testing: create a group, add testers from the holder’s App Store Connect users (up to 100). No review; available as soon as processing finishes.
- External testing: create a group, add testers by email or public link (up to 10,000). The first build of each version goes through Beta App Review (usually 1-2 days).
- Testers install the TestFlight app and accept the invitation. Widgets are part of the app bundle; nothing extra to distribute.
8. Mac via TestFlight (Catalyst)
The iOS build does not cover the Mac. TestFlight for Mac needs a separate Catalyst archive, uploaded to the same app record with the macOS platform added, and Mac App Store rules apply:
- App Sandbox is mandatory (upload rejected with ITMS-90296 otherwise). Add to
Minuta.entitlements(and the widget entitlements) for the Catalyst build:<key>com.apple.security.app-sandbox</key> <true/> <key>com.apple.security.files.user-selected.read-write</key> <true/>StorageLocationManageralready persists security-scoped bookmarks for the user-picked folder, so the Dropbox / iCloud Drive storage flow keeps working under sandbox. - App Group naming on macOS: groups must be prefixed with the Team ID (
HOLDER_TEAM_ID.tools.minuta.apps); agroup.-prefixed one triggers the macOS 15 “wants to access data from other apps” prompt. This means a different group name than iOS, so the constant would have to be platform-conditional. - What breaks under sandbox:
CLIConfigServicewrites<realHome>/Library/Application Support/minuta/storage-path.txt(the CLI discovery pointer) outside the container. The write is denied, the app keeps running, and the CLI falls back to~/Documents/Minutaunless the user passes--storage/MINUTA_STORAGE. TheMinutaTrayplugin bundle is inside the app bundle and loads fine. - Signing material: the holder additionally creates Mac App Store profiles (Profiles > + > Distribution > Mac App Store, platform Mac Catalyst) for both App IDs, named
tools.minuta.app MacAppStoreandtools.minuta.app.widgets MacAppStore, using the same Apple Distribution certificate plus a Mac Installer Distribution certificate for the.pkg. A second xcconfig withPROVISIONING_PROFILE_SPECIFIER = $(PRODUCT_BUNDLE_IDENTIFIER) MacAppStoreand-destination 'generic/platform=macOS,variant=Mac Catalyst'does the archive;ExportOptions.plistmaps the bundle IDs to the Mac profile names.
If Mac testing is not urgent, keep distributing the Mac build through the Homebrew cask / direct download and use TestFlight for iOS only. The Homebrew build must stay signed with the personal team and keep MANUAL_DISTRIBUTION, so never commit TestFlight.xcconfig values into project.yml.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Red “Certificates, Identifiers, & Profiles” under the team in Xcode Apple Accounts | Expected on an Individual membership; use manual signing, nothing to fix |
| Team missing from the developer.apple.com switcher | Same cause; only the Account Holder sees it |
| “No signing certificate ‘Apple Distribution’ found” | .p12 not imported, or exported without the private key; re-export from the holder’s keychain via My Certificates |
| “No profiles for ‘tools.minuta.app’ were found” / “doesn’t match” | Profile not installed, wrong name (must be <bundle id> AppStore), or created with a different certificate than the imported one |
| “Provisioning profile doesn’t include the … application-groups entitlement” | App ID lacks App Groups, or the profile was created before the capability was added; regenerate the profile |
| “An App ID with Identifier ‘tools.minuta.app’ is not available” (holder side) | ID still registered under UKMJ95C96B; delete it there (section 1) |
| Bundle ID missing from the New App dropdown | App ID not registered under the holder’s team yet |
| “The name you entered is already being used” | App name taken; use another name, rename later |
| Upload rejected, agreement message | Account Holder must accept the current PLA |
| “Build already exists” | Bump CURRENT_PROJECT_VERSION |
| “Missing Compliance” on the build in TestFlight | Add ITSAppUsesNonExemptEncryption=false (section 3) or answer “No” in the UI |
| ITMS-90296 “App sandbox not enabled” | Mac upload without the sandbox entitlement (section 8) |
ITMS-90242 missing LSApplicationCategoryType | Mac upload without the category key (section 3) |
| Testers see an “enable updates” pill | Build still compiled with MANUAL_DISTRIBUTION; archive with the xcconfig |
| Widget shows placeholder / no data | App Group entitlement missing on one side, or the group name differs between app and extension |
Checklist
- Holder: PLA accepted, App Group, two App IDs with App Groups, Apple Distribution certificate, two App Store profiles named
<bundle id> AppStore,.p12+ profiles + Team ID handed over. - Bundle IDs and App Group deleted from the personal team before the holder registers them.
.p12imported, profiles installed,security find-identityshows the Apple Distribution identity.- Project: widgets target enabled, App Group constant renamed to
group.tools.minuta.apps,Minuta/TestFlight/*.entitlementscreated, Info.plist keys, build number bumped,Minuta/TestFlight.xcconfigcreated. - App record created in App Store Connect under the holder’s team.
- Archive with
-xcconfig TestFlight.xcconfig, export with manualExportOptions.plist, upload. - Internal testers added; external group + Beta App Review if needed.
- Mac (optional): sandbox entitlements, Team-ID-prefixed App Group, Mac App Store profiles, Catalyst archive, macOS platform on the app record.