Migrating an Old Project from CocoaPods to SPM, and Then Back Again

I had a plan for the migration, and still produced a 972-file commit that a senior colleague asked to roll back. What moving over and back involved, and the difference between planning a migration and planning how to land it.

On this page
  1. I did have a plan, and I still made one huge commit
  2. What moving over actually involved
  3. Why we went back
  4. Going back took an afternoon of its own
  5. The plan I wrote afterward
  6. Looking back

I maintained an old UIKit project with all its third-party dependencies on CocoaPods: Firebase, Google Maps, RxSwift, a keyboard library, a calendar control, and more. It had four schemes for its different environments.

I wanted to move the dependencies to Swift Package Manager. CocoaPods is in maintenance mode, and it was already falling behind new Xcode project formats. So I migrated, it built, and about seven weeks later I put it all back.

I did have a plan, and I still made one huge commit

Before touching anything, I wrote down a baseline: every dependency, its exact resolved version, and how it should move. Some rules were good. Migrate to the same version first, and never mix a migration with an upgrade. Move families like Firebase and Rx together, not one library at a time. Expect some libraries to need a spike or a replacement.

Then the actual work happened over about two days, and the biggest commit changed 972 files, with 104,895 lines added. Of those, 103,679 were third-party source I had vendored into a local package, for libraries with no suitable SPM release.

So my plan covered what to migrate and how to handle versions. It said nothing about the size of a change someone else could reasonably review. That’s the gap I’d point to now.

What moving over actually involved

The first pass kept things in a mixed state. Firebase moved to SPM, most libraries moved or got vendored, and a local package gathered the third-party dependencies in one place, doing the Podfile’s job of showing what you depend on and at which version. Google Maps stayed on CocoaPods. Google Maps on SPM appeared to need iOS 15, which was above the app’s minimum, and raising a minimum version isn’t a decision to slip in with a build change.

A few hours later I removed CocoaPods altogether. Then came an evening of commits fixing what broke: SwiftGen, the calendar control, a checkbox library, the keyboard library, the Crashlytics build script. The next morning I brought in the Google Maps framework by hand.

Why we went back

A senior colleague looked at the change and was worried. Moving everything to SPM in one go could cause problems nobody would see. Put simply, it changed too much. They asked to roll it back.

The worry holds up when you look at what a dependency manager touches:

  • Compiling isn’t the same as working. Resources may not reach the bundle, build scripts may stop running, and target membership can differ between schemes, all while the build succeeds.
  • Problems become hard to pin down. With nearly a thousand files changed, any later issue might or might not come from the migration.
  • Release builds happen elsewhere. Signing and packaging run on a release machine through Jenkins and Fastlane, so a local build doesn’t prove much.
  • Users gain nothing they’d notice. The team takes the risk, and the benefit is mostly for developers.

Going back took an afternoon of its own

It wasn’t a git revert, because by then other work had landed on top. I restored the Podfile, lockfile, and workspace, restored the CI and Fastlane files, rewired the app to CocoaPods, and patched a project format CocoaPods didn’t recognize:

require 'xcodeproj'

# Remove once CocoaPods understands objectVersion 70.
versions = Xcodeproj::Constants::COMPATIBILITY_VERSION_BY_OBJECT_VERSION
unless versions.key?(70)
  Xcodeproj::Constants.send(:remove_const, :COMPATIBILITY_VERSION_BY_OBJECT_VERSION)
  Xcodeproj::Constants.const_set(
    :COMPATIBILITY_VERSION_BY_OBJECT_VERSION,
    versions.merge(70 => 'Xcode 16.0').freeze
  )
end

Then the code stopped compiling for a new reason. The keyboard library has a different module name and API depending on how you get it, so I made the code work with either:

#if canImport(IQKeyboardManagerSwift)
import IQKeyboardManagerSwift
#elseif canImport(IQKeyboardManager)
import IQKeyboardManager
#endif

func enableKeyboardManager() {
    #if canImport(IQKeyboardManagerSwift)
    IQKeyboardManager.shared.enable = true
    #elseif canImport(IQKeyboardManager)
    IQKeyboardManager.shared().isEnabled = true
    #endif
}

I’d keep this pattern for the next attempt, since it lets both setups compile during a transition.

The local packages I’d written myself stayed, including the mock tool and the alert queue. They don’t depend on the migration and were already in use.

The plan I wrote afterward

The same afternoon, I wrote a plan for whoever came next, with one main idea: don’t combine changes that fail in different ways. I’d been running dependency migration, a move to folder-synchronized groups, and SwiftLint on overlapping branches. If they land together, you can’t tell which one broke something, and you can’t roll back only one.

The order:

  1. Keep the CocoaPods baseline buildable.
  2. Add SwiftLint with conservative rules that don’t block on old code.
  3. Try central SPM management on a small set of dependencies.
  4. Decide on Google Maps explicitly.
  5. Widen the SPM migration only after all four schemes validate.
  6. Convert selected folders to folder-synchronized groups.
  7. Clean up duplicate build warnings separately.

It also defines when the work counts as done: all four schemes build, signed builds come out of Fastlane, the Jenkins pipeline runs, SwiftGen and localization still work, Google Maps has a decision, someone has reviewed the project file diff, and a rollback path is written down. Those are criteria for later, not things I finished.

Looking back

The first attempt taught me the difference between a plan for the migration and a plan for landing it. Mine knew every library and version. It didn’t ask how a teammate would review the result, or what we’d do if something broke a month later. My colleague was asking exactly those questions.