The longer an app stays in production, the heavier its "old assumptions" about the build environment quietly become. For me, that weight was Firebase. I have been building iOS apps on my own since 2014, and the cumulative downloads have passed 50 million. Many of those apps rely on Firebase, and CocoaPods—a natural choice back when I adopted it—had slowly turned into a source of friction every time I dealt with build times on Apple Silicon or privacy manifest requirements.
This time I moved just one app, out of several, over to Swift Package Manager (SPM). The migration itself was straightforward; what proved genuinely useful was drawing the line between what I handed to Claude Code and what I decided myself. Here is that record.
Why move to SPM now
To be honest, CocoaPods still worked. Touching something that works is always a little scary. I went ahead anyway for three reasons.
First, clean build times. Any flow that includes pod install quietly eats time, both on CI and locally. Second, Firebase's SPM distribution has become reliable enough since Xcode 15. I used to hear plenty of horror stories about the SPM version, but today it installs cleanly. Third, privacy manifests. The Firebase SDK now ships its manifest, and I find the path through SPM clearer to reason about.
The flip side is that apps not suffering from any of these don't need to move at all. "Respect what works, and fix things in the order the pain appears" is, in my experience, how you keep an indie practice going for the long haul.
What I actually did
The migration broke down into three stages: remove CocoaPods, reinstall via SPM, and fix the build phases.
First, strip Firebase out of the Podfile.
# Before (Podfile)
target 'MyWallpaperApp' do
use_frameworks!
pod 'Firebase/Analytics'
pod 'Firebase/Crashlytics'
pod 'Firebase/Messaging'
pod 'SomeOtherLib' # keep non-Firebase pods as they are
end
# After (Podfile) — delete the three Firebase lines
target 'MyWallpaperApp' do
use_frameworks!
pod 'SomeOtherLib'
endIf you depend on pods other than Firebase, it is safer to leave the Podfile intact and remove only the Firebase lines. Then tidy up the CocoaPods footprint.
# Reflect the removed Firebase lines
pod install
# Only if no non-Firebase pods remain, remove CocoaPods entirely
# pod deintegrate
# rm Podfile Podfile.lock
# rm -rf Pods
# From here on, open the .xcodeproj, not the .xcworkspaceNext, add Firebase via SPM in Xcode. From File → Add Package Dependencies, point at firebase-ios-sdk and attach only the products you need to your target (in my case FirebaseAnalytics, FirebaseCrashlytics, and FirebaseMessaging). Resisting the urge to add everything here pays off later in build time. Your existing GoogleService-Info.plist works as-is, so there is nothing to swap.
Up to this point, what I handed to Claude Code was the before/after diff of the Podfile and the changed sections of project.pbxproj. Having it mechanically surface leftover references from the CocoaPods era—like Base Configuration pointing at Pods-*.xcconfig—was the single most helpful use, the kind of thing human eyes tend to skip.
The Crashlytics dSYM that tripped me up
Just when the migration looked smooth, the Crashlytics dSYM upload caught my foot at the very end. The Run Script from the CocoaPods era relied on a ${PODS_ROOT} path like this.
# The breaking case (a leftover from CocoaPods)
"${PODS_ROOT}/FirebaseCrashlytics/run"SPM has no Pods directory, so this path naturally fails to resolve: the build still succeeds, but symbolicated crash reports stop arriving. It is an easy failure to miss.
The correct approach is to add a new Run Script to Build Phases that calls the run script inside the package SPM checked out, and to declare the dSYM and Info.plist paths explicitly in Input Files.
# Run Script after the SPM migration (add to Build Phases)
"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run"# Add to the same Run Script's Input Files
${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}
$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)To arrive at this path, I pasted the build log straight into Claude Code. It separated the two symptoms—"the dSYM can't be found" and "the run script is in the wrong place"—which spared me a round of blind path-hunting. Handing over the entire error message and asking it to form a hypothesis about the cause is, I find, an especially good fit for environment-dependent trouble like this.
What I delegated, and what I decided
The clearest lesson this time was the division of labor with Claude Code.
Mechanical work—surfacing leftover references, isolating causes from a build log, drafting a Run Script template—was faster and more accurate to delegate. The judgment calls, on the other hand, stayed firmly mine to the end: which app to move first, how much to change at once, and whether to migrate now or defer.
Both of my grandfathers were temple carpenters. The sense that working with your hands is itself a kind of devotion sits at my core. However many convenient tools appear, the responsibility for deciding where to make the cut is something I take on myself—and as long as I hold that line, a migration with an AI partner becomes deeply reassuring. The speed of the work and the weight of the decision are healthier kept apart.
What comes next
Now that one app has established the pattern, I will roll the same steps out to the rest in turn. My next two verification themes are how far the privacy manifest is pulled in automatically through SPM, and how to fold version pinning via Package.resolved into my release process.
If you have been feeling that something "still works, but I'd like to move it soon," I hope this record helps. Thank you for reading to the end.