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_NAME (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 Do not add a subpath to copy:: destination: plugins already resolves to Contents/PlugIns on Catalyst, and a subpath built from $(CONTENTS_FOLDER_PATH) nests a second Minuta.app inside PlugIns and breaks codesign.
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.
History: on 2026-09-06 the holder’s widget profile had been issued for App ID tools.minuta.app.widget (missing the trailing “s”), so the MinutaWidgets dependency and its scheme entry were commented out and the first TestFlight build shipped app-only. On 2026-10-01 the holder registered tools.minuta.app.widgets and reissued the profile; on 2026-10-02 both entries were restored and the widget profile added to Minuta/ExportOptions-ios.plist.
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. The value must be empty: $(inherited) in a command-line xcconfig resolves to the target-level $(inherited) MANUAL_DISTRIBUTION and keeps the flag (verified with -showBuildSettings on 2026-09-08; harmless on iOS since the code behind the flag is Catalyst-only, critical for the Mac App Store build).
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) 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
scripts/release/testflight.sh ios (and the ios step of scripts/release.sh, see Release) runs these commands with signing checks on the archive; the manual commands below stay valid.
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
xcodebuild -exportArchive \
-archivePath "$BUILD/Minuta-iOS.xcarchive" \
-exportPath "$BUILD/export-ios" \
-exportOptionsPlist ExportOptions-ios.plist Minuta/ExportOptions-ios.plist is committed (no secrets): app-store-connect, upload, teamID G86VRDBMDK, manual Apple Distribution signing, profiles tools.minuta.app AppStore and tools.minuta.app.widgets AppStore. The Mac App Store export uses ExportOptions-mac.plist (section 8).
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. Full plan with templates: 919-plan-mac-distribution.
- App Sandbox is mandatory (upload rejected with ITMS-90296 otherwise). Ready-made
Minuta/TestFlight/Minuta.macOS.entitlements(committed):app-sandbox,files.user-selected.read-write,files.bookmarks.app-scope(without itbookmarkData(options: .withSecurityScope)inStorageLocationManager.saveBookmarkfails under sandbox),application-groups = [group.tools.minuta.apps]. The widget target in the Mac build uses the iOS fileTestFlight/MinutaWidgets.entitlements(group only): in a Catalyst archive it is compiled as an iOS target and not embedded (step 5).TestFlight/MinutaWidgets.macOS.entitlements(sandbox + group) is committed, butTestFlight-mac.xcconfigdoes not use it. No network entitlements: the app is offline and the Mac xcconfig dropsMANUAL_DISTRIBUTION. - The App Group name stays
group.tools.minuta.apps. Since macOS 15 beta 4 agroup.-prefixed group is allowed when it is in the embedded provisioning profile or the build comes from the Mac App Store; the “wants to access data from other apps” prompt does not appear then. The constant in code does not change. - CLI pointer under sandbox:
CLIConfigServicewrites<realHome>/Library/Application Support/minuta/storage-path.txtoutside the container.StorageLocationManager.publishResolvedStoragePath()skips that write whenAPP_SANDBOX_CONTAINER_IDis in the environment; the version marker inside the storage folder is written as usual. The CLI falls back to~/Documents/Minutaunless the user passes--storage/MINUTA_STORAGE. TheMinutaTrayplugin inside the bundle loads fine. - Signing material (received from the holder and installed; status in
TESTFLIGHT-HOLDER.md): the Mac Catalyst capability on both App IDs, a Mac Installer Distribution certificate (.p12), Mac App Store Connect / Mac Catalyst profiles namedtools.minuta.app MacAppStoreandtools.minuta.app.widgets MacAppStorewith the same Apple Distribution certificate. Minuta/TestFlight-mac.xcconfig(untracked). In a Catalyst archiveMinutaTrayis really built and embedded, and an[sdk=macosx*]condition cannot separate it from the app (both use the macosx SDK). Profile and entitlements are selected by target name through nested substitution; forMinutaTrayboth variables are undefined, so it is signed with Apple Distribution without a profile or entitlements.MinutaWidgets(platformFilter: iOS) is still compiled as an iOS target in a Catalyst archive (not embedded), so it needs the iOS profile: the Mac widget profile fails with “platform macOS does not match iOS” and stays unused:
Check the substitution:CODE_SIGN_STYLE = Manual DEVELOPMENT_TEAM = G86VRDBMDK CODE_SIGN_IDENTITY = Apple Distribution PROFILE_Minuta = tools.minuta.app MacAppStore PROFILE_MinutaWidgets = tools.minuta.app.widgets AppStore PROVISIONING_PROFILE_SPECIFIER = $(PROFILE_$(TARGET_NAME)) ENTITLEMENTS_Minuta = TestFlight/Minuta.macOS.entitlements ENTITLEMENTS_MinutaWidgets = TestFlight/MinutaWidgets.entitlements CODE_SIGN_ENTITLEMENTS = $(ENTITLEMENTS_$(TARGET_NAME)) SWIFT_ACTIVE_COMPILATION_CONDITIONS =xcodebuild -showBuildSettings -scheme MinutaArchive -destination 'generic/platform=macOS,variant=Mac Catalyst' -xcconfig TestFlight-mac.xcconfig | grep -E 'PROVISIONING_PROFILE_SPECIFIER|CODE_SIGN_ENTITLEMENTS'.- Archive and export (
scripts/release/testflight.sh macand themacstep ofscripts/release.shrun these with the signing checks below):xcodebuild clean archive -scheme MinutaArchive -configuration Release -destination 'generic/platform=macOS,variant=Mac Catalyst' -archivePath ../build/testflight/Minuta-mac.xcarchive -xcconfig TestFlight-mac.xcconfig, thenxcodebuild -exportArchive ... -exportOptionsPlist ExportOptions-mac.plist(committed:app-store-connect,upload, manual signing,installerSigningCertificatewith the full identity name3rd Party Mac Developer Installer: <holder> (G86VRDBMDK), exact value under that key inMinuta/ExportOptions-mac.plist: the generic selectorMac Installer Distributionfails with “No certificate for team … matching”, verified 2026-10-02; profiletools.minuta.app MacAppStore). The first Mac build 1.0.4 (6) was uploaded on 2026-10-02. Before exporting,codesign -d --entitlements :- Minuta.appshows sandbox, user-selected, bookmarks and the group;Contents/PlugIns/MinutaTray.bundleis signed by the holder’s team. - Under sandbox the default
~/Documents/Minutais the container’s Documents. Users moving from the Homebrew build pick their folder in Settings (bookmarks work).
The Homebrew build (cask and direct download) is from now on signed with the Developer ID Application certificate of the same holder team and notarized: scripts/release-brew.sh does it by itself when Minuta/DeveloperID.xcconfig (untracked) and the minuta-notary keychain profile for notarytool exist (both need material from the holder: the Developer ID .p12 and notarization access; what has arrived and what is still open is recorded in TESTFLIGHT-HOLDER.md). It keeps MANUAL_DISTRIBUTION and does not use the sandbox. Never move the values of any of the three xcconfigs (TestFlight, TestFlight-mac, DeveloperID) into project.yml: regular builds stay on the personal team.
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 |
| macOS: “wants to access data from other apps” prompt | The group is missing from the embedded profile (profile created before App Groups was added to the App ID); recreate the Mac App Store profile |
| “No profiles for ‘tools.minuta.app.widgets’” on the iOS archive | The profile was issued for App ID tools.minuta.app.widget without the “s”; the holder registers tools.minuta.app.widgets and reissues the tools.minuta.app.widgets AppStore profile (done 2026-10-01, section 3) |
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 the committedExportOptions-ios.plist(manual signing), upload. - Internal testers added; external group + Beta App Review if needed.
- Mac:
Minuta/TestFlight/Minuta.macOS.entitlements,TestFlight-mac.xcconfig, Mac Installer.p12andMacAppStoreprofiles from the holder, Catalyst archive + export withExportOptions-mac.plist, macOS platform on the app record. - Homebrew: Developer ID
.p12and App Store Connect API access from the holder,notarytool store-credentials minuta-notary,Minuta/DeveloperID.xcconfig,scripts/release-brew.shwithout the warning,postflightremoved from the cask.