Semantic Versioning in Swift

This was originally mentioned on the swift-nio issue tracker here, but the issue tracker doesn't necessarily get a load of traffic so I wanted to bring this up on the forums.

Specifically, it seems like it would be useful to try to come to a community understanding of how semantic versioning should map to Swift. Swift is in a uniquely difficult place becuase of the way it allows users to extend arbitrary types.

For the rest of this post I'll use a hypothetical package A, that depends on packages B and C. In particular, this package can run into the following cases:

  1. Package A may conform a type from Package B to a protocol from Package C. If Package B later adds that conformance, this will cause a compile error. (This is very common in the Swift community in the case where Package C == the Swift standard library).
  2. Package A may extend a type from Package B with a method or computed property. At some later time, Package C publicly extends the same type from Package B with the same method or computed property. This will cause a compile error in package A.

These possibilities require us to revise what is commonly accepted as a "breaking" change in SemVer for the Swift community. Given that the Swift language entirely allows both 1) and 2) above, in principle it becomes almost impossible to make a SemVer-minor change to a Swift library, as it could always conflict with a hypothetical public extension in a third-party library that depends on it. This interpretation makes SemVer almost entirely useless for Swift, as there will never be a SemVer-minor: only SemVer-patch and SemVer-major.

Because we don't want SemVer to be useless, we should as a community come up with a set of guidelines as to what kinds of changes are safe for SemVer-minor. This will need to involve defining a set of best-practices regarding type extensions such that software that follows them will safely get the SemVer guarantees.

I'd like to use this thread to solicit feedback as to what those guidelines should be.

1 Like

The NIO team have noted the following cases should probably be considered violations of best-practices.

Conforming Types You Don't Own To Protocols You Don't Own

In general, if you conform someone else's type to someone else's protocol you are at risk of being broken in a minor version bump. This is because the owner of the type (or the protocol) may choose to add their own conformance, which will conflict with yours.

For this reason, you should never conform a type you don't own to a protocol you don't own unless you're willing to encounter breakage in a SemVer minor release. Again, note that libraries do not own standard library types or standard library protocols.

If your codebase defines the type or the protocol, adding a conformance is fine. You only have to own one of the parts.

Public Extending Types With Common Method Signatures

This is an extension of the first case. If you provide a public extension of a type you do not own, and that extension provides methods or computed properties that both a) have a simple name and b) use only types/protocols your code does not own, you are at risk of breaking either your own or other people's code.

If you wish to provide a public extension to someone else's type, the following situations are fine:

  1. Use a name that is unlikely to collide. For example, provide a prefix to all method names and computed property names that relates to your package in some way.
  2. Have your method signature include a type/protocol that your code owns. That will make it impossible for someone else to add the same method signature unless they themselves break this rule.

Does anyone have other suggestions?

4 Likes

It's been my policy to completely ignore the fact people can add methods in extensions when considering version numbers. If we started to consider that, then yes, you would be bumping major versions every time you add a method or property. Same goes for adding a protocol conformance, although that's been rarer in my experiences.

hmm, I don't think I can reproduce this: If I add this

extension ByteBuffer {
    mutating func set<S: Sequence>(bytes: S, at index: Int) -> Int where S.Element == UInt8 {
        return 5
    }
}

which clearly clashes with a ByteBuffer from NIO defined method and I call precondition(5 == self.b.set(bytes: DispatchData.empty, at: 0)) that works just fine. So Swift correctly picks up the one defined in this module.

It would be great is Swift had a disambiguation mechanism for this stuff built in so that if there was a conflict, users have a way of disambiguating without the framework makers having to use gross prefixes or containers. This seems like a feature that could come along with namespace or submodule support.

3 Likes

Indeed it would. Unfortunately, until such time as Swift ships such a feature, I think we still need to work out a set of guidelines about what the community can and cannot safely do with most Swift libraries. :slightly_frowning_face:

I disagree that it is in the spirit of semantic versioning to restrict a downstream client for what an API author can foresee. In this case, new public conformances to protocols should really be major version bumps, IMHO.

@millenomi unfortunately in Swift that affects every new (public) method on any public type and every new top-level type and function.

Say you add a public function to one of our types and we happen to add one with the same name, your code wouldn’t compile anymore. Interpreting SemVer that way would mean that pretty much any API addition becomes a major release.

1 Like

I've found SemVer works best when you're not afraid of major releases, or being a bit conservative with your versioning.

1 Like

In most cases I think users are interested in when the API actually breaks, ie. method renamed, parameter/function/type removed, ... That is what I think constitutes a backwards incompatible change.

If you declare even the tiniest addition as a backwards incompatible change, then there's no digit left in SemVer express that something is actually a breaking change. So in the end, following that model in Swift would lead to everybody having a different locked down major version which leads to lots of incompatibility.

1 Like

Totally agreed @Jon_Shier. We're not afraid of releasing a new major at all, in fact we're already collecting PRs for NIO 2.0.0. But I believe it's also important to not release new major versions for not actually breaking changes. Because then we had no 'automated way' of communicating that something is actually a breaking change.

For the ecosystem I believe it's good if we try to restrict the number of breaking versions because then a large number of packages using from: "1.0.0" can work together in one large package graph. Had we released a new major version for every addition to the NIO API, then today we would have released NIO 8.0.0. And some of our clients would probably have a Package.swift stating from: "1.0.0", others from: "2.0.0", yet others from: "7.0.0" despite the fact that they're all totally compatible and we haven't removed a single type, function, parameter, or module nor changed the 'meaning' of them.

So basically we just collecting breaking API changes in bulk and as soon as we have a good reason, we'll release them all together as NIO 2.0.0.

@Helge_Hess1 we want to be a healthy open-source project and we're literally at the beginning. So we want to encourage people to contribute and give them access to the latest & greatest as soon as we have it. It's quite discouraging if you contribute something but have to wait half a year to actually use it in a released version.

From what I understand, the compilation will only fail iff any of the following is true

  1. you extend on of NIO's types with a public method/property,
  2. you conform one of our types to a protocol that you don't control
  3. or we get an unfortunate clash of a public symbol

(2) is definitely bad style and asking for trouble; (1) kind of smells too (you can add methods, just not public ones) and (3) is preventable by using (the arguably not so great) import struct NIO.ByteBuffer syntax.

I wish there were less pitfalls in Swift and we had qualified imports etc but unfortunately we don't have that (yet).

So I think there is a workable model for Swift without bumping the major version for any API addition.

Out of interest, can you or @millenomi outline what kinds of changes would constitute semver minor in your reading of the spec? What kind of change can we make to NIO that would justify a x.y+1 release?

My concern is that in the model where extending our own types is semver major I can’t think of any, so I’d like to know where I have gotten lost.

Sorry, I wasn’t clear. I don’t mean what changes are worth a minor version bump. I mean literally, for a library at any level of the stack, what can be done in a minor version bump?

I don’t have a good mental picture of what that looks like. Do you think you could sketch out what such an interface would look like and how you would extend it?

Which is why I don’t follow a strict SemVer for my libraries. Otherwise I’d be up to major version 112.

IMO strict SemVer is very hard to follow, since it severely restricts what you can do. I’m not objecting to the premise behind that strictness. I just prefer having more flexibility with what I can do.

I'm not sure why it isn't possible to respect SemVer? There are several things one can do in a minor, including:

  • Any rearranging of internal symbols
  • Adding new classes or structs
  • Adding method to structs and to existing public (not open) classes
  • Adding methods to existing protocols, as long as a default implementation is also provided
  • Making classes conform to new internal protocols
  • Deprecating (without removing; with @available(… deprecated…) or similar*) existing functionality and replacing it with new methods

etc.

*: Pitches are welcome for deprecation schemes that do not involve OS versions.

I see no problem with a major version 112. All that tells me is that you probably release often (good!), and 112 of those releases required a little work to upgrade from a previous release.

If Swift is designed such that many releases are breaking changes, then so be it that we'll just have high major version numbers. The versioning scheme isn't going to change the fact that a breaking change is a breaking change.

Technically if you follow strict SemVer, adding just about anything in Swift could cause a compilation failure somewhere down the line. Which means most changes require a major bump.

I'm not sure this holds; as it is, Swift developers face continuous additions to their underlying libraries (e.g. in macOS/iOS etc. releases) and so far this has had little to no source compatibility breakage, especially since Swift introduced source compatibility based on language versions (e.g. Swift 3 mode). So, I'm confused.

What do you mean by that precisely?