Let's Go Further Building, versioning and quality control › Managing and automating version numbers
Previous · Contents · Next
Chapter 19.6.

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:

File: cmd/api/main.go
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.

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
File: 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:

File: cmd/api/main.go
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:

File: Makefile
...

## 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