Versioning Go Applications

Nov 18, 2023·
Mani Soundararajan
Mani Soundararajan
· 3 min read
Photo by Angèle Kamp on Unsplash

Inject version information in Go applications using linker flags.

Overview

One of the must haves when it comes to running a Go application in production is to have a clear version number printed in the logs at the start of the application. This is helpful in the future when debugging a bug by looking at the logs, and seeing which version of the app caused this behavior.

Get Version

The version information can be stored as global variables with zero values, with the values being injected at build time by the linker.

Here is an example:

package main

import "log"

var APP_VERSION string
var APP_PRERELEASE string
var APP_BUILD string

func main() {
	log.Println("Hello world", getVersionString())
}

func getVersionString() string {
	version := ""

	if len(APP_VERSION) > 0 {
		version = APP_VERSION
	} else {
		version = "1.0.0"
	}

	if len(APP_PRERELEASE) > 0 {
		version = version + "-" + APP_PRERELEASE
	} else {
		version = version + "-local.1"
	}

	if len(APP_BUILD) > 0 {
		version = version + "+" + APP_BUILD
	}

	return version
}

Notice that the variables APP_VERSION, APP_PRERELEASE and APP_BUILD are declared at the global scope, but left at the zero value ("").

If you run this as is, you will see the following output:

$ go run main.go
2023/11/17 18:13:27 Hello world 1.0.0-local.1

Linker flags

As the next step, we will be injecting these variables at build time using linker flags:

$ go mod init github.com/msound/helloworld
$ go build -o helloworld -ldflags "-X 'main.APP_VERSION=1.0.0' -X 'main.APP_PRERELEASE=stage.a862776' -X 'main.APP_BUILD=build.422'"

This would build a binary named helloworld which would have the version information baked into the binary. Now if we run the binary:

$ ./helloworld
2023/11/17 18:20:57 Hello world 1.0.0-stage.a862776+build.422

Dockerfile

You are probably going to run your Go app as a container, so you would want to pass these values through as arguments into a Dockerfile. Here’s how you would do it:

FROM golang:1.21 AS build-env

ENV GOOS=linux GOARCH=amd64 CGO_ENABLED=0

ARG APP_VERSION
ARG APP_PRERELEASE
ARG APP_BUILD

WORKDIR /app

COPY . /app

RUN go build \
  -o helloworld \
  -ldflags "-X 'main.APP_VERSION=${APP_VERSION}' -X 'main.APP_PRERELEASE=${APP_PRERELEASE}' -X 'main.APP_BUILD=${APP_BUILD}'"


###########################

FROM alpine:3.11

RUN apk update && \
  apk add --no-cache --purge ca-certificates curl tzdata && \
  rm -rf /var/cache/apk/* /tmp/*

RUN adduser -D appuser --uid 3000

COPY --from=build-env /app/helloworld /app/helloworld

USER appuser

CMD ["/app/helloworld"]

The Dockerfile receives the version information as ARGs, and passes them on to go build as linker arguments.

CI/CD Pipeline

The final step is to pass the arguments to docker build from the CI/CD Pipeline. Here is the snippet of this for Gitlab. For Github actions, you can adapt this as needed:

script:
  - |
    export APP_VERSION=${CI_COMMIT_TAG}
    export APP_PRERELEASE=stage.${CI_COMMIT_SHORT_SHA}
    export APP_BUILD=build.${CI_JOB_ID}
    docker build \
      --build-arg "APP_VERSION=${APP_VERSION}" \
      --build-arg "APP_PRERELEASE=${APP_PRERELEASE}" \
      --build-arg "APP_BUILD=${APP_BUILD}" \
      -t helloworld:${CI_COMMIT_TAG} .    

References and Credits

Mani Soundararajan
Authors
Fractional CTO
My interests include DevOps, Cloud Native Computing, Machine Learning, and Startups.