Llim.run

Build with remote Xcode

Limrun lets you compile iOS apps on a remote Mac. Your laptop or CI runner doesn't need to be one. After a successful build, you either run the 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 a paired 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 with their SDK and drive the build with lim xcode build. See the SDK capability matrix.

Two ways to use the Xcode sandbox

The right shape depends on whether you also need a simulator right now.

Limrun lets your agent compose its environment step-by-step: get an Xcode sandbox for the build, attach a simulator only when it's time to test, drop the simulator and go back to just Xcode (or swap to Android) when you're not. You only pay for what's currently running. Future services like Blender and Unity follow the same pattern.

Paired with an iOS simulator (most common)

When the agent is going to test on a simulator right after the build, create one iOS instance with the Xcode sandbox attached. Pass --xcode to the CLI, or set the sandbox config on the SDK call as shown below. After a successful build, the app auto-installs and auto-launches on the simulator.

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

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

const instance = await lim.iosInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: { labels: { session: 'demo' } },
  spec: {
    sandbox: { xcode: { enabled: true } },
  },
});

const xcodeUrl = instance.status.sandbox!.xcode!.url!;
const xcode = await lim.xcodeInstances.createClient({
  apiUrl: xcodeUrl,
  token: instance.status.token,
});
# Python provisions the paired instance; the build step itself is not in the
# Python SDK today. Drive the build with `lim xcode build` from your CI step.
instance = client.ios_instances.create(
    wait=True,
    reuse_if_exists=True,
    metadata={"labels": {"session": "demo"}},
    spec={"sandbox": {"xcode": {"enabled": True}}},
)
xcode_url = instance.status.sandbox.xcode.url
// Go provisions the paired instance; the build step itself is not in the
// Go SDK today. Drive the build with `lim xcode build` from your CI step.
instance, err := lim.IosInstances.New(ctx, limrun.IosInstanceNewParams{
    Wait:          param.NewOpt(true),
    ReuseIfExists: param.NewOpt(true),
    Metadata: limrun.IosInstanceNewParamsMetadata{
        Labels: map[string]string{"session": "demo"},
    },
    Spec: limrun.IosInstanceNewParamsSpec{
        Sandbox: limrun.IosInstanceNewParamsSpecSandbox{
            Xcode: limrun.IosInstanceNewParamsSpecSandboxXcode{
                Enabled: param.NewOpt(true),
            },
        },
    },
})
xcodeURL := instance.Status.Sandbox.Xcode.URL

On the SDK side, xcode is the handle you use in the sync and build sections below. On the CLI, the --xcode flag is what carries the sandbox attachment through.

Standalone, without a simulator

For pure CI builds, real-device IPA distribution, or build steps where the agent doesn't need a simulator yet, create a top-level Xcode instance. Attach a simulator later only when it's time to run the app (see Auto-install on the simulator).

lim xcode create --reuse-if-exists --label session=ci-build
const xcodeInstance = await lim.xcodeInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: { labels: { session: 'ci-build' } },
});

const xcode = await lim.xcodeInstances.createClient({ instance: xcodeInstance });
xcode_instance = client.xcode_instances.create(
    wait=True,
    reuse_if_exists=True,
    metadata={"labels": {"session": "ci-build"}},
)
# Drive the build with `lim xcode build` against this instance's ID.

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, either provision an iOS instance with the Xcode sandbox attached (the paired pattern above), POST to /v1/xcode_instances directly, or call lim xcode create from your process.

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 (paired iOS 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', // or 'iphoneos', 'watchsimulator', 'watchos'
});

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://..."
}

logsUrl is a time-limited link to the persisted plain-text build log. Read that log for build diagnostics when status is FAILED. The instance, console, log, timing, and exit-code fields are omitted when they are unavailable.

Webhook delivery follows these rules:

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 for the simulator), iphoneos for device builds, watchsimulator / watchos for watchOS.

To pass a custom build setting, repeat --build-setting KEY=VALUE on the CLI or set buildSettings on the SDK call. Only allowlisted keys are accepted: any APP_CONFIG_* key, plus SWIFT_ACTIVE_COMPILATION_CONDITIONS. Other keys are rejected. 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',
    },
  },
);

Settings outside the allowlist, like CURRENT_PROJECT_VERSION, can't be passed here. Bump those in the Xcode project itself.

Sign for real-device builds

To produce a signed IPA for distribution, switch the target SDK to iphoneos, pass a P12 certificate, and pass a provisioning profile. Add an upload target so the IPA lands in Asset Storage with a downloadable URL.

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

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.

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 device build (--sdk iphoneos 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' } },
);

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

Auto-install on the simulator

When the Xcode sandbox is paired with a simulator, every successful build auto-installs and auto-launches on it. No extra step.

For standalone Xcode instances, attach a simulator at any time:

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

After the attach call returns, the next successful build auto-installs on that simulator. 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.