
If you drive iOS builds from Windows or Linux, provisioning profile renewal is the recurring interruption. The Xcode GUI needs a Mac, but a .mobileprovision file does not: it is a CMS-signed plist in a known directory, and every step — fetch, inspect, install, prune — runs from a shell. Apple's App Store Connect API reference documents the REST side, and the entitlement semantics the profile carries are in Apple's Adding capabilities to your app documentation.
On Figment, the $29 virtual Intel macOS instance is a real macOS environment you reach through the browser desktop console (2 vCPU, 8 GiB RAM, 80 GiB disk). It is enough to script profile management and to build and sign Intel-compatible targets with Xcode 26 or earlier, per Apple's porting-to-Apple-silicon guidance. If you also need arm64 execution, Xcode 27, or an arm64-only simulator, request a physical M-series Mac starting at $95/month; the exact chip, RAM, and disk are confirmed in the quote, and physical plans currently collect requests until supply is confirmed. Start at /mac-hosting or go straight to the configuration request.
Xcode 15 and earlier read profiles from:
~/Library/MobileDevice/Provisioning Profiles/
Starting with Xcode 16, Xcode writes and reads from a new location:
~/Library/Developer/Xcode/UserData/Provisioning Profiles/
xcodebuild on Xcode 16+ follows the new path. If you still target older Xcode versions in parallel, treat both directories as authoritative and install profiles into whichever the active toolchain uses. Every profile is stored as the-UUID.mobileprovision; the UUID lives inside the signed plist, so a renewed profile gets a new UUID and sits alongside the old one until you prune it. xcodebuild resolves by bundle ID and signing identity, so stale files do not usually break builds, but they do make -showBuildSettings output ambiguous.
Create the directory before copying anything — it does not exist on a clean install:
mkdir -p "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
Manual path. Download the .mobileprovision from the Apple Developer portal under Certificates, Identifiers & Profiles → Profiles. That download lives on the Apple Developer site; it is not inside the App Store Connect app-management UI. How you then get the file onto the Mac depends on the plan:
Physical M-series Mac delivered by Figment. Confirm the access method with Figment at provisioning time. Where SSH is enabled on the machine, scp the file in:
scp AppStore_com.example.app.mobileprovision \
user@your-mac-host:'~/Library/Developer/Xcode/UserData/Provisioning Profiles/'
Figment $29 Intel VM. Inbound SSH is not provided and the browser desktop console is not a file-transfer channel. The VM instead pulls the profile outbound: open the VM's browser and download it directly from the Apple Developer portal, or have your own CI or artifact store serve it over HTTPS and curl it in from the VM's desktop terminal.
Provisioning profiles are not meant to be published artifacts. The embedded plist contains your team identifier, the included device UDIDs, and (for distribution profiles) the identifiers tying the signing identity to your developer account. Treat the file as sensitive: keep it out of chat logs and public artifact stores, prefer short-lived signed URLs over e-mail attachments, and restrict permissions on disk.
Automated path with the App Store Connect API. Apple's API exposes GET /v1/profiles/{id}. The response's data.attributes includes profileContent (Base64 of the raw .mobileprovision bytes), uuid, name, and expirationDate. Authenticate with a signed JWT built from a team API key, kept in your CI secret manager — never echoed to logs, never written to a shared disk.
Shell pipelines like curl ... | python3 - <<'PY' ... PY look convenient but they break: the here-doc feeds the Python interpreter the script on stdin, so json.load(sys.stdin) sees an empty stream. Use one Python program that performs the HTTP call itself and stages the file into a private temp directory before moving it into place with the real UUID:
#!/usr/bin/env python3
# fetch-profile.py — install one provisioning profile by ID.
# Env: ASC_JWT (from your CI secret manager), PROFILE_ID (e.g. ABCDE12345).
import base64, json, os, re, stat, sys, tempfile, shutil, pathlib, urllib.request
jwt = os.environ["ASC_JWT"]
pid = os.environ["PROFILE_ID"]
dest_dir = pathlib.Path.home() / "Library/Developer/Xcode/UserData/Provisioning Profiles"
dest_dir.mkdir(parents=True, exist_ok=True)
req = urllib.request.Request(
f"https://api.appstoreconnect.apple.com/v1/profiles/{pid}",
headers={"Authorization": f"Bearer {jwt}"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.load(resp)
attrs = payload["data"]["attributes"]
uuid = attrs["uuid"]
if not re.fullmatch(r"[0-9A-Fa-f-]{10,64}", uuid):
sys.exit(f"refusing suspicious uuid: {uuid!r}")
raw = base64.b64decode(attrs["profileContent"])
# Stage into a private 0700 temp dir on the same filesystem as dest_dir,
# then atomically move the file in. mktemp-style suffix appending is avoided.
with tempfile.TemporaryDirectory(prefix="prof-", dir=str(dest_dir.parent)) as tmp:
os.chmod(tmp, stat.S_IRWXU)
staged = pathlib.Path(tmp) / f"{uuid}.mobileprovision"
staged.write_bytes(raw)
os.chmod(staged, stat.S_IRUSR | stat.S_IWUSR)
shutil.move(str(staged), dest_dir / f"{uuid}.mobileprovision")
print(f"installed {uuid}.mobileprovision ({len(raw)} bytes)")
This is an unexecuted example. Try it once against one known profile ID and cross-check attributes.profileContent, uuid, and expirationDate against the App Store Connect API release notes, because field names have shifted between revisions. Never commit the API key .p8 file; generate the JWT in-process or inject it as a secret.
A .mobileprovision is a CMS envelope around a plist. security cms -D decodes it; plutil -extract pulls fields out using a dotted keypath. Point at a concrete file — do not leave <the-UUID> literally in the command, because <...> is input redirection in shells and will fail:
# Replace REPLACE_WITH_UUID with the UUID of a profile you installed.
PROFILE="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/REPLACE_WITH_UUID.mobileprovision"
security cms -D -i "$PROFILE" # full plist
security cms -D -i "$PROFILE" | plutil -extract ExpirationDate raw -o - - # expiry only
security cms -D -i "$PROFILE" | plutil -extract Name raw -o - - # team-visible name
ExpirationDate is emitted as an ISO-8601 UTC timestamp (e.g. 2026-11-10T12:00:00Z). Confirm the exact format against one real profile before relying on a parser. The iOS Entitlements.application-identifier entitlement is reachable with the plain dotted keypath; macOS entitlements such as com.apple.application-identifier contain dots inside the key name, so plain dot-notation keypaths will not match — extract the whole Entitlements dictionary and grep it, or write the decoded plist to a temp file and query that.
The script below reports status for every installed profile and, only with --prune, removes files whose expiry has already passed. It exits non-zero on any unexpected error so a CI step fails fast rather than counting a broken run as success.
#!/usr/bin/env bash
# audit-profiles.sh — list expiry; --prune removes expired files
set -euo pipefail
DIRS=(
"$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
"$HOME/Library/MobileDevice/Provisioning Profiles"
)
PRUNE=0
[ "${1:-}" = "--prune" ] && PRUNE=1
NOW=$(date -u +%s)
printf "%-36s %-28s %-20s %s\n" UUID AppIDName ExpirationDate Status
printf -- '---\n'
shopt -s nullglob
for dir in "${DIRS[@]}"; do
[ -d "$dir" ] || continue
for f in "$dir"/*.mobileprovision; do
plist=$(security cms -D -i "$f" 2>/dev/null) || { echo "unreadable: $f"; continue; }
name=$(printf '%s' "$plist" | plutil -extract AppIDName raw -o - - 2>/dev/null || echo unknown)
exp=$( printf '%s' "$plist" | plutil -extract ExpirationDate raw -o - - 2>/dev/null || echo "")
uuid=$(basename "$f" .mobileprovision)
status="unknown"
if [ -n "$exp" ]; then
exp_epoch=$(date -j -u -f "%Y-%m-%dT%H:%M:%SZ" "$exp" +%s 2>/dev/null || echo 0)
if [ "$exp_epoch" -eq 0 ]; then
status="unparseable ExpirationDate ($exp)"
elif [ "$exp_epoch" -lt "$NOW" ]; then
if [ "$PRUNE" -eq 1 ]; then rm -- "$f"; status="EXPIRED removed"
else status="EXPIRED (rerun with --prune)"; fi
else
status="OK ($(( (exp_epoch - NOW) / 86400 )) days left)"
fi
fi
printf "%-36s %-28s %-20s %s\n" "$uuid" "$name" "$exp" "$status"
done
done
To confirm xcodebuild actually picks up the renewed profile, run -showBuildSettings against your own workspace and scheme — substitute the real names from your project:
xcodebuild \
-workspace "$PROJECT.xcworkspace" \
-scheme "$SCHEME" \
-configuration Release \
-showBuildSettings \
CODE_SIGN_STYLE=Manual \
PROVISIONING_PROFILE_SPECIFIER="$PROFILE_NAME" \
| grep -E 'PROVISIONING_PROFILE|CODE_SIGN_IDENTITY|DEVELOPMENT_TEAM'
If PROVISIONING_PROFILE still shows the old UUID, the stale file is still on disk — run the audit script with --prune and repeat.
The profile tooling above is pure CLI, so the main question is whether the rest of your build needs arm64 execution. A short worksheet for a one-developer iOS pipeline that renews and signs profiles, with occasional Intel-compatible builds:
| Need | Figment plan | Monthly | Notes |
|---|---|---|---|
| Profile scripts + Intel-toolchain signing/dry-runs | Virtual Intel macOS | $29 | 2 vCPU, 8 GiB RAM, 80 GiB disk; browser-console access; no inbound SSH |
| arm64 execution, arm64-only simulators, Xcode 27 | Physical M-series | from $95 | Specific chip/RAM/disk confirmed in quote; request-based supply |
| CI runner, backups, monitoring, 30 min/mo support | Managed Build Mac | $249 | One M-series Mac with CI setup included |
| Two Macs, two CI runners, 1 hr/mo support | Managed Mac Team | $999 | Team setup, backups, monitoring |
Twelve months on the entry M-series plan at $95 is $1,140. CloudMac publishes a $99.99/month M4/16GB/256GB on its pricing page and a $999/year prepay on its homepage — that annual works out to $83.25/month and is $141 lower than 12 × $95; the 256GB row shows Reserve now while 512GB and 1TB are Fully reserved. Figment's $95 is an unspecified M-series entry, not a verified M4/16GB match, so treat the comparison as entry-price only; the chip, RAM, disk, and any term discount are set in the Figment quote. Figment's differentiator at $249 and $999 is the specific managed CI work — runner setup, backups, monitoring, and scheduled support time — not a hardware spec race. Taxes and any setup fee are as stated in the actual offer you receive from either vendor.
If you only need to run the CLI profile workflow above and your toolchain is Intel-compatible, the $29 VM is the cheapest honest answer. If you build arm64 or want someone else to own the CI runner, step up to an M-series or Managed plan. Open a configuration request at /mac-hosting/start.
Related guides: Get SwiftUI Previews Working on a Rented Mac · Build Your First iOS App on a Rented Mac Mini · GitHub Actions Self-Hosted Runner on Mac