Managing and Automating Version Numbers
Right at the start of this book, we hard-coded the version number for our application as the constant "1.0.0" in the cmd/api/main.go file.
In this chapter, we’re going take steps to make it easier to view and manage this version number, and also explain how you can generate version numbers automatically based on Git commits and integrate them into your application.
Displaying the version number
Let’s start by updating our application so that we can easily check the version number by running the binary with a -version command-line flag, similar to this:
$ ./bin/api -version Version: 1.0.0
Conceptually, this is fairly straightforward to implement. We need to define a boolean version command-line flag, check for this flag on startup, and then print out the version number and exit the application if necessary.
If you’re following along, go ahead and update your cmd/api/main.go file like so:
package main import ( "context" "database/sql" "expvar" "flag" "fmt" // New import "log/slog" "os" "runtime" "strings" "sync" "time" "greenlight.alexedwards.net/internal/data" "greenlight.alexedwards.net/internal/mailer" _ "github.com/lib/pq" ) const version = "1.0.0" ... func main() { var cfg config flag.IntVar(&cfg.port, "port", 4000, "API server port") flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") flag.StringVar(&cfg.db.dsn, "db-dsn", "", "PostgreSQL DSN") flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections") flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections") flag.DurationVar(&cfg.db.maxIdleTime, "db-max-idle-time", 15*time.Minute, "PostgreSQL max connection idle time") flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter") flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second") flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst") flag.StringVar(&cfg.smtp.host, "smtp-host", "sandbox.smtp.mailtrap.io", "SMTP host") flag.IntVar(&cfg.smtp.port, "smtp-port", 25, "SMTP port") flag.StringVar(&cfg.smtp.username, "smtp-username", "a7420fc0883489", "SMTP username") flag.StringVar(&cfg.smtp.password, "smtp-password", "e75ffd0a3aa5ec", "SMTP password") flag.StringVar(&cfg.smtp.sender, "smtp-sender", "Greenlight <no-reply@greenlight.alexedwards.net>", "SMTP sender") flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error { cfg.cors.trustedOrigins = strings.Fields(val) return nil }) // Create a new version boolean flag with the default value of false. displayVersion := flag.Bool("version", false, "Display version and exit") flag.Parse() // If the version flag value is true, then print out the version number and // immediately exit. if *displayVersion { fmt.Printf("Version:\t%s\n", version) os.Exit(0) } ... } ...
OK, let’s try this out. Go ahead and re-build the executable binaries using make build/api, then run the ./bin/api binary with the -version flag.
You should find that it prints out the version number and then exits, similar to this:
$ make build/api Building cmd/api... go build -ldflags="-s" -o="./bin/api" ./cmd/api GOOS=linux GOARCH=amd64 go build -ldflags="-s" -o="./bin/linux_amd64/api" ./cmd/api $ ./bin/api -version Version: 1.0.0
Automated version numbering with Git
Since version 1.18, Go now embeds version control information in your executable binaries when you run go build on a main package that is tracked with Git, Mercurial, Fossil, or Bazaar.
There are two ways to access this version control information — either by using the go version -m command on your binary, or from within your application code itself by calling debug.ReadBuildInfo().
Let’s take a look at both approaches.
If you’re following along (and haven’t done it already), please go ahead and initialize a new Git repository in the root of your project directory:
$ git init Initialized empty Git repository in /home/alex/Projects/greenlight/.git/
Then make a new commit containing all the files in your project directory, like so:
$ git add . $ git commit -m "Initial commit"
If you look at your commit history using the git log command, you’ll see the hash for this commit.
$ git log
commit 59bdb76fda0c15194ce18afae5d4875237f05ea9 (HEAD -> master)
Author: Alex Edwards <alex@alexedwards.net>
Date: Wed Feb 22 18:14:42 2023 +0100
Initial commit
In my case the commit hash is 59bdb76fda0c15194ce18afae5d4875237f05ea9 — but yours will very likely be a different value.
Next run make build again to generate a new binary and then use the go version -m command on it. Like so:
$ make build/api
Building cmd/api...
go build -ldflags="-s" -o=./bin/api ./cmd/api
GOOS=linux GOARCH=amd64 go build -ldflags="-s" -o=./bin/linux_amd64/api ./cmd/api
$ go version -m ./bin/api
./bin/api: go1.23.0
path greenlight.alexedwards.net/cmd/api
mod greenlight.alexedwards.net (devel)
dep github.com/go-mail/mail/v2 v2.3.0 h1:wha99yf2v3cpUzD1V9ujP404Jbw2uEvs+rBJybkdYcw=
dep github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
dep github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
dep github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
dep golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
dep golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
build -buildmode=exe
build -compiler=gc
build -ldflags="-s"
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=3f5ab2cbaaf4bf7c936d03a1984d4abc08e8c6d3
build vcs.time=2023-09-10T06:37:03Z
build vcs.modified=true
The output from go version -m shows us some interesting information about the binary. We can see the version of Go that it was built with (go1.23.0 in my case), the module dependencies, and information about the build settings — including the linker flags used and the OS and architecture it was built for.
However, the things that we’re most interested in right now are the vcs build settings at the bottom.
vcs=gittells us that the version control system being used is Git.vcs.revisionis the hash for the latest Git commit.vcs.timeis the time that this commit was made.vcs.modifiedtells us whether the code tracked by the Git repository has been modified since the commit was made. A value offalseindicates that the code has not been modified, meaning that the binary was built using the exact code from thevcs.revisioncommit. A value oftrueindicates that the version control repository was ‘dirty’ when the binary was built — and the code used to build the binary may not be the exact code from thevcs.revisioncommit.
As I mentioned briefly above, all the information that you see in the go version -m output is also available to you at runtime.
Let’s leverage this and adapt our main.go file so that the version value is set to the Git commit hash, rather than the hardcoded constant "1.0.0".
To assist with this, we’ll create a small internal/vcs package which generates a version number for our application based on the commit hash from vcs.revision plus an optional -dirty suffix if vcs.modified=true. In order to do that, we’ll need to:
- Call the
debug.ReadBuildInfo()function. This will return adebug.BuildInfostruct which contains essentially the same information that we saw when running thego version -mcommand. - Loop through the
debug.BuildInfo.Settingsfield to extract thevcs.revisionandvcs.modifiedvalues.
Like so:
$ mkdir internal/vcs $ touch internal/vcs/vcs.go
package vcs import ( "fmt" "runtime/debug" ) func Version() string { var revision string var modified bool bi, ok := debug.ReadBuildInfo() if ok { for _, s := range bi.Settings { switch s.Key { case "vcs.revision": revision = s.Value case "vcs.modified": if s.Value == "true" { modified = true } } } } if modified { return fmt.Sprintf("%s-dirty", revision) } return revision }
Now that’s in place, let’s head back to our main.go file and update it to set the version number using this new vcs.Version() function:
package main import ( "context" "database/sql" "expvar" "flag" "fmt" "log/slog" "os" "runtime" "strings" "sync" "time" "greenlight.alexedwards.net/internal/data" "greenlight.alexedwards.net/internal/mailer" "greenlight.alexedwards.net/internal/vcs" // New import _ "github.com/lib/pq" ) // Make version a variable (rather than a constant) and set its value to vcs.Version(). var ( version = vcs.Version() ) ...
Alright, let’s try this out. Go ahead and rebuild the binary again…
$ make build/api Building cmd/api... go build -ldflags="-s" -o=./bin/api ./cmd/api GOOS=linux GOARCH=amd64 go build -ldflags="-s" -o=./bin/linux_amd64/api ./cmd/api
And then run it with the -version flag:
$ ./bin/api -version Version: 59bdb76fda0c15194ce18afae5d4875237f05ea9-dirty
In my case we can see that the version number reported is 59bdb76fda0c15194ce18afae5d4875237f05ea9-dirty. That makes sense — the last commit hash was 59bdb76fda0c15194ce18afae5d4875237f05ea9 and we’ve changed the codebase since that commit which is why it includes the -dirty suffix.
Let’s fix that by committing our recent changes…
$ git add . $ git commit -m "Generate version number automatically"
And when you rebuild the binary and check the version number again you should see a new version number without the -dirty suffix, similar to this:
$ make build/api Building cmd/api... go build -ldflags="-s" -o=./bin/api ./cmd/api GOOS=linux GOARCH=amd64 go build -ldflags="-s" -o=./bin/linux_amd64/api ./cmd/api $ ./bin/api -version Version: 1c9b6ff48ea800acdf4f5c6f5c3b62b98baf2bd7
But all in all, this is really good. Our application version number now aligns with the commit history in our Git repository, meaning that it’s easy for us to identify exactly what code a particular binary contains or a running application is using. All we need to do is run the binary with the -version flag, or call the healthcheck endpoint, and then cross-reference the version number against the Git repository history.
Additional Information
Including commit time in the version number
If you want, you could extend the vcs.Version() function to include the commit time in the version number too.
func Version() string { var ( time string revision string modified bool ) bi, ok := debug.ReadBuildInfo() if ok { for _, s := range bi.Settings { switch s.Key { case "vcs.time": time = s.Value case "vcs.revision": revision = s.Value case "vcs.modified": if s.Value == "true" { modified = true } } } } if modified { return fmt.Sprintf("%s-%s-dirty", time, revision) } return fmt.Sprintf("%s-%s", time, revision) }
Making that change would result in version numbers that look similar to this:
2022-04-30T10:16:24Z-1c9b6ff48ea800acdf4f5c6f5c3b62b98baf2bd7-dirty
Using linker flags
Prior to Go 1.18 the idiomatic way to manage version numbers automatically was to ‘burn-in’ the version number when building the binary using the -X linker flag. Using debug.ReadBuildInfo() is now the preferred method, but the old approach can still be useful if you need to set the version number to something that isn’t available via debug.ReadBuildInfo().
For example, if you wanted to set the version number to the value of a VERSION environment variable on the machine building the binary, you could use the -X linker flag to ‘burn-in’ this value to the main.version variable. Like so:
... ## build/api: build the cmd/api application .PHONY: build/api build/api: @echo 'Building cmd/api...' go build -ldflags='-s -w -X main.version=${VERSION}' -o=./bin/api ./cmd/api GOOS=linux GOARCH=amd64 go build -ldflags='-s -w -X main.version=${VERSION}' -o=./bin/linux_amd64/api ./cmd/api