Back

Modify Variables In Go Binary During Build

You can set the variabel values during build, such as version number and more with the Build command.

by Percy Bolmér, March 18, 2022

Image by Percy Bolmér. Gopher by Takuya Ueda, Original Go Gopher by Renée French (CC BY 3.0)
Image by Percy Bolmér. Gopher by Takuya Ueda, Original Go Gopher by Renée French (CC BY 3.0)

Have you ever used hardcoded version numbers, or tried passing configurations to a binary that states the version of the software?

I have, and it works, but it is very error-prone. It is easy to forget to change that configuration etc or maintain the version across multiple merge requests.

I finally found a great solution, we can modify variables inside the binaries during the build time by adding build flags. This allows us to set up the CI/CD to pass the git commit as a version etc, or have different releases specify the runner ID based on the deployment.

Using this approach makes it easier to maintain and control the specific values since we can easily place them inside the CI/CD. We can do this by leveraging the Linker flags.

A video recording of this tutorial

Linker & Ldflags

The go build tool allows us to pass options to the Linker, which is the component responsible for assembling the binary.

We can pass options to the Linker by using the –ldflags flag to the build tool. There are very many options you can pass, but in this article, we will focus on only one of them. –ldflags accepts a string of configurations, so the input will be enclosed by "".

You can view all the options by running the build with the –help option.

go build --ldflags="--help"
percy@DESKTOP-3DP516F:~/development/buildflags$ go build -ldflags="-help"
# programmingpercy.tech/buildflags
usage: link [options] main.o
  -B note
        add an ELF NT_GNU_BUILD_ID note when using ELF
  -E entry
        set entry symbol name
  -H type
        set header type
  -I linker
        use linker as ELF dynamic linker
  -L directory
        add specified directory to library path
  -R quantum
        set address rounding quantum (default -1)
  -T address
        set text segment address (default -1)
  -V    print version and exit
  -X definition
        add string value definition of the form importpath.name=value
  -a    no-op (deprecated)
  -aslr
        enable ASLR for buildmode=c-shared on windows (default true)
  -benchmark string
        set to 'mem' or 'cpu' to enable phase benchmarking
  -benchmarkprofile base
        emit phase profiles to base_phase.{cpu,mem}prof
  -buildid id
        record id as Go toolchain build id
  -buildmode mode
        set build mode
  -c    dump call graph
  -compressdwarf
        compress DWARF if possible (default true)
  -cpuprofile file
        write cpu profile to file
  -d    disable dynamic executable
  -debugtextsize int
        debug text section max size
  -debugtramp int
        debug trampolines
  -dumpdep
        dump symbol dependency graph
  -extar string
        archive program for buildmode=c-archive
  -extld linker
        use linker when linking in external mode
  -extldflags flags
        pass flags to external linker
  -f    ignore version mismatch
  -g    disable go package data checks
  -h    halt on error
  -importcfg file
        read import configuration from file
  -installsuffix suffix
        set package directory suffix
  -k symbol
        set field tracking symbol
  -libgcc string
        compiler support lib for internal linking; use "none" to disable
  -linkmode mode
        set link mode
  -linkshared
        link against installed Go shared libraries
  -memprofile file
        write memory profile to file
  -memprofilerate rate
        set runtime.MemProfileRate to rate
  -msan
        enable MSan interface
  -n    dump symbol table
  -o file
        write output to file
  -pluginpath string
        full path name for plugin
  -r path
        set the ELF dynamic linker search path to dir1:dir2:...
  -race
        enable race detector
  -s    disable symbol table
  -strictdups int
        sanity check duplicate symbol contents during object file reading (1=warn 2=err).
  -tmpdir directory
        use directory for temporary files
  -v    print link trace
  -w    disable DWARF generation
All the linker options available to use in the build tool

The option we are interested in using is the -X option. This is the definition from the help print.

-X definition    add string value definition of the form importpath.name=value

As you can see, we can use the -X flag and target the variable we want to modify by using the import path (the path you use to import a package) and using  .variableName to target a certain variable using its name, and then the value we want.

Let us try this out with a simple example project.

-X, Setting The Variable Value

Begin by creating a new project, I use the module name programmingpercy.tech/buildflags. This is important to note because it will later be used as an import path.

mkdir buildflags
go mod init programmingpercy.tech/buildflags
touch main.go

We will fill the main.go with a simple function that prints the version and client name.


package main

import "log"

var (
	version = "0.0.1"

	runner = "client-1.0.0"
)

func main() {

	log.Println("Starting runner", runner, "version", version)
}
main.go — The simple program that prints the variabel values.

You can try viewing the output by building and running the program.

go build -o main &&./main
# Outputs
2022/03/08 19:23:28 Starting runner client-1.0.0 version 0.0.1

Let us try rebuilding the binary, but this time we will use the –ldflags and apply the -X command to set the runner into client-2.0.0. The package we work in is main so that will be the import path.

go build -o main --ldflags="-X 'main.runner=client-2.0.0'"

Try running the program again, and view the output change the runner name.

./main
# Outputs
2022/03/08 19:27:21 Starting runner client-2.0.0 version 0.0.1

You can modify multiple flags, all you need to do is add a new -X flag inside the command.

go build -o main --ldflags="-X 'main.runner=client-2.0.0' -X 'main.version=0.0.2'"

You should try running the binary, no surprise, the version is also changed.

Even better, you can also target sub-packages if you want to. To try this, create a subfolder named version, and add a file named version.go which contains the Version variable instead of main.go.

package version

var (
	Version = "0.0.1"
)
version/version.go — The subpackage that we will be modifying

Make sure we update the main.go to use the amazing new version package.

I don’t actually recommend you having a version package, but you get the idea

package main

import (
	"log"
	"programmingpercy.tech/buildflags/version"
)

var (
	runner = "client-1.0.0"
)

func main() {
	log.Println("Starting runner", runner, "version", version.Version)
}
main.go — The main package that now uses the build package.

Now we can try to rebuild the binary, and make sure to update the version number using the -X flag. This time, you can pass the full import path, which is the module name.

go build -o main --ldflags="-X 'programmingpercy.tech/buildflags/version.Version=0.0.2' -X 'main.runner=client-1.0.1'"

Upon running that binary, you should see the values change.

Adding Git Commit Tag As Version

One cool thing is that now when we can modify values during the build, you can automate version handling by using the git commit, etc.

In the folder of the project, we will init a git repository and commit the current files so we can get a commit hash.

git init
git add.
git commit -m "test"

To print a short commit hash, you can use the following git command.

git rev-parse --short HEAD

We can simply inject that into the -X flag, and set the value of that output.

go build -o main --ldflags="-X 'programmingpercy.tech/buildflags/version.Version=$(git rev-parse --short HEAD)' -X 'main.runner=client-1.0.1'"

Running the program with this binary for me now prints the following result

2022/03/08 20:54:35 Starting runner client-1.0.1 version f525917

Conclusion

The example we use is very simple, but you can do many things with this. I have seen versions being injected, feature flags, and more. One easy solution for feature flags that should differ between clients can be passing a true value for the enabled functions. This is probably the easiest feature flag solution to use, no need for frameworks to manage it.

The best place to have these flags is in the CI/CD according to me, to control different releases, versions, and client ids.

I hope you found this interesting, I found it very useful when I learned this.

Are you using ldflags already, if so, for what? And what are the use cases you can think of? I would love to hear from you regarding your thoughts about these flags on any of my social media.

If you enjoyed my writing, please support future articles by buying me an Coffee

Sign up for my Awesome newsletter