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

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:

Like so:

$ mkdir internal/vcs
$ touch internal/vcs/vcs.go
File: 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:

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

File: Makefile
...

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