
Short answer. A Mac Catalyst CI pipeline is an ordinary iOS pipeline with a second archive path: -destination 'generic/platform=macOS,variant=Mac Catalyst', a Developer ID (or Apple Distribution, for the Mac App Store) signing identity, and a notarization step for anything shipped outside the Mac App Store. You can compile and sign a universal Catalyst archive on either architecture; executing or testing the arm64 slice requires an Apple silicon host. Figment options, stated once: a virtual Intel macOS VM is $29/month (2 vCPU, 8 GiB RAM, 80 GiB disk, no GPU/audio, x86_64 only); M-series Macs start at $95/month with chip, RAM and disk confirmed in the quote; the Managed Build Mac is $249/month (one M-series Mac, CI setup, backups, monitoring, 30 minutes monthly support) and Managed Mac Team is $999/month (two M-series Macs, two CI runners, team setup, backups, monitoring, one hour monthly support). Physical provisioning is request-based and VM access is through the browser console. Start a quote at /mac-hosting/start.
Primary references used throughout: Apple's Mac Catalyst framework page and Apple's "Customizing the notarization workflow" guide.
A standard iOS archive uses a generic destination so CI does not pin a simulator UDID:
xcodebuild archive \
-scheme "$SCHEME" \
-destination 'generic/platform=iOS' \
-archivePath build/MyApp-iOS.xcarchive
Mac Catalyst uses the macOS platform with an explicit variant:
xcodebuild archive \
-scheme "$SCHEME" \
-destination 'generic/platform=macOS,variant=Mac Catalyst' \
-archivePath build/MyApp-Catalyst.xcarchive
Drop variant=Mac Catalyst and xcodebuild can pick a native macOS target that happens to share the scheme, producing the wrong artifact. For the test phase, a destination such as platform=macOS,variant=Mac Catalyst,arch=x86_64 is runnable on an Intel host; any arm64-only Catalyst binary (or arch=arm64 test matrix) needs an Apple silicon host. Simulator destinations cannot archive or export a distributable binary.
Replace MyApp and $SCHEME with your real values. The snippets below are templates — none of them were executed against your project, so review them before you wire them into CI.
Not every iOS capability survives the Catalyst translation. Audit the macOS column in Signing & Capabilities for every entitlement your app sets before you automate.
| Capability | iOS | Mac Catalyst |
|---|---|---|
| Push Notifications (APNs) | Yes | Yes |
| HealthKit | Yes | Not available |
| CarPlay | Yes | Not available |
| App Clips | Yes | Not available |
| iCloud Documents | Yes | Yes |
| Hardened Runtime | Optional | Required for notarization |
A short check between export and notarization keeps forbidden entitlements out of distributable builds:
set -euo pipefail
APP="build/catalyst-export/MyApp.app"
ENTS=$(codesign -d --entitlements :- "$APP" 2>&1)
for KEY in com.apple.developer.healthkit com.apple.developer.carplay; do
if printf '%s' "$ENTS" | grep -q "$KEY"; then
echo "ERROR: forbidden entitlement $KEY present in Catalyst build" >&2
exit 1
fi
done
Each variant also wants a different certificate:
Store each identity as an encrypted .p12 (certificate plus private key) and each profile as a .provisionprofile file. Install profiles into ~/Library/MobileDevice/Provisioning Profiles/ on the runner. Do not dump the environment or the whole keychain to disk — either can carry private keys off the host. Transfer just the required secret names through your CI's secret manager and rehydrate on the runner. In -exportOptionsPlist, use developer-id for notarized distribution and app-store-connect for Mac App Store submissions (Xcode 16.4 keeps app-store as a deprecated alias; prefer the current name).
Apple's own guidance is explicit: you cannot submit a .app to the notary service directly. From Customizing the notarization workflow: "Because you can't upload the .app bundle directly to the notary service, you'll need to create a compressed archive containing the app." The accepted container types are "disk images (UDIF format), signed flat installer packages, and ZIP archives." You staple against the .app (or dmg/pkg); Apple also notes that you "can notarize a ZIP archive, but you can't staple to it directly."
A correct Developer ID flow for a Catalyst .app:
set -euo pipefail
SCHEME="MyApp"
WORKSPACE="MyApp.xcworkspace"
ARCHIVE="build/catalyst.xcarchive"
EXPORT_DIR="build/catalyst-export"
APP="$EXPORT_DIR/MyApp.app"
ZIP="$EXPORT_DIR/MyApp.zip"
xcodebuild archive \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Release \
-destination 'generic/platform=macOS,variant=Mac Catalyst' \
-archivePath "$ARCHIVE" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Developer ID Application" \
PROVISIONING_PROFILE_SPECIFIER="$CATALYST_PROFILE_NAME"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportOptionsPlist ExportOptions-Catalyst.plist \
-exportPath "$EXPORT_DIR"
# Build a container the notary service accepts.
/usr/bin/ditto -c -k --keepParent "$APP" "$ZIP"
# Submit the ZIP, wait for a verdict, and fail the job on anything other than Accepted.
SUBMIT_JSON=$(xcrun notarytool submit "$ZIP" \
--key "$ASC_KEY_P8_PATH" \
--key-id "$ASC_KEY_ID" \
--issuer "$ASC_ISSUER_ID" \
--output-format json \
--wait)
STATUS=$(printf '%s' "$SUBMIT_JSON" \
| /usr/bin/python3 -c 'import json,sys;print(json.load(sys.stdin)["status"])')
if [ "$STATUS" != "Accepted" ]; then
echo "Notarization status: $STATUS" >&2
exit 1
fi
# Staple the .app (not the zip), then validate.
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
Put set -euo pipefail at the top so a failed archive, a rejected submission, or a stapler error exits non-zero and does not get logged as a successful run. The .p8 App Store Connect key belongs in your CI secret manager, written to a short-lived file for the job only; no secret literals in the Fastfile, in build logs, or in shell history. Apple does not publish a static IP allowlist for the notary service, so only your own firewall, artifact store, and CI service allowlists are candidates to update.
An equally valid flow signs a .dmg built with hdiutil (or create-dmg) and submits that; stapler then staples the .dmg. Pick one container type per lane — ZIP or DMG — rather than mixing.
Score one point for every yes below, then read the host column.
xcodebuild test against an arm64-only Catalyst slice or against an arm64-only dependency?| Score | Fit | Figment option |
|---|---|---|
| 0 | Compile-only Intel pipeline, x86_64 Catalyst slice, no arm64 execution | $29/month virtual Intel macOS VM |
| 1–2 | Mixed: arm64 archive required, occasional arm64 test | M-series Mac from $95/month (chip and RAM confirmed in the quote) |
| 3–4 | Dedicated CI runner with backups and setup done for you | Managed Build Mac $249/month |
| 5 | Two runners, team ownership, more support hours | Managed Mac Team $999/month |
The $29 VM is Intel on Linux/KVM, so it can cross-compile and sign a universal Catalyst archive — including an arm64 slice via ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO — but it cannot execute the arm64 result and it cannot run Xcode 27. Request a configuration at /mac-hosting/start. Outbound connections from the VM shell (App Store Connect, your CI service, your artifact store) work normally.
ios_test runs the iOS target against the simulator; catalyst_release archives, exports, and notarizes the Catalyst build. Fill the environment variables from your CI secret manager before you run.
# Fastfile - ios_test + catalyst_release
default_platform(:ios)
SCHEME = ENV.fetch("SCHEME")
WORKSPACE = ENV.fetch("WORKSPACE")
TEAM_ID = ENV.fetch("TEAM_ID")
BUNDLE_ID = ENV.fetch("BUNDLE_ID")
platform :ios do
lane :ios_test do
run_tests(
workspace: WORKSPACE,
scheme: SCHEME,
destination: "platform=iOS Simulator,name=iPhone 16,OS=latest",
output_types: "junit",
fail_build: true
)
end
lane :catalyst_release do
unlock_keychain(
path: "~/Library/Keychains/CI.keychain-db",
password: ENV.fetch("KEYCHAIN_PASSWORD")
)
gym(
workspace: WORKSPACE,
scheme: SCHEME,
configuration: "Release",
destination: "generic/platform=macOS,variant=Mac Catalyst",
export_method: "developer-id",
export_options: {
teamID: TEAM_ID,
signingStyle: "manual",
provisioningProfiles: {
BUNDLE_ID => ENV.fetch("CATALYST_PROFILE_NAME")
}
},
output_name: "MyApp-Catalyst",
output_directory: "build/catalyst"
)
# Fastlane's notarize action wraps ditto + notarytool + stapler.
# Documented parameters only: package, api_key_path, api_key,
# username, asc_provider, skip_stapling, print_log, verbose.
notarize(
package: "build/catalyst/MyApp-Catalyst.app",
api_key_path: ENV.fetch("ASC_API_KEY_PATH"),
print_log: true
)
end
end
Run from CI:
bundle exec fastlane ios_test
bundle exec fastlane catalyst_release
If you prefer not to depend on the Fastlane action, replace the notarize call with the shell flow above (ditto, notarytool submit, stapler staple, stapler validate) and invoke it from a reviewed script in the lane. Either way, keep signing and notarization in one step and read the notary log even on success — Apple's log often contains warnings that would block a later submission.
Related guides:
Ready to run Catalyst CI on a hosted Mac? Request a configuration quote or review the plans on Figment Mac hosting.