●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Claude Code × Xcode Cloud — A One-Week Migration of ci_scripts and TestFlight Auto-Delivery
Notes from migrating a long-running indie iOS CI from Fastlane to Xcode Cloud in one week, with the three ci_scripts/ hook scripts in full, TestFlight automation, and dSYM upload — all paired with Claude Code.
I am Masaki Hirokawa, an artist and creator. Since 2014, I have been running iOS wallpaper and ambient apps on my own. Once cumulative downloads crossed fifty million, the hours available for CI maintenance dropped to nearly zero, and I kept my old Fastlane + GitHub Actions pipeline running with the attitude of "it works, do not touch it."
Earlier this year I moved my main development machine to Apple Silicon, and around the same time the certificate sync inside Fastlane match started misbehaving. TestFlight delivery began to fail about once a week. Half of the cause was my local environment, half was the GitHub Actions macOS runner image refresh cycle. As a one-person operation, being paged at seven in the morning by Slack was not a sustainable shape.
So in early May 2026, just after the long Golden Week holidays, I committed to migrating fully to Xcode Cloud. I gave myself one week. Claude Code was the partner I called from the CLI throughout, the three ci_scripts/ hooks went from draft to production, and TestFlight delivery with Crashlytics dSYM upload was running end-to-end by Friday. This article is the record. If you are an indie iOS developer wondering whether to make the same jump, my hope is that the traps I tripped over save you a couple of evenings.
Why Xcode Cloud, now
I picked Xcode Cloud for three reasons.
First, Apple's signing flow stays inside "automatic signing." With Fastlane match, you keep an encrypted repo of signing certificates and decrypt them inside CI. It is fine when it works, but Apple Developer ID renewals and Provisioning Profile regeneration broke the path a few times a year for me. Xcode Cloud uses the team certificates registered on App Store Connect directly, and that entire layer disappears.
Second, the compute included in Apple Developer Program — twenty-five hours of compute time per month — is realistic for an indie running four apps. My pipeline averages six to ten minutes per build and about twenty builds per week. The same workloads on a GitHub Actions macOS runner took twelve to eighteen minutes per build, so the budget comparison was straightforward.
Third, TestFlight delivery can be triggered directly from an Xcode Cloud workflow definition. Compared to calling pilot from Fastlane, the surface area for failure shrinks. The upload_to_testflight step that occasionally hung simply no longer exists.
The trade-off is real: Xcode Cloud is not a free-form CI. If you want to run arbitrary long-running tasks, GitHub Actions or Bitrise are still better suited. But if your scope is "ship iOS apps reliably from a one-person shop," the footprint is very small and the number of moving parts shrinks meaningfully.
What ci_scripts/ actually is
The hook points in Xcode Cloud live in a ci_scripts/ directory at the root of your Xcode project. Three scripts are picked up automatically:
ci_post_clone.sh — right after the repository clones, before dependency resolution
ci_pre_xcodebuild.sh — immediately before xcodebuild runs
ci_post_xcodebuild.sh — right after xcodebuild finishes, success or failure
All three are plain bash. They need a #!/bin/sh -e shebang and the execute bit set. A non-zero exit halts the build, so the discipline is to never return an unexpected exit code.
The first stumbling block is that there is no good way to test these scripts locally — Xcode Cloud launches from the GUI, and xcrun simctl style interactive testing does not apply. I asked Claude Code to "write ci_post_clone.sh so I can dry-run it locally as CI_WORKSPACE=$(pwd) bash -e ci_scripts/ci_post_clone.sh." It came back with a template that fell back on :- for the environment variables Xcode Cloud injects, so the same script could run both in the cloud and locally.
#!/bin/sh -e# Xcode Cloud sets CI_WORKSPACE to the project root.# For local dry runs, pass the same variable from the caller.WORKSPACE="${CI_WORKSPACE:-$(pwd)}"cd "$WORKSPACE"echo "[ci_post_clone] workspace=$WORKSPACE"
Adopting "can I dry-run this with CI_WORKSPACE=$(pwd) bash -e ci_scripts/...?" as a hard rule at the start made the rest of the week noticeably calmer.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The three ci_scripts/ hooks — ci_post_clone.sh, ci_pre_xcodebuild.sh, ci_post_xcodebuild.sh — shown in full as the working versions I ended up with, along with the exact prompts I gave Claude Code
✦Three concrete pitfalls when leaving Fastlane match for Xcode Cloud's automatic signing, each shown as a Before/After code comparison so the trap is hard to miss
✦How I integrated Crashlytics dSYM upload safely into TestFlight delivery, and the small Xcode Cloud knobs that cut build time from 14 minutes down to 6
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
My project uses both CocoaPods and Swift Package Manager. Xcode Cloud handles SwiftPM resolution automatically, but CocoaPods needs an explicit pod install. The shape I settled on:
#!/bin/sh -eWORKSPACE="${CI_WORKSPACE:-$(pwd)}"cd "$WORKSPACE"echo "[ci_post_clone] node $(node --version 2>/dev/null || echo 'not installed')"echo "[ci_post_clone] xcode-select: $(xcode-select -p)"# CocoaPods is not part of the Xcode Cloud image.if ! command -v pod >/dev/null 2>&1; then echo "[ci_post_clone] installing cocoapods via brew" brew install cocoapods >/dev/nullfi# Branch on whether Podfile.lock exists.if [ -f "Podfile.lock" ]; then pod install --deployment --repo-updateelse pod install --repo-updatefiecho "[ci_post_clone] done"
One trap to call out: pod install can fail with xcrun: error: invalid active developer path if the Xcode Cloud image has several Xcode versions and xcode-select does not know which one is active. I was tempted to fix this with sudo xcode-select -s /Applications/Xcode.app/Contents/Developer, but pinning the Xcode version inside the Xcode Cloud workflow ("Xcode 26.3" in my case) is the right answer. Avoid the temptation to solve it inside the script — that was my conclusion with Claude Code at the time.
ci_pre_xcodebuild.sh — getting secrets into the build
My apps inject a GoogleService-Info.plist for Firebase, an AdMob App ID, and a Stripe publishable key through .xcconfig at build time. None of those values belong in the repository, so under Xcode Cloud I register them as environment variables and assemble the .xcconfig inside ci_pre_xcodebuild.sh.
This was the most frustrating part of the migration away from Fastlane match. My old scripts assumed a .env.ci file would auto-load via dotenv. The first version Claude Code produced still carried that habit, and it bit me.
Before — still living in the Fastlane world:
#!/bin/sh -e# Assumes .env.ci will auto-load (does not work on Xcode Cloud)if [ -f "$CI_WORKSPACE/.env.ci" ]; then set -a . "$CI_WORKSPACE/.env.ci" set +aficat > "$CI_WORKSPACE/Config/Secrets.xcconfig" <<EOFADMOB_APP_ID = $ADMOB_APP_IDSTRIPE_PUBLISHABLE_KEY = $STRIPE_PUBLISHABLE_KEYEOF
This passed locally with a .env.ci present, so the initial PR looked green. The moment I pushed to Xcode Cloud, ADMOB_APP_ID came through as an empty string, an empty .xcconfig got written, and AdMob initialization blew up with Invalid Application ID on real devices. Crashlytics caught it before users did, but only just.
After — trust the Xcode Cloud environment variables and fail loudly:
#!/bin/sh -eWORKSPACE="${CI_WORKSPACE:-$(pwd)}"cd "$WORKSPACE"# On Xcode Cloud, env vars are already exported by the runtime.# If anything is missing, halt the build instead of writing an empty .xcconfig.require_env() { name="$1" value=$(eval echo "\$$name") if [ -z "$value" ]; then echo "[ci_pre_xcodebuild] required env not set: $name" >&2 exit 64 fi}require_env ADMOB_APP_IDrequire_env STRIPE_PUBLISHABLE_KEYrequire_env FIREBASE_DATABASE_URLmkdir -p "Config"cat > "Config/Secrets.xcconfig" <<EOF// Generated by ci_pre_xcodebuild.sh - DO NOT EDITADMOB_APP_ID = $ADMOB_APP_IDSTRIPE_PUBLISHABLE_KEY = $STRIPE_PUBLISHABLE_KEYFIREBASE_DATABASE_URL = $FIREBASE_DATABASE_URLEOFecho "[ci_pre_xcodebuild] secrets.xcconfig generated"
The key change is the require_env helper. When moving off Fastlane match, delete every code path that reads .env.ci, and add an explicit exit 64 for any missing variable. An empty .xcconfig lets the build succeed, lets the binary upload to TestFlight, and then breaks on real user devices. That is the worst possible failure mode.
Apple's .xcconfig syntax treats // and everything after it as a comment, so a value with // in it (like a URL) will silently lose half of itself. I now keep URLs out of .xcconfig entirely and rebuild them in Swift from a base key plus the bundle identifier.
ci_post_xcodebuild.sh — dSYM upload without breaking the workflow
TestFlight delivery works at this point. The remaining task is uploading dSYM files to Firebase Crashlytics. Without dSYMs (symbol information), crash reports degrade to raw memory addresses and become useless.
Xcode Cloud is post-bitcode, so dSYMs land in $CI_ARCHIVE_PATH/dSYMs/ as part of xcodebuild. We call Firebase's upload-symbols script from ci_post_xcodebuild.sh.
#!/bin/sh -eWORKSPACE="${CI_WORKSPACE:-$(pwd)}"cd "$WORKSPACE"# Failed builds may not have a dSYM directory. Bail cleanly.if [ "$CI_XCODEBUILD_EXIT_CODE" != "0" ]; then echo "[ci_post_xcodebuild] xcodebuild failed (exit=$CI_XCODEBUILD_EXIT_CODE), skipping dSYM upload" exit 0fi# Only run dSYM upload on delivery workflows.if [ "$CI_WORKFLOW" != "TestFlight Beta" ] && [ "$CI_WORKFLOW" != "Production" ]; then echo "[ci_post_xcodebuild] workflow=$CI_WORKFLOW, skipping dSYM upload" exit 0fiDSYMS_DIR="$CI_ARCHIVE_PATH/dSYMs"if [ ! -d "$DSYMS_DIR" ]; then echo "[ci_post_xcodebuild] dSYMs directory not found at $DSYMS_DIR" exit 0fiUPLOAD_SYMBOLS="$WORKSPACE/Pods/FirebaseCrashlytics/upload-symbols"if [ ! -x "$UPLOAD_SYMBOLS" ]; then echo "[ci_post_xcodebuild] upload-symbols not found, did pod install run?" exit 0fi"$UPLOAD_SYMBOLS" \ -gsp "$WORKSPACE/Apps/Wallpaper/GoogleService-Info.plist" \ -p ios \ "$DSYMS_DIR"echo "[ci_post_xcodebuild] dSYM upload completed"
Two points worth pausing on. First, the heavy use of exit 0. A non-zero exit in ci_post_xcodebuild.sh marks the entire build as failed, even if TestFlight delivery already succeeded. dSYM upload is auxiliary, so the failure should log loudly but never sink the parent build.
Second, the CI_WORKFLOW branch. I defined three workflows — "Pull Request," "TestFlight Beta," and "Production." Pull Requests do not deliver to TestFlight and do not upload dSYMs. The first version I shipped lacked this branch, and every PR build dumped a fresh symbol set into Firebase. The symbol history page filled up with PR builds within a day. Add the branch from the start.
Three workflows: Pull Request, TestFlight Beta, Production
Xcode Cloud workflows are defined in the App Store Connect GUI, not as text in xcshareddata/xcschemes/. The configuration lives on Apple's servers as project-external metadata. If you want a backup, screenshot the GUI into Notion or Dropbox — there is no first-class export.
The split I run:
Pull Request — Trigger: PR open/update. Action: build + unit tests. No delivery.
Production — Trigger: tag push v*. Action: build + TestFlight (External Group) + dSYM upload + Slack notification.
Only Production has a Slack notification, for one reason: External Group delivery goes through Apple's Beta App Review, which adds a few hours to a day of latency. Internal Group delivery is immediate, so the TestFlight app's native notification is enough.
A week of running it: the three failures that mattered
After one week of real runs I had collected three failures worth recording.
The first failure was leaving the "Start Condition" set to "Auto" on every branch for the first three days. Every push to a feature/* branch fired the TestFlight Internal workflow, and the team's TestFlight app filled up with builds nobody should be testing. The fix is a "Branch Starts With: develop" filter; obvious in hindsight, but the GUI defaults to the most permissive setting.
The second failure was discovering that "Continue on script failure" was on for the workflow, so my exit 64 in ci_pre_xcodebuild.sh did not actually halt the build — TestFlight got an empty-secrets binary. Internal Group only, so no external blast radius, but it took until the Crashlytics report came in to notice. Turn "Continue on script failure" off. Script failures should always halt the build.
The third failure was a slow creep in build time. The first TestFlight Beta workflow took fourteen minutes; I had to get it under ten to live with it. With Claude Code I tried a few knobs. The three that mattered, in order of impact, were dropping --repo-update from the default pod install and running it manually only when needed (around three minutes saved), turning on the Xcode Cloud option to reuse the SwiftPM cache under ~/Library/Developer/Xcode/DerivedData/ (about two minutes), and splitting tests into a "Smoke" and a "Full" suite and switching between them based on the trigger (another three minutes). The final number sits at six minutes.
Closing thoughts
Fastlane match is a tool I had touched for close to ten years, and walking away from it was harder than I expected. In the end Fastlane stays for two things only: emergency local deliveries and automated screenshot capture. Every regular delivery path now goes through Xcode Cloud. The amount of mental space CI used to take from my weekday mornings dropped to almost zero.
Both of my grandfathers were master temple carpenters, and there is a quality to "everything aligns just a little better" that calms me. With less attention spent on CI, the attention available to think about what the apps should actually feel like came back. For a one-person operation, that is a bigger change than it looks on paper.
If you are considering the same move, my one piece of practical advice is to start by writing the three ci_scripts/ so they can dry-run locally with CI_WORKSPACE=$(pwd) bash -e ci_scripts/.... Once your CI lives as text rather than as GUI clicks, conversations with Claude Code lock in noticeably faster, and a week is genuinely enough to land it.
Thank you for reading this far. I hope it helps you if you are running iOS apps on your own.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.