Often when building a Go binary, we want to embed the specific commit-revision that it was built from in the binary, this is useful for doing things like command --version or exposing in an HTTP API endpoint (or possibly header).

For example, kubectl provides really good information in its output:

$ kubectl version
Client Version: version.Info{Major:"1", Minor:"18", GitVersion:"v1.18.4", GitCommit:"c96aede7b5205121079932896c4ad89bb93260af", GitTreeState:"clean", BuildDate:"2020-06-18T07:39:54Z", GoVersion:"go1.14.3", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"18", GitVersion:"v1.18.3", GitCommit:"2e7996e3e2712684bc73f0dec0200d64eec7fe40", GitTreeState:"clean", BuildDate:"2020-05-20T12:43:34Z", GoVersion:"go1.13.9", Compiler:"gc", Platform:"linux/amd64"}

So, how do you do this in your compilations?

Let’s start with the simplest possible Go file.

package main

import "fmt"

var gitRevision = "unknown"

func main() {
    fmt.Printf("version %s\n", gitRevision)
}

Quickly put this into a git repo so that I can actually get the version

$ git init . && git add main.go && git commit -m "Initial commit."

Building at the command-line

$ go build main.go
$ ./main
version unknown

Ok, nothing totally unexpected there…but…

$ GIT_COMMIT=$(git rev-parse HEAD) && go build -ldflags "-X main.gitRevision=${GIT_COMMIT}" ./main.go
$ ./main
version 49e5416e820fb376fdb277243dbec8e2e81fbc29

The "-X main.gitRevision=${GIT_COMMIT}" embeds the environment variable GIT_COMMIT into the binary, by replacing the path main.gitRevision.

The -X parameter is passed to the Go link tool and from that documentation:

-X importpath.name=value Set the value of the string variable in importpath named name to value. This is only effective if the variable is declared in the source code either uninitialized or initialized to a constant string expression. -X will not work if the initializer makes a function call or refers to other variables. Note that before Go 1.5 this option took two separate arguments.

That importpath is fairly crucial to understanding the next part, for the simple case, the Go package is main, but we can also write into any package in a Go source tree.

Adding a go.mod path to the path.

That was fairly easy, but what if our package is in a go.mod package?

$ go mod init github.com/bigkevmcd/demo
go: creating new go.mod: module github.com/bigkevmcd/demo

And I’ll move the version into a specific package.

Editing pkg/git/git.go

package git

var Revision = "unknown"

And changing the original main.go to

package main

import (
	"fmt"

	"github.com/bigkevmcd/demo/pkg/git"
)

func main() {
	fmt.Printf("version %s\n", git.Revision)
}

So, I’m importing the version from a package.

I need to slightly adjust the command-line for building this:

$ GIT_COMMIT=$(git rev-parse HEAD) && go build -ldflags "-X github.com/bigkevmcd/demo/pkg/git.Revision=${GIT_COMMIT}" ./main.go
$ ./main
version abd2bcd0497f7a0f4e04aa157e879b478f1b0f95

NOTE that the full Go “package path” to the value I want to update is provided.

Building in a Dockerfile

So, now that I’ve got the basics right, building it in a Dockerfile is fairly simple:

FROM golang:latest AS build
WORKDIR /go/src
COPY . /go/src
RUN ls -la
RUN GIT_COMMIT=$(git rev-parse HEAD) && \
    CGO_ENABLED=0 GOOS=linux go build \
    -ldflags "-X github.com/bigkevmcd/demo/pkg/git.Revision=${GIT_COMMIT}" ./main.go

FROM alpine
WORKDIR /root/
COPY --from=build /go/src/main .
ENTRYPOINT ["./main"]

I can easily build and push this to an image repository.

$ docker build -t bigkevmcd/demo .
$ docker push bigkevmcd/demo

And I can run this image in Kubernetes with a one-liner:

$ kubectl run --image=bigkevmcd/demo demo-pod --restart=Never
pod/demo-pod created
$ kubectl logs pod/demo-pod
version abd2bcd0497f7a0f4e04aa157e879b478f1b0f95