Xcode Cloud CI/CD
When to Use
- Setting up automated build, test, and TestFlight distribution for the manga reader
- Running custom setup steps (secrets injection, dependency install, asset generation)
- Using CI_BUILD_NUMBER to auto-increment build numbers without a script
- Connecting App Store Connect workflows to GitHub branch/PR events
- Troubleshooting why a cloud build fails but local succeeds
Core Rules
- Custom scripts live in
ci_scripts/at the repo root — Xcode Cloud discovers them by name convention, not by configuration. - Script files must be executable:
chmod +x ci_scripts/ci_post_clone.shand committed with those permissions. CI_BUILD_NUMBERis auto-incremented per workflow — use it directly asCFBundleVersion; don't maintain your own counter.- Environment variables are set in the workflow settings in App Store Connect — never commit secrets to scripts.
- Xcode Cloud always does a clean clone — anything not in the repo must be fetched in
ci_post_clone.sh. - Swift packages are resolved automatically — no need to
swift package resolvein scripts unless using a private registry. - Test plans (
.xctestplan) are the canonical way to configure which tests run in cloud — more reliable than-testIdentifierflags. - Use
CI_DERIVED_DATA_PATHandCI_ARCHIVE_PATHfor paths to build artifacts in post-build scripts. - Xcode Cloud does NOT support macOS-only tools without Rosetta — stick to
xcodebuild,xcrun, standard Unix tools, and SPM packages. - If a workflow needs a secret (API key, token), use a custom environment variable in App Store Connect — scripts read it via
$MY_VAR.
Custom Script Timing
| Script name | When it runs | Common uses |
|---|---|---|
ci_post_clone.sh |
After repo clone, before build | Install tools, inject configs, set up env |
ci_pre_xcodebuild.sh |
Before xcodebuild runs | Bump version, generate code, patch plists |
ci_post_xcodebuild.sh |
After xcodebuild completes | Upload dSYMs, notify Slack, run post-analysis |
ci_post_clone.sh — Install Dependencies
#!/bin/zsh
# ci_scripts/ci_post_clone.sh
set -euo pipefail
echo "--- CI Post-Clone ---"
echo "Xcode Cloud build: $CI_BUILD_NUMBER"
echo "Branch: $CI_BRANCH"
echo "Workflow: $CI_WORKFLOW"
# Install Homebrew tools if needed (slow — cache if possible)
# Xcode Cloud runs on macOS; brew is pre-installed but many formulae aren't
if ! command -v swiftlint &> /dev/null; then
echo "Installing SwiftLint..."
brew install swiftlint
fi
# Inject a config file from an env variable (secret stored in ASC)
# In ASC Workflow > Environment > Add Variable: MANGA_API_KEY (secret)
if [ -n "${MANGA_API_KEY:-}" ]; then
echo "MANGA_API_KEY=$MANGA_API_KEY" > "$CI_WORKSPACE/MangaReader/Secrets.xcconfig"
echo "Injected API key config"
fi
# Resolve private Swift packages (if using a private package registry)
# cd "$CI_WORKSPACE"
# swift package resolve # usually not needed for public packages
echo "--- Post-Clone Complete ---"
ci_pre_xcodebuild.sh — Version Stamping
#!/bin/zsh
# ci_scripts/ci_pre_xcodebuild.sh
set -euo pipefail
echo "--- CI Pre-xcodebuild ---"
# Xcode Cloud provides CI_BUILD_NUMBER automatically.
# Use it as CFBundleVersion (must be unique per submission):
PLIST="$CI_WORKSPACE/MangaReader/Info.plist"
if [ -f "$PLIST" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $CI_BUILD_NUMBER" "$PLIST"
echo "Set build number to $CI_BUILD_NUMBER"
fi
# Optionally set marketing version from a VERSION file in repo
if [ -f "$CI_WORKSPACE/VERSION" ]; then
MARKETING_VERSION=$(cat "$CI_WORKSPACE/VERSION")
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $MARKETING_VERSION" "$PLIST"
echo "Set marketing version to $MARKETING_VERSION"
fi
# Run SwiftLint (fail build on errors)
if command -v swiftlint &> /dev/null; then
echo "Running SwiftLint..."
cd "$CI_WORKSPACE"
swiftlint --reporter xcode --strict || {
echo "SwiftLint found errors — failing build"
exit 1
}
fi
echo "--- Pre-xcodebuild Complete ---"
ci_post_xcodebuild.sh — Upload dSYMs & Notify
#!/bin/zsh
# ci_scripts/ci_post_xcodebuild.sh
set -euo pipefail
echo "--- CI Post-xcodebuild ---"
echo "Archive path: ${CI_ARCHIVE_PATH:-not set}"
echo "Result bundle: ${CI_RESULT_BUNDLE_PATH:-not set}"
# Upload dSYMs to Crashlytics (Firebase)
# Requires FIREBASE_TOKEN env var set in ASC
if [ -n "${CI_ARCHIVE_PATH:-}" ] && [ -n "${FIREBASE_TOKEN:-}" ]; then
DSYM_DIR="$CI_ARCHIVE_PATH/dSYMs"
if [ -d "$DSYM_DIR" ]; then
echo "Uploading dSYMs to Firebase Crashlytics..."
# firebase-tools must be installed in ci_post_clone.sh
# ./firebase crashlytics:symbols:upload --app "$FIREBASE_APP_ID" "$DSYM_DIR"/*.dSYM
fi
fi
# Slack notification
if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
BUILD_STATUS="success"
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"text\": \"MangaReader build #$CI_BUILD_NUMBER on $CI_BRANCH: $BUILD_STATUS\"}"
fi
echo "--- Post-xcodebuild Complete ---"
Workflow Configuration (App Store Connect UI)
Set up in App Store Connect > Xcode Cloud > Manage Workflows:
Workflow: "TestFlight Beta"
├── Environment
│ ├── Xcode: latest release
│ ├── macOS: latest release
│ └── Environment Variables:
│ ├── MANGA_API_KEY (secret) = sk-...
│ └── SLACK_WEBHOOK_URL (secret) = https://hooks.slack.com/...
├── Start Conditions
│ ├── Branch changes: main, release/*
│ └── Pull Request changes: any branch
├── Actions
│ ├── Build: scheme=MangaReader, platform=iOS
│ ├── Test: test plan=MangaReaderTests, platform=iOS Simulator
│ └── Archive: scheme=MangaReader
└── Post-Actions
└── TestFlight (Internal) → group: "Internal Testers"
Test Plans
Create MangaReaderTests.xctestplan via Xcode: Product > Test Plan > New Test Plan
// MangaReaderTests.xctestplan (simplified structure)
{
"configurations": [
{
"id": "primary",
"name": "Primary",
"options": {
"codeCoverage": true,
"environmentVariableEntries": [
{"key": "USE_MOCK_API", "value": "1"}
],
"testTimeoutsEnabled": true,
"defaultTestExecutionTimeAllowance": 60
}
}
],
"defaultOptions": {
"codeCoverageTargets": [{"name": "MangaReader", "productType": "com.apple.product-type.application"}],
"maximumTestRepetitions": 3,
"testRepetitionMode": "retryOnFailure"
},
"testTargets": [
{
"target": {"name": "MangaReaderTests", "projectPath": "MangaReader.xcodeproj"},
"skippedTests": ["SlowIntegrationTests"]
}
],
"version": 1
}
Using CI_BUILD_NUMBER in Code
// Access build metadata at runtime
struct BuildInfo {
static var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
}
static var version: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
}
static var isCI: Bool {
// Xcode Cloud sets CI=TRUE in its environment (not available at runtime,
// but you can embed it during ci_pre_xcodebuild.sh into a config)
Bundle.main.infoDictionary?["BuildEnvironment"] as? String == "CI"
}
static var displayVersion: String { "v\(version) (\(buildNumber))" }
}
Environment Variables Available in Scripts
# Repo & workspace
CI_WORKSPACE # /Volumes/workspace/repository
CI_DERIVED_DATA_PATH # derived data location
CI_ARCHIVE_PATH # path to .xcarchive (post-archive only)
CI_RESULT_BUNDLE_PATH # test results
# Build metadata
CI_BUILD_NUMBER # unique incrementing integer (1, 2, 3…)
CI_BRANCH # git branch name
CI_TAG # git tag (if triggered by tag)
CI_COMMIT # git commit SHA
CI_PULL_REQUEST_NUMBER
# Workflow
CI_WORKFLOW # workflow name
CI_PRODUCT_PLATFORM # iOS, macOS, tvOS, watchOS
CI_XCODE_SCHEME # scheme being built
# Identity
CI=TRUE # always set by Xcode Cloud
Common Gotchas
| Problem | Cause | Fix |
|---|---|---|
chmod: Operation not permitted |
Script not executable in git | git update-index --chmod=+x ci_scripts/*.sh |
command not found: brew formula |
Not installed in clone step | Install in ci_post_clone.sh |
| Build number conflicts | Using manual counter | Use $CI_BUILD_NUMBER exclusively |
| Secrets visible in logs | Logging env vars | Never echo $SECRET — set but don't print |
| SPM resolution fails | Private package needs auth | Use SSH key or netrc configured in clone step |
| Test plan not found | Wrong path in workflow | Path is relative to workspace root |
| Archive path empty in post-build | Action order wrong | ci_post_xcodebuild only has archive after archive action |
Common Patterns
Conditional Logic by Branch
#!/bin/zsh
# Only upload to TestFlight external group on main branch
if [ "$CI_BRANCH" = "main" ]; then
echo "Main branch — will distribute to external testers"
# (actual distribution is configured in workflow post-actions, not scripts)
fi
# Only run heavy tasks for release branches
if [[ "$CI_BRANCH" == release/* ]]; then
echo "Release branch — running full validation"
# ... extra steps
fi
Generating a BuildInfo.plist During CI
# In ci_pre_xcodebuild.sh — embed CI metadata into app bundle
/usr/libexec/PlistBuddy -c "Add :BuildEnvironment string CI" "$PLIST"
/usr/libexec/PlistBuddy -c "Add :BuildBranch string $CI_BRANCH" "$PLIST"
/usr/libexec/PlistBuddy -c "Add :BuildCommit string $CI_COMMIT" "$PLIST"
Related Skills
ios-testing— automated testingapp-store-connect— App Store submissionmobile-ci-cd— mobile CI/CD patterns
GitNexus Index
This skill is indexed by GitNexus for knowledge graph traversal. Index path: /Users/localuser/.claude/skills/xcode-cloud/.gitnexus Last indexed: 2026-05-23