Safely pinning SPM dependencies to exact versions

Sponsored

Zenitizer logo
Zenitizer - Meditation Timer

Enjoy clutter-free meditation with Zenitizer's clean UI and soothing sounds. For Apple geeks: Shortcuts, Apple Health, widgets and fully-featured watchOS and visionOS apps! Get 25% OFF with code: POL24

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

Xcode project with a single dependency

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:

Package.resolved
{
  "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:

Package.resolved
{
  "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.

Terminal
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 🎉:

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