
Short answer. Producing a distributable .ipa in CI is two xcodebuild commands: xcodebuild archive writes the .xcarchive, then xcodebuild -exportArchive with an ExportOptions.plist writes the signed IPA. The friction lives in that plist and in how secrets reach the build machine. This guide gives you a drop-in script, explains the fields that matter, and wires it into GitHub Actions, Buildkite, and GitLab CI.
Where to run it on Figment. Any Figment Mac can drive these two commands as long as the Xcode you pin is compatible with the host. A Figment M-series Mac from $95/month (exact chip, RAM, and storage confirmed in the quote) is the right choice when your scheme needs arm64 simulators, Swift macros, or Xcode 27. The virtual Intel macOS VM at $29/month (2 vCPU, 8 GiB RAM, 80 GiB disk, no GPU acceleration, no audio) and the i3 Mac at $49/month can run the same xcodebuild commands when you pin an Intel-compatible Xcode—Apple's porting guide states: Intel Macs must use Xcode 26 or an earlier release (Porting your macOS apps to Apple silicon). Physical i3 and M-series plans collect requests until supply is confirmed; the exact configuration is agreed in the quote. If you'd rather hand the pipeline over, Managed Build Mac is $249/month (one M-series Mac, CI setup, backups, monitoring, 30 minutes of monthly support) and Managed Mac Team is $999/month (two M-series Macs, two CI runners, team setup, backups, monitoring, one hour of monthly support).
xcodebuild archive compiles the app for the selected scheme and writes a .xcarchive bundle that contains the binary, dSYMs, and signing metadata. xcodebuild -exportArchive then reads that archive plus an ExportOptions.plist, applies the requested distribution method, re-signs where needed, and writes a .ipa to an export directory. Apple's Technical Note TN2339 states the export command literally as:
xcodebuild -exportArchive -archivePath <xcarchivepath> -exportPath <destinationpath> -exportOptionsPlist <path>
The commands are sequential: the archive must exist before export begins, and an export failure must never be masked as success.
# unexecuted example — run against your own workspace and scheme
# Step 1 – build the archive
xcodebuild archive \
-workspace MyApp.xcworkspace \
-scheme "$SCHEME" \
-configuration Release \
-archivePath ./build/MyApp.xcarchive \
CODE_SIGN_STYLE=Manual
# Step 2 – export the IPA
xcodebuild -exportArchive \
-archivePath ./build/MyApp.xcarchive \
-exportPath ./build/export \
-exportOptionsPlist ExportOptions.plist
TN2339 is explicit that the authoritative list of plist keys comes from your toolchain: "To get all available keys for -exportOptionsPlist, run the following command in Terminal: xcodebuild -help." Run that on the build Mac before locking a plist, because the list changes between Xcode versions.
Three keys drive nearly every pipeline:
| Key | Type | Purpose |
|---|---|---|
method | String | Distribution channel. Historically accepted values include app-store, ad-hoc, development, enterprise, and developer-id. Newer Xcode versions also expose names that align with the flow in Distributing your app for beta testing and releases (for example release-testing, debugging). Confirm the exact spelling for your Xcode with xcodebuild -help. |
teamID | String | Your 10-character Apple developer team identifier. |
provisioningProfiles | Dictionary | Maps each bundle ID to a provisioning profile name or UUID when signingStyle is manual. |
Also useful: uploadSymbols (include dSYMs with App Store submissions), signingStyle (manual or automatic), thinning (app thinning for ad-hoc exports), and compileBitcode/uploadBitcode where still relevant. Bitcode submission was removed for App Store in recent Xcode releases, so xcodebuild -help is again the authoritative check.
The script below reads TEAM_ID, BUNDLE_ID, PROVISIONING_PROFILE_NAME, METHOD, SCHEME, and WORKSPACE or PROJECT from the environment, writes a temporary plist, archives, exports, and exits non-zero on any failure. Keep sensitive values in your CI secret manager and reference them by name. Import the encrypted .p12 signing certificate (it carries the private key) into the build Mac's login keychain before the run, and copy each .mobileprovision into ~/Library/MobileDevice/Provisioning Profiles/—provisioning profiles are files on disk, not Keychain items. Do not dump the shell environment to a plaintext file or log: unrelated secrets sit next to the variables you need, and a separate CI step runs in a separate shell that cannot see a temp filename from the previous one unless you persist it carefully.
#!/usr/bin/env bash
# export-ipa.sh — Produce a signed IPA from CI environment variables.
# Unexecuted example. Run against your own workspace and scheme first.
#
# Required env vars:
# TEAM_ID 10-character Apple team identifier
# BUNDLE_ID App bundle identifier (e.g. com.example.myapp)
# PROVISIONING_PROFILE_NAME Profile name as shown in Apple Developer portal
# METHOD Valid for your Xcode (see `xcodebuild -help`)
# SCHEME Xcode scheme to archive (yours, not a guess)
# WORKSPACE or PROJECT Path to .xcworkspace or .xcodeproj
#
# Optional:
# ARCHIVE_PATH (default: build/App.xcarchive)
# EXPORT_PATH (default: build/export)
set -euo pipefail
umask 077
: "${TEAM_ID:?}" "${BUNDLE_ID:?}" "${PROVISIONING_PROFILE_NAME:?}" \
"${METHOD:?}" "${SCHEME:?}"
ARCHIVE_PATH="${ARCHIVE_PATH:-build/App.xcarchive}"
EXPORT_PATH="${EXPORT_PATH:-build/export}"
# Create the plist with a single mktemp call so the restrictive mode sticks.
EXPORT_OPTIONS_PLIST="$(mktemp -t ExportOptions.XXXXXX)"
trap 'rm -f "$EXPORT_OPTIONS_PLIST"' EXIT
mkdir -p "$(dirname "$ARCHIVE_PATH")" "$EXPORT_PATH"
cat > "$EXPORT_OPTIONS_PLIST" <<PLIST
<?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>${METHOD}</string>
<key>teamID</key>
<string>${TEAM_ID}</string>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>${BUNDLE_ID}</key>
<string>${PROVISIONING_PROFILE_NAME}</string>
</dict>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
PLIST
if [[ -n "${WORKSPACE:-}" ]]; then
SOURCE_FLAGS=(-workspace "$WORKSPACE")
elif [[ -n "${PROJECT:-}" ]]; then
SOURCE_FLAGS=(-project "$PROJECT")
else
echo "ERROR: set WORKSPACE or PROJECT" >&2; exit 1
fi
echo "==> Archiving scheme: $SCHEME"
xcodebuild archive \
"${SOURCE_FLAGS[@]}" \
-scheme "$SCHEME" \
-configuration Release \
-archivePath "$ARCHIVE_PATH" \
CODE_SIGN_STYLE=Manual
echo "==> Exporting IPA to $EXPORT_PATH"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST"
IPA_PATH="$(find "$EXPORT_PATH" -maxdepth 2 -name '*.ipa' | head -1)"
[[ -n "$IPA_PATH" ]] || { echo "ERROR: no IPA found in $EXPORT_PATH" >&2; exit 1; }
echo "IPA_PATH=${IPA_PATH}"
This is an unexecuted example—run it against your own workspace and scheme before trusting it. set -euo pipefail plus the : parameter checks stop the script on a missing variable or a non-zero xcodebuild exit, so a failed build is never counted as a successful artifact. umask 077 runs before the plist is written so the temp file is only readable by the running user. The trap removes the plist on normal exit and on error, but a hard machine failure can still leave it behind—do not promise if: always() cleans after a force termination.
"No signing certificate found" — the .p12 was not imported into the build Mac's login keychain, or it has expired. Verify with security find-identity -v -p codesigning.
"Provisioning profile does not match bundle identifier" — the key in provisioningProfiles must be the app's bundle ID exactly, and the value must match the profile name shown in the Apple Developer portal, not the filename on disk.
"Missing required entitlement" — the embedded profile does not include a capability the app declares. Regenerate the profile in the portal, copy the updated .mobileprovision into ~/Library/MobileDevice/Provisioning Profiles/, and re-run.
Non-zero exit with no visible reason — add -verbose to either xcodebuild command and grep the log for lines starting with error:. Signing failures surface there before the final ** ARCHIVE FAILED ** summary.
The IPA lands inside EXPORT_PATH with a filename matching the scheme's product name. The script prints IPA_PATH=<path> so downstream CI steps can consume it. In GitHub Actions, append the value to $GITHUB_OUTPUT. In Buildkite, capture it with buildkite-agent meta-data set. In GitLab CI, write it to a dotenv artifact and read it in the next job.
GitHub Actions
- name: Build IPA
env:
WORKSPACE: MyApp.xcworkspace
SCHEME: MyApp
METHOD: app-store
TEAM_ID: ${{ secrets.TEAM_ID }}
BUNDLE_ID: com.example.myapp
PROVISIONING_PROFILE_NAME: ${{ secrets.PROFILE_NAME }}
run: bash ci/export-ipa.sh
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: MyApp-ipa
path: build/export/*.ipa
retention-days: 7
GitLab CI
build_ipa:
tags: [mac-arm64]
script:
- bash ci/export-ipa.sh
artifacts:
paths: [build/export/*.ipa]
expire_in: 1 week
Buildkite
steps:
- label: ":iphone: Build IPA"
command: bash ci/export-ipa.sh
env:
WORKSPACE: MyApp.xcworkspace
SCHEME: MyApp
METHOD: ad-hoc
artifact_paths: "build/export/*.ipa"
If your runners sit behind an egress firewall, update only the allowlists you control—your Git host, your artifact store, your secret manager, and the Apple endpoints your pipeline actually calls. There is no published blanket App Store Connect or TestFlight IP range to paste in.
Storing the IPA as a CI artifact first gives you a downloadable build to inspect before testers see it, and keeps "archive" and "distribute" as separate failure domains. Once verified, a follow-on step can upload with xcrun altool --upload-app or the App Store Connect API. If you upload automatically, treat any rejection as a build failure and surface the response in the CI log rather than retrying silently.
Match the Mac to the archive it has to produce.
If the scheme requires arm64 simulators, Swift macros, or Xcode 27, use a Figment M-series Mac from $95/month (exact chip and RAM confirmed in the quote). Apple's porting guide notes that Xcode 27 runs on Apple silicon, and if you still need to produce an Intel binary from an Apple silicon host you add x86_64 to the ARCHS build setting. If your project pins Xcode 26 or earlier and you only need periodic signed IPAs, the $29/month virtual Intel macOS VM or the $49/month i3 Mac can run the same two xcodebuild commands at a lower entry price. The $29 VM is a virtual Intel macOS environment on Linux/KVM with 2 vCPU, 8 GiB RAM, 80 GiB disk, no GPU acceleration, and no audio; it is not an Apple silicon Mac, and ARM-only execution or Xcode 27 belongs on an M-series host. The "lower entry price" phrase is literal: a $29 VM against an M-series listing is a lower starting price, not equivalent hardware.
Access on the $29 VM is via the browser console today; the VM's own desktop terminal runs the commands, and outbound CI agents connect from the VM to your runner service. Inbound public SSH to the VM is not currently implemented—agree on the access path in the quote. There is no free trial; request a configuration quote or agree an evaluation before purchase.
If you want someone else to own the pipeline, Managed Build Mac is $249/month for one M-series Mac plus CI setup, backups, monitoring, and 30 minutes of monthly support, and Managed Mac Team is $999/month for two M-series Macs, two runners, team setup, backups, monitoring, and an hour of monthly support. For fleets larger than a team, contact us for an enterprise quote with the delivery window.
Request a configuration quote or provision a build Mac at /mac-hosting/start.
Related guides