Version Constraints and Go
Requiring a newer runtime version at compile time
As new Go versions are released, they come with new sets of features that can be used to enhance your current software with additional functionality. By depending on these newer features, you now require that consumers of your project use the newer version of the runtime as well. In quite a few cases, if the functionality isn’t there the source code will fail to compile.
But what happens if the functionality change in the newer version of Go doesn’t cause the source code to fail to compile on older versions, subsequently introducing silent bugs? How do you require developers use a newer Go version, while ensuring the build failure is actionable and informative?
New Functionality, Same Interface
With the release of Go 1.9,
time.Time now has a built-in transparent monotonic time support. A monotonic time source is one that progresses at a constant rate, and isn’t impacted by things like clock drift. Operating Systems often provide a monotonic time source, separate from the wall-clock of the system, for this reason.
However if you needed a monotonic time source in Go, prior to 1.9, there wasn’t one exposed via an API that could be easily used. This lack of monotonic time source gained attention after CloudFlare suffered a short, but widespread, outage due to a leap second causing a
time.Time.Sub() to return a negative value because the clock had gone back in time¹.
Fortunately, and unfortunately, the developers of Go have introduced this functionality in a way that requires no source code changes. This means those using
time.Now() in an unsafe way are suddenly safe when compiled against Go 1.9+. The downside of this approach is that it also means code compiled against older versions of Go is unsafe, and it may not be immediately obvious to the developer building the project.
So how do we prevent the source code from building on < Go 1.9, while also providing a build failure message that’s helpful to the developer, even if the source code is being compiled using
Go Build Tags to the Rescue! Sorta…
Go’s build system supports providing build tags at the top of a file, which allow you to control when that file should be built. You can specify whether the file should only be built on
amd64, only on Go 1.1+, or any other combination of valid build tags. So let’s experiment with how we can use build tags to require specific Go runtime versions².
For restricting a file to be Go 1.9+ only we only need to add
// +build go1.9 to the top of the file:
Great! Now the
go19.go file we have will only build on Go 1.9 and our functionality should be properly guarded! Let’s switch our runtime to use Go 1.8.3, and build the project to see what happens:
./simple.go:6: undefined: stringForThing
./simple.go:7: undefined: otherStringThing
Well, that’s great our project failed to properly build! But, wait a second… it failed to build because things from
go19.go are undefined.
Build Tags and Code Visibility
Build tags are powerful in that they allow you to have different files for different architectures and operating systems without causing the compilation to fail. The downside is that the only way a compilation fails, due to a build tag constraint, is if a constant, variable, or function is no longer built by the compiler while referenced elsewhere. The build tags don’t provide a way to immediately abort a build if a condition is met (or not met).
This means that the source code being guarded by the build tag is no longer available, so all references to anything from that now result in an undefined identifier causing the build to fail.
Constants, Variables, Build Tags, oh my!
At this point our code is failing to build, which means we’ve met the minimum requirements to prevent our code to be built against a Go version that doesn’t have the behavior we want. But how can we change the code to provide an error to the developer to indicate the Go version isn’t compatible?
The first change we need to make is to include a file that has non-functional versions of the identifiers that we are missing from the first approach. The caveat is that we need to build
go19_nobuild.go if the Go version is less than Go 1.9 with
// +build !go1.9:
Of course if we want to accomplish our goal of failing to build, we should never reach the point of the panic statements being evaluated. That said, it’s good to ensure that the code (if compiled) will not work. However, at this point the code no longer fails to build on Go 1.9 because we’ve now created the missing identifiers (
otherStringThing). The end result would be that the application to panic when first invoking one of these functions, which is not the build failure we were looking for.
To enforce the build failure, we can create a constant (
softwareRequiresGo19) that is defined only in the file that will be built if the runtime version is correct. If we make this constant’s name descriptive, and reference it in another file, the build will fail with a semi-descriptive message:
./simple2.go:16: undefined: softwareRequiresGo19
In comparison to our first approach, this error message is a lot more actionable. It is still a bit confusing with an undefined identifier causing the build failure, but the name of the identifier points us in the direction of what the true problem is.
Now that the problem can be solved with a descriptive error message, can we build a way to have this be done with a single line of code being added to your source code that requires specific Go version runtimes?
Reusable Build Constraints: goconstraint
By moving this functionality out in to a small package³, we can use a blank import to require that your software only be compiled against a certain version of the Go runtime:
By simply adding
_ "github.com/theckman/goconstraint/go1.9/gte to the import of our file, our project fails to build with the following error message:
../vendor/github.com/theckman/goconstraint/go1.9/gte/constraint.go:10: undefined: __SOFTWARE_REQUIRES_GO_VERSION_1_9__
You can find out more about the
goconstraints package here:
Using a combination of build tags, constants, and blank variables we can create a safeguard that prevents our source code from compiling against an undesirable Go version while also providing a useful error message.
This implementation mechanism for this constraint can easily be migrated out to a shared package. Using this mechanism, it allows you to declare minimum supported Go runtime versions in your code and have it fail to build when the constraint isn’t met with minimal changes to your source.
go build tooling would allow for a compilation process to be aborted if a build tag’s constraints aren’t met. One idea of how to accomplish this might be to add an
abort tag that takes effect if the condition isn’t true:
// +build go1.9,abort.
How are you doing Go version build constraints if you need them?
Update // 2017–08–28
I’ve opened an issue on the Go project around the need for this type of functionality in