Ця стаття ще не доступна вашою мовою. Показуємо англійську версію.
iOS CI Pipeline: Fix Module Boundaries Before Cache Keys
An iOS CI pipeline gets slow in the codebase before it gets slow in the config. Why module boundaries come before cache keys, what flaky tests cost, and what a mandatory Xcode bump does to both.
The Jenkins queue on an enterprise mobile program I worked on a few years back was the thing every retro mentioned and nobody owned. Engineers complained about it every two weeks. Nobody put it on a roadmap, because “fix the build” doesn’t look like a feature and nobody wanted to be the one who spent a quarter on it with nothing customer-facing to show. I ended up owning it anyway, alongside modular SPM packaging and an async/await migration, because the three were the same problem: the codebase’s structure was the actual bottleneck, and the iOS CI pipeline was just where it showed up first.
Most iOS CI pipeline advice treats CI as a caching problem: cache CocoaPods, cache derived data, parallelize test targets. Those help, and I did all of them. They come second. Do them first and you end up caching a slow build faster instead of making it fast.
Treat CI/CD as a deliverable, not background debt
The judgment call that actually mattered on that program was refusing to file CI work as “tech debt” in the backlog, where it competes with features and always loses. I treated modular packaging, the async/await migration, and CI/CD as one deliverable, with its own scope and its own definition of done, the same way a feature ships. That reframing is what got it prioritized at all. “Background debt” is backlog language for “nobody has to defend this in planning,” and background debt never wins a planning meeting against a customer-facing ticket.
The result was measured in shipping cadence and automated-test coverage, not lines of CI config touched. That’s the number that matters to the business anyway. Nobody outside engineering cares that the build got faster; they care that releases stopped slipping.
Order of operations: structure before caching
Caching a build that recompiles half the app on every incremental change just makes the slow thing happen faster. It doesn’t fix why the build is slow. On this program, the actual lever was module boundaries: splitting a monolithic target into SPM packages meant a change to one feature only triggered a recompile of that package’s dependents, not the whole app. That’s the same boundary discipline I’ve written about for modularizing a legacy iOS app without stopping delivery, and it turns out build time is one of the clearest signals that a boundary is drawn correctly: a well-isolated module’s build time stays flat as the app grows around it, and a poorly-isolated one keeps creeping up no matter how much you cache.
Caching goes on top of that structure, not instead of it. Once the module graph was real, CocoaPods and derived-data caching in CI actually paid off, because a cache hit meant “this module genuinely didn’t change” instead of “this module happened not to touch the files our cache key hashes.”
The async/await migration mattered here too, for a reason that has nothing to do with concurrency correctness: completion-handler-heavy code tends to accrete implicit dependencies between files that a compiler can’t parallelize around. Structured concurrency made the dependency graph more explicit, which is also what let the build system parallelize more of the compile.
What actually breaks a CI pipeline mid-migration
The build queue itself wasn’t the only cost. Flaky tests were a hidden build-time tax: a test that fails one run in twenty gets retried, and CI minutes spent re-running a flaky suite are indistinguishable from CI minutes spent on a genuinely slow build, except the flaky ones are pure waste. Fixing the flakiest 10% of the suite did more for perceived CI speed than any caching change did, because engineers stopped re-triggering pipelines “just in case.”
Apple’s own SDK requirements add a forcing function I have never seen written into a CI plan, including mine. App Store Connect requires every upload, new app or update, to be built with the SDK from the previous WWDC cycle, and Apple moves the cutoff each spring. Since April 28, 2026 that means the iOS 26 SDK and Xcode 26 or later (Apple Developer News, February 3, 2026). The number is easy to misread: iOS 26 shipped in September 2025, so the 2026 deadline is enforcing last autumn’s toolchain, not this year’s. Either way it is a mandatory Xcode upgrade on a schedule you don’t control, and the exact date is announced only a couple of months ahead. On that program, which ran Jenkins and Bitrise side by side, that upgrade broke cache keys tied to the old toolchain version and briefly doubled build times until the caching layer was updated to key on the new Xcode version.
The fix was one line, and it is the line I skipped, because the cache “works” until the day it silently doesn’t. A cache key that hashes only the dependency lockfile looks correct for months:
# before: cache goes stale silently on every Xcode upgrade
key: pods-{{ checksum "Podfile.lock" }}
# after: toolchain version is part of the cache identity
key: pods-{{ checksum "Podfile.lock" }}-xcode-{{ env.XCODE_VERSION }}
Without the second line, a mandatory Xcode bump serves a cache built by the old toolchain, CI restores it happily, and the first sign of trouble is a build that’s mysteriously slower or, worse, a stale artifact that passes CI and fails on device. If your CI setup isn’t treating toolchain version as a first-class cache-key input, the next mandatory SDK bump will do this to you too.
I can’t give you a before/after wall-clock number from that program, and the reason is the point of this post rather than a gap in it. We never tracked one. The target we agreed on was shipping cadence and automated-test coverage, so those are the numbers that got instrumented and reviewed, and minutes-per-build stayed an anecdote engineers traded in retro. That was the right metric to pick and the wrong one to stop at: cadence is what the business feels, but without a build-time number nobody could say whether a given change had helped or whether the queue had just been quiet that week. Pick the business metric, and instrument the mechanical one underneath it anyway.
Where I’d do it differently
I’d set the cadence target as a team OKR from the start, not after the paydown was already underway. Framing it as “make CI as product” got the work prioritized, but without an explicit target, it’s easy for the team to declare victory the moment the loudest complaints stop, well before the build is actually fast. A number everyone agreed to upfront, “green build under N minutes by this date,” would have made the finish line visible instead of a moving feeling of “better than it was.” It’s the same mistake I’ve made on other efforts framed as “fix the pain point”: the pain point going quiet doesn’t mean the underlying number moved, it just means the loudest complainer stopped filing tickets.
The iOS CI pipeline order of operations
The order is the whole point, so here it is as a sequence:
- Module boundaries. They determine how much of the app has to rebuild on a typical change. Nothing downstream compounds until these are real.
- Concurrency patterns that make the dependency graph explicit. Async/await over completion handlers, for the build system’s benefit as much as for correctness.
- The flakiest 10% of the test suite. Retried runs are CI minutes that look like slow builds and are pure waste.
- Caching. Only now does it compound, because it’s caching real, stable units of work instead of papering over a monolith.
- Toolchain version in every cache key. Treat Apple’s mandatory SDK bump as a scheduled CI event. Your cache keys need to know about it before it happens.
The same principle behind keeping a CLAUDE.md short enough that an agent actually follows it applies here: name the few things that actually compound, standardize those, and stop trying to legislate speed into a build with a caching flag.
If your team’s build times are creeping up and the fix keeps getting deprioritized behind features, that’s exactly the kind of call I help engineering teams make in strategy sessions, here.
What’s the CI fix your team keeps agreeing is worth doing and keeps not scheduling?