Llim.run

Build with remote Xcode

Limrun lets you compile iOS, watchOS, tvOS, and visionOS apps on a remote Mac. Your laptop or CI runner doesn't need to be one. After a successful build, you either run a supported app on a remote simulator or download a signed IPA to ship.

If your app builds with Bazel instead of xcodebuild, use remote build execution: no source sync, and Bazel keeps running on your side.

The flow:

  1. Provision an Xcode sandbox.
  2. Sync your source folder to it.
  3. Run xcodebuild on the remote Mac. Logs stream back.
  4. Install or ship. Successful builds auto-install on an attached simulator, or you upload the IPA to share.

SDK coverage by language

SurfaceTypeScriptPythonGoCLI
Provision an Xcode instancevia REST
Sync source and run xcodebuildnot in SDKnot in SDK
Run one-shot project commandsnot in SDKnot in SDK

Python and Go users provision the instance over REST (POST /v1/xcode_instances) or with lim xcode create, and drive the build with lim xcode build. See the SDK capability matrix.

Provision the Xcode sandbox

The Xcode sandbox and the iOS simulator are separate instances with separate lifecycles. You create the sandbox, build on it, and attach a simulator only when it's time to run the app. This composability is the point: get an Xcode sandbox for the build, attach a simulator to test, drop the simulator and go back to just Xcode (or swap to Android) when you're not testing. You only pay for what's currently running.

Building before the simulator exists also matters practically: a simulator that sits idle during a long build can hit its inactivity timeout and disappear before the build finishes.

lim xcode create --reuse-if-exists --label session=demo
import Limrun from '@limrun/api';

const lim = new Limrun({ apiKey: process.env['LIM_API_KEY'] });

const xcodeInstance = await lim.xcodeInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: { labels: { session: 'demo' } },
});

const xcode = await lim.xcodeInstances.createClient({ instance: xcodeInstance });

On the SDK side, xcode is the handle you use in the sync and build sections below.

If you only have the URL and token from a different process (not the full instance object), open the client with the raw form:

const xcode = await lim.xcodeInstances.createClient({ apiUrl, token });

The Go SDK doesn't yet have a standalone Xcode resource. From Go, POST to /v1/xcode_instances directly or call lim xcode create from your process.

When you already know you'll test on a simulator right after the build, the CLI can create both instances and attach them in one call: lim ios create --xcode (or lim xcode create --ios). The two instances are still separate; the flag just saves you the attach step described in Auto-install on the simulator.

Choose the Xcode version

A sandbox builds with its node's default Xcode unless you pick another installed major. Xcode 27 (beta) is available beside the default. Pick it once per workspace (your git repo, or a lim set-workspace-dir assignment), the way a version manager remembers a version:

lim xcode version list                    # versions the sandbox can build with; * marks the one in use, beta majors show their seed
lim xcode version set 27                  # prefer 27 here; switches the remembered sandbox now
lim xcode build .                         # builds with 27, so do test, rbe and new sandboxes
lim xcode version                         # "27.0 (27A5252f)"
lim xcode build . --xcode-version 26     # one-off override; the preference is unchanged
lim xcode version unset                   # forget the preference; the sandbox goes back to the node default
const { bound, installed } = await xcode.getXcode();
if (bound.major !== '27') {
  await xcode.setXcode('27');
}

The sandbox itself has one Xcode selected at a time, and the workspace preference wins: when a build finds the sandbox on another major (a colleague switched it, or a fresh sandbox came up on the node default), it says so and switches before building. Every build prints the Xcode it runs with (Building with Xcode 27.0 (27A5252f)).

Sync your source

Syncing pushes your source to the sandbox. The first sync uploads everything; later ones only send what changed.

# One-shot sync followed by build (the most common path)
lim xcode build .

# Continuous sync (watch mode) without building
lim xcode sync . --watch
await xcode.sync('./my-app', {
  watch: true,             // re-sync on file changes
  install: true,           // install after each sync (attached simulator only)
  additionalFiles: [
    { localPath: '/home/dev/.netrc', remotePath: '~/.netrc' },
  ],
});

What gets synced

The SDK skips these paths regardless of .gitignore:

On top of that, paths matched by your .gitignore files are skipped, root and nested ones alike, with git's usual precedence. One exception: .xcconfig files always sync because Xcode needs them on the remote. If a file is both tracked by git and ignored, the SDK warns you so you can fix the mismatch.

Symlinks sync as symlinks when their target is a relative path that stays inside the synced folder, so setups that link shared sources into an app directory build the same remotely as locally. Symlinks with absolute targets are skipped with a warning, and relative links that escape the synced folder fail the sync (pass --ignore to skip them instead).

Dependency caches (Pods/, .swiftpm/, Carthage/Build/) don't need to ship: the remote sandbox detects Podfile / Package.swift / Cartfile and resolves the dependencies on its side before the build runs. Keep them in your local .gitignore as usual.

Some project tools refuse to run outside a Git repository. The sync excludes .git, so pass --git-init to create a repository in the synced workspace before project generation, dependency resolution, and xcodebuild:

lim xcode build . --git-init

To exclude something the SDK doesn't already skip, pass a regex on the CLI or a predicate on the SDK:

lim xcode build . --ignore '^Secrets/' --ignore '\.local\.json$'
await xcode.sync('./my-app', {
  ignore: (relPath) =>
    relPath.startsWith('Secrets/') || relPath.endsWith('.local.json'),
});

The reverse exists too: --include force-syncs paths your .gitignore excludes. Use it when a local codegen step produces gitignored files the build needs.

# A generated local Swift package the build depends on
lim xcode build . --include '^ios/GeneratedKit/'

# Prebuilt Carthage frameworks you'd rather ship than rebuild
lim xcode build . --include '^Carthage/Build/'
await xcode.sync('./my-app', {
  include: (relPath) => relPath.startsWith('ios/GeneratedKit/'),
});

Like --ignore, the pattern is a regular expression, not gitignore syntax. To reach files inside a directory that is ignored as a whole, the pattern must also match the directory path itself, as both examples do.

Additional files

If your build needs files that aren't in your repo (a .netrc for private package access, a CI-only Config.swift, a secrets file), pass them in alongside the sync.

await xcode.sync('./my-app', {
  additionalFiles: [
    { localPath: '/home/dev/.netrc', remotePath: '~/.netrc' },
    { localPath: './ci/Config.swift', remotePath: 'Config.swift' },
  ],
});

On the CLI, repeat --additional-file local=remote (see agents/cli). Paths on the remote side that start with ~/ expand to the sandbox's home directory.

Tune the delta sync

basisCacheDir is the local copy of the last sync that makes re-syncs fast. The default lives in your OS temp directory, which CI runners wipe between jobs; pin it to something persistent like /var/cache/lim in CI.

Generated Xcode projects

If XcodeGen generates your .xcodeproj and the project is gitignored (the recommended XcodeGen setup), there is nothing to configure: sync as usual, and the sandbox generates the project from your project.yml before the build.

lim xcode build .

The spec can sit at the repo root or one directory down, like a monorepo's ios/. Both are found without flags. Try it on sample-xcodegen-app.

If your spec has a non-default name or location, pin it explicitly. The three flags mirror xcodegen generate --spec, --project, and --project-root, with paths relative to the synced folder root:

lim xcode build . --xcodegen-spec specs/app.yml --xcodegen-project ios

--xcodegen-spec names the spec file (default: project.yml at the root). --xcodegen-project sets the directory the project is generated into (default: the spec's directory). --xcodegen-project-root sets the directory the spec's relative paths resolve against (default: the spec's directory). Passing any of these flags always regenerates the project, even when the sync supplied one.

What to know:

Run project commands

Use lim xcode run when the repository needs a macOS command such as a Make target or code generator. The command syncs the current directory, then runs after -- in the remote workspace:

lim xcode run -- make api

The optional positional path changes the remote working directory. It is relative to the synced workspace and defaults to .:

lim xcode run apps/api -- make generate

Add --no-sync when the instance already has the source state the command needs:

lim xcode run --no-sync -- make api

The sandbox includes mise for project tool versions. Its downloads and installed tools stay in the instance's sandbox home:

lim xcode run -- 'mise trust && mise install'
lim xcode run -- mise run generate

Commands are one-shot. They stream stdout and stderr, return the remote exit code, and do not provide an interactive terminal.

TypeScript clients can use the same execution path after syncing:

await xcode.sync('./my-app', { watch: false });

const command = xcode.run('make api', {
  cwd: '.',
  timeoutSeconds: 1800,
});

command.stdout.on('data', (line) => process.stdout.write(line));
command.stderr.on('data', (line) => process.stderr.write(line));
const { exitCode } = await command;

Run xcodebuild

Trigger a build. Logs stream back as it runs.

lim xcode build . --scheme MyApp --workspace MyApp.xcworkspace
const build = xcode.xcodebuild({
  workspace: 'MyApp.xcworkspace',
  scheme: 'MyApp',
  sdk: 'iphonesimulator', // also iphoneos, watchsimulator, watchos, appletvos, xros
});

build.command.on('data', (line) => process.stdout.write(line));
build.stdout.on('data', (line) => process.stdout.write(line));
build.stderr.on('data', (line) => process.stderr.write(line));

const { exitCode, status } = await build;
// status: 'SUCCEEDED' | 'FAILED' | 'CANCELLED'

Build streams

While the build runs, the call exposes three event-emitter channels you can subscribe to:

ChannelCarries
commandThe full command string the sandbox executed. One event, then closes.
stdoutxcodebuild stdout as the build runs.
stderrxcodebuild stderr.

Awaiting the call resolves to a result object with exitCode, status (SUCCEEDED, FAILED, or CANCELLED), and a signedDownloadUrl when you asked for an upload. Share that URL with a teammate or reviewer.

Build-completion webhooks and detached builds

Pass a webhook when a CI job, agent, or backend needs the terminal result without relying on the live log stream. Limrun calls the endpoint when the build reaches SUCCEEDED, FAILED, or CANCELLED. The CLI and TypeScript SDK accept the same URL and optional authentication headers:

lim xcode build . \
  --webhook-url https://ci.example.com/hooks/limrun \
  --webhook-header Authorization="Bearer $HOOK_SECRET"
const build = xcode.xcodebuild(
  { workspace: 'MyApp.xcworkspace', scheme: 'MyApp' },
  {
    webhook: {
      url: 'https://ci.example.com/hooks/limrun',
      headers: { Authorization: 'Bearer <your-webhook-secret>' },
    },
  },
);

The webhook is sent whether the caller keeps streaming or detaches. Limrun POSTs a JSON payload with the build result and debugging links:

{
  "execId": "build-1700000000000000000",
  "command": "xcodebuild",
  "status": "SUCCEEDED",
  "exitCode": 0,
  "startedAt": "2026-07-29T12:00:00Z",
  "finishedAt": "2026-07-29T12:03:25Z",
  "buildDurationMs": 205000,
  "instanceId": "xcode_usw1_...",
  "consoleUrl": "https://console.limrun.com/builds/xcode_usw1_...",
  "logsUrl": "https://...",
  "assetId": "asset_01h455vb4pex5vsknk084sn02q",
  "bundleIdentifier": "com.example.myapp",
  "shortVersion": "1.2.3",
  "buildVersion": "42",
  "displayName": "MyApp",
  "deeplink": "myapp",
  "iconUrl": "https://..."
}

logsUrl is a time-limited link to the persisted plain-text build log. Read that log for build diagnostics when status is FAILED. There is no error field: xcodebuild doesn't expose one deterministic diagnostic, so the log is the answer. The instance, console, log, timing, and exit-code fields are omitted when they are unavailable.

App identity in the payload

The payload also describes the app the build produced, read from the built bundle's Info.plist:

FieldCarries
bundleIdentifierCFBundleIdentifier, e.g. com.example.myapp.
shortVersionCFBundleShortVersionString, e.g. 1.2.3.
buildVersionCFBundleVersion, e.g. 42.
displayNameCFBundleDisplayName, falling back to CFBundleName.
deeplinkThe app's primary URL scheme from CFBundleURLTypes, e.g. myapp for myapp:// links.
assetIdThe Asset Storage asset the artifact was uploaded to.
iconUrlA time-limited link to the app icon PNG stored next to the asset.

This is what lets a receiver tell builds apart without downloading and opening the artifact. In a monorepo that ships several apps, key your own bookkeeping on bundleIdentifier, and use assetId to install exactly the build that a given webhook reported. The Android equivalents are documented in Build with Gradle.

These fields are set on successful builds that produced one app bundle. A lim xcode test run builds for testing, producing an xctestrun tree rather than a single app, so it carries none. assetId and iconUrl additionally need --upload, which is what creates the asset; --signed-upload-url puts the artifact in your own bucket and mints no asset. Reading the bundle is best-effort: a bundle Limrun couldn't read leaves the fields out rather than failing a build that compiled and signed fine.

Delivery rules

Detached builds

Add --detach when the caller should return as soon as limbuild accepts the build instead of holding the SSE stream open:

lim xcode build . \
  --detach \
  --inactivity-timeout 3s \
  --webhook-url https://ci.example.com/hooks/limrun \
  --webhook-header Authorization="Bearer $HOOK_SECRET"

Once the build is accepted, the CLI prints the instance ID, the console page where progress and logs appear, and the webhook target. Pass --json to receive one object with instanceId, execId, consoleUrl, and webhookUrl fields.

--detach requires --webhook-url. --inactivity-timeout skips the cached Xcode target and creates a fresh instance with the requested lifecycle, so it cannot be combined with --id. Active builds continually report activity; the short timeout begins expiring only after build and upload work stops. The inactivity controller checks about every 15 seconds, so a 3s timeout usually tears the instance down 3 to 18 seconds after its last activity rather than at exactly three seconds.

The TypeScript process can detach after the build request is accepted:

const execId = await build.detach();

Build settings

Match what you'd pass to local xcodebuild:

FieldUse
workspacePath to a .xcworkspace, e.g. MyApp.xcworkspace.
projectPath to a .xcodeproj. Use this or workspace, not both.
schemeRequired for multi-scheme apps.
sdkiphonesimulator (default), iphoneos, watchsimulator, watchos, appletvos, or xros. tvOS and visionOS are device-build only.

To pass a custom build setting, repeat --build-setting KEY=VALUE on the CLI or set buildSettings on the SDK call. Any environment-style key (letters, digits, underscores) is accepted. Request values are applied after Limrun's managed settings and replace a managed value with the same key, so a project that needs it can override a default such as ONLY_ACTIVE_ARCH. APP_CONFIG_* values are treated as app configuration and redacted from build logs.

lim xcode build . --scheme MyApp \
  --build-setting APP_CONFIG_API_URL=https://staging.example.com \
  --build-setting SWIFT_ACTIVE_COMPILATION_CONDITIONS=STAGING
xcode.xcodebuild(
  { scheme: 'MyApp' },
  {
    buildSettings: {
      APP_CONFIG_API_URL: 'https://staging.example.com',
      SWIFT_ACTIVE_COMPILATION_CONDITIONS: 'STAGING',
    },
  },
);

Device build defaults

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 for speed.

tvOS and visionOS

Pass the device SDK and scheme explicitly for tvOS and visionOS:

lim xcode build . --scheme TVApp --sdk appletvos
lim xcode build . --scheme VisionApp --sdk xros

These 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 execution are not supported yet.

Sign for real-device builds

To produce a signed IPA for distribution, select a device SDK, pass a P12 certificate, and pass a provisioning profile. Add an upload target so the IPA lands in Asset Storage with a downloadable 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.ipa
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.

Cloud signing

Cloud signing produces a signed IPA without a p12 or provisioning profile. Xcode authenticates with an App Store Connect team API key, then 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.ipa

Choose the method for the IPA you need:

MethodUse
debuggingDevelopment-signed IPA for devices registered to the team.
release-testingDistribution-signed IPA for registered test devices.
app-store-connectDistribution-signed IPA for App Store Connect.

Cloud signing works for device SDK builds: --sdk iphoneos, --sdk watchos, --sdk appletvos, and --sdk 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.

The TypeScript SDK accepts the same configuration:

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' },
  },
);

Preserving entitlements

Cloud signing archives your app unsigned, and Apple's export carries entitlements into the signed IPA only from an existing code signature, so capability entitlements from your project's .entitlements file (HealthKit, CloudKit, app groups, push) do not survive on their own. Pass them explicitly with --entitlements; the build server embeds them into the archive so the export preserves 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.ipa
cloudSigning: {
  method: 'release-testing',
  teamId: 'VMBY3VYW4U',
  apiKeyId: '2X9R4HXF34',
  apiIssuerId: process.env['ASC_ISSUER_ID'],
  apiPrivateKeyBase64: fs.readFileSync('./AuthKey_2X9R4HXF34.p8').toString('base64'),
  // Keyed by bundle id; the empty key targets the app itself.
  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 auto-registers toggleable capabilities (HealthKit, push) there for you, but capabilities needing extra configuration (app group assignments, 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 like $(AppIdentifierPrefix) are rejected, so write the concrete team prefix instead. And keys the export manages itself (application-identifier, com.apple.developer.team-identifier, get-task-allow, beta-reports-active) must be omitted; the export sets them from the generated profile and distribution method.

Verify what landed in the IPA:

unzip -q my-app.ipa -d out && codesign -d --entitlements - out/Payload/MyApp.app

Apps with extensions or a watch app

App Store signing requires a distinct provisioning profile for every bundle the app embeds: the app itself plus each app extension (a WidgetKit widget, share sheet, intents extension) and watch app, all issued for the same distribution certificate. Repeat --provisioning-profile (or pass provisioningProfilesBase64 in the SDK) with one profile per bundle. Each profile is matched to its bundle by the application-identifier it carries, so the order doesn't 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.ipa
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'),
        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 and fails the build with the uncovered bundle id otherwise, so a missing profile surfaces immediately instead of as an App Store validation rejection after upload.

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 via -certfile:

openssl pkcs12 -export -inkey dist.key -in dist.pem -certfile wwdr.pem -out dist-chain.p12

The build result includes a signed download URL when you've configured an upload. Store it in your PR comment or pipeline output. Anyone with the URL can download the IPA without a Limrun API key. The signature expires after 15 minutes; if you need it later, re-fetch a fresh URL with lim asset list --name <asset> --download-url.

Troubleshooting signing failures

Build outputWhat it meansFix
Unknown issuer hashThe certificate's issuing CA isn't recognized and the p12 carries no CA chain.Re-export the p12 with the certificate chain included (see the OpenSSL command above).
code signature verification failedThe produced signature failed Apple's verifier.Retry the build; contact support if it persists.
MAC verification failed or other password errorsThe certificate password doesn't 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 errorThe 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 IdentifierThe bundle ID cannot be registered to this team, usually because another team owns it.Use a bundle ID already owned by the team or one that is available to register.

Upload to App Store Connect

A cloud-signed build can use the same API key for signing and upload. Use the app-store-connect method with --upload-to-appstore:

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.p8
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 is absent when the instance predates the feature.

App Store Connect upload supports iphoneos, appletvos, and xros. Replace the SDK and scheme in the examples above to deliver a tvOS or visionOS build. Existing watchOS upload behavior is unchanged.

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 upload ID is printed so you can check it there. To watch for the verdict instead, pass --asc-wait-timeout <seconds> (waitTimeoutSeconds in the SDK, up to 1800): a rejection within that window (a reused build number, a missing entitlement) fails the build with Apple's own error text, and expiry without a verdict is still a success with the build processing on Apple's side. Combine with --upload if you also want the IPA in Asset Storage.

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. The lookup runs server-side with the same API key used for the upload. Manual signing requires Xcode-standard versioning (CFBundleVersion = $(CURRENT_PROJECT_VERSION)), including modern Xcode templates and Expo prebuilds. Cloud signing updates the unsigned archive before export.

Your API key travels with the build request over TLS. For cloud signing, limbuild 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.

One-time App Store Connect setup

Three App Store Connect settings make TestFlight delivery fully hands-off:

  1. 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 sufficient 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 to the key).
    • --asc-key: the .p8 file 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.
  2. 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 does use non-exempt encryption, answer the compliance questions in App Store Connect instead.

  3. 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.

Requirements and troubleshooting

TestFlight delivery requires a signed iphoneos, appletvos, or xros build plus 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 outputWhat it meansFix
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 ConnectThe key ID, issuer ID, and .p8 don't belong together, or the key was revoked.Re-check the trio in App Store Connect; team keys need the issuer ID, individual keys must omit it.
HTTP 403 from App Store ConnectThe key's role can't 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 doesn't answer the export-compliance question.Set ITSAppUsesNonExemptEncryption in Info.plist as described above.

Upload the build artifact

Two options for distributing a build artifact.

Upload to Asset Storage. The artifact lives in Limrun's managed storage and can be referenced by name in later lim ios create --install-asset <name> calls. Asset Storage also backs the PR Previews flow.

lim xcode build . --scheme MyApp --upload my-build.ipa
xcode.xcodebuild(
  { scheme: 'MyApp' },
  { upload: { assetName: 'my-build.ipa' } },
);

The uploaded asset expires 14 days after the last upload by default: each build upload pushes the expiry out again, so actively rebuilt assets stay alive. Pass --upload-ttl with a Go duration (ttl in the SDK; e.g. 720h, 1d is invalid) to change the window. Assets uploaded with lim asset push are unaffected; they never expire unless given a --ttl.

Upload to your own bucket. Pass a pre-signed S3, GCS, or R2 URL and the artifact PUTs directly to it from the sandbox. The build bytes never travel through your client; the sandbox handles the upload itself, which avoids a round-trip through whatever machine called lim xcode build. Use this when you want the artifact in storage you already manage, or when the build is large enough that pulling it back through your CI runner would be wasteful.

lim xcode build . --scheme MyApp --signed-upload-url '<presigned-url>'
xcode.xcodebuild(
  { scheme: 'MyApp' },
  { upload: { signedUploadUrl: '<presigned-url>' } },
);

When the product name differs from the scheme name. The server locates the built .app on its own, including projects where the scheme and PRODUCT_NAME diverge (a scheme "MyApp Dev" building MyApp-dev.app). If an upload still fails with built artifact not found, pin the bundle filename explicitly (including the .app extension) — the server then takes that name from the build products verbatim and skips discovery:

lim xcode build . --scheme "MyApp Dev" --upload myapp-dev-build --artifact-name MyApp-dev.app
xcode.xcodebuild(
  { scheme: 'MyApp Dev', artifactName: 'MyApp-dev.app' },
  { upload: { assetName: 'myapp-dev-build' } },
);

Auto-install on the simulator

Attach a simulator to the Xcode sandbox at any time. The attach installs the latest successful build immediately, and every later successful build auto-installs and auto-launches on it. lim ios create --attach creates a fresh simulator and attaches it in one step; lim xcode attach-simulator attaches one that already exists:

# Create a simulator and attach it (installs the latest successful build)
lim ios create --attach

# Or attach an existing simulator
lim xcode attach-simulator <ios_instance_id>

Or via the SDK:

await xcode.attachSimulator(iosInstance);
// or with raw URL/token:
await xcode.attachSimulator({ apiUrl: '...', token: '...' });
// or create a fresh simulator and attach it in one call:
const { simulator } = await xcode.attachNewSimulator();

From Python or Go, attach with a direct HTTP call: POST {xcode status.apiUrl}/simulator with the Xcode instance's status.token as bearer and body {"apiUrl": "<simulator status.apiUrl>", "token": "<simulator status.token>"}.

Re-attach to a different simulator if you want to test the same build across multiple device models.

Full example: build a signed IPA on every PR

A complete Linux CI job that builds, signs, uploads, and posts a download link:

.github/workflows/build.yml
name: iOS build
on: { pull_request: { types: [opened, synchronize] } }
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install --global lim
      - name: Build
        env:
          LIM_API_KEY: ${{ secrets.LIM_API_KEY }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
        run: |
          echo "${{ secrets.P12_BASE64 }}" | base64 -d > /tmp/dist.p12
          echo "${{ secrets.PROFILE_BASE64 }}" | base64 -d > /tmp/MyApp.mobileprovision
          lim xcode create --reuse-if-exists --label pr=${{ github.event.number }}
          lim xcode build . \
            --scheme MyApp \
            --sdk iphoneos \
            --certificate-p12 /tmp/dist.p12 \
            --certificate-password "$P12_PASSWORD" \
            --provisioning-profile /tmp/MyApp.mobileprovision \
            --upload my-app-pr-${{ github.event.number }}.ipa

What this workflow does, step by step:

  1. Checks out the PR's code on an Ubuntu runner.
  2. Installs the Limrun CLI globally with npm.
  3. Decodes the signing certificate and provisioning profile from base64-encoded GitHub secrets into the runner's filesystem.
  4. Provisions an Xcode sandbox labelled with the PR number, so re-pushes to the same PR reuse the same sandbox instead of spawning new ones.
  5. Runs a signed device build (--sdk iphoneos plus signing flags) and uploads the IPA to Asset Storage under a name tied to the PR number. The CLI prints the signed download URL to the runner log.

No runs-on: macos-latest. No Apple-licensed CI runner. The build happens on Limrun's Mac fleet; the runner only needs to call lim.

Next steps

smartphone

Run an iOS Simulator

Drive the simulator the build just installed onto: taps, screenshots, recordings, logs.

git-pull-request

Automatic PR Previews

Wire lim xcode build --upload into a GitHub workflow that posts a preview link on every PR.

package

Asset Storage

Manage uploaded build artifacts, pre-install apps at boot, and browse the Limrun App Store.

book-open

SDK Reference

Auth, errors, the instance state machine, every resource × CRUD.