Pinning an Xcode Version in GitHub Actions

Pin the macOS runner label, set DEVELOPER_DIR, and assert the resolved toolchain in the job log so a moving alias or an image update cannot change Xcode.

Pinning an Xcode version in GitHub Actions takes two decisions inside the same job: the runs-on label decides which Xcode bundles exist on the machine, and DEVELOPER_DIR decides which of those bundles the build uses. Pin both, assert the resolved version in the job log before any compile starts, and move a new toolchain onto one job before the rest of the pipeline follows.

Diagnosis

A job with no pin still resolves to a specific Xcode. It resolves to whatever the image default happens to be on the day it runs, which is why an Xcode pipeline goes red on a morning when nobody touched the workflow file.

The label is an alias. warp-macos-latest-arm64-6x and warp-macos-latest-arm64-12x resolve to the macOS 15 labels, in sync with GitHub's macos-latest tag (cloud runners documentation, checked on 2026-08-13). The day an alias moves to another image, every job naming it gets a different Xcode set with no diff in the repository to explain it.

The image updates under a stable label. WarpBuild macOS images are built from the matching upstream GitHub images and carry the same tooling (preinstalled software documentation), and GitHub regenerates those images on a release cadence. What moves is the bundle inventory: an exact bundle name you hardcoded can be absent from the next build of the same image. The published inventories are the macOS 14 ARM64, macOS 15 ARM64, and macOS 26 ARM64 readmes.

Half the pipeline is pinned. The build job names a bundle and the test or archive job inherits the image default. Module output then gets compiled by one toolchain and read by another, and the error text talks about a module built by a different compiler version rather than about pinning. The same mismatch poisons a DerivedData cache whose key omits the toolchain identifier.

One drift is cheap to rule out because it fails loudly: retired labels. macOS 13 runners were removed on June 8, 2026, so a workflow still naming one queues without a match (cloud runners documentation).

Fix

Three mechanisms can carry a pin. They differ in what they fix and in how the failure presents.

RankMechanismWhat it pinsHow it failsWhen you notice
1Versioned label in runs-on, such as warp-macos-26-arm64-6xThe set of Xcode bundles present on the machineThe label is retired and the job finds no matching runnerImmediately, as a queued job that never starts
2DEVELOPER_DIR set at workflow levelWhich installed bundle every xcodebuild, swift, and xcrun call usesThe bundle path is absent from the image, so the developer directory is invalid and every tool inherits the bad valueOn the first tool call, with an error that names the path
3Runtime selection: sudo xcode-select -s, or an action resolving a version spec such as 16.xThe active developer directory for the rest of the jobThe spec matches a different patch release after an image update and the build silently changes toolchainOnly if something printed the resolved version

Rank one and rank two are the pin. Rank three is a convenience for a matrix leg that genuinely wants a range, and it belongs behind an assertion whenever it is used.

Pin once and read the value everywhere. Put DEVELOPER_DIR in workflow level env rather than repeating a path in three jobs. Build, test, and archive then agree by construction, and a toolchain move is a one line diff that reviewers can see.

Assert the resolved version instead of trusting the path. A missing bundle and a bundle that quietly moved produce the same symptom later in the run. A guard step that tests the directory, prints xcodebuild -version, and compares that output against an expected string turns both into a ten second failure with the installed bundles listed in the log.

Keep the toolchain in every cache key. Generate a key fragment from xcodebuild -version and put it in the DerivedData and SwiftPM cache keys. A tree saved under one Xcode and restored under another costs the compiler the time to read it before it discards it.

Treat the runner label as the real switch. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners that register with GitHub as self-hosted runners carrying warp- labels, so moving a job to a different macOS image is the value of runs-on. The macOS 26 labels ship Xcode 27.0 (build 27A5194q) with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes on top of the Xcode versions in the upstream GitHub macOS 26 image, while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released (macOS 26 tooling). A workflow pinning the Xcode 27.0 path takes one edit at that switch.

Configuration

The pin lives in two places: the label on each job and one DEVELOPER_DIR value at the top of the file.

name: ios

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

env:
  DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer
  EXPECTED_XCODE: "Xcode 27.0"

jobs:
  build-and-test:
    runs-on: warp-macos-26-arm64-6x
    steps:
      - name: Assert the pinned toolchain
        run: |
          if [ ! -d "$DEVELOPER_DIR" ]; then
            echo "pinned developer dir missing: $DEVELOPER_DIR"
            ls -1 /Applications | grep -i '^Xcode'
            exit 1
          fi
          resolved=$(xcodebuild -version | head -1)
          echo "resolved: $resolved"
          [ "$resolved" = "$EXPECTED_XCODE" ] || {
            echo "expected $EXPECTED_XCODE"
            exit 1
          }
          xcrun simctl list runtimes | grep -i ios

      - uses: actions/checkout@v4

      - name: Build and test
        run: |
          xcodebuild test \
            -scheme App \
            -destination "platform=iOS Simulator,name=iPhone 17,OS=27.0" \
            -resultBundlePath TestResults.xcresult

Three details decide whether that file holds up over a year.

  1. The guard runs before actions/checkout, so a broken pin costs seconds of macOS time rather than a checkout plus a dependency resolution.
  2. EXPECTED_XCODE is compared against the first line of xcodebuild -version, which is the only string that reflects what the compiler will be. A path check alone passes on a symlink that was repointed.
  3. Every job in the file inherits the workflow level DEVELOPER_DIR. A job that deliberately wants a different toolchain overrides it in its own env block, and that override is visible in review.

Point the label at the versioned image rather than an alias in any workflow where the toolchain matters, and read the per-image Xcode set from the Xcode image reference before you write the bundle path.

The upgrade path

Moving a whole pipeline to a new Xcode in one commit means every job fails together if the assumption is wrong. Run a canary instead.

  xcode-canary:
    runs-on: warp-macos-26-arm64-6x
    if: github.event_name == 'schedule'
    continue-on-error: true
    env:
      DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer
    steps:
      - uses: actions/checkout@v4
      - name: Build against the candidate toolchain
        run: |
          xcodebuild -version
          xcodebuild build-for-testing \
            -scheme App \
            -destination "platform=iOS Simulator,name=iPhone 17,OS=27.0" \
            -derivedDataPath "${{ github.workspace }}/.dd-canary"

The sequence that keeps the fleet green: add the canary on a nightly schedule with continue-on-error: true, give it its own DerivedData path so it cannot write over the pinned cache, let it run for a week of real commits, then change the workflow level DEVELOPER_DIR once it has been green across a dependency bump and a release branch. Runner image changes are published in the WarpBuild changelog, which is the event that should send you back to the canary.

When a canary fails in a way the log does not explain, inspect the machine rather than guessing at the image. The Action Debugger opens a session into a running workflow so you can list /Applications and run xcrun simctl list runtimes by hand.

Cost or Time Model

macOS sizes and rates, from the cloud runners documentation, checked on 2026-08-13:

LabelvCPUMemoryStorageRate per minute
warp-macos-26-arm64-6x622GB120GB SSD$0.08
warp-macos-26-arm64-12x1244GB270GB SSD$0.16
warp-macos-15-arm64-6x622GB120GB SSD$0.08

The guard step is the cheapest line in the file. Assume 10 seconds, which is 0.17 minutes, on the 6 vCPU label at $0.08 per minute: $0.0133 per job, or $8.00 per month across 600 jobs.

Compare that against one drift event on the same repository. A build that fails at the 28 minute mark costs 28 x $0.08 = $2.24 in macOS time, and a moving alias hits every job in the queue, not one. Forty jobs into the morning that is 40 x $2.24 = $89.60 of macOS time plus the debugging hour, against $8.00 of guard steps for the whole month.

The canary carries its own number. One nightly build of 12 minutes on the 6 vCPU label is 12 x $0.08 = $0.96 per run, or 30 x $0.96 = $28.80 per month. A week of canary before the switch is 7 x $0.96 = $6.72.

Rates for every size are on the pricing page.

Where the model breaks: a repository that archives on every pull request has a longer failing job than 28 minutes, which pushes the drift cost up, and a repository whose jobs are dominated by simulator tests should price the 12 vCPU label at $0.16 per minute instead. Substitute your own job length and job count; the arithmetic above carries whatever you put in. Start from the label list on the macOS runner hub, and take the pipeline shape from iOS pipelines on GitHub Actions.

FAQ

What is the most reliable way to pin an Xcode version in GitHub Actions?

Pin the versioned runner label first, because the label decides which Xcode bundles exist on the machine, then set DEVELOPER_DIR at workflow level so every job selects the same bundle. Runtime selection through a version spec is the least reliable of the three, because it resolves against whatever the image carries on the day it runs. The job level mechanics of both forms are in how to select an Xcode version in a GitHub Actions job.

How do I stop an Xcode pin from breaking after a runner image update?

Add a guard step that fails the job when the pinned bundle path is missing and that compares xcodebuild -version against the string you expect. The job then fails in ten seconds with the installed bundles printed in the log, instead of failing forty minutes later with a compiler error that reads like a code problem.

How do I test a new Xcode version before moving every job to it?

Add one scheduled canary job that runs the same build against the candidate bundle path with continue-on-error: true. Let it run for a week, then move the workflow level DEVELOPER_DIR once the canary is green and once the DerivedData cache keys carry the toolchain identifier.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.