In February I started to show how awesome Go 1.18 is. That time, it was still in beta, but now it’s officially released and 1.18 is the latest stable version. I did not have time to publish more on the topic, but I’ll try my best to keep it up.
I read an awesome article from Christian Rebischke about the changes
in the runtime/debug
package, but I though we can do more if we are clever.
So the article shows, we can access VCS information with BuildInfo
. Sadly it
knows only the revision, date, and the “dirty” state. That’s good, and I know a
few applications where they use only revision or date for versioning, like
mc (MinIO command line tool) has a very simple version number:
❯ mc --version
mc version RELEASE.2022-03-31T04-55-30Z
It’s sad it does not contain information about git tags, but after reading that article I though there should be a way we can get tags into our application without using global variables. If there is a way to achieve this, I’ll find it, and for sure I found a way.
BuildInfo
First of all, BuildInfo.Settings
is a key-value list.
type BuildInfo struct {
GoVersion string // Version of Go that produced this binary.
Path string // The main package path
Main Module // The module containing the main package
Deps []*Module // Module dependencies
Settings []BuildSetting // Other information about the build.
}
type BuildSetting struct {
// Key and Value describe the build setting.
// Key must not contain an equals sign, space, tab, or newline.
// Value must not contain newlines ('\n').
Key, Value string
}
With a simple application we can check what’s inside the BuildInfo
struct by
default if we are inside a git repository.
package main
import (
"fmt"
"runtime/debug"
)
func main() {
info, _ := debug.ReadBuildInfo()
fmt.Println(info)
}
And the output is:
❯ go run .
go go1.18
path build-info-check
mod build-info-check (devel)
build -compiler=gc
build CGO_ENABLED=1
build CGO_CFLAGS=
build CGO_CPPFLAGS=
build CGO_CXXFLAGS=
build CGO_LDFLAGS=
build GOARCH=amd64
build GOOS=linux
build GOAMD64=v1
build vcs=git
build vcs.revision=6aff455dea150ab05d71da4aba69de2af3a10c38
build vcs.time=2022-04-04T11:05:34Z
build vcs.modified=true
Let’s try to add an ldflag
as we did before to set versions with global
variables.
❯ go run -ldflags="-X main.Version=0.0.1" .
go go1.18
path build-info-check
mod build-info-check (devel)
build -compiler=gc
build -ldflags="-X main.Version=0.0.1"
build CGO_ENABLED=1
build CGO_CFLAGS=
build CGO_CPPFLAGS=
build CGO_CXXFLAGS=
build CGO_LDFLAGS=
build GOARCH=amd64
build GOOS=linux
build GOAMD64=v1
build vcs=git
build vcs.revision=6aff455dea150ab05d71da4aba69de2af3a10c38
build vcs.time=2022-04-04T11:05:34Z
build vcs.modified=true
We have that information in the binary. We can say “that’s it, done”, but not
yet. That would be ugly and potentially hurtful to set a global variable. What
if (should not) the application has a Version
variable for a totally
different purpose?
ldflags
We can do a lot of things with ldflags
. There is an interesting linker flag:
-buildid id
record id as Go toolchain build id
Disclaimer: I don’t know if it’s used by Go by default. I did not make
extensive testing, it is possible it can break things. Right now, I don’t see
how and where, but keep that in mind. The basic logic is the same from here.
If it breaks things, we can go back to -X main.DefinitelyNotDefinedVersion
.
❯ go run -ldflags="-buildid=v0.0.1" .
go go1.18
path build-info-check
mod build-info-check (devel)
build -compiler=gc
build -ldflags=-buildid=v0.0.1
build CGO_ENABLED=1
build CGO_CFLAGS=
build CGO_CPPFLAGS=
build CGO_CXXFLAGS=
build CGO_LDFLAGS=
build GOARCH=amd64
build GOOS=linux
build GOAMD64=v1
build vcs=git
build vcs.revision=6aff455dea150ab05d71da4aba69de2af3a10c38
build vcs.time=2022-04-04T11:05:34Z
build vcs.modified=true
Yes, it’s there, we can parse it runtime. We have to iterate through the build
settings list and find a -ldflags
key and a value that starts with
-buildid=
. While we are there, we can save all other information as well, they
can be useful.
type versionInformation struct {
Version string
Time string
Revision string
Dirty bool
}
func findVersion() versionInformation {
version := versionInformation{}
info, _ := debug.ReadBuildInfo()
fmt.Println(info)
for _, setting := range info.Settings {
if setting.Key == "-ldflags" && strings.HasPrefix(setting.Value, "-buildid=") {
version.Version = strings.TrimPrefix(setting.Value, "-buildid=")
} else if setting.Key == "vcs.revision" {
version.Revision = setting.Value
} else if setting.Key == "vcs.time" {
version.Time = setting.Value
} else if setting.Key == "vcs.modified" {
version.Dirty = true
}
}
return version
}
func main() {
fmt.Println(findVersion())
}
With this, we have a nice compact version information in an easy to use struct.
❯ go run -ldflags="-buildid=v0.0.1" .
{v0.0.1 2022-04-04T11:05:34Z 6aff455dea150ab05d71da4aba69de2af3a10c38 true}
We can even implement a String() string
function on versionInformation
:
func (info versionInformation) String() string {
parts := []string{AppName}
if info.Version != "" {
parts = append(parts, info.Version)
} else if info.Revision != "" {
parts = append(parts, info.Revision)
}
if info.Dirty {
parts = append(parts, "*dirty*")
}
if info.Time != "" {
parts = append(parts, fmt.Sprintf("(%s)", info.Time))
}
return strings.Join(parts, " ")
}
And now, we have a well formatted version information as a string:
❯ go run -ldflags="-buildid=v0.0.1" .
MyFancyApp v0.0.1 *dirty* (2022-04-04T11:05:34Z)
But that’s the same thing with extra steps
Indeed, it’s much longer, but does not use global variables and I hate global variables.
And I already published a module just for that. With that package, the final output will be much cleaner:
package main
import (
"fmt"
"github.com/go-asset/build"
)
const AppName = "MyFancyApp"
func main() {
version, _ := build.ReadVersion(AppName)
fmt.Println(version)
}
With this package, we get the same output:
❯ go run -ldflags="-buildid=v0.0.1" .
MyFancyApp v0.0.1 *dirty* (2022-04-04T11:05:34Z)
There is a catch, while built-in VCS information is not removed with -s
(disable symbol table), ldflags
will not survive that.