Xcode Builds

Remote Xcode builds on real Apple silicon, dedicated M4 hosts, ephemeral VMs, no Mac required in CI or on your desk.


Overview#

Xcode builds need a Mac. Most CI fleets don't have one, and the ones that do are either a handful of Mac minis someone racked in a closet or an expensive per-minute line item on a hosted provider. Sandal runs Xcode builds on dedicated Apple silicon (M4) hosts, with each build executing inside its own ephemeral VM via Virtualization.framework, one tenant per VM, destroyed the moment the build finishes.

Use it for:

  • CI builds and archives without maintaining a Mac build farm
  • PR preview builds that auto-install on a device for review
  • Fastlane/Xcode Cloud-style pipelines you want API-driven instead of Apple-hosted
  • Agent-driven iOS development, an agent that writes Swift and needs to know if it compiles

You don't get a shell into someone else's Mac mini. You get a clean VM, your source, and a build.

Source sync#

Uploading a full iOS repo on every build is slow, Pods/, .build/, and .git history routinely dwarf the actual source. Sandal syncs source with content-addressed, chunked upload: files are split into 8 MiB chunks, each chunk hashed, and only chunks the server doesn't already have (from a previous build of the same repo) are actually transferred.

const build = await sandal.builds.xcode.run({
  source: "./MyApp", // local path, synced automatically
  scheme: "MyApp",
});
  • Delta sync: unchanged chunks from your last build are reused server-side, so incremental builds after a small diff upload closer to kilobytes than gigabytes.
  • .git and Pods are skipped by default. Sandal resolves dependencies server-side instead of shipping them over the wire (see below). Exclude anything else with a .sandalignore file using the same syntax as .gitignore.
  • Server-side dependency resolution: if the synced source contains a Package.swift, Podfile, or Cartfile, Sandal resolves and fetches those dependencies on the build host itself, from a warm package cache, rather than requiring you to vendor or upload resolved packages.

Run a build#

import { Sandal } from "@sandal/sdk";

const sandal = new Sandal({ apiKey: process.env.SANDAL_API_KEY });

const build = await sandal.builds.xcode.run({
  source: "./MyApp",
  scheme: "MyApp",
  configuration: "Release",
  destination: "generic/platform=iOS",
  signing: { grant: "sign_8f2c1a" }, // see Signing, below
});

for await (const line of build.logs()) {
  process.stdout.write(line);
}

console.log(build.status, build.artifacts);

destination takes the same value you'd pass to xcodebuild -destination, a specific simulator (platform=iOS Simulator,name=iPhone 16), a generic device build for signing (generic/platform=iOS), or platform=macOS for a Mac target. Logs stream as they're produced by xcodebuild, unbuffered, so a slow compile shows progress instead of going silent for ten minutes.

From the CLI:

sandal xcode build . --scheme App
sandal xcode build . --scheme App --configuration Release --destination "generic/platform=iOS"

Artifacts#

A finished build returns download URLs for whatever xcodebuild produced:

ArtifactWhen it's present
.appSimulator or device builds
.ipaArchive builds with signing configured
.xcarchiveArchive builds (xcodebuild archive)
.xcresultAny build, test results, warnings, build timing
dSYMRelease builds with debug symbols enabled
logsFull raw xcodebuild output, always
console.log(build.artifacts.ipa?.url);
console.log(build.artifacts.xcresult?.url);

Artifact URLs are signed, single-build, and retained for 14 days after the build completes, pull anything you need into your own storage before then if you need it longer.

Signing#

Signing material, certificates, provisioning profiles, API keys for App Store Connect, is never uploaded as part of a build request. You register it once as a signing grant, and reference it by grant id:

const grant = await sandal.signing.grants.create({
  name: "myapp-appstore",
  certificate: fs.readFileSync("./dist.p12"),
  password: process.env.CERT_PASSWORD,
  profiles: [fs.readFileSync("./MyApp_AppStore.mobileprovision")],
});

await sandal.builds.xcode.run({
  source: "./MyApp",
  scheme: "MyApp",
  signing: { grant: grant.id },
});

The build request itself carries only grant.id. The certificate and provisioning profile are decrypted into the build VM's keychain at build time and destroyed with the VM when the build finishes, they never touch disk outside that single ephemeral VM, and they're never logged or returned in the build's logs() stream.

Caches#

DerivedData, Swift Package Manager checkouts, and CocoaPods repos are the majority of a clean build's wall-clock time on a cold cache. Sandal keeps cache volumes for each, keyed by a digest of the resolved dependency graph (Package.resolved / Podfile.lock) plus a namespace you control:

await sandal.builds.xcode.run({
  source: "./MyApp",
  scheme: "MyApp",
  cache: { namespace: "myapp-main" },
});

A cache volume is restored just-in-time when a build's dependency digest matches one already cached under that namespace, no cache hit means a cold resolve, same as running pod install fresh. Different branches or forks can share a namespace to share cache, or use separate namespaces (e.g. per-branch) to avoid one branch's dependency changes invalidating another's warm cache.

Attach to a simulator#

A build can install itself directly onto a running simulator instance instead of just producing a .app you have to place yourself:

const dev = await sandal.devices.create({ kind: "ios-simulator" });

const build = await sandal.builds.xcode.run({
  source: "./MyApp",
  scheme: "MyApp",
  destination: "generic/platform=iOS Simulator",
  attachTo: dev.id,
});

// once build.status === "succeeded", the .app is already installed and launched on `dev`

This is the loop an agent typically wants: write code, build, see the result running on a live device stream, iterate, without a manual xcrun simctl install step in between.

PR previews#

A GitHub Action that builds on every PR and comments a preview link:

.github/workflows/xcode-preview.yml
name: Xcode Preview
on: pull_request

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: sandal-dev/xcode-build-action@v1
        id: build
        with:
          api-key: ${{ secrets.SANDAL_API_KEY }}
          scheme: MyApp
          destination: "generic/platform=iOS Simulator"
      - uses: sandal-dev/pr-comment-action@v1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          preview-url: ${{ steps.build.outputs.preview-url }}

Note the runs-on: ubuntu-latest, the action calls the Sandal API and never needs macOS itself, since the actual build runs on Sandal's M4 hosts. The comment includes a link that opens the build's .app on a fresh simulator session for anyone reviewing the PR, no local checkout required.

Pricing#

Xcode builds are billed per active build minute on M4 hosts, from the moment xcodebuild starts to the moment it exits. Source sync, cache restore, and queue time are not billed as build minutes.

Next steps#

  • Simulators, attach a build directly to a live iOS simulator session
  • Runners, for CI that isn't Xcode-shaped
  • Pricing, full platform pricing