Specifying Swift version for command-line SPM builds

I’m doing some CI for my Swift packages, and I want to test that they work on all of the Swift versions they support. I don’t see anything in the output of swift build --help that allows me to specify a Swift version for building. Is there a way to specify this so I can test in Swift 4.0, 4.2, 5.0, and 5.1 to make sure I don’t inadvertently break something?

Yes, there is a way, and it is horrifying:

$ swift test -Xswiftc -swift-version -Xswiftc ${LANGUAGE_VERSION}
2 Likes

Horrifying or not, that works! Thank you!

For months I thought there was no way to do it, then it became a necessity when a bug happened only when running in a compatibility mode.

You need to test with many compiler versions, and then in each relevant compatibility mode of each compiler.

1 Like

FYI, the language modes of a single version of the Swift compiler aren’t that helpful in maintaining compatibility. Best to use separate versions entirely, though GitHub currently only offers Xcode 11, so it’s not possible there.

Yep, hopefully this would at least catch syntax differences.

For older versions of Swift that aren't available with the cloud runners of GitHub Actions, I use swiftenv to manually install the version of Swift I need.

Here's an example from SwiftCurrency.

3 Likes

There is not much point in telling the same toolchain to use different language modes. SwiftPM already passes a particular language mode to the compiler based on the declarations in the manifest and this is resolved separately for different pieces of the package graph.

Furthermore, what the compiler actually does when it receives two differing -swift-version flags is undefined (as far as I know). So duplicating the automatic one with an additional -Xswiftc option may not even be having the effect you think it does.

Swift is supposed to be source compatible from Swift 3.0 onward. Most of the time, you should simply set the swift-tools-version to the oldest version of the toolchain that you care about supporting and leave it at that. If that succeeds but something newer ends up failing, then there is a bug in Swift, not in your code. Note that no build will ever pick a newer language version anyway.

However, bugs do get fixed from one release to the next. So sometimes something might work with a new toolchain that won’t work with an old one, due to a bug that had been fixed in between the two releases. For that reason, it is wise to use the exact toolchain that matches your declared swift-tools-version—at least in CI. In most cases, a new toolchain using an old language mode contains the same bug fixes as the new language mode, so simply asking for an old language mode does nothing to help you avoid tripping such bugs. If you are paranoid and want to know for absolute certain that everything works across several toolchains, then you’ll have to actually test with those toolchains.

Testing with different toolchains can be done most easily with the various official docker tags. Or as @Mordil mentioned, you can use swiftenv, although its shimming strategy is not an officially supported install layout, and it can break some of the more advanced uses of the Swift toolchain.

In the rare case that you decide to mess with swiftLanguageVersions and enable more than one, then you should still use separate toolchains to test it. In this situation, Swift will always be picking the newest language mode available to the current toolchain. Clients will only get an old language mode if they use an old toolchain, and then—as explained above—most of the bugs they encounter will be related to the toolchain, not the language version.

But most of the time, touching swiftLanguageVersions brings no real benefit in the first place.

But what if...

  • What if there is #if swift in the source code?

    Then you should think long and hard about why.

    If it is used in internal code, then you probably have branching implementations for no real gain. Optimizer improvements and the like usually apply to all language modes of a toolchain. You’re usually better off implementing once for the oldest supported language mode and leaving it at that.

    If it is in the API, then you may have added API that appears and disappears based on things outside your control. For example, let’s say in Swift 5.1 you have this in your module tagged as Version 1.0.0:

      public func doSomething() {}
    

    Now let’s say a client puts it to use with from: "1.0.0":

    import YourModule
    
    doSomething()
    

    At this point Swift 5.2 comes out with some new feature, so you decide to be clever and add conditional support for something new. To keep support for 5.1, you make your declared versions 5.1 and 5.2 and do this:

    #if swift(>=5.2)
      public func doItABetterWay() {}
    #else
      public func doSomething() {}
    #endif
    

    Now comes the puzzle: Was that actually an additive change, or was it a breaking change? If you tag version 1.1.0, what happens to your client? What happens to your client’s clients?

    It gets so messy and hard to reason about that I would basically never advise using #if swift in the API of a package.

    Instead, use @available where you can (which the compiler reasons about for you), and if you absolutely have to, use #if compiler, because then the top‐level client retains full and intuitive control over what happens.

  • What if there is #if compiler in the source code?

    Then you need a different toolchain anyway. Language modes won’t help at all.

  • What if there is #if swift in the manifest?

    That #if statement is doing nothing. The manifest will always be compiled according to the swift-tools-version at the top.

  • What if there is #if compiler in the manifest?

    This is the same as if it were in the source code. You’ll need a separate toolchain to test it, not just a different language mode.

One of the main reasons language modes exist is so that a package graph does not have to be written for the version all the way from top to bottom, which is in turn so that each module does not have to support more than one version at once. Just write for one version and let the language modes do their job. Putting effort into targeting several versions at once is missing the point.

The point is merely to verify that a package that supports multiple language versions actually does so. E.g. I don't accidentally slip in an implicit return when using Swift 5.1 but need to support Swift 5, which isn't something the language mode catches, as 5.0 and 5.1 are the same mode, bizarrely. While some packages may see no changes between language versions, this isn't true when using Apple's frameworks, as the overlays aren't guaranteed to be stable between major versions of the SDKs.

4 Likes

Yes. I think we are saying the same thing; it’s just that I failed to be as succinct as you: to meaningfully validate version support, you need an actual separate toolchain, not just a language mode flag.

2 Likes

When declaring other SPM-based dependencies you must specify which version you want. How do you specify which version your own package is? I don't see anything in the API snaptube vidmate to do this...

You mean how to release a version of your own package? That isn’t specified inside the manifest, since it needs to be known ahead of time in order for SwiftPM to locate the manifest in the first place.

To release a version of a package, you tag a semantic version with Git. This command line invocation would vend release 1.0.0 from your own local repository at its current commit:

$ git tag 1.0.0

If you want it vended from an external service, you will have to look up how to add Git tags with that service. For example, in GitHub, creating a “release” will generate a tag.