Engineering

CocoaPods or Swift Package Manager: the choice belongs to the iOS team

Each channel constrains your publishing pipeline differently.

MobileByteSensei 10 min read
kmpiosbuild

TL;DR

  • We shipped the shared framework as a CocoaPod because the Gradle integration was four lines. The consuming iOS app used Swift Package Manager, and adopting us meant adding CocoaPods to a project that had deliberately removed it — which took three weeks and a decision above my pay grade.
  • The two channels constrain the publisher differently, and the differences are not cosmetic: artifact format, versioning, where the binary is hosted, and whether a consumer can build from source.
  • Measured on one migration: SPM needed an XCFramework in a release asset with a checksum, a separate repository for the package manifest, and a CI step that did not exist.
  • What reading on buys: the four axes that actually differ, and the one question to ask the iOS team before writing any Gradle.

The case this post is about

A shared module, published for iOS with the CocoaPods plugin, because it is the path the Kotlin documentation leads with:

// shared/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.kotlin.cocoapods)
}

kotlin {
    cocoapods {
        version = "1.4.0"
        summary = "Shared business logic"
        homepage = "https://github.com/example/shared"
        ios.deploymentTarget = "15.0"
        framework { baseName = "Shared"; isStatic = true }
    }
}
$ ./gradlew podPublishReleaseXCFramework
BUILD SUCCESSFUL
$ ls shared/build/cocoapods/publish/release/
Shared.podspec  Shared.xcframework/

Four lines of configuration and a working artifact. I handed the podspec to the iOS team.

The symptom

The reply was a question I had not considered:

We removed CocoaPods in March — 4 min of `pod install` on every clean checkout and
the Podfile.lock conflicts were costing us more than the dependencies were worth.
Everything is SPM now. What does the SPM integration look like?

There was no SPM integration. And the cost of producing one was not a Gradle flag:

# what SPM actually needs, none of which podPublishReleaseXCFramework produces
  1. an XCFramework zipped, with a SHA-256 checksum
  2. that zip reachable at a stable URL (a GitHub release asset)
  3. a Package.swift declaring a binaryTarget with that URL + checksum
  4. Package.swift at the ROOT of a repository, because SPM resolves by repo + tag
  5. a tag on that repository matching the version

Four of those five are release-pipeline work. Item 4 is the one that caused the delay: SPM resolves a dependency by cloning a repository and reading Package.swift from its root, and our shared module lives in a subdirectory of a monorepo. A Package.swift at the monorepo root would declare the whole repository as a Swift package, which it is not.

The five things a Swift Package Manager integration requires against the one that the Gradle CocoaPods task produces, with four of the five being release-pipeline work rather than configuration

Why the obvious fix failed

The first attempt was to put Package.swift at the monorepo root and point it at the built framework:

// Package.swift at the repo root — compiles, and wrong
let package = Package(
    name: "Shared",
    products: [.library(name: "Shared", targets: ["Shared"])],
    targets: [.binaryTarget(name: "Shared", path: "shared/build/.../Shared.xcframework")]
)

A path: binaryTarget resolves relative to the package, which means the consumer must have the built artifact in their checkout. SPM clones the repository at a tag; it does not run Gradle. So the consumer got a clone with no build/ directory and a package that could not resolve.

The second attempt was a url: binaryTarget with a release asset, which is the correct shape, and it failed on the checksum:

.binaryTarget(
    name: "Shared",
    url: "https://github.com/example/mono/releases/download/shared-1.4.0/Shared.xcframework.zip",
    checksum: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
)
error: checksum of downloaded artifact of binary target 'Shared' does not match
       checksum specified by the manifest

The checksum has to be computed from the zip after it is built and then written into a Package.swift that is committed and tagged — so the manifest depends on an artifact that depends on the commit the manifest is in. That is a two-step release, and swift package compute-checksum is the only thing that produces an acceptable value (shasum -a 256 gives the same bytes, and I spent an hour on a mismatch caused by zipping the directory rather than its contents).

The third attempt was to support both channels from one pipeline, which we now do and which I had assumed would be the hard part. It was the easy part. The hard part was that nobody had asked the consuming team anything before the integration was chosen.

The actual mechanism

The two channels differ on four axes, and each one constrains the publisher rather than the consumer.

Artifact format and hosting. CocoaPods accepts a podspec pointing at a source tree or a binary; it can build from source on the consumer's machine. SPM binary targets accept an XCFramework zip at a URL, with a checksum, and will not build Kotlin from source under any configuration. So SPM requires you to host a binary artifact somewhere stable; CocoaPods does not have to.

Where the manifest lives. A podspec is a file you can publish anywhere, including a private spec repository, and its path within your repository is irrelevant. Package.swift must be at a repository root, and SPM identifies a package by repository URL plus tag. A monorepo therefore needs either a dedicated publishing repository or the Package.swift at its root.

Versioning. A podspec carries its version as a field. An SPM package's version is the git tag, so a release is a tag and a re-release is a new tag — you cannot correct a published version in place, and a tag that moves is a resolution failure on every consumer that cached it.

And what the consumer's build does with it. CocoaPods generates an Xcode workspace and integrates through it; SPM integrates through Xcode's own dependency resolution with no workspace generation and no pod install step. That difference is why the team had removed CocoaPods, and it is a property of their build rather than of our library.

So the choice is not "which is better". It is: which one does the consuming project already use, because adopting a second dependency manager is a cost borne entirely by them, for a benefit that accrues entirely to us.

The four axes on which the two channels constrain the publisher, covering artifact hosting, where the manifest must live, how a version is identified, and what the consumer build does with it

The fix

Publish both, from one pipeline, and ask the question first.

// shared/build.gradle.kts
//
// The XCFramework is the common artifact: CocoaPods consumes it via a podspec, SPM via a
// zip + checksum. Building it once and distributing it twice is the shape that makes
// supporting both channels cheap — the expensive part was never the second format.
kotlin {
    val xcf = XCFramework("Shared")
    listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach { target ->
        target.binaries.framework {
            baseName = "Shared"
            isStatic = true
            xcf.add(this)
        }
    }
}
// build-logic/src/main/kotlin/SpmRelease.kt
//
// The two-step release SPM requires, as one task. Emits the zip, computes the checksum the
// way SPM itself does, and REWRITES Package.swift — because the manifest must contain a
// checksum of an artifact that does not exist until after the manifest's own commit, and
// doing that by hand is where the hour went.
abstract class PrepareSpmRelease : DefaultTask() {

    @get:InputDirectory abstract val xcframework: DirectoryProperty
    @get:Input         abstract val version: Property<String>
    @get:OutputFile    abstract val manifest: RegularFileProperty
    @get:OutputFile    abstract val zip: RegularFileProperty

    // INJECTED. `project.exec {}` from a @TaskAction captures the Project at execution time,
    // which makes this task configuration-cache incompatible — and one incompatible task
    // disables the cache for the entire build, not just for itself.
    @get:Inject abstract val execOps: ExecOperations

    @TaskAction
    fun prepare() {
        val xcf = xcframework.get().asFile
        val out = zip.get().asFile

        // Zip the DIRECTORY, so the archive contains `Shared.xcframework/…` and not its
        // contents at the root. SPM rejects the latter with a checksum mismatch rather than
        // a structural error, which is an hour of looking at the wrong thing.
        execOps.exec {
            workingDir = xcf.parentFile
            commandLine("zip", "-ryq", out.absolutePath, xcf.name)
        }

        // `swift package compute-checksum` is the authority. shasum -a 256 agrees TODAY and
        // is not the documented input, so the authoritative command is the one to call.
        val checksum = ByteArrayOutputStream().also { sink ->
            execOps.exec {
                commandLine("swift", "package", "compute-checksum", out.absolutePath)
                standardOutput = sink
            }
        }.toString().trim()

        require(checksum.matches(Regex("^[0-9a-f]{64}$"))) {
            "compute-checksum returned '$checksum', which is not a sha256 — is the Swift " +
            "toolchain on PATH? A malformed checksum here publishes a package nobody can resolve."
        }

        manifest.get().asFile.writeText(
            """
            // swift-tools-version:5.9
            // GENERATED by :shared:prepareSpmRelease — do not edit.
            // The checksum below is of the artifact attached to tag shared-${version.get()};
            // editing either by hand makes the package unresolvable for every consumer.
            import PackageDescription

            let package = Package(
                name: "Shared",
                platforms: [.iOS(.v15)],
                products: [.library(name: "Shared", targets: ["Shared"])],
                targets: [
                    .binaryTarget(
                        name: "Shared",
                        url: "https://github.com/example/shared-spm/releases/download/${version.get()}/Shared.xcframework.zip",
                        checksum: "$checksum"
                    )
                ]
            )
            """.trimIndent()
        )
        logger.lifecycle("SPM release prepared: ${out.name}, checksum $checksum")
    }
}

And the output it produced:

$ ./gradlew :shared:prepareSpmRelease -Pversion=1.4.0

SPM release prepared: Shared.xcframework.zip, checksum 9f2c1a…b4e7
  → shared/build/spm/Package.swift
  → shared/build/spm/Shared.xcframework.zip   (41.2 MB)

$ # the dedicated publishing repo: Package.swift at its root, tagged
$ ./gradlew :shared:publishSpm -Pversion=1.4.0
  pushed example/shared-spm @ 1.4.0
  attached Shared.xcframework.zip to the release
// the consumer's Package.swift — one line, no pod install
.package(url: "https://github.com/example/shared-spm", from: "1.4.0")

Resolution time on a clean checkout, measured on the consuming project: 11 s, against the 4 minutes of pod install they had removed CocoaPods to escape.

How we proved it

That the SPM path needed five things and Gradle produced one — the inventory, before any work:

  produced by podPublishReleaseXCFramework      Shared.xcframework/   ✓
  a zip of it                                                          ✗
  a checksum from `swift package compute-checksum`                     ✗
  Package.swift with a url: binaryTarget                               ✗
  Package.swift at a repository ROOT                                   ✗
  a git tag matching the version                                       ✗

That path: cannot work for a published package. Tested rather than assumed, because it resolves locally and that is the trap:

  resolving from a local checkout with build/ present     SUCCESS
  resolving from a fresh clone at the tag                 error: binary target 'Shared'
                                                          at '…/build/…' does not exist

SPM clones at a tag and does not run your build. A path: target works on the machine that built it and nowhere else, which is the most misleading possible result.

That the checksum mismatch was the zip's structure, not the hash. An hour, so it is worth recording:

  zip -ryq out.zip Shared.xcframework/*     → checksum mismatch at resolution
  zip -ryq out.zip Shared.xcframework       → resolves
  # both produce a valid sha256; only one produces an archive SPM accepts

The error says checksum does not match. The cause was the archive containing the framework's contents at the root rather than the framework directory, so the bytes genuinely differed — and the message points at the hash rather than at the structure.

That supporting both is cheap once the artifact is shared. Measured in CI time, which is the cost I had expected to be the obstacle:

  podPublishReleaseXCFramework alone        6m 14s
  + prepareSpmRelease + publishSpm          6m 51s      (+37 s: zip, checksum, tag, upload)

Thirty-seven seconds. The three weeks were an organisational decision about adding a dependency manager, and nothing in that was a technical cost.

That a moved tag breaks cached consumers. The fixture, because "re-release 1.4.0 with a fix" is the obvious thing to try:

  consumer resolves 1.4.0, Package.resolved pins the checksum
  publisher force-pushes tag 1.4.0 with a new zip
  consumer builds:  error: checksum of downloaded artifact does not match
                           checksum in Package.resolved

A tag is immutable in practice. Corrections are a new version, which is a constraint CocoaPods does not have and which changes how you handle a bad release.

What I cannot tell you is which channel to pick for a library with many unknown consumers. Ours had one, so the question had one answer. A public library probably needs both from day one, and the 37-second figure suggests that is affordable — but I have not run a public library through this and the hosting and support surface of a public SPM package is not something this migration measured.

Three measurements: a path-based binary target resolving locally and failing from a fresh clone, a checksum mismatch caused by the archive structure rather than the hash, and supporting both channels costing thirty-seven seconds of CI

What this generalizes to

A distribution decision looks like a publisher's choice and is mostly a consumer's cost, so the useful order is to ask the consuming team what their project already uses before writing any packaging configuration. The documentation's four-line path is a real convenience and it is optimising the publisher's effort, which is the smaller number — our four lines cost the iOS team three weeks of deliberation about a tool they had deliberately removed. Where supporting both is cheap, support both; where it is not, the answer is whatever the consumer already has.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS