Releasing V2 in Go
Photo by Guillaume Coupy on Unsplash
Introduction
Even though Go itself has a pretty good backwards compatibility promise, we, as engineers of libraries and packages, often do not.
Usually, we make mistakes the first time around, or more commonly, the project and its use cases evolve in ways we could not have foreseen when we initially started. Inherently, there is nothing wrong with this, and especially in Go, it is very easy to make sure your users don’t suffer too much from your breaking changes.
Breaking Changes
Recently, I encountered this for the first time. I have a very simple pagination library for the gin framework that I made quite some time ago. I was looking at my GitHub and realized I wasn’t happy with the state of it and was even slightly embarrassed that it was on my profile. I built it when I hadn’t yet spent the time learning the ins and outs of Go, and I knew very little about writing idiomatic Go and what the best practices were.
By the way, I don’t claim to be an expert in any of that now, but I absolutely know more than I did 2 years ago and wanted my library to reflect that.
So I started refactoring a little bit, changing variable names and functions, and at some point I came to the conclusion I needed to break the API.
Forgetting about whether I should have done this and if the v2 library is really much better than v1, what I quickly want to demonstrate in this post, is how easy it is to release a new major version of a module in Go (and the silly and humbling mistake I made when trying to do so).
What I did wrong?
For starters, my journey with this would have been short and simple had I just read the Go docs on go.dev, but hey, where’s the fun in that?
Here is what I did instead:
- I made a branch with all my chances and merged this as a PR to the master branch.
- After all my CI passed, I created a release (and a tag) on GitHub
v2.0.0
set it to the latest release, and thought that was it.
I took one of my other projects that uses this pagination library, and updated it to v2 to see how my users would experience it. I ran the following command to see if it would pick up the latest tag, and surprise surprise it didn’t seem to upgrade past my last v1 version.
go get -u github.com/webstradev/gin-pagination
I tried specifying the version explicitly, and then I was met with the following error:
go get github.com/webstradev/gin-pagination@v2.0.0
go: github.com/webstradev/gin-pagination@v2.0.0: invalid version: module contains a go.mod file, so module path must match major version ("github.com/webstradev/gin-pagination/v2")
Sidebar: I’ve left this broken version of v2.0.0 in place as a reminder for myself that I’m an idiot and totally did not try to hide my silly mistake by overwriting the 2.0.0 tag and pushing it again, only to find out that the module is on the GOPROXY for eternity. One day I might need to make a plaque out of this in case the GOPROXY ever goes down.
But back to the error: AH! I had seen this before! In Go, modules can contain a version reference in them, I had even upgraded dependencies from v3 to v4 before. The version reference doesn’t actually have to be a part of the file path, so all I needed to do was change my import to /v2. Wrong!
go get github.com/webstradev/gin-pagination/v2
go: module github.com/webstradev/gin-pagination@upgrade found (v1.0.4), but does not contain package github.com/webstradev/gin-pagination/v2
Still not finding v2.0.0
let’s try and explicitly mention it. Still Wrong!
go get github.com/webstradev/gin-pagination/v2@v2.0.0
go: module github.com/webstradev/gin-pagination@upgrade found (v1.0.4), but does not contain package github.com/webstradev/gin-pagination/v2
So after a bit of reading and looking at a big open source library that was already in v7, I figured out my error. See, I knew about modules with versions in them, I had used them and even upgraded to them plenty of times before. What I hadn’t realized is that they are actually just separate modules with different names and some smart pattern matching in the Go tool chain.
So the fix was simple. In the go.mod of my library, change the go module from:
module github.com/webstradev/gin-pagination
to
module github.com/webstradev/gin-pagination/v2
Just another one of those silly but very humbling moments as an engineer when adding 3 characters fixes everything.
What I love about it
Was I a little frustrated with the silliness of my mistake? Perhaps! But I was actually more excited about how nicely Go has solved this problem. It’s usually hard/complicated to make a new major version and make sure your users don’t have a negative experience. Especially as you don’t know how they have specified the versions and how they update them.
If you want to reference a new major version of your Go module, you actually change the name (and thus the import path) of your module. When running go get
the go tool chain only allows you to get the new version if the major version you are specifying in the module name is the same as the one in the tag.
This means that a user of github.com/webstradev/gin-pagination
cannot accidentally upgrade to the v2 package, even if he specifies the new tag accidentally, because they would need to change the import path to /v2
as well. So you will never have users who accidentally upgrade to the new version of your library, which might break their own code.
Maintaining two versions
This also means that you can quite easily keep maintaining the old version (just as in other languages, really). This is never a great idea in the long-term, but if you want to keep providing security updates or bug fixes in the short-term, you can just put your old version on a separate branch, and create new releases there by tagging them on that branch. I have done this for gin-pagination as well. I have a branch called v1 and I aim to provide security updates to that for another year or so, when Dependabot screams at me again. Even if a user just tries to go get @latest, it will always respect the major version (unless they explicitly mention /v2 in the module name)
Using two versions
Another nice benefit is that, as a user of a library that has multiple major versions, one can import both modules, as they are basically separate go modules. This might be useful if you aren’t migrating all usages in one go or if you want to test functionality in both versions. Yet another reason I like the way Go handles major versioning.
package main
import (
"github.com/gin-gonic/gin"
paginationv1 "github.com/webstradev/gin-pagination"
"github.com/webstradev/gin-pagination/v2/pkg/pagination"
)
func main() {
gin.Default()
oldPagination := paginationv1.Default()
newPagination := pagination.New()
}
Wrapping up
Don’t be afraid to release a new major version of a library if you have to. Yes, breaking changes can be annoying for your users to resolve, but at least the way go modules work it is a very deliberate choice whether they choose to update and take the time to resolve them.
Oh, and read the bloody docs!