CocoaPods or Swift Package Manager: the choice belongs to the iOS team
Each channel constrains your publishing pipeline differently.
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
XCFrameworkin 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.

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 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.

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.
Related
- A framework builds for one architecture. iOS consumers need an XCFramework — the artifact both channels distribute, and why the simulator-only build fails on a device.
- Publishing a KMP library is a contract with four consumers — the other three channels, and what each one freezes at release time.
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".