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 to 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 rebuild 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
Go 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 19 19:01:34 2025 +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.25.0
path greenlight.alexedwards.net/cmd/api
mod greenlight.alexedwards.net v0.0.0-20250219190134-59bdb76fda0c
dep github.com/julienschmidt/httprouter v1.3.0
dep github.com/lib/pq v1.10.9
dep github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
dep github.com/wneessen/go-mail v0.6.2
dep golang.org/x/crypto v0.41.0
dep golang.org/x/text v0.22.0
dep golang.org/x/time v0.12.0
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=59bdb76fda0c15194ce18afae5d4875237f05ea9
build vcs.time=2025-02-19T19:01:34Z
build vcs.modified=false
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.25.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 mod line and 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.
The line mod greenlight.alexedwards.net v0.0.0-20250219190134-59bdb76fda0c tells us that the main module in the compiled binary is greenlight.alexedwards.net and that the module has the version v0.0.0-20250219190134-59bdb76fda0c.
As I mentioned briefly above, all the information that you see in the go version -m output is also available to you at runtime. More specifically, you can access it by calling the debug.ReadBuildInfo() function, which will return a debug.BuildInfo struct that contains essentially the same information that we saw when running the go version -m command
Let’s leverage this and adapt our main.go file so that the version value is set to the pseudo-version that we’ve just discussed, rather than the hardcoded constant "1.0.0".
To assist with this, we’ll create a small internal/vcs package containing a Version() function, like so:
$ mkdir internal/vcs $ touch internal/vcs/vcs.go
package vcs import ( "fmt" "runtime/debug" ) func Version() string { // Use debug.ReadBuildInfo() to retrieve a debug.BuildInfo struct. If this is available, // the ok value will be true, and we return the pseudo-version contained in the // Main.Version field. bi, ok := debug.ReadBuildInfo() if ok { return bi.Main.Version } return "" }
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: v0.0.0-20250219190134-59bdb76fda0c+dirty
Great! That’s now reporting the pseudo-version number contained in the binary. But because we’ve changed the codebase since the previous commit, the pseudo-version now has the +dirty suffix to indicate that the codebase has uncommitted changes.
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: v0.0.0-20250221115919-f79a5dbadf36
Tagging releases
In some projects you may want to annotate certain Git commits with a semantic version number tag, often to denote a formal release.
To illustrate this, let’s add the v1.0.0 tag to our latest commit like so:
$ git tag v1.0.0
$ git log
commit f79a5dbadf3665b825aef58b27406920ac6382d7 (HEAD -> master, tag: v1.0.0)
Author: Alex Edwards <alex@alexedwards.net>
Date: Fri Feb 21 12:59:19 2025 +0100
Generate version number automatically
...
If you rebuild the application and check the version again, you should see that it now reports v1.0.0 as the version, instead of the automatically generated pseudo-version.
$ make build/api $ ./bin/api -version Version: v1.0.0
Now, just to illustrate what happens next, let’s make another (empty) commit to our codebase.
$ git commit --allow-empty -m "Empty commit" [master 5370944] Empty commit
At this point, our base version is still v1.0.0, but we are ahead of the commit that we tagged as v1.0.0. So now Go will no longer report v1.0.0 as the version, and instead will go back reporting a pseudo-version again.
When you rebuild the application and check it, you should see that the version looks a bit like this:
$ make build/api $ ./bin/api -version Version: v1.0.1-0.20250221122119-5370944738f9
The timestamp and commit hash are those of the latest commit, just like before. But we can also see that the semantic version number part is v1.0.1-0, which is a little bit weird at first glance.
What’s happening here is that Go is applying the following rule: if you have the base version vX.Y.Z, but your latest commits are ahead of it, then the start of the pseudo-version will be in the format vX.Y.(Z+1)-0. For more information about the rationale behind this, please see the Go Modules Reference.
So all in all, this is really good. We’ve got a version number that is automatically generated based on our Git history and embedded in our binary, and it’s easy 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
Reporting just the commit timestamp and hash
If you aren’t tagging commits, and want to use just the latest commit timestamp and hash as your version number, you can loop through the debug.BuildInfo.Settings field to extract the vcs.time, vcs.revision and vcs.modified values and create your own version string. Like so:
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) }
Using this would result in version numbers that look similar to this:
2025-02-21T10: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 -X main.version=${VERSION}' -o=./bin/api ./cmd/api GOOS=linux GOARCH=amd64 go build -ldflags='-s -X main.version=${VERSION}' -o=./bin/linux_amd64/api ./cmd/api