Claude got the app building on my machine, then the first clean CI run fell over four different ways. Each had the same root cause I had to point out: the build was quietly leaning on something only my laptop had.
Claude wired up continuous integration for one of my mobile apps. Everything built on my machine, so it looked finished. Then a clean runner picked it up and it failed in ways neither of us had seen, on steps you would not know to go looking for. The common thread across every one of these was the same: the build was quietly leaning on something that only my laptop happened to have, and the clean machine exposed the dependency. Four of them surfaced, and catching each one was the part that needed someone who had been here before.
The iOS toolchain that was too old, then too new
The iOS job failed first because the runner’s default Xcode was too old. React Native’s Podfile enforces a minimum Xcode version and refuses to install dependencies below it, so the build died before a single line of Swift compiled, complaining that it found an older Xcode than required.
So Claude pinned the job to a specific Xcode major version, and it failed again in the opposite direction. A transitive Swift package had raised its required tools version, and the version it had pinned to was now below the floor. “Too old” had been fixed by creating “too new for the next dependency.”
The real fix is to stop naming a version at all and instead select the newest non-beta Xcode the runner image provides. Both of these floors only ever rise as the ecosystem moves forward, so any version you hardcode is a future failure with a delay timer on it. Pick the newest, print what was available to the log so the next mismatch is diagnosable, and bump the runner image when even the newest is too old.
The Android build that could not accept a license
The Android job failed during Gradle configuration, before compiling anything, because it could not install the exact NDK version React Native pins. The error looked like a missing toolchain, but it was not. The build tooling will happily auto-install that NDK. It just cannot do it on a clean runner, because installing it requires accepting an SDK license, and a non-interactive shell has no way to click “I agree.”
Claude’s first fix attempt was clever and wrong. It tried to read the pinned NDK version out of the project’s Gradle files so it could install it explicitly, and it was not there, because current React Native carries that default inside the package rather than writing it into the app’s build files. The load-bearing fix is much simpler: accept the SDK licenses before the build, and let the tooling install whatever version it wants. Do not hardcode the version, for the same reason as the Xcode floor. It rises.
The keystore that broke Google sign-in on every clean build
This one was sneaky. On-device Google sign-in opened the account picker, the user picked an account, and then the app failed with a generic error right after selection. The cause was a signing-certificate mismatch. Google sign-in keys on the SHA-1 of the certificate that signs your build, and a single Android OAuth client accepts exactly one SHA-1.
The trap is that under continuous native generation, the Android project is regenerated rather than committed, so every clean prebuild, every fresh clone, and every new machine mints a brand new debug keystore with a brand new SHA-1. Locally it worked because my machine kept reusing its keystore. The moment a clean environment built it, the signing identity no longer matched the one registered with Google. The fix is to make the debug signing identity stable everywhere, either by committing a debug keystore and copying it in during prebuild, or by building the dev client in the cloud and registering that one cloud SHA-1. A regenerated or machine-local keystore can never satisfy a one-client, one-SHA-1 constraint.
The build server that disabled a feature behind our back
The last one is my favorite, because the build system was actively working against us. A preview iOS build failed during credential setup, trying to turn off Sign in with Apple. The app config on that branch did not mention Sign in with Apple at all. So why was the build trying to disable it?
Because the build service auto-syncs your Apple App ID’s capabilities to match what your build declares, and it disables capabilities your config does not mention. An earlier build from a feature branch had turned Sign in with Apple on at Apple’s end. The current config was silent about it, so the sync tried to switch it off, and Apple’s API rejected the change. Making it worse, the preview build profile had quietly collapsed onto the production bundle identifier because a variant environment variable was unset, so “preview” was not even a separate identity. The fix is to keep capabilities consistent across every profile that shares a bundle id and to declare them in config so the config is the source of truth, with an escape hatch to skip the sync when you need to unblock a build without touching the App ID.
The lesson worth keeping
None of these were really about Xcode or NDKs or keystores. Each one was a hidden dependency on local state that a clean machine refused to provide. That is exactly what CI is for. A green build on your laptop tells you it works on your laptop. The clean runner is the only thing that tells you it works anywhere else, which is the only kind of “works” that matters once someone else is involved. None of it was going to surface from the assistant that wrote the config, either, which is the quiet argument for keeping a human who knows where these bodies are buried.