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.