Xcode Cloud Single-Track CI
When to invoke
- Starting a new Apple-platform project and setting up CI.
- Deciding between GitHub Actions and Xcode Cloud.
- Writing
ci_scripts/ci_post_clone.sh / ci_pre_xcodebuild.sh / ci_post_xcodebuild.sh.
- Bumping the Xcode version and handling snapshot baselines.
- User asks "how to split PR / Main / Release workflow", "auto-upload to TestFlight or not".
Default decisions
Single-track on Xcode Cloud
- GitHub Actions is not enabled yet — wait for real pain (PR metadata rules, external lint jobs, Selective Testing, etc.) to appear.
- The repo is hosted on GitHub, but CI runs on Xcode Cloud.
4 workflows
| Workflow |
Trigger |
Action |
| PR CI |
PR open / push (enable "Merge with base branch before building") |
Build + Test (unit / integration with fakes / snapshot) |
| Main CI |
Merge to main |
Build + Archive + upload to internal TestFlight; do not re-run tests (already verified by PR CI in pre-merged state) |
| Release |
git tag v* |
Build + upload to App Store Connect (manual submission for review) |
| Periodic / Manual |
Scheduled + manual trigger |
Project-specific batch jobs (nightly export, metadata updates, etc.) |
Scheduling granularity caveat: Xcode Cloud's "On a Schedule" start condition supports hourly / daily / weekly granularity only — arbitrary cron expressions are not supported. For monthly-or-longer cadence, schedule weekly and add a script-side date guard inside ci_post_clone.sh that early-exits when the date doesn't match the desired condition.
Environment lock
- Xcode version in the workflow matches the README /
foundations.md toolchain line.
- When bumping Xcode, open a dedicated PR to refresh snapshot baselines.
- Xcode Cloud's build environment does not include mise — Apple documents it as including only Homebrew among third-party tools. Commit a bootstrapped
bin/mise wrapper (mise generate bootstrap -l -w bin/mise, from mise-tool-management) and call every tool inside ci_scripts/ through it (./bin/mise trust, ./bin/mise install, ./bin/mise exec -- <tool> <args>) instead of a bare mise invocation, which fails with "command not found".
- Test environment disables iCloud / Game Center sign-in; all tests go through protocol fakes.
Build number & version automation
- Two build settings, two jobs:
MARKETING_VERSION (→ CFBundleShortVersionString, the SemVer-style version users see) is bumped deliberately per release; CURRENT_PROJECT_VERSION (→ CFBundleVersion, the build number) increments on every build, including PR builds.
- Xcode Cloud manages the build number for you — it assigns a sequential integer per build starting at
1, independent of whatever CURRENT_PROJECT_VERSION says in the repo, and exposes it to ci_scripts/ as CI_BUILD_NUMBER. For a new app this is enough: e.g. 1.2.2 (1) is a valid, unique version+build pair even after a prior manually-numbered 1.2.1 (42).
- Exception — existing Mac apps: macOS requires the build number to strictly increase across versions too, not just be unique within one, so restarting Xcode Cloud's counter at
1 can collide with a prior higher build number. Fix once, on the ASC side: app → Xcode Cloud tab → Settings → Build Number tab → Edit → set the next build number above your last shipped one. This is an App Store Connect setting, not a ci_scripts/ variable.
- If something in the repo needs
CURRENT_PROJECT_VERSION itself to reflect Xcode Cloud's build number (e.g. crash-symbolication tooling that reads it from the binary), write it early in ci_post_clone.sh: agvtool new-version -all "$CI_BUILD_NUMBER" — requires VERSIONING_SYSTEM = apple-generic (agvtool enabled) on the target.
- Release tooling that mints
versionString for the ASC API (→ asc-api-automation) should read this project's MARKETING_VERSION rather than track a second version counter — one SemVer source of truth.
Three Xcode Cloud hooks
Apple provides:
ci_post_clone.sh — runs right after clone, before any build resources are spent (secret scan, the bin/mise bootstrap go here, cheapest stage)
ci_pre_xcodebuild.sh — before build
ci_post_xcodebuild.sh — after build
Minimal ci_post_clone.sh, using the committed bin/mise wrapper (see Environment lock above):
#!/bin/sh
set -eu
cd "$CI_PRIMARY_REPOSITORY_PATH"
./bin/mise trust
./bin/mise install
./bin/mise exec -- swiftlint lint
Rationale
- For solo / small teams, CI usage is light and Xcode Cloud's free quota is enough; dual-track adds ops cost with no matching value.
- PR CI with pre-merge fundamentally resolves the common "fails only after merge to main" race.
- Main CI skips re-running tests: PR already ran them in pre-merged state, so rerunning is waste; it archives and ships to TestFlight instead.
- Periodic workflow is built into Xcode Cloud (no separate cron service required).
Deviation considerations
When to add GitHub Actions
- PR metadata rules (conventional commits, PR title lint, auto-label / required reviewer)
- SwiftLint / SwiftFormat or other binary tools running on PR
docs/ link checks, meetings/ index auto-updates
- Wiring up Selective Testing
- Using
nektos/act to reproduce non-build jobs locally
Starting point: when one of the above real pain points appears, add a single workflow first, don't go dual-track in one shot.
Known race condition
When two PRs each pass pre-merge and merge back to back, their combined result was never tested.
- Solo projects rarely hit this.
- Multi-person teams who care: enable GitHub's "Require branches to be up to date before merging", or add minimal smoke tests to Main CI.
Verification checklist
- The Xcode version in the Xcode Cloud workflow matches the README /
foundations.md toolchain line.
- PR CI has "Merge with base branch before building" enabled.
bin/mise is committed; ci_post_clone.sh starts with ./bin/mise trust then ./bin/mise install, not a bare mise call.
- Periodic workflow trigger time is explicit (UTC recommended).
- Existing Mac apps: Xcode Cloud's next build number (App Store Connect → Xcode Cloud → Settings → Build Number) is set above the last shipped build number.
Related skills
mise-tool-management: ci_scripts/ tools installed via mise.
swift-testing-baseline: CI skips real-network integration tests.
apple-public-repo-security: PR CI adds a gitleaks step as the second line of defence.
apple-platform-targets: Xcode version lock.
asc-api-automation: release-side versionString and changelog automation, once the build exists in ASC — reuses this project's MARKETING_VERSION / CI_BUILD_NUMBER.
1---2name: xcode-cloud-single-track-ci3description: Default CI strategy for solo / small-team Apple-platform projects — Xcode Cloud single-track only (defer GitHub Actions until real pain appears), 4 workflow types (PR / Main / Release / Periodic), pre-merge main on PR CI, Xcode version locked to local. Invoke when setting up CI on a new project, deciding GitHub Actions vs Xcode Cloud, writing ci_scripts/, or when asked "how should CI be set up, do I need dual-track".4---56# Xcode Cloud Single-Track CI78## When to invoke910- Starting a new Apple-platform project and setting up CI.11- Deciding between GitHub Actions and Xcode Cloud.12- Writing `ci_scripts/ci_post_clone.sh` / `ci_pre_xcodebuild.sh` / `ci_post_xcodebuild.sh`.13- Bumping the Xcode version and handling snapshot baselines.14- User asks "how to split PR / Main / Release workflow", "auto-upload to TestFlight or not".1516## Default decisions1718### Single-track on Xcode Cloud1920- **GitHub Actions is not enabled yet** — wait for real pain (PR metadata rules, external lint jobs, Selective Testing, etc.) to appear.21- The repo is hosted on GitHub, but CI runs on Xcode Cloud.2223### 4 workflows2425| Workflow | Trigger | Action |26|---|---|---|27| **PR CI** | PR open / push (**enable "Merge with base branch before building"**) | Build + Test (unit / integration with fakes / snapshot) |28| **Main CI** | Merge to `main` | Build + Archive + upload to internal TestFlight; **do not re-run tests** (already verified by PR CI in pre-merged state) |29| **Release** | git tag `v*` | Build + upload to App Store Connect (manual submission for review) |30| **Periodic / Manual** | Scheduled + manual trigger | Project-specific batch jobs (nightly export, metadata updates, etc.) |3132> **Scheduling granularity caveat**: Xcode Cloud's "On a Schedule" start condition supports **hourly / daily / weekly** granularity only — arbitrary cron expressions are not supported. For monthly-or-longer cadence, schedule weekly and add a script-side date guard inside `ci_post_clone.sh` that early-exits when the date doesn't match the desired condition.3334### Environment lock3536- Xcode version in the workflow matches the README / `foundations.md` toolchain line.37- When bumping Xcode, open a dedicated PR to refresh snapshot baselines.38- **Xcode Cloud's build environment does not include mise** — Apple documents it as including only Homebrew among third-party tools. Commit a bootstrapped `bin/mise` wrapper (`mise generate bootstrap -l -w bin/mise`, from `mise-tool-management`) and call every tool inside `ci_scripts/` through it (`./bin/mise trust`, `./bin/mise install`, `./bin/mise exec -- <tool> <args>`) instead of a bare `mise` invocation, which fails with "command not found".39- Test environment disables iCloud / Game Center sign-in; all tests go through protocol fakes.4041### Build number & version automation4243- Two build settings, two jobs: `MARKETING_VERSION` (→ `CFBundleShortVersionString`, the SemVer-style version users see) is bumped deliberately per release; `CURRENT_PROJECT_VERSION` (→ `CFBundleVersion`, the build number) increments on every build, including PR builds.44- **Xcode Cloud manages the build number for you** — it assigns a sequential integer per build starting at `1`, independent of whatever `CURRENT_PROJECT_VERSION` says in the repo, and exposes it to `ci_scripts/` as `CI_BUILD_NUMBER`. For a new app this is enough: e.g. `1.2.2 (1)` is a valid, unique version+build pair even after a prior manually-numbered `1.2.1 (42)`.45- **Exception — existing Mac apps**: macOS requires the build number to strictly increase *across* versions too, not just be unique within one, so restarting Xcode Cloud's counter at `1` can collide with a prior higher build number. Fix once, on the ASC side: app → **Xcode Cloud** tab → **Settings** → **Build Number** tab → **Edit** → set the next build number above your last shipped one. This is an App Store Connect setting, not a `ci_scripts/` variable.46- If something in the repo needs `CURRENT_PROJECT_VERSION` itself to reflect Xcode Cloud's build number (e.g. crash-symbolication tooling that reads it from the binary), write it early in `ci_post_clone.sh`: `agvtool new-version -all "$CI_BUILD_NUMBER"` — requires `VERSIONING_SYSTEM = apple-generic` (agvtool enabled) on the target.47- Release tooling that mints `versionString` for the ASC API (→ `asc-api-automation`) should read this project's `MARKETING_VERSION` rather than track a second version counter — one SemVer source of truth.4849### Three Xcode Cloud hooks5051Apple provides:52- `ci_post_clone.sh` — runs right after clone, before any build resources are spent (**secret scan, the `bin/mise` bootstrap go here, cheapest stage**)53- `ci_pre_xcodebuild.sh` — before build54- `ci_post_xcodebuild.sh` — after build5556Minimal `ci_post_clone.sh`, using the committed `bin/mise` wrapper (see Environment lock above):5758```sh59#!/bin/sh60set -eu61cd "$CI_PRIMARY_REPOSITORY_PATH"62./bin/mise trust63./bin/mise install64./bin/mise exec -- swiftlint lint65```6667## Rationale6869- For solo / small teams, CI usage is light and Xcode Cloud's free quota is enough; dual-track adds ops cost with no matching value.70- PR CI with pre-merge fundamentally resolves the common "fails only after merge to main" race.71- Main CI skips re-running tests: PR already ran them in pre-merged state, so rerunning is waste; it archives and ships to TestFlight instead.72- Periodic workflow is built into Xcode Cloud (no separate cron service required).7374## Deviation considerations7576### When to add GitHub Actions7778- PR metadata rules (conventional commits, PR title lint, auto-label / required reviewer)79- SwiftLint / SwiftFormat or other binary tools running on PR80- `docs/` link checks, `meetings/` index auto-updates81- Wiring up Selective Testing82- Using `nektos/act` to reproduce non-build jobs locally8384Starting point: when one of the above real pain points appears, **add a single workflow first**, don't go dual-track in one shot.8586### Known race condition8788When two PRs each pass pre-merge and merge back to back, **their combined result was never tested**.89- Solo projects rarely hit this.90- Multi-person teams who care: enable GitHub's "Require branches to be up to date before merging", or add minimal smoke tests to Main CI.9192## Verification checklist9394- The Xcode version in the Xcode Cloud workflow matches the README / `foundations.md` toolchain line.95- PR CI has "Merge with base branch before building" enabled.96- `bin/mise` is committed; `ci_post_clone.sh` starts with `./bin/mise trust` then `./bin/mise install`, not a bare `mise` call.97- Periodic workflow trigger time is explicit (UTC recommended).98- Existing Mac apps: Xcode Cloud's next build number (App Store Connect → Xcode Cloud → Settings → Build Number) is set above the last shipped build number.99100## Related skills101102- `mise-tool-management`: `ci_scripts/` tools installed via mise.103- `swift-testing-baseline`: CI skips real-network integration tests.104- `apple-public-repo-security`: PR CI adds a gitleaks step as the second line of defence.105- `apple-platform-targets`: Xcode version lock.106- `asc-api-automation`: release-side `versionString` and changelog automation, once the build exists in ASC — reuses this project's `MARKETING_VERSION` / `CI_BUILD_NUMBER`.