Plan: Mac distribution under the holder’s team

Status: materials received 2026-10-01; 2026-10-02 the notarized Homebrew release 1.0.3 / CLI 0.1.2 shipped (cask postflight removed) and iOS 1.0.3 (5) with widgets was uploaded to TestFlight; the Mac Installer .p12 arrived later the same day and the Mac App Store build 1.0.4 (6) was uploaded to TestFlight (installerSigningCertificate needs the full identity name, see ExportOptions-mac.plist). Supersedes 917-plan-mac-testflight. Tracked in the backlog under Publishing.

Context

Two Mac channels, one holder team (G86VRDBMDK, Individual membership):

  • Homebrew / direct download. The shipped Minuta.app is signed with the free personal team’s Apple Development certificate (UKMJ95C96B), carries the get-task-allow debug entitlement, and spctl -a -t exec rejects it. The cask hides this by stripping com.apple.quarantine in postflight; the direct download needs right-click > Open. The CLI tarball is ad-hoc signed. The fix is a Developer ID Application certificate plus Apple notarization. The holder’s Apple Distribution certificate cannot do this (App Store only), and only the Account Holder can create a Developer ID certificate.
  • Mac App Store / TestFlight. A sandboxed Catalyst archive uploaded to the same App Store Connect record with the macOS platform added: Mac App Store profile, Mac Installer Distribution certificate, a per-target Mac xcconfig, and a sandbox guard for the CLI discovery pointer (from plan 917).
  • Widgets in TestFlight. The holder’s profile tools.minuta.app.widgets AppStore was issued for App ID tools.minuta.app.widget (missing the trailing “s”), so the widget dependency is commented out in Minuta/project.yml and the MinutaArchive scheme.

All three need materials from the holder, so they share one request (TESTFLIGHT-HOLDER.md) and one plan. Everything on our side is done; the release runs happen after the materials arrive. The App Store Connect API key used for notarization is also the prerequisite for “upload without an Xcode login” in the publishing-automation backlog item.

Needed from the Account Holder

The request was sent to the holder as TESTFLIGHT-HOLDER.md (Russian). Since the materials arrived that file only records what was received and one optional item, so the steps are listed here:

  1. Widgets. Register App ID tools.minuta.app.widgets (explicit, App Groups capability with group.tools.minuta.apps) if absent, delete the profile bound to the misspelled App ID, recreate an App Store Connect / iOS profile for the right App ID named exactly tools.minuta.app.widgets AppStore.
  2. Developer ID Application certificate (Account Holder only): CSR on the holder’s Mac, Certificates > + > Developer ID Application, install, export .p12 with the private key. Valid 5 years.
  3. App Store Connect API access: Users and Access > Integrations > App Store Connect API > Request Access (Account Holder only). After that an Admin creates a Team key (role Developer) for notarytool; alternatively the holder creates and sends AuthKey_<KEYID>.p8 + Key ID + Issuer ID. An app-specific password is the fallback, not recommended.
  4. Mac App Store: Mac Catalyst capability on both App IDs; Mac Installer Distribution certificate exported as .p12; Mac App Store Connect / Mac Catalyst profiles named exactly tools.minuta.app MacAppStore and tools.minuta.app.widgets MacAppStore with the existing Apple Distribution certificate.
  5. Send: the widgets .mobileprovision, Developer ID .p12 + password, API access confirmation or .p8 + Key ID + Issuer ID, Mac Installer .p12 + password, both .provisionprofile files. Passwords through a different channel than the files.

On our side (Admin): App Store Connect > Minuta > add the macOS platform.

Developer ID build for Homebrew

Minuta/DeveloperID.xcconfig (untracked, in .gitignore)

// Developer ID build for Homebrew / direct download. Holder's Individual team.
// Not committed (.gitignore). Personal-team defaults stay in project.yml.
CODE_SIGN_STYLE = Manual
DEVELOPMENT_TEAM = G86VRDBMDK
CODE_SIGN_IDENTITY = Developer ID Application
// No profile: Developer ID needs one only for profile-backed entitlements
// (iCloud, push, App Groups); ours are empty. The empty specifier also lets
// the same xcconfig apply to MinutaTray (a macOS bundle rejects a manual profile).
PROVISIONING_PROFILE_SPECIFIER =
ENABLE_HARDENED_RUNTIME = YES
OTHER_CODE_SIGN_FLAGS = --timestamp
// Xcode injects com.apple.security.get-task-allow from its base entitlements
// even with this identity on Catalyst; notarization rejects it (verified 2026-10-02).
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO
  • No CODE_SIGN_ENTITLEMENTS: the committed Sources/Minuta.entitlements is an empty dict and MinutaTray has none. No App Sandbox: not required for Developer ID, and it would break the CLI pointer write.
  • SWIFT_ACTIVE_COMPILATION_CONDITIONS is not overridden, so MANUAL_DISTRIBUTION (the opt-in update check) stays in the Homebrew build.
  • CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO is required: the first run on 2026-10-02 produced a correct Developer ID signature but still carried get-task-allow, and the script’s guard stopped before submission.
  • project.yml is untouched; dev builds keep the personal team. The build action is kept (no archive + -exportArchive): -xcconfig applies to every target built in the invocation, including the implicit MinutaTray dependency, so Xcode signs the plugin and then the app inside-out with one identity and no --deep re-sign is needed.

scripts/release-brew.sh

  • Switch: NOTARIZE=1 when MINUTA_NOTARIZE is not 0 and Minuta/DeveloperID.xcconfig exists. The signing identity is resolved from security find-identity -v -p codesigning filtered by Developer ID Application: and the team from the xcconfig (the keychain can hold Developer ID identities of other teams). The notarytool keychain profile (NOTARY_PROFILE, default minuta-notary) must answer notarytool history. Otherwise a loud warning and the old path: personal-team app + ad-hoc CLI, not for upload.
  • notarize <zip>: xcrun notarytool submit --keychain-profile <profile> --wait; on anything but status: Accepted it prints notarytool log <id> (names the offending file) and exits 1.
  • CLI: codesign --force --options runtime --timestamp --sign "<identity>", codesign --verify --strict, the bare binary is zipped with ditto -c -k (notarytool accepts zip/dmg/pkg, not tar.gz), notarized, and the tarball is built from the signed binary. A bare Mach-O cannot be stapled; Gatekeeper fetches the ticket online.
  • App: xcodebuild ... -xcconfig DeveloperID.xcconfig build, then guards (no get-task-allow, codesign --verify --deep --strict, Authority=Developer ID Application on the app and on Contents/PlugIns/MinutaTray.bundle), a throwaway zip for notarytool, xcrun stapler staple Minuta.app, stapler validate, spctl -a -t exec -vv (expect Notarized Developer ID), and only then the published zip.
  • scripts/upload-brew-artifacts.sh is unchanged.

Local one-time setup (after the materials)

security import DeveloperID.p12 -k ~/Library/Keychains/login.keychain-db -P '<pw>' -T /usr/bin/codesign -T /usr/bin/security
security find-identity -v -p codesigning | grep 'Developer ID Application: .* (G86VRDBMDK)'
mkdir -p ~/.appstoreconnect/private_keys && mv AuthKey_<KEYID>.p8 ~/.appstoreconnect/private_keys/
xcrun notarytool store-credentials minuta-notary \
  --key ~/.appstoreconnect/private_keys/AuthKey_<KEYID>.p8 --key-id <KEYID> --issuer <ISSUER-UUID>
xcrun notarytool history --keychain-profile minuta-notary

Then create Minuta/DeveloperID.xcconfig from the template above and run ./scripts/release-brew.sh, ./scripts/upload-brew-artifacts.sh.

Future releases run all channels (Homebrew artifacts and tap, TestFlight iOS, Mac App Store, site) through scripts/release.sh: Release.

Tap and site

With the first notarized release, in the same tap commit as the sha bump:

  • Casks/minuta.rb: remove the postflight block that runs xattr -dr com.apple.quarantine; bump version / sha256.
  • Formula/minuta.rb: url / sha256 bump.
  • README.md: replace the Gatekeeper note with one sentence (Developer ID signed and notarized).

The downloads page and the CLI reference already describe the notarized state; the xattr command stays there as troubleshooting for builds before the notarized one.

Mac App Store build for TestFlight

Key finding (Apple Forums, DTS, macOS 15 beta 4+): a Mac Catalyst app should keep the iOS-style group name group.*, authorized by the embedded provisioning profile. The macOS 15 prompt “wants to access data from other apps” does not appear when the group is in the profile or the build comes from the Mac App Store. So WidgetDataConstants.appGroupIdentifier = "group.tools.minuta.apps" stays and no App Group code changes.

Project changes (done)

  1. Minuta/TestFlight/Minuta.macOS.entitlements (committed): com.apple.security.app-sandbox, files.user-selected.read-write, files.bookmarks.app-scope (without it bookmarkData(options: .withSecurityScope) in StorageLocationManager.saveBookmark fails under sandbox), application-groups = [group.tools.minuta.apps]. No network entitlements: the app is offline and the Mac xcconfig drops MANUAL_DISTRIBUTION. MinutaWidgets.macOS.entitlements: sandbox + App Group, for when Mac widgets are enabled.

  2. Sandbox guard in StorageLocationManager.publishResolvedStoragePath(): when APP_SANDBOX_CONTAINER_ID is in the environment the CLI pointer write to <realHome>/Library/Application Support/minuta/storage-path.txt is skipped and logged; the version marker (inside the storage folder) is still written. The environment is injectable through init(environment:); covered by StorageLocationManagerTests.testSandboxedLaunchSkipsPointerWriteButWritesMarker.

  3. Minuta/ExportOptions-mac.plist (committed, no secrets): method app-store-connect, destination upload, teamID G86VRDBMDK, signingStyle manual, signingCertificate Apple Distribution, installerSigningCertificate Mac Installer Distribution, provisioningProfiles { tools.minuta.app: "tools.minuta.app MacAppStore" }.

  4. Minuta/TestFlight-mac.xcconfig (untracked, in .gitignore). In a Catalyst archive MinutaTray is 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; for MinutaTray both variables are undefined, so it is signed with Apple Distribution without a profile or entitlements (a plugin inside a sandboxed app needs neither):

    CODE_SIGN_STYLE = Manual
    DEVELOPMENT_TEAM = G86VRDBMDK
    CODE_SIGN_IDENTITY = Apple Distribution
    
    PROFILE_Minuta = tools.minuta.app MacAppStore
    // iOS profile on purpose: the Catalyst archive compiles the iOS-only widget target
    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))
    
    // Mac App Store build: no update check against the site. An empty value is
    // required: $(inherited) resolves to the target-level "$(inherited) MANUAL_DISTRIBUTION"
    // and keeps the flag (verified with -showBuildSettings, 2026-09-08).
    SWIFT_ACTIVE_COMPILATION_CONDITIONS =

    Check the substitution before archiving:

    cd Minuta && xcodebuild -showBuildSettings -scheme MinutaArchive \
      -destination 'generic/platform=macOS,variant=Mac Catalyst' \
      -xcconfig TestFlight-mac.xcconfig | grep -E 'PROVISIONING_PROFILE_SPECIFIER|CODE_SIGN_ENTITLEMENTS'
  5. Scheme: MinutaArchive (Tray comes in as a dependency, tests are excluded). Version: MARKETING_VERSION stays; build numbers are unique per platform in App Store Connect, bump CURRENT_PROJECT_VERSION only on “Build already exists”.

Archive, export, upload (after the materials)

  1. Import the Mac Installer .p12; security find-identity -v | grep "Mac Developer Installer" shows 3rd Party Mac Developer Installer: <holder> (G86VRDBMDK).

  2. Install the profiles as ~/Library/Developer/Xcode/UserData/Provisioning Profiles/<UUID>.provisionprofile (UUID via security cms -D -i <file> | plutil -extract UUID raw -). Confirm Platform contains OSX and the entitlements list group.tools.minuta.apps.

  3. Archive:

    cd Minuta && xcodebuild clean archive -project Minuta.xcodeproj -scheme MinutaArchive \
      -configuration Release -destination 'generic/platform=macOS,variant=Mac Catalyst' \
      -archivePath ../build/testflight/Minuta-mac.xcarchive -xcconfig TestFlight-mac.xcconfig
  4. Before exporting: codesign -d --entitlements :- <archive>/Products/Applications/Minuta.app shows app-sandbox, files.user-selected.read-write, files.bookmarks.app-scope, application-groups; Contents/PlugIns/MinutaTray.bundle is present and signed by the holder’s team.

  5. xcodebuild -exportArchive -archivePath ../build/testflight/Minuta-mac.xcarchive -exportOptionsPlist ExportOptions-mac.plist -exportPath ../build/testflight/mac until Upload succeeded. Typical rejections: ITMS-90296 (sandbox) > entitlements above; ITMS-90242 (category) > LSApplicationCategoryType is already in Info.plist; “Mac Installer certificate not found” > step 1.

  6. App Store Connect > TestFlight > the macOS build > internal group; install through the TestFlight app on the Mac.

Known behaviour: under sandbox the default ~/Documents/Minuta is the container’s Documents. Users moving from the Homebrew build to the App Store build pick their real folder in Settings (security-scoped bookmarks work). Follow-up in the backlog: write the CLI discovery pointer into the group container ~/Library/Group Containers/group.tools.minuta.apps/ and add that path to StorageResolver.

Widgets in TestFlight (after the corrected profile)

  1. Install the new tools.minuta.app.widgets AppStore.mobileprovision; security cms -D -i <file> | grep -A1 application-identifier must end in .widgets.
  2. Uncomment the MinutaWidgets dependency in Minuta/project.yml and MinutaWidgets: [archive] in the MinutaArchive scheme, xcodegen generate.
  3. Archive iOS with TestFlight.xcconfig as in the TestFlight guide.
  4. MinutaWidgets is platformFilter: iOS, so neither Mac build changes. Verify that ./scripts/release-brew.sh still succeeds with the widget target back in the scheme (Catalyst filters it out; the original reason for commenting it out was the personal team failing to sign it in Release).

Execution log (2026-10-02)

  • Keychain: Developer ID Application: <holder> (G86VRDBMDK) imported from the holder’s .p12 (valid to 2031-09-17) plus the Apple Developer ID G2 intermediate. Profiles installed under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/: tools.minuta.app AppStore (caaad332-8fe4-46b9-9bf8-e0be6c315624, reissued), tools.minuta.app.widgets AppStore (ee47b42f-5cae-4ba6-9e6a-bb9234077ba6), tools.minuta.app MacAppStore (ec546bd4-4d8e-4927-b29f-af44008445f6), tools.minuta.app.widgets MacAppStore (a3e77764-72c1-427e-96d5-6a17142ab1f5); the two old iOS profiles were removed.
  • notarytool: the App Store Connect Team Keys page errors out for the Admin account (“An error has occurred. Try again later.”), so minuta-notary is an Apple ID + app-specific password profile (--apple-id --team-id G86VRDBMDK --password), stored from the untracked root .env. Issuer ID for a future API key: 8b60b2ec-8676-41fa-92ca-820560cf3044.
  • First release-brew.sh run stopped at the guard: get-task-allow was still injected -> CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO added to the xcconfig template. Second run tripped the script’s own codesign | grep -q check under pipefail (SIGPIPE) although the signature was correct -> the checks now capture output first. Third run: CLI and app status: Accepted, app stapled, spctl accepted source=Notarized Developer ID. Each run re-notarizes the CLI (one extra round trip per retry).
  • spctl -a -t exec on the bare CLI binary answers rejected (the code is valid but does not seem to be an app); that is spctl’s standard reply for a non-bundle Mach-O, and the quarantined binary runs without a prompt.
  • Tap commit c840b23 (notarized 1.0.3): cask 1.0.3 without postflight, formula 0.1.2; site deployed, version.json advertises 1.0.3. brew info resolves cask 1.0.3 / formula 0.1.2; Homebrew 7 refuses to install from a third-party tap until brew trust ramil-k/minuta is run once (new in brew 7, see the install docs).
  • iOS: TestFlight.xcconfig fixed to an empty SWIFT_ACTIVE_COMPILATION_CONDITIONS; archive signed by the holder with the App Group on app and MinutaWidgets.appex, no Tray, no update pill; -exportArchive with ExportOptions-ios.plist (Xcode Apple ID login) -> Upload succeeded, build 1.0.3 (5).
  • Mac: with MinutaWidgets back in MinutaArchive, a Catalyst archive still compiles the widget target as iOS, so TestFlight-mac.xcconfig must give it the iOS profile and entitlements (tools.minuta.app.widgets AppStore, TestFlight/MinutaWidgets.entitlements); the Mac widget profile errors with “platform macOS does not match iOS” and stays unused. Archive succeeded with sandbox + bookmarks + group entitlements and a holder-signed MinutaTray.bundle; -exportArchive failed with No certificate for team 'G86VRDBMDK' matching 'Mac Installer Distribution' found until the .p12 arrived, and still failed with the generic selector after the import (security find-identity -p macappstore flags the identity “Missing required extension”); installerSigningCertificate set to the full identity name (the value of installerSigningCertificate in Minuta/ExportOptions-mac.plist) uploads fine. 1.0.4 (6) for macOS uploaded 2026-10-02.

Risks and notes

  • Minuta/build DerivedData is shared between personal-team and Developer ID builds. Xcode re-signs on setting changes; if codesign -dvv ever shows the wrong authority, rm -rf Minuta/build.
  • Hardened runtime + Bundle(url:).load() of MinutaTray.bundle in TrayIconManager: a same-team signature satisfies library validation, no disable-library-validation exception.
  • Three untracked xcconfigs, one rule: TestFlight.xcconfig (iOS), TestFlight-mac.xcconfig (Mac App Store), DeveloperID.xcconfig (Homebrew). project.yml keeps the personal-team defaults; never move their values there.
  • Gatekeeper shows the Developer ID holder’s name as the verified developer; the same team publishes on TestFlight, so one publisher across channels.
  • Release order for Homebrew is unchanged: artifacts, then tap bump (with the postflight removal), then site deploy.

Verification

  1. Now, without materials: ./scripts/release-brew.sh prints the warning and produces the same artifacts as before (MINUTA_NOTARIZE=0 forces that path); with a DeveloperID.xcconfig but no notary profile it exits 1 with a clear message. cd Shared && swift test; xcodebuild test -scheme Minuta -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MinutaTests (the sandbox guard test is green); ./scripts/run-mac.sh still works (the guard is inert without APP_SANDBOX_CONTAINER_ID); -showBuildSettings with TestFlight-mac.xcconfig gives Minuta the MacAppStore profile and macOS entitlements and MinutaTray both empty; git status shows no xcconfig.
  2. Homebrew, after materials: the script ends with spctl ... accepted source=Notarized Developer ID and stapler validate OK; codesign -dvv on the app and on MinutaTray.bundle show Authority=Developer ID Application: <holder> (G86VRDBMDK); entitlements have no get-task-allow; the CLI binary with a simulated quarantine attribute (xattr -w com.apple.quarantine "0083;...;Safari;" minuta) is accepted by spctl -a -t exec. Fresh user: brew reinstall --cask minuta, then spctl -a -t exec -vv /Applications/Minuta.app shows Notarized Developer ID; the direct zip opens without the dialog.
  3. TestFlight Mac, after materials: ARCHIVE SUCCEEDED, entitlements show sandbox + group and the holder’s TeamIdentifier, Upload succeeded, the macOS build shows in TestFlight, installs on a Mac, launches with no “access data from other apps” prompt, and the storage folder chosen in Settings persists across relaunch.
  4. Widgets: the iOS archive with widgets uploads; the Homebrew Catalyst build still succeeds with the widget target in the scheme.