Sign and distribute iOS builds
A device build on an Xcode sandbox can come out signed and ready to ship. Sign with your own certificate and provisioning profiles, or let Apple's cloud signing manage them, then upload the IPA to Asset Storage or straight to App Store Connect for TestFlight.
Every flag here adds to a normal Xcode build.
Device builds
Device builds (iphoneos, watchos, appletvos, xros) compile every target for its standard architectures and turn code coverage instrumentation off, matching what Xcode's archive action produces. An embedded watch app keeps the arm64_32 slice App Store Connect requires, and no binary carries coverage sections, so the IPA passes App Store validation. Projects that pin an unsupported architecture such as armv7 in ARCHS fail as they do in Xcode. Simulator builds compile only the host architecture.
tvOS and visionOS
Pass the device SDK and the scheme explicitly for tvOS and visionOS:
lim xcode build . --scheme TVApp --sdk appletvos
lim xcode build . --scheme VisionApp --sdk xrosThese builds use the same unsigned IPA, manual signing, cloud signing, and upload paths as iOS device builds. tvOS and visionOS simulator builds, simulator attachment, and XCTest runs are not supported yet.
Sign with your certificate
To produce a signed IPA for distribution, select a device SDK and pass a P12 certificate, its password, and a provisioning profile. Add an upload so the IPA lands in Asset Storage with a download URL. If you omit the SDK, signing defaults to iphoneos; pass watchos, appletvos, or xros explicitly for another platform.
lim xcode build . \
--sdk iphoneos \
--scheme MyApp \
--certificate-p12 ./signing/dist.p12 \
--certificate-password "$P12_PASSWORD" \
--provisioning-profile ./signing/MyApp.mobileprovision \
--upload my-app-pr-42.ipaFrom the TypeScript SDK, pass the material base64-encoded:
import fs from 'node:fs';
const build = xcode.xcodebuild(
{ scheme: 'MyApp', sdk: 'iphoneos' },
{
signing: {
certificateP12Base64: fs.readFileSync('./dist.p12').toString('base64'),
certificatePassword: process.env['P12_PASSWORD'],
provisioningProfilesBase64: [fs.readFileSync('./MyApp.mobileprovision').toString('base64')],
},
upload: { assetName: 'my-app-pr-42.ipa' },
},
);
const { exitCode, signedDownloadUrl } = await build;Limrun verifies the signed app with Apple's code-signing verifier before uploading it. A signature that Apple's tooling would reject fails the build with a clear error instead of producing a broken IPA, so a signed build that succeeds has already passed Apple's verification.
Apps with extensions or a watch app
App Store signing needs a distinct provisioning profile for every bundle the app embeds: the app itself plus each app extension (a WidgetKit widget, a share sheet, an intents extension) and watch app, all issued for the same distribution certificate. Repeat --provisioning-profile with one profile per bundle, or pass several entries in provisioningProfilesBase64. Each profile is matched to its bundle by the application-identifier it carries, so order does not matter, but every profile must name an explicit, non-wildcard bundle ID.
lim xcode build . \
--sdk iphoneos \
--scheme MyApp \
--certificate-p12 ./signing/dist.p12 \
--certificate-password "$P12_PASSWORD" \
--provisioning-profile ./signing/MyApp.mobileprovision \
--provisioning-profile ./signing/MyAppWidgets.mobileprovision \
--upload my-app-pr-42.ipaimport fs from 'node:fs';
const build = xcode.xcodebuild(
{ scheme: 'MyApp', sdk: 'iphoneos' },
{
signing: {
certificateP12Base64: fs.readFileSync('./dist.p12').toString('base64'),
certificatePassword: process.env['P12_PASSWORD'],
provisioningProfilesBase64: [
fs.readFileSync('./MyApp.mobileprovision').toString('base64'),
fs.readFileSync('./MyAppWidgets.mobileprovision').toString('base64'),
],
},
upload: { assetName: 'my-app-pr-42.ipa' },
},
);Before signing, Limrun checks that every embedded bundle in the built app has a matching profile. A missing one fails the build with the uncovered bundle ID, so you see it immediately instead of as an App Store validation rejection after upload.
Include the certificate chain
Export your p12 with its certificate chain included, so signing works regardless of which Apple WWDR intermediate issued your certificate. Keychain Access exports include the chain. With OpenSSL, pass the chain with -certfile:
openssl pkcs12 -export -inkey dist.key -in dist.pem -certfile wwdr.pem -out dist-chain.p12Download the signed IPA
When you configure an upload, the build result includes a signed download URL. Anyone with the URL can download the IPA without a Limrun API key, but the signature expires after 15 minutes, so use it right away and fetch a fresh one later with lim asset list --name <asset-name> --download-url. For links reviewers open days later, use PR previews.
Cloud signing
Cloud signing produces a signed IPA without a p12 or provisioning profile. Xcode authenticates with an App Store Connect team API key, and Apple creates or reuses a cloud-managed certificate and the required profiles during export.
lim xcode build . \
--sdk iphoneos \
--configuration Release \
--scheme MyApp \
--signing-method release-testing \
--team-id VMBY3VYW4U \
--asc-key-id 2X9R4HXF34 \
--asc-issuer-id "$ASC_ISSUER_ID" \
--asc-key ./signing/AuthKey_2X9R4HXF34.p8 \
--upload my-app-pr-42.ipaChoose the method for the IPA you need:
| Method | Use |
|---|---|
debugging | Development-signed IPA for devices registered to the team. |
release-testing | Distribution-signed IPA for registered test devices. |
app-store-connect | Distribution-signed IPA for App Store Connect. |
Cloud signing works for device SDK builds: --sdk iphoneos, watchos, appletvos, and xros. It requires a team API key, its issuer ID, and a --team-id that matches the key's Apple Developer team. For release-testing and app-store-connect, the key must be an Admin key or have Access to cloud-managed distribution certificates enabled in App Store Connect.
From the TypeScript SDK, pass cloudSigning:
import fs from 'node:fs';
const build = xcode.xcodebuild(
{ scheme: 'MyApp', sdk: 'iphoneos', configuration: 'Release' },
{
cloudSigning: {
method: 'release-testing',
teamId: 'VMBY3VYW4U',
apiKeyId: '2X9R4HXF34',
apiIssuerId: process.env['ASC_ISSUER_ID']!,
apiPrivateKeyBase64: fs.readFileSync('./AuthKey_2X9R4HXF34.p8').toString('base64'),
},
upload: { assetName: 'my-app-pr-42.ipa' },
},
);Preserve entitlements
Cloud signing archives your app unsigned, and Apple's export carries entitlements into the signed IPA only from an existing code signature. Capability entitlements from your project's .entitlements file (HealthKit, CloudKit, app groups, push) therefore do not survive on their own. Pass them with --entitlements, and the build server embeds them into the archive so the export keeps them. A bare path applies to the app. Use <bundleId>=<path> for embedded bundles such as widgets or a watch app, repeating the flag per bundle.
lim xcode build . \
--sdk iphoneos \
--configuration Release \
--scheme MyApp \
--signing-method release-testing \
--team-id VMBY3VYW4U \
--asc-key-id 2X9R4HXF34 \
--asc-issuer-id "$ASC_ISSUER_ID" \
--asc-key ./signing/AuthKey_2X9R4HXF34.p8 \
--entitlements ./MyApp/MyApp.entitlements \
--entitlements com.example.myapp.widgets=./Widgets/Widgets.entitlements \
--upload my-app-pr-42.ipaIn the TypeScript SDK, entitlements is keyed by bundle ID, and the empty key targets the app:
cloudSigning: {
method: 'release-testing',
teamId: 'VMBY3VYW4U',
apiKeyId: '2X9R4HXF34',
apiIssuerId: process.env['ASC_ISSUER_ID']!,
apiPrivateKeyBase64: fs.readFileSync('./AuthKey_2X9R4HXF34.p8').toString('base64'),
entitlements: {
'': fs.readFileSync('./MyApp/MyApp.entitlements').toString('base64'),
'com.example.myapp.widgets': fs.readFileSync('./Widgets/Widgets.entitlements').toString('base64'),
},
},Three rules apply:
- Every capability must be enabled on your App ID. The export registers toggleable capabilities such as HealthKit and push on the App ID for you. Capabilities that need extra configuration, such as app group assignments or special-agreement entitlements, must be set up in the developer portal first, or the export fails naming them.
- Values must be fully expanded. Build-setting references such as
$(AppIdentifierPrefix)are rejected; write the concrete team prefix instead. - Omit the keys the export manages. Leave out
application-identifier,com.apple.developer.team-identifier,get-task-allow, andbeta-reports-active; the export sets them from the generated profile and distribution method.
Check what landed in the IPA:
unzip -q my-app.ipa -d out && codesign -d --entitlements - out/Payload/MyApp.appTroubleshoot signing
| Build output | Cause | Fix |
|---|---|---|
Unknown issuer hash | The certificate's issuing CA is not recognized and the p12 carries no CA chain. | Re-export the p12 with the certificate chain included (see Include the certificate chain). |
code signature verification failed | The produced signature failed Apple's verifier. | Retry the build; contact support if it persists. |
MAC verification failed or other password errors | The certificate password does not match the p12. | Check the value you pass as the certificate password. |
signing preflight failed: no provisioning profile covers ... | An embedded bundle (extension, watch app) has no matching provisioning profile. | Pass one --provisioning-profile per bundle ID named in the error. |
Cloud signing permission error | The API key cannot use cloud-managed distribution certificates. | Use an Admin key or enable Access to cloud-managed distribution certificates for the key. |
No Account for Team | --team-id does not match the API key's team. | Pass the Apple Developer team ID associated with the API key. |
Failed Registering Bundle Identifier | The bundle ID cannot be registered to this team, usually because another team owns it. | Use a bundle ID the team already owns, or one that is available to register. |
Set up App Store Connect once
Before the first upload, configure three App Store Connect settings. They make TestFlight delivery hands-off:
-
Create an App Store Connect team API key. In App Store Connect, go to Users and Access, open the Integrations tab, select App Store Connect API, and generate a Team Key. A Developer key is enough for upload with manual signing. Cloud distribution signing requires an Admin key, or Access to cloud-managed distribution certificates enabled for the key. One team key serves all your apps. Collect the three values the build flags need:
--asc-key-id: the Key ID shown next to the new key.--asc-issuer-id: the Issuer ID shown at the top of the Integrations page. It belongs to the team, not the key.--asc-key: the.p8file from the key's Download link. Download it right away and store it like a password: Apple keeps no copy, and the link disappears once you leave the page.
-
Answer the encryption question at build time, or every build stalls in App Store Connect with "Missing Compliance" until someone answers manually. If your app only uses exempt encryption (HTTPS and the like), declare it in your project:
// Expo: app.json { "expo": { "ios": { "config": { "usesNonExemptEncryption": false } } } }<!-- Native: Info.plist --> <key>ITSAppUsesNonExemptEncryption</key> <false/>If your app uses non-exempt encryption, answer the compliance questions in App Store Connect instead.
-
Enable automatic distribution on an internal beta group. In your app's TestFlight tab, create an internal group and choose automatic distribution, the option that gives the group access to all builds. Members then receive every new build with no per-build assignment. The setting is create-only, so if your existing group lacks it, create a new group with it enabled.
With these in place, one build command puts the app on your testers' devices as soon as Apple finishes processing.
Upload to App Store Connect
--upload-to-appstore sends the signed IPA to App Store Connect after the build, where it becomes available for TestFlight and App Store distribution. It works with manual or cloud signing. A cloud-signed build can use the same API key for signing and upload with the app-store-connect method:
lim xcode build . \
--sdk iphoneos \
--configuration Release \
--scheme MyApp \
--signing-method app-store-connect \
--team-id VMBY3VYW4U \
--upload-to-appstore \
--asc-key-id 2X9R4HXF34 \
--asc-issuer-id "$ASC_ISSUER_ID" \
--asc-key ./signing/AuthKey_2X9R4HXF34.p8From the TypeScript SDK, pass appstore next to the signing options:
import fs from 'node:fs';
const build = xcode.xcodebuild(
{ scheme: 'MyApp', sdk: 'iphoneos', configuration: 'Release' },
{
cloudSigning: {
method: 'app-store-connect',
teamId: 'VMBY3VYW4U',
apiKeyId: '2X9R4HXF34',
apiIssuerId: process.env['ASC_ISSUER_ID']!,
apiPrivateKeyBase64: fs.readFileSync('./AuthKey_2X9R4HXF34.p8').toString('base64'),
},
appstore: {
apiKeyId: '2X9R4HXF34',
apiIssuerId: process.env['ASC_ISSUER_ID'],
apiPrivateKeyBase64: fs.readFileSync('./AuthKey_2X9R4HXF34.p8').toString('base64'),
},
},
);
const { exitCode, appstore } = await build;
// appstore.state: 'uploading' | 'processing' | 'accepted' | 'failed' | 'unknown'.
// The field may be absent.With manual signing, keep the --certificate-p12, --certificate-password, and --provisioning-profile flags and add --upload-to-appstore with the --asc-* flags. App Store Connect upload supports iphoneos, appletvos, and xros; replace the SDK and scheme to deliver a tvOS or visionOS build. A standalone watchos build cannot use --upload-to-appstore; the CLI rejects it with --upload-to-appstore requires --sdk iphoneos, --sdk appletvos, or --sdk xros. An embedded watch app ships inside the iOS IPA. Combine with --upload if you also want the IPA in Asset Storage.
Build with a released Xcode: Apple rejects uploads built with a beta. Xcode and tool versions explains how a bare-major pin guarantees that.
Wait for Apple's verdict
The build log streams the upload progress. By default the build succeeds as soon as the upload commits. Apple's processing routinely takes many minutes, so the verdict is left to App Store Connect, and the build prints the upload ID so you can check it there.
To watch for the verdict instead, pass --asc-wait-timeout <seconds> (waitTimeoutSeconds in the SDK's appstore options, up to 1800). A rejection within that window, such as a reused build number or a missing entitlement, fails the build with Apple's own error text. Expiry without a verdict is still a success, with the build processing on Apple's side.
Number builds automatically
Every upload needs a CFBundleVersion higher than the last one for that version. Pass --auto-build-number (autoIncrementBuildNumber: true in the SDK's appstore options) to set it to one more than the highest build number already in App Store Connect, or 1 for a new app. It requires --upload-to-appstore. The lookup runs server-side with the same API key used for the upload.
Manual signing requires Xcode-standard versioning (CFBundleVersion = $(CURRENT_PROJECT_VERSION)), which modern Xcode templates and Expo prebuilds use. Cloud signing updates the unsigned archive before export.
How the API key is handled
Your API key travels with the build request over TLS. For cloud signing, the build service writes it with owner-only permissions in the disposable sandbox directory for the export command, then deletes it. The sandbox directory is discarded with the instance.
Troubleshoot App Store Connect
TestFlight delivery requires a signed iphoneos, appletvos, or xros build, manual or cloud signing, and an existing app record in App Store Connect for your bundle ID. Apple's API cannot create app records. Each upload needs a CFBundleVersion higher than the last one for that version.
| Build output | Cause | Fix |
|---|---|---|
bundle version must be higher (or similar Apple text) | The CFBundleVersion was already used by an earlier upload. | Rebuild with --auto-build-number, or bump the build number manually. |
HTTP 401 from App Store Connect | The key ID, issuer ID, and .p8 do not belong together, or the key was revoked. | Re-check the three values in App Store Connect. Team keys need the issuer ID; individual keys must omit it. |
HTTP 403 from App Store Connect | The key's role cannot upload builds. | Use a key with the Developer role or higher. |
no App Store Connect app with bundle id ... | No app record exists for the bundle ID you built. | Create the app record in App Store Connect first (Apps, then the plus button). |
| Build stuck in "Missing Compliance" | The binary does not answer the export-compliance question. | Set ITSAppUsesNonExemptEncryption in Info.plist as described above. |
Next steps
GitHub Actions recipes
Build a signed IPA on every pull request from a Linux runner.
Publish to the stores
Add App Store and Google Play publishing to your own platform.
Build logs and webhooks
Detach from long release builds and receive the result on your endpoint.
Asset Storage
Where uploaded IPAs live and how to fetch fresh download URLs.
Was this guide helpful?