CI/CD with Xcode Cloud and the Command Line

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Up to this page, every FieldNotes build ran on a developer’s Mac: someone pressed ⌘U to test, ⌘B to build and Product > Archive to ship. This page moves that work to machines that do it on every change. Two routes are shown side by side. Xcode Cloud is Apple’s CI/CD service, configured from Xcode and App Store Connect. The command line (xcodebuild, simctl, devicectl, notarytool and the App Store Connect API) is what any other CI system runs; the example here uses GitHub Actions macOS runners. Everything targets the 27 releases of iOS, iPadOS, macOS, watchOS and visionOS, built with Xcode 27 and Swift 6.4, and keeps the app’s deployment minimum of 26. Xcode Cloud has two canonical workflows for FieldNotes, PR Checks and Release to TestFlight; the other CI route is .github/workflows/ios.yml.

What CI builds and ships is covered elsewhere and linked, not repeated: the tests and the FieldNotes.xctestplan test plan are on Unit Testing with Swift Testing and UI Tests, Test Plans, Performance Tests and Coverage; signing identities and profiles are on Membership, Signing, Capabilities and Info.plist; the TestFlight and App Review steps that follow an upload are on App Store Connect, TestFlight and App Review; and the Mac direct-download release is on Distributing Mac Apps with Developer ID and Notarization. The same CI concepts for a React Native app, including fastlane and EAS, are on CI/CD and Over-the-Air Updates.

The Shape of the FieldNotes Pipeline

Every change reaches main through a pull request. The PR Checks workflow builds and tests the pull request on each push. After the merge, Release to TestFlight repeats the tests, archives the app and hands the build to TestFlight. When a build finishes, a webhook tells the team’s dashboard.

Figure: From a push to TestFlight

flowchart LR P["Push to a branch or
pull request into main"] --> B subgraph W["Xcode Cloud workflow"] direction LR B["Build
xcodebuild build-for-testing"] --> M{"Test matrix
FieldNotes.xctestplan"} M --> T1["iPhone 17
iOS 27"] M --> T2["iPad Pro 13-inch (M5)
iPadOS 27"] M --> T3["Mac
macOS 27"] M --> T4["Apple Vision Pro
visionOS 27"] T1 & T2 & T3 & T4 --> A["Archive
TestFlight and App Store"] end A --> TF["Post-action: TestFlight
internal testers"] TF --> H["Webhook BUILD_COMPLETED
to the team dashboard"]

PR Checks stops after the test matrix: it has no archive action and no post-action, so it can’t reach TestFlight. Release to TestFlight runs the whole chain. A GitHub Actions workflow follows the same shape with jobs instead of actions, as shown near the end of the page.

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

Xcode Cloud build, analyze and archive actions

Yes

Yes

Yes

Yes

Yes

Xcode Cloud test destinations

Simulators

Simulators

The Mac build machine

Simulators

Simulators

TestFlight post-action

Yes

Yes

Yes

Yes, through the iPhone’s TestFlight app

Yes

xcodebuild build, test, archive, -exportArchive

Yes

Yes

Yes

Yes

Yes

simctl (Simulator control)

Yes

Yes

Not applicable (tests run on the Mac itself)

Yes

Yes

simctl reboot

New in Xcode 27

New in Xcode 27

Not applicable

New in Xcode 27

New in Xcode 27

devicectl (physical devices)

Yes

Yes

Not applicable

Yes

Yes

notarytool

Not available

Not available

Yes

Not available

Not available

GitHub Actions macOS runners (build and simulator tests)

Yes

Yes

Yes

Yes

Yes

Every row runs on a Mac: Xcode Cloud uses Apple’s macOS virtual machines, and other CI services need macOS runners, because Xcode only runs on macOS. Xcode 27 requires macOS Tahoe 26.6 or later on the build machine.

Xcode Cloud

Xcode Cloud builds, tests and distributes an app on Apple’s infrastructure. It clones the repository into a temporary virtual machine for each build and deletes it afterwards; Apple does not store the source code. It is configured in Xcode’s Report navigator > Cloud tab and in the Xcode Cloud tab of App Store Connect. The unit of configuration is a workflow: its start conditions, environment, actions and post-actions.

Membership and compute hours

Xcode Cloud needs a paid Apple Developer Program membership. Apple’s official pages say so in several places (checked on 2026-09-24): the Xcode Cloud overview page (Xcode Cloud requires Apple Developer Program membership), the Before you begin list in Getting started with Xcode Cloud (Join the Apple Developer Program), and the membership’s What’s included page.

Some secondary summaries of WWDC26 said that Xcode 27 lets people build in Xcode Cloud without a membership. None of Apple’s pages says that. What Apple does describe for Xcode 27 is a shorter onboarding: you connect the repository and start building and testing first, and create the App Store Connect app record later, from Xcode, when you set up distribution. This page follows the official pages. If Apple publishes a free tier, the requirement above changes and its date will be on the Xcode Cloud page.

Since January 2024, as announced on December 7, 2023, every membership includes 25 compute hours per month at no extra cost. A compute hour is one hour of work on Apple’s machines. Five 12-minute tests are one compute hour, and actions run in parallel, so a build’s wall-clock time is lower than the compute it uses. On 2026-09-24 the Xcode Cloud page listed monthly subscriptions of 100 hours (US$49.99), 250 hours (US$99.99), 1,000 hours (US$399.99) and 10,000 hours (US$3,999.99) for teams that need more. Usage per team and per app is in App Store Connect (Users and Access > Xcode Cloud) and in the Apple Developer app.

A FieldNotes build that tests on four destinations and archives uses a lot more compute than a single build. That is why PR Checks tests fewer destinations than Release to TestFlight (below): 25 hours go quickly if every push tests on every device.

Getting started in Xcode 27

Setting up takes a few steps. New in Xcode 27, the flow no longer asks for distribution details up front:

  1. Push the project to a Git repository (GitHub, GitLab, Bitbucket, or a self-hosted edition of them).

  2. In Xcode, open Report navigator > Cloud and click Get Started. Pick the FieldNotes product and the team.

  3. Grant Xcode Cloud access to the repository. Private Swift package dependencies on other hosts are granted the same way, and a repository split out later is added with Manage Repositories.

  4. Click Start First Build. Xcode creates a default workflow that builds main on every change with the latest macOS and Xcode, and saves xcshareddata/xcodecloud/manifest.json in the project. Commit that file.

  5. When the app is ready for testers, Control-click the product and choose Set Up Distribution. Xcode Cloud finds or creates the App Store Connect app record for com.example.fieldnotes and adds a workflow that distributes to internal TestFlight testers.

The first build fails quickly if a dependency can’t be resolved. Xcode Cloud never resolves Swift packages automatically: it uses the committed Package.resolved, so commit FieldNotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved and keep it out of .gitignore. The local packages FieldNotesModel, FieldNotesServices and FieldNotesUI live in the same repository and need nothing extra.

The two FieldNotes workflows

Workflows are edited in Xcode or App Store Connect, not in a file in the repository. The App Store Connect API returns them as ciWorkflows resources, and that JSON is the most compact way to show one. This is PR Checks, as GET /v1/ciWorkflows/{id} returns it (relationships to the product, repository, Xcode and macOS versions left out):

{
  "data": {
    "type": "ciWorkflows",
    "id": "00000000-0000-0000-0000-000000000000",
    "attributes": {
      "name": "PR Checks",
      "description": "Builds every platform and runs FieldNotes.xctestplan on each pull request into main.",
      "isEnabled": true,
      "clean": false,
      "containerFilePath": "FieldNotes.xcodeproj",
      "pullRequestStartCondition": {
        "source": { "isAllMatch": true },
        "destination": { "isAllMatch": false, "patterns": [{ "pattern": "main", "isPrefix": false }] },
        "filesAndFoldersRule": {
          "mode": "DO_NOT_START_IF_ALL_FILES_MATCH",
          "matchers": [{ "directory": "docs" }, { "fileExtension": "md" }]
        },
        "autoCancel": true
      },
      "actions": [
        {
          "name": "Build watchOS",
          "actionType": "BUILD",
          "platform": "WATCHOS",
          "scheme": "FieldNotesWatch",
          "destination": "ANY_WATCHOS_SIMULATOR",
          "isRequiredToPass": true
        },
        {
          "name": "Test iOS",
          "actionType": "TEST",
          "platform": "IOS",
          "scheme": "FieldNotes",
          "isRequiredToPass": true,
          "testConfiguration": {
            "kind": "SPECIFIC_TEST_PLANS",
            "testPlanName": "FieldNotes",
            "testDestinations": [
              {
                "kind": "SIMULATOR",
                "deviceTypeName": "iPhone 17",
                "deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-17",
                "runtimeName": "iOS 27.0",
                "runtimeIdentifier": "com.apple.CoreSimulator.SimRuntime.iOS-27-0"
              }
            ]
          }
        },
        {
          "name": "Test macOS",
          "actionType": "TEST",
          "platform": "MACOS",
          "scheme": "FieldNotes",
          "isRequiredToPass": true,
          "testConfiguration": {
            "kind": "SPECIFIC_TEST_PLANS",
            "testPlanName": "FieldNotes",
            "testDestinations": [{ "kind": "MAC", "deviceTypeName": "Mac" }]
          }
        }
      ]
    }
  }
}

See Apple Developer Documentation — CiWorkflow and Apple Developer Documentation — CiAction. The identifiers are placeholders. Real device-type and runtime identifiers come from the Xcode version the workflow uses, which the API returns as a ciXcodeVersions resource.

The two workflows differ on purpose:

Setting PR Checks Release to TestFlight

Start conditions

Pull Request Changes, target branch main, skipping pull requests that only touch docs/ or Markdown. Auto-cancel Builds on.

Branch Changes on main, plus Manual Start for re-runs. A second copy on On a Schedule (Fridays) for the weekly external build.

Environment

Latest release of Xcode 27 and macOS; incremental builds (cached derived data).

Same Xcode; Clean turned on for the scheduled external build, which requires a clean build.

Actions

Build for watchOS; Test with FieldNotes.xctestplan on iPhone 17 and Mac. The Sanitizers configuration runs only here.

Test on iPhone 17, iPad Pro 13-inch (M5), Mac and Apple Vision Pro; Archive for iOS, macOS and visionOS with Deployment Preparation set to TestFlight and App Store.

Post-actions

Notify the author on failure.

TestFlight Internal Testing to the FieldNotes Team group; the scheduled copy adds TestFlight External Testing (subject to beta app review).

A few details of these settings are worth knowing:

  • Start conditions are Branch Changes, Pull Request Changes, Tag Changes, On a Schedule and Manual Start. Each can be limited to branch or tag prefixes and to changes in certain files or folders. For a pull request, Xcode Cloud checks out both branches and merges them in the build machine, so the build tests the merge result. Adding [ci skip] to the latest commit message skips the build. The Required to pass result can block merging: see Configuring requirements for merging a pull request.

  • Actions match scheme actions: Build, Test, Analyze and Archive. A test action runs in two phases, xcodebuild build-for-testing and then xcodebuild test-without-building on every destination in parallel, and the source code is not available during the second phase. More destinations cost more compute hours.

  • Archive’s Deployment Preparation decides how the archive is signed: None, TestFlight (Internal Testing Only) or TestFlight and App Store. Xcode Cloud signs with cloud-managed certificates, so no .p12 is involved.

  • Post-actions send notifications (email, Slack) and distribute: TestFlight Internal Testing, TestFlight External Testing or App Store Connect upload for later submission. External testing needs a clean build.

  • Build numbers start at 1 and increase with each build. A Mac app’s build number must keep increasing across versions, so FieldNotes, which already shipped build 42, sets Next Build Number to 43 in App Store Connect (Xcode Cloud > Settings > Build Number) before its first cloud build.

Caching between builds

Xcode Cloud keeps each workflow’s derived data and other cached data between builds, so an incremental build of FieldNotes recompiles only what changed. Clean turns that off for one workflow; use it only where it’s required (external TestFlight distribution) or when you suspect a stale cache, because a clean build takes much longer and uses more compute. There is nothing to configure for Swift packages beyond committing Package.resolved.

Custom build scripts: ci_scripts/

Some tasks don’t fit a workflow setting. Xcode Cloud runs three optional shell scripts from a ci_scripts folder next to FieldNotes.xcodeproj:

Script Runs FieldNotes uses it to

ci_post_clone.sh

After the repository is cloned, before anything is built

Lint the sources on pull requests

ci_pre_xcodebuild.sh

Before each xcodebuild command

(not used)

ci_post_xcodebuild.sh

After each xcodebuild command, even when it failed

Write TestFlight notes; notarize the Developer ID copy of the Mac build

Each script must be executable (chmod +x), start with a shebang (without one, Xcode Cloud runs it with zsh), exit non-zero on failure, and never call sudo. Files that a script creates are deleted after the build and aren’t included in its artifacts. Helper scripts can live in ci_scripts/ too, but shouldn’t start with ci_.

#!/bin/zsh
# ci_scripts/ci_post_clone.sh -- runs once per action, after the clone.
set -euo pipefail

# Only pull requests are linted; the release workflow trusts what was merged.
if [[ -n "${CI_PULL_REQUEST_NUMBER:-}" ]]; then
  echo "Linting pull request #$CI_PULL_REQUEST_NUMBER from $CI_PULL_REQUEST_SOURCE_BRANCH"
  cd "$CI_PRIMARY_REPOSITORY_PATH"
  xcrun swift-format lint --strict --recursive FieldNotes Packages
fi

The post-xcodebuild script writes TestFlight’s What to Test notes from the last three commit messages, which Xcode Cloud picks up from a TestFlight/WhatToTest.<locale>.txt file next to the project. For the Mac archive, it also notarizes the Developer ID-signed copy that Xcode Cloud exports:

#!/bin/zsh
# ci_scripts/ci_post_xcodebuild.sh -- runs after every xcodebuild command.
set -euo pipefail

# Nothing to do if xcodebuild failed; Xcode Cloud already reports the failure.
[[ "${CI_XCODEBUILD_EXIT_CODE:-1}" == "0" ]] || exit 0

# 1. TestFlight notes for builds that can go to TestFlight.
if [[ -d "${CI_APP_STORE_SIGNED_APP_PATH:-}" ]]; then
  cd "$CI_PRIMARY_REPOSITORY_PATH"
  mkdir -p TestFlight
  git fetch --deepen 3
  git log -3 --pretty=format:"- %s" > TestFlight/WhatToTest.en-US.txt
fi

# 2. Notarize the Developer ID export of the Mac archive with an App Store Connect API key
#    kept in secret workflow variables (never echo them).
if [[ "${CI_PRODUCT_PLATFORM:-}" == "macOS" && -d "${CI_DEVELOPER_ID_SIGNED_APP_PATH:-}" ]]; then
  # A private (0700) scratch directory; the trap removes exactly it, key and zip included.
  work_dir=$(mktemp -d "${TMPDIR:-/tmp}/fieldnotes-notary.XXXXXX")
  trap 'rm -rf "$work_dir"' EXIT
  key_file="$work_dir/AuthKey_${FIELDNOTES_ASC_KEY_ID}.p8"
  (umask 077 && print -r -- "$FIELDNOTES_ASC_PRIVATE_KEY" > "$key_file")

  zip_path="$work_dir/FieldNotes.zip"
  ditto -c -k --keepParent "$CI_DEVELOPER_ID_SIGNED_APP_PATH/FieldNotes.app" "$zip_path"
  xcrun notarytool submit "$zip_path" --wait \
    --key "$key_file" --key-id "$FIELDNOTES_ASC_KEY_ID" --issuer "$FIELDNOTES_ASC_ISSUER_ID"
fi

See Apple Developer Documentation — Including notes for testers with a beta release of your app and Apple Developer Documentation — Environment variable reference. Notarization needs the Developer ID setup described on Distributing Mac Apps with Developer ID and Notarization and an App Store Connect API key. Because Xcode Cloud deletes files that scripts create, a real release job also staples the app and uploads the DMG to the download server in this script; that upload depends on your hosting and isn’t shown.

Environment variables

Xcode Cloud passes the build’s context to scripts as environment variables. The ones FieldNotes’s scripts use:

Variable Value

CI_XCODE_CLOUD

TRUE when the script runs in Xcode Cloud (useful in scripts that also run locally)

CI_WORKFLOW, CI_BUILD_NUMBER, CI_BUILD_URL

The workflow’s name (PR Checks), the build number, a link to the build in App Store Connect

CI_PRIMARY_REPOSITORY_PATH, CI_BRANCH, CI_TAG, CI_COMMIT

Where the repository was cloned, and what started the build

CI_PULL_REQUEST_NUMBER, CI_PULL_REQUEST_SOURCE_BRANCH, CI_PULL_REQUEST_TARGET_BRANCH

Only set for Pull Request Changes builds

CI_XCODEBUILD_ACTION, CI_XCODEBUILD_EXIT_CODE

The action being run (build-for-testing, archive, …​) and its result (in the post-xcodebuild script)

CI_PRODUCT_PLATFORM

iOS, macOS, watchOS, visionOS or tvOS

CI_ARCHIVE_PATH, CI_APP_STORE_SIGNED_APP_PATH, CI_DEVELOPER_ID_SIGNED_APP_PATH

The archive and its exported, signed copies (archive action only)

CI_TEST_PLAN, CI_RESULT_BUNDLE_PATH

The test plan and result bundle of a test action

Custom variables are set in each workflow’s Environment section, or shared across workflows in App Store Connect. FieldNotes defines FIELDNOTES_ASC_KEY_ID, FIELDNOTES_ASC_ISSUER_ID and FIELDNOTES_ASC_PRIVATE_KEY, with the last one marked Secret (Keep value redacted in Xcode) so its value is replaced by asterisks in logs. For test actions, Xcode Cloud passes every variable to xcodebuild with a TEST_RUNNER_ prefix, and xcodebuild strips the prefix again for the test runner, so a test reads a workflow variable by its plain name.

Webhooks

A webhook sends a JSON payload to an HTTPS endpoint when a build is created, when it starts and when it completes (BUILD_CREATED, BUILD_STARTED, BUILD_COMPLETED). A product can have up to five. In Xcode 27 they are managed from the Cloud navigator (Control-click the product, Manage Webhooks), where each webhook also shows its delivery history; App Store Connect has the same under Xcode Cloud > Settings > Webhooks. The endpoint must answer with a success status within 30 seconds, or Xcode Cloud retries.

FieldNotes’s dashboard service decodes only the fields it shows. The payload types below are page-local helpers for that service, not part of the app:

// Dashboard/XcodeCloudBuildEvent.swift -- the fields the FieldNotes dashboard reads from a webhook.
import Foundation

struct XcodeCloudBuildEvent: Decodable, Sendable {
    struct Metadata: Decodable, Sendable {
        struct Attributes: Decodable, Sendable { let eventType: String; let createdDate: String }
        let attributes: Attributes
    }
    struct Workflow: Decodable, Sendable {
        struct Attributes: Decodable, Sendable { let name: String }
        let id: String
        let attributes: Attributes
    }
    struct BuildRun: Decodable, Sendable {
        struct Attributes: Decodable, Sendable {
            let number: Int
            let executionProgress: String
            let completionStatus: String?      // absent until the build completes
            let isPullRequestBuild: Bool
        }
        let id: String
        let attributes: Attributes
    }

    let metadata: Metadata
    let ciWorkflow: Workflow
    let ciBuildRun: BuildRun
}

enum XcodeCloudWebhookError: Error { case unexpectedEvent(String) }

/// Decodes a webhook body. Throws on malformed JSON instead of guessing, so the endpoint can answer 400.
func decodeBuildEvent(_ body: Data) throws -> XcodeCloudBuildEvent {
    let event = try JSONDecoder().decode(XcodeCloudBuildEvent.self, from: body)
    let known: Set = ["BUILD_CREATED", "BUILD_STARTED", "BUILD_COMPLETED"]
    guard known.contains(event.metadata.attributes.eventType) else {
        throw XcodeCloudWebhookError.unexpectedEvent(event.metadata.attributes.eventType)
    }
    return event
}

The payload documentation describes no signature header. Treat the payload as a notification, not as proof: use a hard-to-guess URL path, and before doing anything consequential (closing an issue, merging a pull request), fetch the build from the App Store Connect API by its ciBuildRun.id and act on that answer. The API is the next section.

The payload reference also lists UNRECOGNIZED as an event type, which Apple uses for types added later. The function above rejects it on purpose, so the dashboard logs it instead of silently mis-counting builds.

The Command Line

Everything Xcode Cloud does is xcodebuild underneath, and any other CI system runs the same commands. They are also what a developer runs to reproduce a CI failure. Installing Xcode and its platforms on a fresh machine (xcode-select, xcodebuild -runFirstLaunch, xcodebuild -downloadPlatform) is on Getting Started with Apple Platforms, and the scheme actions behind these commands are on Xcode Projects, Targets, Schemes and Previews.

Resolving packages and caching them

A CI job should use exactly the versions in Package.resolved and never update them on its own. It should also put the package checkouts in a known folder, so the CI system can cache that folder between runs:

# Resolve once, into a folder the CI cache can save and restore (keyed on Package.resolved).
xcodebuild -resolvePackageDependencies \
  -project FieldNotes.xcodeproj -scheme FieldNotes \
  -clonedSourcePackagesDirPath build/SourcePackages \
  -disableAutomaticPackageResolution

# Private packages over SSH, with the machine's own git configuration (proxies, URL rewrites).
xcodebuild -resolvePackageDependencies -project FieldNotes.xcodeproj -scheme FieldNotes \
  -clonedSourcePackagesDirPath build/SourcePackages -scmProvider system

See Apple Developer Documentation — Building Swift packages or apps that use them in continuous integration workflows. Every later xcodebuild command in the job passes the same -clonedSourcePackagesDirPath and -disableAutomaticPackageResolution. The package structure itself is on Swift Packages and Modularizing an App.

Building every destination

An iOS-only job doesn’t catch a compile error in the watch or visionOS code, which is exactly the kind of error #if os(...) branches hide. Build each generic destination once:

# Compile-check every platform FieldNotes ships on, without running anything.
for platform in iOS macOS visionOS; do
  xcodebuild build -project FieldNotes.xcodeproj -scheme FieldNotes \
    -destination "generic/platform=$platform" \
    -clonedSourcePackagesDirPath build/SourcePackages -disableAutomaticPackageResolution \
    CODE_SIGNING_ALLOWED=NO
done
xcodebuild build -project FieldNotes.xcodeproj -scheme FieldNotesWatch \
  -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO

# Which destinations does a scheme support on this machine?
xcodebuild -showdestinations -project FieldNotes.xcodeproj -scheme FieldNotes

See Apple Developer Documentation — Build system. CODE_SIGNING_ALLOWED=NO skips signing, which a compile check doesn’t need; archives must be signed.

Testing with the test plan

xcodebuild test runs FieldNotes.xctestplan from UI Tests, Test Plans, Performance Tests and Coverage. Several -destination options run the same tests on several simulators at once, and a result bundle keeps everything (logs, attachments, coverage) for later:

# The Default configuration on an iPhone and an iPad simulator, in parallel.
xcodebuild test -project FieldNotes.xcodeproj -scheme FieldNotes \
  -testPlan FieldNotes -only-test-configuration Default \
  -destination 'platform=iOS Simulator,name=iPhone 17,OS=27.0' \
  -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M5),OS=27.0' \
  -resultBundlePath build/FieldNotes-iOS.xcresult

# Build once, then test the same products elsewhere (this is what Xcode Cloud's two phases do).
xcodebuild build-for-testing -project FieldNotes.xcodeproj -scheme FieldNotes -testPlan FieldNotes \
  -destination 'generic/platform=iOS Simulator' -derivedDataPath build/DerivedData
xcodebuild test-without-building -xctestrun build/DerivedData/Build/Products/FieldNotes_FieldNotes_iphonesimulator27.0-arm64.xctestrun \
  -destination 'platform=iOS Simulator,name=iPhone 17,OS=27.0'

# A readable summary of the result bundle for the CI log.
xcrun xcresulttool get test-results summary --path build/FieldNotes-iOS.xcresult

See Apple Developer Documentation — Running tests and interpreting results. The .xctestrun file name depends on the scheme, test plan, SDK and architecture; list build/DerivedData/Build/Products/ to find it.

Archiving and exporting

An archive is the Release build plus its dSYMs. -exportArchive signs it for a distribution method and, with destination set to upload, sends it to App Store Connect. On CI, automatic signing uses an App Store Connect API key instead of an Xcode account, and cloud-managed certificates, so the machine never holds a distribution private key:

<?xml version="1.0" encoding="UTF-8"?>
<!-- ExportOptions.plist; App Store Connect distribution (see App Store Connect, TestFlight and App Review) -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>app-store-connect</string>
    <key>teamID</key>
    <string>ABCDE12345</string>
    <key>destination</key>
    <string>upload</string>
</dict>
</plist>
# Archive with a CI build number; the key file comes from a CI secret (see GitHub Actions below).
xcodebuild archive -project FieldNotes.xcodeproj -scheme FieldNotes \
  -destination 'generic/platform=iOS' \
  -archivePath build/FieldNotes-iOS.xcarchive \
  -clonedSourcePackagesDirPath build/SourcePackages -disableAutomaticPackageResolution \
  -allowProvisioningUpdates \
  -authenticationKeyPath "$ASC_KEY_PATH" \
  -authenticationKeyID "$ASC_KEY_ID" \
  -authenticationKeyIssuerID "$ASC_ISSUER_ID" \
  CURRENT_PROJECT_VERSION="$BUILD_NUMBER"

# Sign for App Store Connect and upload in one step.
xcodebuild -exportArchive -archivePath build/FieldNotes-iOS.xcarchive \
  -exportOptionsPlist ExportOptions.plist -exportPath build/export-iOS \
  -allowProvisioningUpdates \
  -authenticationKeyPath "$ASC_KEY_PATH" \
  -authenticationKeyID "$ASC_KEY_ID" \
  -authenticationKeyIssuerID "$ASC_ISSUER_ID"

See Apple Developer Documentation — Distributing your app for beta testing and releases and Apple Developer Documentation — Customizing the notarization workflow. Needs a paid membership and an API key with the Admin or App Manager role (automatic signing creates profiles). Run xcodebuild -help for the complete list of export-options keys and authentication flags.

Since April 28, 2026, apps uploaded to App Store Connect must be built with Xcode 26 or later against a 26 SDK. Xcode 27 meets that, but a CI image that still defaults to an older Xcode would be rejected at upload.

The Mac direct download adds three commands after the export: hdiutil, notarytool submit --wait and stapler staple. The full script is on Distributing Mac Apps with Developer ID and Notarization. On CI, notarytool takes the API key directly (--key, --key-id, --issuer), as in ci_post_xcodebuild.sh above, instead of the fieldnotes-notary keychain profile a developer’s Mac uses.

Simulators: simctl

A CI job that runs UI tests or captures screenshots gets more reliable results from a fresh, dedicated simulator with fixed settings than from whatever the image happens to have:

# A throwaway iPhone for this job, booted and ready before tests start.
udid=$(xcrun simctl create "FieldNotes CI" "iPhone 17" com.apple.CoreSimulator.SimRuntime.iOS-27-0)
# Always clean up, even when a later step fails. Shutting down an already shut-down simulator
# fails, so that step must not stop the delete.
trap 'xcrun simctl shutdown "$udid" || true; xcrun simctl delete "$udid"' EXIT
xcrun simctl boot "$udid"
xcrun simctl bootstatus "$udid"

# Deterministic screenshots: fixed status bar, location permission granted up front.
xcrun simctl status_bar "$udid" override --time "9:41" --batteryState charged --batteryLevel 100
xcrun simctl privacy "$udid" grant location com.example.fieldnotes

# Install the test build, launch it the way UI tests do, open a deep link and capture the screen.
xcrun simctl install "$udid" build/DerivedData/Build/Products/Debug-iphonesimulator/FieldNotes.app
xcrun simctl launch "$udid" com.example.fieldnotes --uitesting
xcrun simctl openurl "$udid" "fieldnotes://note/3F2504E0-4F89-11D3-9A0C-0305E82C3301"
xcrun simctl io "$udid" screenshot build/screens/note-detail.png

# New in Xcode 27: reboot a simulator without deleting it (for example after changing a setting).
xcrun simctl reboot "$udid"

See Apple Developer Documentation — Xcode command-line tool reference and Apple Developer Documentation — Xcode 27 Release Notes. Run the block as one script (one CI step) so its EXIT trap deletes the simulator however the step ends; the || true keeps a failed shutdown (for example, a simulator that is already shut down) from skipping the delete. Run xcrun simctl help for every subcommand. --uitesting is FieldNotes’s launch argument that makes AppServices use an in-memory store, as described on UI Tests, Test Plans, Performance Tests and Coverage.

Physical devices: devicectl

Simulators don’t have the camera, most sensors or real performance. A self-hosted Mac with devices attached (a small device lab) runs the same tests on hardware. devicectl installs and launches apps, and xcodebuild tests against a device by its identifier:

# Devices this Mac can use. In Xcode 27 the list shows a UDID (or ECID, or CoreDevice ID) per device.
xcrun devicectl list devices
xcrun devicectl list devices --json-output build/devices.json

# Install and relaunch the development build on one device.
xcrun devicectl device install app --device "$DEVICE_ID" \
  build/DerivedData/Build/Products/Debug-iphoneos/FieldNotes.app
xcrun devicectl device process launch --device "$DEVICE_ID" --terminate-existing com.example.fieldnotes

# Run the test plan on the device.
xcodebuild test -project FieldNotes.xcodeproj -scheme FieldNotes -testPlan FieldNotes \
  -destination "platform=iOS,id=$DEVICE_ID" -allowProvisioningUpdates

See Apple Developer Documentation — Xcode command-line tool reference and Apple Developer Documentation — Xcode 27 Release Notes. Needs a paired device with Developer Mode on and a development provisioning profile that includes it; Device Hub and pairing are on Getting Started with Apple Platforms.

Scripts that read devicectl JSON should use the properties dictionary. As of JSON version 5 (Xcode 27), hardwareProperties, deviceProperties and connectionProperties are deprecated and each result carries a _deprecationNotice field listing them.

The App Store Connect API

The App Store Connect API is a REST API for everything App Store Connect does: builds, TestFlight groups and testers, app metadata, and Xcode Cloud itself (products, workflows, build runs, artifacts). CI systems use it to upload and distribute builds, and teams use it to start Xcode Cloud builds from other tools.

Access starts with an API key. An Admin creates a team key in Users and Access > Integrations > App Store Connect API and gives it a role; the private key (AuthKey_<key ID>.p8) can be downloaded once. Store it as a CI secret and never commit it. Each request carries a short-lived JSON Web Token signed with that key:

{ "alg": "ES256", "kid": "YOUR_KEY_ID", "typ": "JWT" }

{ "iss": "YOUR_ISSUER_UUID", "iat": 1790000000, "exp": 1790001200, "aud": "appstoreconnect-v1" }

The header names the key; the payload names the issuer (your team’s issuer ID) and an expiry at most 20 minutes ahead. A small Swift script signs it with CryptoKit, so CI needs no third-party JWT tool:

#!/usr/bin/env swift
// Scripts/asc-token.swift -- prints an App Store Connect API token valid for 20 minutes.
import CryptoKit
import Foundation

func base64URL(_ data: Data) -> String {
    data.base64EncodedString()
        .replacingOccurrences(of: "+", with: "-")
        .replacingOccurrences(of: "/", with: "_")
        .replacingOccurrences(of: "=", with: "")
}

let environment = ProcessInfo.processInfo.environment
guard let keyID = environment["ASC_KEY_ID"],
      let issuerID = environment["ASC_ISSUER_ID"],
      let keyPath = environment["ASC_KEY_PATH"] else {
    FileHandle.standardError.write(Data("Set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_PATH.\n".utf8))
    exit(64)
}

do {
    let key = try P256.Signing.PrivateKey(pemRepresentation: String(contentsOfFile: keyPath, encoding: .utf8))
    let now = Int(Date().timeIntervalSince1970)
    let header: [String: Any] = ["alg": "ES256", "kid": keyID, "typ": "JWT"]
    let claims: [String: Any] = ["iss": issuerID, "iat": now, "exp": now + 20 * 60, "aud": "appstoreconnect-v1"]
    let signingInput = base64URL(try JSONSerialization.data(withJSONObject: header)) + "."
        + base64URL(try JSONSerialization.data(withJSONObject: claims))
    // JWT's ES256 signature is the raw 64-byte r || s value, which is CryptoKit's rawRepresentation.
    let signature = try key.signature(for: Data(signingInput.utf8))
    print(signingInput + "." + base64URL(signature.rawRepresentation))
} catch {
    FileHandle.standardError.write(Data("Could not create the token: \(error)\n".utf8))
    exit(1)
}

With a token, the API is plain HTTPS. Two calls FieldNotes’s tooling uses: listing recent builds, and starting the Release to TestFlight workflow from a chat command:

TOKEN=$(swift Scripts/asc-token.swift)

# The five most recently uploaded builds (APP_ID is the app's Apple ID in App Store Connect).
curl -sS -g -H "Authorization: Bearer $TOKEN" \
  "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=$APP_ID&sort=-uploadedDate&limit=5"

# Start a build of a workflow (WORKFLOW_ID from GET /v1/ciProducts/{id}/workflows).
curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  https://api.appstoreconnect.apple.com/v1/ciBuildRuns \
  -d "{\"data\":{\"type\":\"ciBuildRuns\",\"attributes\":{},\"relationships\":{\"workflow\":{\"data\":{\"type\":\"ciWorkflows\",\"id\":\"$WORKFLOW_ID\"}}}}}"

The webhook check from the previous section is one more call: Apple Developer Documentation -- Read Build Run Information (GET /v1/ciBuildRuns/{id}) returns the run’s real status, whoever sent the webhook.

GitHub Actions and Other CI Services

Any CI service with macOS machines can run the commands above: GitHub Actions, GitLab, Bitrise, CircleCI, Jenkins on a Mac mini. The choice against Xcode Cloud is mostly about what else the pipeline does:

Xcode Cloud GitHub Actions (or another CI service)

Setup

A few clicks in Xcode; no YAML, no machines

A workflow file in the repository; you pick the runner image and the Xcode version

Signing

Automatic, with cloud-managed certificates

An App Store Connect API key (or imported certificates) stored as secrets

TestFlight

A post-action

xcodebuild -exportArchive with destination upload, or fastlane

Non-Apple work

Only what ci_scripts can do

Anything: the backend, the website and the app in one pipeline

Cost

25 compute hours a month with the membership, then subscriptions

The service’s macOS minutes, typically several times the price of Linux minutes

The FieldNotes workflow on GitHub Actions

GitHub’s hosted runner labels change with each Xcode release. On 2026-09-24 GitHub’s runner-images README listed macos-26 (default Xcode 26.6) as generally available and an xcode-27 image (macOS 27 with Xcode 27.0 and the iOS, watchOS and visionOS 27 simulators) as a public preview. FieldNotes needs the Xcode 27 SDKs, so it uses xcode-27 and pins the Xcode path so a later default change can’t surprise it:

# .github/workflows/ios.yml
name: FieldNotes

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

# A new push to the same branch cancels the run in flight: macOS minutes are expensive.
concurrency:
  group: fieldnotes-${{ github.ref }}
  cancel-in-progress: true

env:
  DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer
  SPM_DIR: build/SourcePackages

jobs:
  test:
    name: Test (${{ matrix.name }})
    runs-on: xcode-27
    timeout-minutes: 45
    strategy:
      fail-fast: false
      matrix:
        include:
          - name: iPhone
            destination: platform=iOS Simulator,name=iPhone 17,OS=27.0
          - name: iPad
            destination: platform=iOS Simulator,name=iPad Pro 13-inch (M5),OS=27.0
          - name: Mac
            destination: platform=macOS
          - name: Vision
            destination: platform=visionOS Simulator,name=Apple Vision Pro,OS=27.0
    steps:
      - uses: actions/checkout@v7

      - name: Cache Swift packages
        uses: actions/cache@v6
        with:
          path: ${{ env.SPM_DIR }}
          key: spm-${{ runner.os }}-${{ hashFiles('FieldNotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
          restore-keys: spm-${{ runner.os }}-

      - name: Resolve packages
        run: |
          xcodebuild -version
          xcodebuild -resolvePackageDependencies -project FieldNotes.xcodeproj -scheme FieldNotes \
            -clonedSourcePackagesDirPath "$SPM_DIR" -disableAutomaticPackageResolution

      - name: Test
        run: |
          xcodebuild test -project FieldNotes.xcodeproj -scheme FieldNotes -testPlan FieldNotes \
            -destination '${{ matrix.destination }}' \
            -clonedSourcePackagesDirPath "$SPM_DIR" -disableAutomaticPackageResolution \
            -resultBundlePath "build/FieldNotes-${{ matrix.name }}.xcresult"

      - name: Keep the result bundle
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: xcresult-${{ matrix.name }}
          path: build/FieldNotes-${{ matrix.name }}.xcresult

  testflight:
    name: Upload to TestFlight
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: xcode-27
    environment: release          # secrets below are scoped to this environment
    env:
      ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
      ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
    steps:
      - uses: actions/checkout@v7

      - name: Write the App Store Connect key
        env:
          ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
        run: |
          umask 077
          printf '%s' "$ASC_PRIVATE_KEY" > "$RUNNER_TEMP/AuthKey.p8"
          echo "ASC_KEY_PATH=$RUNNER_TEMP/AuthKey.p8" >> "$GITHUB_ENV"

      - name: Archive and upload
        run: |
          BUILD_NUMBER=$((1000 + GITHUB_RUN_NUMBER))
          xcodebuild archive -project FieldNotes.xcodeproj -scheme FieldNotes \
            -destination 'generic/platform=iOS' -archivePath build/FieldNotes-iOS.xcarchive \
            -allowProvisioningUpdates -authenticationKeyPath "$ASC_KEY_PATH" \
            -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID" \
            CURRENT_PROJECT_VERSION="$BUILD_NUMBER"
          xcodebuild -exportArchive -archivePath build/FieldNotes-iOS.xcarchive \
            -exportOptionsPlist ExportOptions.plist -exportPath build/export-iOS \
            -allowProvisioningUpdates -authenticationKeyPath "$ASC_KEY_PATH" \
            -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID"

      - name: Remove the key
        if: always()
        run: rm -f "$RUNNER_TEMP/AuthKey.p8"

See GitHub — actions/runner-images README and Apple Developer Documentation — Building Swift packages or apps that use them in continuous integration workflows. Needs a paid membership and the three ASC_* secrets in a GitHub environment named release. The runner image and the Xcode_27.0.app path are GitHub’s naming, not Apple’s; check the runner-images README when a new Xcode ships.

Three choices in this file are deliberate:

  • The test matrix is a job matrix, so a visionOS failure doesn’t hide an iPad result, and each result bundle is kept even when its job fails.

  • The cache key is Package.resolved, as in the React Native page’s advice on lockfile keys: an unchanged dependency set restores the checkouts, and a changed one starts fresh. Derived data isn’t cached here; on hosted runners restoring it rarely beats rebuilding.

  • Only one system uploads. FieldNotes ships to TestFlight from Xcode Cloud. The testflight job is there for teams that don’t use Xcode Cloud. Running both would upload two builds per merge with clashing build numbers, which is why this job starts its numbers at 1000.

fastlane

fastlane is a popular open-source Ruby toolchain that wraps these same tools: match for shared signing assets, gym for xcodebuild archive and -exportArchive, scan for tests and pilot for TestFlight. It is not an Apple tool and this section doesn’t use it: everything above works with Xcode alone. How fastlane fits a pipeline, with the lanes a React Native app uses, is on CI/CD and Over-the-Air Updates.

Practical Guidelines

  • Make CI reproducible locally. Every CI step is an xcodebuild, simctl or notarytool command a developer can paste into Terminal. Keep them in scripts in the repository, not only in a web UI.

  • Pin Xcode and commit Package.resolved. Most "works on my Mac" failures are a different Xcode or a package that resolved to a newer version.

  • Test few destinations on pull requests, all of them after merging. Compute hours and macOS minutes are the budget; spend them where a failure is still cheap to fix.

  • Keep secrets out of logs. Mark Xcode Cloud variables as secret, write key files with umask 077 and delete them in an always step, and never print tokens. Use API keys with the smallest role that works.

  • Build every platform on every pull request. A watch or visionOS compile error found a week later costs more than the few minutes a generic build takes.

  • Treat webhooks as hints. Verify through the App Store Connect API before acting on one.

  • Re-read the dated rules. SDK minimums, included compute hours, prices and runner images change; each one on this page carries the date it was checked and its source.

References