
Schedule a daily Fastlane lane that reads each profile's expirationDate from the App Store Connect API and only calls match(force: true) when a profile falls inside a 14 day window. Run the preflight on any Mac that has your Fastlane toolchain; run the production iOS build on an Apple silicon runner. Need a hosted runner? Start on Figment Mac hosting.
Provisioning profiles expire silently. The file in your Match repo has a hard expiry baked into it, and any build that signs against an expired profile fails even though the same job succeeded the day before. The failure almost always lands mid sprint with a codesign error that reads like a configuration problem, not an expiry. The fix is to detect and renew in a scheduled job, not from the release build itself.
A provisioning profile is a binary blob signed by Apple. It carries your bundle ID, the certificates permitted to sign it, the devices allowed (for development profiles) and an expiry date; App Store distribution profiles typically last twelve months. When Match fetches signing assets, codesign happily refuses an expired profile with a message that often looks like:
error: No profile for team '...' matching 'match AppStore com.example.MyApp' found
That message sends engineers hunting for missing entitlements or broken keychains. The actual cause is a date that passed overnight, and it is visible from a single API call days in advance.
The App Store Connect API exposes provisioning profile metadata at /v1/profiles. Each record's attributes.expirationDate is an ISO 8601 timestamp. Confirm the exact field shape against the version your Fastlane build links before relying on the example below:
curl -s \
-H "Authorization: Bearer $ASC_JWT_TOKEN" \
"https://api.appstoreconnect.apple.com/v1/profiles?filter%5BprofileType%5D=IOS_APP_STORE&limit=200" \
| jq -r '.data[] | [.attributes.name, .attributes.expirationDate] | @tsv'
Inside Fastlane you can skip the raw HTTP and use Spaceship::ConnectAPI, which ships with Fastlane and uses the same API key. The Fastlane match documentation covers force: true and renew_expired_certs: true; both work with App Store Connect API key authentication.
Fourteen days is a useful default: long enough that a Friday failure can wait until Monday and a cross timezone team can see the alert, short enough that profiles are not churned monthly. Shorter windows (3 to 5 days) regularly straddle a weekend or a public holiday. Make the threshold an environment variable so individual pipelines can tune it.
check_profiles Preflight LaneThis is the artifact for this guide. It reads the API key path from the CI secret manager (no literal secrets in shell history, no whole environment written to disk), lists profiles through Spaceship, renews only those inside the window, and optionally posts to Slack.
# Fastfile
require 'date'
lane :check_profiles do
key_id = ENV.fetch("ASC_KEY_ID")
issuer_id = ENV.fetch("ASC_ISSUER_ID")
key_path = ENV.fetch("ASC_KEY_PATH") # written by the CI secret manager
api_key = app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_filepath: key_path,
duration: 1200,
in_house: false
)
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
key_id: key_id,
issuer_id: issuer_id,
filepath: key_path
)
threshold = Integer(ENV.fetch("PROFILE_EXPIRY_DAYS", "14"))
today = Date.today
needs_renew = false
Spaceship::ConnectAPI::Profile.all(limit: 200).each do |profile|
expiry = Date.parse(profile.expiration_date)
days_left = (expiry - today).to_i
state = days_left < threshold ? "EXPIRING" : "OK"
UI.message "#{state} #{profile.name}: #{days_left}d (expires #{expiry})"
needs_renew = true if days_left < threshold
end
unless needs_renew
UI.success "All profiles have >= #{threshold} days remaining."
next
end
UI.important "Renewing expiring profiles with match(force: true)"
match(
type: "appstore",
force: true,
renew_expired_certs: true,
api_key: api_key,
readonly: false
)
next unless (webhook = ENV["SLACK_WEBHOOK_URL"])
slack(
message: "Provisioning profiles renewed (threshold #{threshold}d)",
channel: ENV.fetch("SLACK_CHANNEL", "#ios-builds"),
slack_url: webhook,
success: true
)
end
This lane is an unexecuted example. Run it once interactively against your installed Fastlane to confirm the Spaceship method names in your version and that profile.expiration_date returns the value you expect before enabling the schedule.
A note on match(force: true): it regenerates the profile on every run and is intended for the renewal case, not for every build. Keep it inside check_profiles so commit triggered jobs never call it.
Match commits the refreshed profile to the cert repo automatically. Give the runner Git write access with a deploy key stored as a CI secret and set the identity once:
git config --global user.email "ci-bot@example.com"
git config --global user.name "CI Bot"
Schedule the lane on its own, independent of commit triggered builds, so it runs on quiet days:
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
jobs:
preflight:
runs-on: [self-hosted, macOS, arm64]
steps:
- uses: actions/checkout@v4
- name: Rotate expiring profiles
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_PATH: ${{ runner.temp }}/asc_key.p8
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
umask 077
printf '%s' "${{ secrets.ASC_KEY_CONTENT }}" > "$ASC_KEY_PATH"
trap 'rm -f "$ASC_KEY_PATH"' EXIT
bundle exec fastlane check_profiles
umask 077 runs before the secret is written so the .p8 file is not world readable, and the trap removes it on exit. Do not dump the whole environment to a file, and do not import the profile into a Keychain: profiles are files on disk, only signing certificates need Keychain handling (encrypted .p12 with private key).
The preflight job itself is light: it talks to Apple's API and runs Fastlane. A Figment virtual Intel macOS plan at $29/month (2 vCPU, 8 GiB RAM, 80 GiB disk) can host the scheduled lane with a supported Intel Xcode toolchain (Xcode 26 or earlier on Intel, per Apple's porting guidance). For the production iOS build, archive and simulator work on Xcode 27 you want an Apple silicon host: Figment M-series starts at $95/month, with the exact chip, RAM and disk confirmed on the quote, and physical requests are queued until supply is confirmed.
Compare entry prices honestly. CloudMac lists a Mac mini M4, 16 GB, 256 GB at $99.99/month, and the CloudMac homepage advertises a $999/year option; that annual rate works out to about $83.25/month, roughly $141 less per year than paying twelve months at Figment's $95 entry. Figment's $95 is an unspecified M-series entry, not a confirmed M4/16 GB match, so treat this as an entry price comparison, not equal hardware. Where Figment earns its number is managed work: the Managed Build Mac at $249/month includes one M-series Mac, CI setup, backups, monitoring and 30 minutes of monthly support, and the Managed Mac Team at $999/month includes two M-series Macs, two CI runners, team setup, backups, monitoring and one hour of monthly support. If you want someone else to own the check_profiles rollout, that is the plan to ask about.
Related guides:
Ready to run this lane on a hosted Mac? Start on Figment Mac hosting.