Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve semantic versioning #777

Closed
dominikh opened this issue Jun 7, 2020 · 15 comments
Closed

Improve semantic versioning #777

dominikh opened this issue Jun 7, 2020 · 15 comments

Comments

@dominikh
Copy link
Owner

dominikh commented Jun 7, 2020

Go modules, for all intents and purposes, require projects to use Semantic Versioning. Because of the way many people install staticcheck – by including it in their go.mod as a dependency – we are not exempt from this requirement.

Staticcheck does not provide a stable API (but doesn't hide all packages in /internal/ – users are free to use our APIs, without any guarantees), nor does it provide a stable CLI – Staticcheck releases can make backwards incompatible changes. If we were to use a major version >= 1, then most releases would require bumping the major version, which in turn would require changing our import paths. This creates unnecessary churn – both for us and our users. This means we can only use major version 0, as it carries no backwards compatibility guarantee.

Staticcheck adopted its official versioning scheme long before the introduction of Go modules. We chose the format <year>.<seq>.<patch>. <year> would correspond to the current year, <seq> would increment with each feature release and reset at the beginning of the year, and <patch> would denote bugfix releases of a given feature release. This versioning scheme was chosen to add some meaning to versions – based on the <year> component, users can tell how old their version of Staticcheck is, in terms of time. This contrasts with Semantic Versioning, which carries no such information. It was and is my belief that Semantic Versioning is primarily of use to libraries, not end-user software. Staticcheck is end-user software.

However, under Go modules, we cannot simply use our versioning scheme. v2020.1.0 would be major version 2020, and we've established that only major version 0 is viable for us. This leaves us with two options:

  1. Abandon our versioning scheme, use plain Semantic Versioning, incrementing the minor version with each feature release.

  2. Somehow encode our versioning scheme in Semantic Versioning.

Currently, we use a form of option 2 (let's call it option 2a): versions are tagged as v0.0.1-<year>.<seq>.<patch> – that is, we store our own version in the pre-release portion of Semantic Versioning. The actual version is fixed at v0.0.1 and doesn't change. This scheme has worked somewhat well, but suffers from two (related) problems.

  1. We can't make actual pre-releases. Say we wanted to release version 2020.2-beta.1 – there is no way for us to do so. If we used v0.0.1-2020.2-beta.1, then this version would sort higher than v0.0.1-2020.1, despite being a beta release, defaulting people to using the beta.

  2. Because we use release branches, and tags are on commits that are unique to these release branches, our current versioning scheme makes it difficult to use the master branch, as can be seen in cmd/go: go mod tidy reverts upgrade golang/go#38985. In short, the pseudo-versions that Go generates for commits on master will sort lower than the latest actual release, even if these commits are chronologically newer. To fix that, while still using release branches, would require pre-release tags on the master branch. And that runs into the first problem.

The other way (option 2b) of implementing option 2 is to store the <year>.<seq> portion in the minor component, resulting in v0.202001.0 and v0.202002.0-beta.1. These versions would constitute "proper" Semantic Versions, sort as expected and allow us to make bugfix releases as well as pre-releases. Their downside is that they're exceptionally ugly and difficult to read for at least all of 2020, and probably in general.

Option 1 can be split into two sub-options:

a. completely abandon our versioning scheme
b. use basic semantic versions for Go, but use our existing versioning scheme officially

Option a is not very satisfying. We would switch from our established versioning scheme to an inferior one. We would also be stuck on major version 0, which would make Staticcheck look less stable than it is. It would also create a break in our versions, going from 2019.1 to 2019.2 to 2020.1 to… 0.2.0.

Option b requires maintaining a mapping, and will ienvitably confuse users. We could tag our releases with both versions, so that v0.2.0 and 2020.2 point to the same commit. This would allow go get ...@2020.2 to work, transparently resolving to v0.2.0. However, users will now see v0.2.0 in their go.mod files and have no idea what version that corresponds to.

In summary: Option 1a is unsatisfying, option 1b is confusing, Option 2a is insufficient and option 2b is ugly.

Is there another option I have missed? If not, which of these mediocre options is the least bad?

/cc @dmitshur @bcmills @myitcv @mvdan

@mvdan
Copy link
Sponsor Contributor

mvdan commented Jun 7, 2020

I have been thinking about "end-user versions" versus "library versions" for a while too. I agree that your own versioning scheme might have benefits over semver for end users, but I also have to admit that most users will expect semver, so there's some value in following that expectation.

There is one semver feature supported by Go that you might not be considering - build metadata. For example, following your example from option 1b, the tag v0.2.0+2020.2 would compare as equal to v0.2.0 for Go, but the extra 2020.2 string would signal to the user what "end-user version" they're looking at. You still have two parallel versioning schemes which can be kind of ugly, but at least you don't need to keep the mapping between them separate.

Something else that comes to mind is a versioning scheme that's somewhere in between yours and semver - something like Chrome's or Firefox's, where the "main version" string is just a number that gets incremented at regular intervals (such as every three months). Then, if you're on staticcheck v0.35.0 and you see there's an update to v0.39.0, you can know that you'd roughly get a year's worth of changes, without the versions needing to contain the calendar year.

This is not me advocating for the Chrome/Firefox version numbering scheme. Just another idea to add to the pile, since it could mean replacing YYYY.NN for just NN. This would make option 2b more palatable, in my opinion. As an end user, I find Firefox v77 easier to deal with than firefox v2020.3 or somesuch.

@dominikh
Copy link
Owner Author

dominikh commented Jun 7, 2020

build metadata

I'm not sure you're actually supposed to include build metadata in tags? I was always under the impression that it's an artifact of the build process itself, but I've never tried to see how Go behaves. They also can't contain dots, and we may want to use them for their intended purpose at some time, for example to offer downloads built with different versions of Go.

something like Chrome's or Firefox's

I'm not sure I quite understand how that is different from option 1a? In option 1a, we'd have the following feature releases: 0.2.0, 0.3.0, ... – with bugfix releases as 0.2.1, 0.2.2 and so on. How is that different from Firefox's scheme, other than Firefox having absurdly large numbers?

@mvdan
Copy link
Sponsor Contributor

mvdan commented Jun 7, 2020

I'm not sure you're actually supposed to include build metadata in tags?

Well, if Go modules didn't let you put metadata in the tag strings, where could you possibly put them instead? As far as I know it should work, though I haven't used it myself yet. Also, I'm pretty sure they can contain dots; from https://semver.org/#spec-item-10:

Build metadata MAY be denoted by appending a plus sign and a series of dot separated identifiers

we may want to use them for their intended purpose at some time, for example to offer downloads built with different versions of Go.

I guess this depends on what you want to prioritise more. You could also do both at the same time; there is no limit to how many "dot separated identifiers" you can have, as far as I can tell. You could do +2020.2.go1.15, for example.

I'm not sure I quite understand how that is different from option 1a?

You said "users can tell how old their version of Staticcheck is, in terms of time". I'm saying that you can accomplish practically the same if you increase the "major" version number at regular intervals, like every three months. That could replace a version like v2022.3 with something like v33, which makes it easier to fit into a v0 semver string.

@dominikh
Copy link
Owner Author

dominikh commented Jun 7, 2020

Well, if Go modules didn't let you put metadata in the tag strings, where could you possibly put them instead?

Well, as far as semver is concerned, build metadata is just noise appended to a version. That is v1.0.0 and v1.0.0+foobar are the exact same version. To that end, you can do go get github.com/go-kit/kit@v0.9.0+foobar and it'll fetch the v0.9.0 tag, without a v0.9.0+foobar tag existing.

You could put build metadata in the names of your binary downloads, for example, or a version output by the program. For example, you could have v0.1.0+go15 for a binary built with Go 1.5.

I don't think Go likes you including build metadata in git tags, either. If you have an actual tag called v0.0.1+buildtag, then go get ...@v0.0.1 does not work (even though it maybe possibly should, as per semver rules). But more importantly, go get ...@v0.0.1+buildtag gets me v0.0.2-0.20200607164203-55eda7246725.

I'm saying that you can accomplish practically the same if you increase the "major" version number at regular intervals

Are you suggesting that the version number increases on a schedule, even if there are no actual releases being made? So one release would be v33 and the next might be v36?

@mvdan
Copy link
Sponsor Contributor

mvdan commented Jun 7, 2020

Ah, I might have a wrong understanding when it comes to how Go handles build metadata appended to versions.

Are you suggesting that the version number increases on a schedule

Yes. That works for large products like Chrome/Firefox as they have lots of engineers and want to release regularly, anyway. That might not work for you, but you are already tying your release names to dates anyway. If you didn't have any release in 2021, you would jump from 2020 to 2022, I presume.

@dominikh
Copy link
Owner Author

dominikh commented Jun 7, 2020

If you didn't have any release in 2021, you would jump from 2020 to 2022, I presume

I would, and have. But a gap in year numbers seems easier to digest than a gap in a simple counter. People recognize years. They won't know, just from looking at it, that v30 is a number that increments by 1 every 3 months. As a user I'd probably wonder where v31 and v32 have gone. (Also, Firefox and Chrome have been mocked for their absurd version numbers :P)

@ainar-g
Copy link
Contributor

ainar-g commented Jun 7, 2020

Please no Chrome-like major version circus, I beg you!

As for the problems with the current versioning scheme… You'll probably consider this proposal (option 2c?) “ugly” but you could spell 2021.1 as 2021.r.1 (read as “version 2021, revision 1”) and 2021.2-b.1 as 2021.b.1.r.2 (read as “version 2021, beta 1 of revision 2”). From what I see, this would always sort beta releases under the final ones. It's literally backwards, but modern problems require modern hacks solutions.

@bcmills
Copy link

bcmills commented Jun 8, 2020

I would be inclined to use sequential minor versions for releases, and just dual-tag the repo: once with the semantic version, and once with the date string.

If you want to encode the date in the version string as well, perhaps you could move it to the patch string? That would keep the “minor” comparison easy to eyeball, but still encode the date information as well, and would continue to encode “minor” bumps as minor versions and “patch” bumps as patches to the same minor version.

For example:
v0.2.20200100 (2020.1.0)
v0.3.20200200 (2020.2.0)
v0.3.20200201 (2020.2.1, a patch release for 2020.2.0)

@myitcv
Copy link

myitcv commented Jun 9, 2020

Thanks @dominikh. Nothing to add from my perspective; @bcmills' solution is the kind of thing I had in mind.

@bcmills
Copy link

bcmills commented Jun 9, 2020

I guess that does imply a hard upper bound on the number of patch releases per main release, but I hope you don't hit 100 patches in half a year anyway... 😅

@dominikh
Copy link
Owner Author

dominikh commented Jun 9, 2020

I'm not sure how illegal this abuse of the patch level is. The spec says this:

Patch version MUST be reset to 0 when minor version is incremented.

Though technically no rules at all apply for major version 0.

Nevertheless. I feel like you're showing me these workarounds to convince me not to use any of them :)

While they're quite clever, I don't think any of them are user friendly, only causing more confusion. At this point, I'm very inclined to just go with

use sequential minor versions for releases, and just dual-tag the repo: once with the semantic version, and once with the date string.

@dominikh
Copy link
Owner Author

I was tempted to drop our <year>.<seq> scheme entirely, but that brought up another issue: some OS distributions package staticcheck using that versioning scheme. We can't switch to using semver with major version 0 without breaking the sort order.

@dominikh
Copy link
Owner Author

dominikh commented Jul 25, 2020

Here are my plans going forward:

  • Staticcheck 2020.2 will be tagged as v0.1.0 and 2020.2
  • The first commit on master after the release of 2020.2 will be tagged as v0.2.0-0.dev
  • Patch releases will be tagged as v0.1.z and 2020.2.z
  • Future releases will follow the same scheme, incrementing the minor version and resetting the patch version. That is, Staticcheck 2020.3 will be v0.2.0 and 2020.3

We do not retag old releases, because we don't want them to sort higher than the latest v0.0.1-YYYY.N pseudo version. That is, if we tagged 2020.1 as v0.1.0, then it would sort higher than v0.0.1-2020.1.4.

We will continue using our year-based version numbers in documentation, the output of staticcheck -version and for packages in OS distributions. This will ensure that new releases continue to sort correctly. Versions of the kind v0.1.0 will only exist for the sake of the Go module system. They will be included in documentation and staticcheck -version so people can more easily make the connection between versions stored in go.mod and the official versions.

We will use -0.dev pseudo versions on the master branch to establish a sort order between the master branch and release branches. This will allow users to depend on commits on master without them sorting lower than release branches (see golang/go#38985 (comment))

After the change to the new tagging scheme we can start making pre-releases, such as v0.2.0-rc.1 – I do not know yet if we'll also make tags such as 2020.2-rc.1. There should be no technical reason not to, but there might be UX reasons. The exact format of pre-release versions is TBD.

@dmitshur
Copy link
Sponsor Contributor

Overall, I like the plan and the future versions.

As discussed over chat, I think you should avoid retroactively giving existing older releases (all but the latest current release, 2020.1.4) a semantic version that sorts higher than their current version, as that can cause a problem for modules using the current version scheme for the existing releases.

As a minor note, v0.3.0-rc1 should be v0.3.0-rc.1 so that sorting works as expected if there are more than 10 such versions, and to set a better example for others to follow. This is a part of best practices of using semver, also see https://semver.org/#spec-item-11 and the examples of pre-release versions provided there.

@dominikh
Copy link
Owner Author

Thank you, Dmitri. I've thought about tagging 2020.1.4, but ultimately decided against it. Having a 0.1.4 but no 0.1.0–0.1.3 would be too confusing IMO. I think it's preferable to just leave old releases alone and only use our new scheme for new releases.

dominikh added a commit that referenced this issue Dec 14, 2020
We are switching to using two versioning schemes: our original one and
a proper Semantic Versioning compatible one. See #777 for the
motivation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants