A client needed a closed-source SDK. I used to deliver these as a Cocoapods vendored framework; since moving to SPM, binaryTarget gives me a much cleaner flow. Here are the setup steps and the pitfalls I hit.
Producing an XCFramework
A binary target expects a .xcframework. I build it in two steps:
xcodebuild archive
-scheme MySDK
-destination "generic/platform=iOS"
-archivePath build/MySDK-iOS
SKIP_INSTALL=NO
BUILD_LIBRARY_FOR_DISTRIBUTION=YES
xcodebuild archive
-scheme MySDK
-destination "generic/platform=iOS Simulator"
-archivePath build/MySDK-iOS-Simulator
SKIP_INSTALL=NO
BUILD_LIBRARY_FOR_DISTRIBUTION=YES
xcodebuild -create-xcframework
-framework build/MySDK-iOS.xcarchive/Products/Library/Frameworks/MySDK.framework
-framework build/MySDK-iOS-Simulator.xcarchive/Products/Library/Frameworks/MySDK.framework
-output build/MySDK.xcframeworkBUILD_LIBRARY_FOR_DISTRIBUTION=YES is the non-negotiable one. Skip it and you lose module stability, which crashes consumers built with a different Swift version.
Package.swift: remote binary target
let package = Package(
name: "MySDK",
platforms: [.iOS(.v16)],
products: [
.library(name: "MySDK", targets: ["MySDK"])
],
targets: [
.binaryTarget(
name: "MySDK",
url: "https://sdk.example.com/releases/1.2.0/MySDK.xcframework.zip",
checksum: "a3b2c1d4..."
)
]
)Get the checksum with swift package compute-checksum MySDK.xcframework.zip. A wrong checksum fails at resolve time.
The archive format matters
First time around I tried to ship a tar.gz. SPM rejected it. It only accepts .zip. The xcframework also has to be at the root of the zip, not nested in a folder:
zip -r MySDK.xcframework.zip MySDK.xcframeworkForget the -r flag and the zip only contains an empty folder, and the error message is misleading.
Local testing with a path-based binary target
Before a release I let the consumer test the SDK from a local file:
.binaryTarget(
name: "MySDK",
path: "./artifacts/MySDK.xcframework"
)I drop the SDK into the consumer project and point Package.swift at the local path. No checksum required.
Versioning: the git tag is mandatory
The consumer pulls the SDK with .package(url:, from:), which needs a semver tag. On every release:
git tag 1.2.0
git push origin 1.2.0Then I update the URL and checksum in Package.swift to match the new release. Miss the tag and the consumer pulls old code with the new binary, and you have an inconsistency.
Bitcode and strip
Bitcode is deprecated in iOS 17. Even so, SDKs built with older Xcode still ship with bitcode and fail App Store upload. Fix: add ENABLE_BITCODE=NO during archive. For symbol stripping, set STRIP_INSTALLED_PRODUCT=NO or you’ll lose crash symbolication.
Debugging on the consumer side
You can’t set breakpoints inside a framework from a binary target. For source-level debugging the consumer needs the .dSYM too. In my releases I zip them alongside and keep both in an internal S3:
MySDK.xcframework.zip
MySDK.xcframework.dSYM.zipPrivacy Manifest
Since iOS 17, a PrivacyInfo.xcprivacy needs to live inside the SDK. Bundled inside the binary target, consumer apps can aggregate it into their own privacy manifest. Leave it out and the App Store submission warns you about it.
When I skip binary targets
For an open-source library, sharing source is better: consumers can build against whichever Swift version they’re on. Binary targets earn their keep only for closed source or license-restricted cases. For very large SDKs, Carthage or a vendored framework flow is still viable, but SPM causes less pain in CI.
Final advice
Before you publish the SDK, spin up a separate test project and write a small app that consumes it through SPM. Most integration problems only surface from the consumer’s perspective.