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
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 |
|
Yes |
Yes |
Yes |
Yes |
Yes |
|
Yes |
Yes |
Not applicable (tests run on the Mac itself) |
Yes |
Yes |
|
New in Xcode 27 |
New in Xcode 27 |
Not applicable |
New in Xcode 27 |
New in Xcode 27 |
|
Yes |
Yes |
Not applicable |
Yes |
Yes |
|
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.
See Apple Developer — Xcode Cloud, Apple Developer Documentation — Getting started with Xcode Cloud, Apple Developer — What’s included, Apple Developer — 25 hours of Xcode Cloud now included with the Apple Developer Program and Apple Developer Documentation — Reviewing Xcode Cloud usage data.
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:
-
Push the project to a Git repository (GitHub, GitLab, Bitbucket, or a self-hosted edition of them).
-
In Xcode, open Report navigator > Cloud and click Get Started. Pick the
FieldNotesproduct and the team. -
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.
-
Click Start First Build. Xcode creates a default workflow that builds
mainon every change with the latest macOS and Xcode, and savesxcshareddata/xcodecloud/manifest.jsonin the project. Commit that file. -
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.fieldnotesand 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 |
Branch Changes on |
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 |
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). |
See Apple Developer Documentation — Xcode Cloud workflow reference, Apple Developer Documentation — Configuring start conditions and Apple Developer Documentation — Configuring your Xcode Cloud workflow’s actions.
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-testingand thenxcodebuild test-without-buildingon 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
.p12is 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 |
|---|---|---|
|
After the repository is cloned, before anything is built |
Lint the sources on pull requests |
|
Before each |
(not used) |
|
After each |
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
See Apple Developer Documentation — Writing custom build scripts.
swift-format itself is described on Build and Tooling.
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 |
|---|---|
|
|
|
The workflow’s name ( |
|
Where the repository was cloned, and what started the build |
|
Only set for Pull Request Changes builds |
|
The action being run ( |
|
|
|
The archive and its exported, signed copies (archive action only) |
|
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
}
See Apple Developer Documentation — Configuring webhooks in Xcode Cloud and Apple Developer Documentation — Xcode Cloud webhook payload reference.
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" }
See Apple Developer Documentation — Creating API Keys for App Store Connect API and Apple Developer Documentation — Generating Tokens for API Requests.
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)
}
See Apple Developer Documentation — Generating Tokens for API Requests and Apple Developer Documentation — P256.Signing.PrivateKey.
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\"}}}}}"
See Apple Developer Documentation — List Builds, Apple Developer Documentation — Start a build and Apple Developer Documentation — Xcode Cloud Workflows and Builds.
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 |
|
Non-Apple work |
Only what |
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
testflightjob 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,simctlornotarytoolcommand 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 077and delete them in analwaysstep, 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
-
Apple Developer Documentation — Getting started with Xcode Cloud
-
Apple Developer Documentation — Distributing your Xcode Cloud builds through TestFlight
-
Apple Developer Documentation — Making dependencies available to Xcode Cloud
-
Apple Developer Documentation — Xcode Cloud workflow reference
-
Apple Developer Documentation — Configuring start conditions
-
Apple Developer Documentation — Configuring your Xcode Cloud workflow’s actions
-
Apple Developer Documentation — Configuring requirements for merging a pull request
-
Apple Developer Documentation — Setting the next build number for Xcode Cloud builds
-
Apple Developer Documentation — Writing custom build scripts
-
Apple Developer Documentation — Environment variable reference
-
Apple Developer Documentation — Sharing environment variables across Xcode Cloud workflows
-
Apple Developer Documentation — Including notes for testers with a beta release of your app
-
Apple Developer Documentation — Configuring webhooks in Xcode Cloud
-
Apple Developer Documentation — Xcode Cloud webhook payload reference
-
Apple Developer Documentation — Reviewing Xcode Cloud usage data
-
Apple Developer — 25 hours of Xcode Cloud now included with the Apple Developer Program
-
Apple Developer Documentation — Xcode command-line tool reference
-
Apple Developer Documentation — Running tests and interpreting results
-
Apple Developer Documentation — Distributing your app for beta testing and releases
-
Apple Developer Documentation — Customizing the notarization workflow
-
Apple Developer Documentation — Creating API Keys for App Store Connect API
-
Apple Developer Documentation — Generating Tokens for API Requests
-
Apple Developer Documentation — Xcode Cloud Workflows and Builds