Version-specific Package.swift files

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

Apple announced numerous new features and improvements to the Swift language and ecosystem during WWDC.

Some of these features are SPM-specific and, while they are not yet publicly available in a stable release, it is a good idea to start integrating them and preparing your Swift packages for when these features are released.

In this article, I will go through how you can define version-specific package manifest (i.e. Package.swift files) so you can test out new features available only in the latest Swift toolchain without breaking backward compatibility for your existing clients.

Defining a version-specific Package.swift

You can make a Package.swift only apply to a specific version by changing the name of the file to Package@swift-<MAJOR>.<MINOR>.<PATCH>.

In the file name, you must always provide the major version component and you can optionally provide the minor and patch version components for more granular control.

For example, you could have the following manifest variants for Swift 5.7 depending on the level of granularity you need:

  1. Package@swift-5.7.0.swift: This manifest would apply exclusively to Swift 5.7.0.
  2. Package@swift-5.7.swift: This manifest would apply to all patch versions of Swift 5.7.
  3. Package@swift-5.swift: This manifest would apply to all minor and patch versions of Swift 5.

💡 If you want to learn more, this feature is documented in the Swift Package Manager repository.

swift-tools and manifest names

It is important to note that the swift-tools version you define in your Package.swift must be either the same or compatible with the version you define in your file name.

For example, let’s say I have a manifest file called Package@swift-5.7.1.swift but I then define the swift-tools version inside it to be 5.9.

When I try to build the package with the 5.7.1 toolchain, the build system will detect the correct manifest from the file name but it will then fail to parse it because the swift-tools version is not compatible with the Swift toolchain version:

A screenshot of the terminal showing an error due to incompatible swift tools versions.

No matched manifests

If the Swift build system can’t find any specific manifests from their file names, it will pick the one that’s closest to the current Swift toolchain.

For example, if you have three manifests: Package@swift-5.7.0.swift, Package@swift-5.7.1.swift and Package@swift-5.7.2.swift, and you are using a Swift 5.9 toolchain, then SPM will pick the 5.7.2 manifest as it is the closest version to the current toolchain.

Examples

The minimum Swift version for a specific package manifest is defined as a comment at the top of the Package.swift file with the word swift-tools followed by the version.

For example, if you wanted to make sure the package is built with Swift 5.9 or newer you would add the following comment at the top of the Package.swift file:

5.9
// swift-tools-version:5.9

import PackageDescription

You need to be careful with what you set this version to, because similarly to what happens with increasing the minimum deployment version for an app, this prevents any clients of the package from building with a Swift toolchain older than the one defined in the manifest.

Most of the time you will be able to keep your swift-tools version low or bump them to the latest stable release without having any issues, but in the following sections, I will show you a couple of examples where having version-specific manifests can be useful.

Example 1: Early adoption

Let’s say you want to adopt a new Swift Package Manager feature that has a considerable impact on the Package Manifest API (i.e.: the API you use to write Package.swift files) from a Swift version that’s still in beta or has recently been released.

To not drop support for your existing clients that haven’t yet updated to the latest Swift toolchain, you can maintain both your current manifest and a temporary version-specific manifest for the latest Swift toolchain.

I had to do this recently to try out the new allowNetworkConnections permission for Swift Package Plugins, which is only available in Package.swift files from Swift 5.9.

I used the AWSLambdaPackager plugin from the swift-aws-runtime repository as an example as it needs to make network requests to the Docker daemon and will benefit from this new feature in the future.

This is what a simplified version of the plugin definition looks like:

Package.swift
// swift-tools-version:5.7

import PackageDescription

let package = Package(
    name: "swift-aws-lambda-runtime",
    platforms: [
        .macOS(.v12),
        .iOS(.v15),
        .tvOS(.v15),
        .watchOS(.v8),
    ],
    products: [
				// ...
        // plugin to package the lambda, creating an archive that can be uploaded to AWS
        .plugin(name: "AWSLambdaPackager", targets: ["AWSLambdaPackager"]),
    ],
    dependencies: [
			// ...
    ],
    targets: [
        .plugin(
            name: "AWSLambdaPackager",
            capability: .command(
                intent: .custom(
                    verb: "archive",
                    description: "Archive the Lambda binary and prepare it for uploading to AWS. Requires docker on macOS or non Amazonlinux 2 distributions."
                )
            )
        )
    ]
)

To implement this change with no disruption despite the manifest API changes only being available from Swift 5.9, I created a brand new manifest for Swift 5.9 only (Package@swift-5.9.swift):

Package@swift-5.9.swift
// 💡 Required tools version
// swift-tools-version:5.9

import PackageDescription

let package = Package(
    name: "swift-aws-lambda-runtime",
    platforms: [
        .macOS(.v12),
        .iOS(.v15),
        .tvOS(.v15),
        .watchOS(.v8),
    ],
    products: [
				// ...
        // plugin to package the lambda, creating an archive that can be uploaded to AWS
        .plugin(name: "AWSLambdaPackager", targets: ["AWSLambdaPackager"]),
    ],
    dependencies: [
			// ...
    ],
    targets: [
        .plugin(
            name: "AWSLambdaPackager",
            capability: .command(
                intent: .custom(
                    verb: "archive",
                    description: "Archive the Lambda binary and prepare it for uploading to AWS. Requires docker on macOS or non Amazonlinux 2 distributions."
                )
            ),
            // ✨ New permission
            // 💡 This would only work if `swift-tools` is 5.9 or newer
            permissions: [
                .allowNetworkConnections(
                    scope: .docker,
                    reason: "AWS Lambda Packager must connect to docker to build on an amazonlinux image"
                )
            ]
        )
    ]
)

Example 2: Version-specific issues

I have recently worked on a full-stack Swift Package mono repo that collects metrics from different CI/CD providers and stores them in a database.

I came across an issue when I was setting up the package’s continuous integration and I was trying to build and deploy all my products using Swift 5.7. Specifically, I was getting a linker error when building my frontend product (built with Vapor).

After scratching my head for quite some time I realised that this issue was specific to Swift 5.7, so I decided to temporarily switch to Swift 5.6 to get my CI/CD pipeline working.

As soon as I did this I ran into a second issue. My package depends on the 1.0.0 version of the swift-aws-runtime package which has a minimum swift-tools version requirement of 5.7.

This is what a simplified version of the Package.swift file looked like:

Package.swift
// swift-tools-version:5.7
import PackageDescription

let package = Package(
    name: "Metrics",
    platforms: [
       .macOS(.v12)
    ],
    products: [
        .executable(name: "XcodeCloudWebhook", targets: ["XcodeCloudWebhook"])
    ],
    dependencies: [
        // 💧 Vapor
        .package(url: "https://github.com/vapor/vapor.git", exact: "4.74.2"),
        .package(url: "https://github.com/vapor/fluent.git", exact: "4.7.1"),
        .package(url: "https://github.com/vapor/fluent-postgres-driver.git", exact: "2.5.1"),
        // ⚡️ AWS Lambda
        .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", exact: "1.0.0-alpha.1"),
        .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", exact: "0.1.0"),
        // 🍁 Leaf
        .package(url: "https://github.com/vapor/leaf.git", exact: "4.2.4"),
    ],
    targets: [
        // 💧 Vapor
        .target(
            name: "App",
            dependencies: [
                .product(name: "Fluent", package: "fluent"),
                .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
                .product(name: "Vapor", package: "vapor"),
                .product(name: "Leaf", package: "leaf")
            ],
            swiftSettings: [
                .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
            ]
        ),
        .executableTarget(name: "Run", dependencies: [.target(name: "App")]),
        // ⚡️ AWS Lambda
        .executableTarget(
            name: "XcodeCloudWebhook",
            dependencies: [
                .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
                .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"),
            ]
        ),
    ]
)

I decided to declare a separate temporary manifest for Swift 5.6 (Package-swift-5.6.swift) which allowed me to remove any incompatible dependencies to build and deploy my Vapor frontend while still keeping the Swift 5.7 manifest for the rest of the products 🎉:

Package-swift-5.6.swift
// 💡 Just used for building the Vapor frontend
// swift-tools-version:5.6
import PackageDescription

let package = Package(
    name: "Metrics",
    platforms: [
       .macOS(.v12)
    ],
    products: [
    ],
    dependencies: [
        // 💧 Vapor
        .package(url: "https://github.com/vapor/vapor.git", exact: "4.74.2"),
        .package(url: "https://github.com/vapor/fluent.git", exact: "4.7.1"),
        .package(url: "https://github.com/vapor/fluent-postgres-driver.git", exact: "2.5.1"),
        // 🍁 Leaf
        .package(url: "https://github.com/vapor/leaf.git", exact: "4.2.4"),
    ],
    targets: [
        // 💧 Vapor
        .target(
            name: "App",
            dependencies: [
                .product(name: "Fluent", package: "fluent"),
                .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
                .product(name: "Vapor", package: "vapor"),
                .product(name: "Leaf", package: "leaf")
            ],
            swiftSettings: [
                .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
            ]
        ),
        .executableTarget(name: "Run", dependencies: [.target(name: "App")])
    ]
)

Before you go…

I want to stress that there are only a few cases where you should resort to using version-specific manifests such as the ones I have described in this article.

I particularly like this approach for adopting changes to the Package Manifest API such as new permissions or targets early, as it gives you a lot of flexibility and control over your package’s development.