Safely pinning SPM dependencies to exact versions
Runway handles the release coordination and busywork so you can focus on building great apps. You do the building, we'll do the shipping.
This week at work we have been looking into migrating third-party CocoaPods dependencies to Swift Package Manager. We wanted to do this in a way where we maintained the same level of reproducibility and security that we currently had with CocoaPods or, if we couldn’t, we reached an acceptable compromise.
We started off by reviewing our current CocoaPods setup:
- We have a
Podfile
at the root of the repo. - We commit both the
Podfile.lock
file and thePods
directory to source control. - We don’t run
pod install
on CI. - Any changes to the
Podfile.lock
file are both checked by the developer committing the changes and the people reviewing the PR.
This setup provides us with a number of benefits such as reproducible builds both locally and on CI and an easier bootstrapping process for new developers (you can clone the repo and build the app without installing CocoaPods or any other third-party tools).
As package resolution is a step of the build process, there is no easy and automated way of installing all Swift Package Manager dependencies into a vendor directory and committing it to source control like we do with CocoaPods.
For this reason, we decided to go for the next best option we had to ensure reproducible builds, which was pinning dependencies to exact versions and committing the Package.resolved
file to source control.
The problem
When you add a Swift Package Manager dependency to an Xcode project or to a Swift package or Xcode project within an .xcworkspace
, the swift build system will generate/update a Package.resolved
file under <workspace_name>.xcworkspace/xcshareddata/swiftpm/Package.resolved
. This file will contain information about the version, revision and URL of all dependencies used by the project.
The way Package.resolved
differs from more traditional lock file implementations is that when something changes in your Swift Package Manager dependencies, the Package.resolved
file will automatically update to reflect this during a build. That being said, if you decide to depend on a specific version of a package, this Package.resolved
file shouldn’t really change during a build but, when we did some tests, we uncovered a potential risk we wanted to mitigate before switching our third-party dependencies over to Swift Package Manager:
Swift Package Manager relies on source control to resolve dependencies, which means that when you specify a version in your Package.swift
, all you’re saying is that you want to check out a specific git tag. Now, if someone decided to re-tag the version of a package you depend on to point to a different commit (with all the implications that could have), the build system would update the Package.resolved
file to reflect this change the next time you build your app.
While you would be able to tell that there’s been a change locally if you track the Package.resolved
file under source control, this can be a big problem for CI builds, as you wouldn’t be able to tell if there’s been any changes. Imagine the scenario where one of your third-party dependencies has re-tagged a release to a different commit (which hasn’t been tested and is different from what you’re expecting) and your release pipeline picks it up in the build that’s going out to your users 😱.
An example
Let’s explain the problem further by creating an Xcode project which relies on a single dependency called TestSPMPackage.
TestSPMPackage has a single tag (v1.0.0), which points to a commit with sha c959141bd7a39740f91e7e7431bb6d56ad84e4f6
.
After adding the dependency to the project and specifying its version rule to be exactly 1.0.0
, the generated Package.resolved
file looks like this:
{
"pins" : [
{
"identity" : "testspmpackage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/polpielladev/TestSPMPackage.git",
"state" : {
// This is the commit sha that the v1.0.0 tag points to
// 👇
"revision" : "c959141bd7a39740f91e7e7431bb6d56ad84e4f6",
"version" : "1.0.0"
}
}
],
"version" : 2
}
We commit this file to source control and push it to our remote repository.
Let’s now consider the scenario where someone decides to change the v1.0.0
tag’s commit to a different one with sha 48c496db8ecd8e12277ad27db7c2a92fd5f93438
. Next time we build the app, there will be a change to the Package.resolved
file:
{
"pins" : [
{
"identity" : "testspmpackage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/polpielladev/TestSPMPackage.git",
"state" : {
// This is the NEW commit sha that the v1.0.0 tag points to
// 👇
"revision" : "48c496db8ecd8e12277ad27db7c2a92fd5f93438",
"version" : "1.0.0"
}
}
],
"version" : 2
}
Locally, the change would show up in our git diff
and we would get the chance to confirm whether we are happy with this change of revision or we want to investigate the change further before proceeding. On the other hand, CI would still build fine and ship our app with the new revision without us even noticing.
The solution
Luckily, there is a flag built-in to xcodebuild
which allows us to mitigate this risk. Passing -disableAutomaticPackageResolution
to the xcodebuild
command tells the build system to not update the Package.resolved
file if any of the dependencies have changed and, if it can’t resolve all packages with the information provided by Package.resolved
, then fail the build.
xcodebuild
-workspace SomeWorkspace.xcworkspace \
-scheme Debug \
-configuration Debug \
-derivedDataPath ./derived_data/ \
# 👇
-disableAutomaticPackageResolution \
-sdk 'iphonesimulator' \
-destination 'platform=iOS Simulator,id=6C6A5129-4D65-4A35-A439-67C894B4FF65' \
-enableCodeCoverage YES \
build-for-testing
Even better, if you are using fastlane, you can set this flag by passing true to the disable_package_automatic_updates
parameter in both the gym and scan actions 🎉:
run_tests(
workspace: SomeWorkspace.xcworkspace,
scheme: 'Debug',
clean: true,
device: 'iPhone 14',
configuration: 'Debug',
sdk: 'iphonesimulator',
derived_data_path: './derived_data/',
reset_simulator: true,
build_for_testing: true
# 👇
disable_package_automatic_updates: true
)
Future considerations
It is important to note that Apple is currently working on a Package Registry Service which would improve the Swift Package Manager ecosystem dramatically and potentially mitigate issues as the one described in this article.
You can find out more about the Package Registry Service by reading proposal SE-0292.