Manually install and auto-switch Golang versions

TOC

If you work on muliple Go projects, you’ll often find they require different Go versions. So how do you handle switching between Go versions?

You can’t rely on your package manager as it might only provide the latest version of Go (such as is the case when using Homebrew on macOS) or it might not provide all prior Go versions (maybe only a subset). Then you have to decide whether the switch is something you do manually or automatically when you cd into a Go project directory (but how do you determine and implement that?).

Typically you’ll use a third-party tool that does this for you. I’ve historically used a lot of tools. The last tool I used was stefanmaric/g and it was working fine …for a while, until one day it stopped working and for the life of me I couldn’t figure out why.

The problem with using a third-party tool is that if it does go wrong, it’s very hard to debug and fix. With this in mind, I decided I would have a go at solving the problem in a way that worked for me.

⚠️ WARNING:
This is NOT a perfect solution, and in some cases it’s a poor solution.

In this article I’m going to show you the code I wrote to implement Go-version switching, as well as covering some of the caveats of the approach I took. But ultimately, this is a solution I’ve implemented from scratch and so I intrinsically understand it and will understand how it works better than anyone and will understand more easily what to do if for some reason there’s a bug or scenario that I didn’t account for when first creating it. This is why I provide the above warning note. Feel free to use my approach, or use the code as an example from which to build your own solution.

💡NOTE:
As an alternative, some people prefer to have a single Go version install (i.e. GOROOT) and then when they use the go binary to install other versions they will simply create an alias (manually) or have a Makefile accept a GO_BIN override. It’s definitely a much simpler approach if you prefer that.

Let’s dig in and see what we have…

Shell Structure

OK, let’s start with how I like to structure my shell files.

I have a .zshrc from which I then load in other shell scripts.

To avoid muddying the water I’ll show a truncated version:

💡 NOTE:
If you want the full version of the code we’re discussing,
then refer to my dotfiles repo.

#!/usr/bin/zsh

function load_script {
	local path=$1
	if test -f $path; then
		source $path
	else
		echo "no $path found"
	fi
}

load_script ~/.config/zsh/tools.zsh

Cool, so we know we need a tools.zsh script. Let’s take a look at the relevant sections of that file. We’ll start with the exports…

Exports

export GOPATH="$HOME/go"
export GOROOT="$HOME/.go"
export PATH="$GOPATH/bin:$GOROOT/bin:$PATH";

The GOPATH is where we install Go CLI programs, and for our purposes it’s where we will install our different Go versions.

The GOROOT is where we install our primary Go version (this is the Go version we start with and is the version we keep up-to-date with the latest Go release).

The PATH is where our shell attempts to lookup executable binaries, such as the go binary.

You can see we make sure $GOPATH/bin is checked first, then failing that it’ll check $GOROOT/bin, before considering any other entries in the $PATH.

You can probably already guess the approach I’m taking, but if not, it’s this: by having $GOPATH/bin as the first entry in my $PATH, it means I can install my different Go version binaries there, and then create a symlink for go in that same directory to point to the specific Go version binary I want to be using.

Installing Go for GOROOT

The first thing we need to do is identify if we have Go installed at all. We do this by checking if there is a go binary file in our $GOROOT/bin directory. If there isn’t a file there, then we identify the latest Go version release and download it into $GOROOT/bin:

if [ ! -f $GOROOT/bin/go ]; then
	mkdir -p "$GOPATH"
	mkdir -p "$GOROOT"

	GO_VERSION=$(golatest)
	OS=$(uname | tr '[:upper:]' '[:lower:]')
	ARCH=$(uname -m)
	URL="https://go.dev/dl/go${GO_VERSION}.${OS}-${ARCH}.tar.gz"
	TMP_DL="/tmp/go.tar.gz"

	echo "Downloading latest Go archive from $URL"
	curl -Lo "$TMP_DL" "$URL"

	# Extract the tar.gz file to the installation directory
	# The --strip-components=1 skips the go/ directory within the archive.
	# This ensures the ~/.go directory contains bin/ rather than ~/.go/go/bin
	echo "Extracting Go archive to $GOROOT"
	tar -C "$GOROOT" --strip-components=1 -xzf "$TMP_DL"

	# Cleanup the downloaded archive
	echo "Cleaning up Go archive from $TMP_DL"
	rm "$TMP_DL"
fi

💡 NOTE:
I’ve used golatest in the above code. The implementation for that is as follows:

alias golatest="curl -L https://github.com/golang/go/tags 2>&1 | \
	rg '/golang/go/releases/tag/go[\w.]+' -o | \
	cut -d '/' -f 6 | \
	grep -v 'rc' | \
	awk NR==1 | \
	rg '\d.+' -o"

Triggering a switch on directory change

So the key part to all this is checking the current directory to see if we need to download a different Go version. In the Zsh shell you have access to a builtin function called chpwd which runs every time you change directory. Changing directory is typically done using cd but it also works if you use a tool like ajeetdsouza/zoxide (like I do) to quickly jump around common project directories.

💡 NOTE:
See the Zsh hook functions docs.

Here is the relevant parts of our chpwd function:

function chpwd() {
	ls

	# figure out go version
	#
	local v=""
	if [ -e go.mod ]; then
		v=$(awk '/^go [0-9]+\.[0-9]+/ { print $2 }' go.mod)
		# go.mod isn't always going to contain a complete version (e.g. 1.20 vs 1.20.1)
		# we need a complete version for installing and symlinking.
		#
		if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
			latest_patch=$(gh api repos/golang/go/tags --jq '.[].name' --paginate \
				| grep -E "^go${v}\.[0-9]+$" \
				| sed 's/^go//' \
				| sort -V \
				| tail -n 1)
			if [ -n "$latest_patch" ]; then
				v="$latest_patch"
			else
				echo "Failed to fetch the latest patch version for $v"
				go_symlink_remove # remove symlink so the PATH lookup finds the GOROOT binary.
				v="" # Ensure v is empty to prevent executing the install steps
			fi
		fi
	elif [ -e .go-version ]; then
		v="$(cat .go-version)"
	fi
	if [ -n "$v" ]; then
		# create go dependencies cache directory if it doesn't exist.
		local cache_dir="$HOME/.cache/go-deps"
		if [[ ! -d "$cache_dir" ]]; then
			mkdir -p "$HOME/.cache/go-deps"
		fi
		local cache_file="$cache_dir/go$v"

		if [[ ! -f "$cache_file" ]]; then
			go_install "$v" # installs the specified Go version
			go_symlink "$v" # ensures `go` now references the specified Go version
			go_tools # ensures we have all the tools we need for this Go version
			touch "$cache_file" # update last_modified date
		else
			go_symlink "$v" # ensures `go` now references the specified Go version

			local current_day=$(date +%Y-%m-%d)
			local last_modified_day=$(date -r "$cache_file" +%Y-%m-%d)

			# if the cache file was last modified on a different day, run the command
			if [ "$current_day" != "$last_modified_day" ]; then
				echo "updating go$v dependencies (last updated: $last_modified_day)"
				go_tools # ensures we have all the tools we need for this Go version
				touch "$cache_file" # update last_modified date
			fi
		fi

		r # reload shell so starship can display the updated go version
	fi

	# clean out any .DS_Store files
	#
	if [[ $PWD != $HOME ]]; then
		# find . -type f -name '.DS_Store' -delete
		fd '.DS_Store' --type f --hidden --absolute-path | xargs -I {} rm {}
	fi
}

In the above script we do the following:

  • Check if the directory contains a go.mod or a .go-version.
  • If there’s a go.mod then we check if it’s a ‘complete’ version.
  • If it’s not a complete version, we identify the latest patch available.
  • If it’s a .go-version then we know that will contain a full version.
  • We store whatever version we find or calculate into v.
  • We then call some custom functions and pass them v.

For that last bullet, the functions we call are:

  • go_install
  • go_symlink
  • go_tools

The implementation for those functions are:

# go_install installs the specified version
function go_install() {
  if [ -z "$1" ]; then
		echo "Pass a Go version (e.g. 1.21.13)"
    return
  fi
	local v="$1"
	go install "golang.org/dl/go$v@latest"
	"$GOPATH/bin/go$v" download
	"$GOPATH/bin/go$v" version
}

# go_symlink is called by chpwd to allow a different go version binary to be used.
# if the specified version binary doesn't exist, we install it first.
function go_symlink() {
  if [ -z "$1" ]; then
		echo "Pass a Go version (e.g. 1.21.13)"
    return
  fi
	local v=$1
	if [ ! -f "$GOPATH/bin/go$v" ]; then
		go_install "$v"
	fi
	ln -sf "$GOPATH/bin/go$v" "$GOPATH/bin/go"
}

# go_tools installs/updates necessary Go tools.
function go_tools {
  local golangcilatest=$(curl -s "https://github.com/golangci/golangci-lint/releases" | \
	grep -o 'tag/v[0-9]\+\.[0-9]\+\.[0-9]\+' | head -n 1 | cut -d '/' -f 2)
  curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \
	sh -s -- -b $(go env GOPATH)/bin "$golangcilatest"
  go install github.com/rakyll/gotest@latest
  go install github.com/mgechev/revive@latest
  go install golang.org/x/tools/gopls@latest
  go install mvdan.cc/gofumpt@latest
  go install honnef.co/go/tools/cmd/staticcheck@latest # https://github.com/dominikh/go-tools
  go install golang.org/x/vuln/cmd/govulncheck@latest
  go install github.com/go-delve/delve/cmd/dlv@latest
  go install go.uber.org/nilaway/cmd/nilaway@latest
  go install golang.org/x/tools/cmd/goimports@latest
  go install github.com/incu6us/goimports-reviser/v3@latest
  go install github.com/google/gops@latest
  go install github.com/securego/gosec/v2/cmd/gosec@latest
}

Finally, we run r which is an alias that reloads the .zshrc file:

alias r="source ~/.zshrc"

Why do we reload the shell configuration? Well, I use the Starship shell prompt, and that has its own logic for determining the Go version, and now with the above workflow it often reports the wrong Go version. But once I reload my shell configuration it’ll pick up the go binary that is now being symlinked to a specific Go version.

Now, there’s a performance improvement I made to the code (which you can see in the earlier code snippet but I didn’t explain):

# create go dependencies cache directory if it doesn't exist.
local cache_dir="$HOME/.cache/go-deps"
if [[ ! -d "$cache_dir" ]]; then
	mkdir -p "$HOME/.cache/go-deps"
fi
local cache_file="$cache_dir/go$v"

if [[ ! -f "$cache_file" ]]; then
	go_install "$v" # installs the specified Go version
	go_symlink "$v" # ensures `go` now references the specified Go version
	go_tools # ensures we have all the tools we need for this Go version
	touch "$cache_file" # update last_modified date
else
	go_symlink "$v" # ensures `go` now references the specified Go version

	local current_day=$(date +%Y-%m-%d)
	local last_modified_day=$(date -r "$cache_file" +%Y-%m-%d)

	# if the cache file was last modified on a different day, run the command
	if [ "$current_day" != "$last_modified_day" ]; then
		echo "updating go$v dependencies (last updated: $last_modified_day)"
		go_tools # ensures we have all the tools we need for this Go version
		touch "$cache_file" # update last_modified date
	fi
fi

That improvement was to check if a cache file exists for the Go version, and if so, we’ll see if the file was updated at some point in the last day. If it has been updated already then we don’t bother updating the Go tools and dependencies for the specified Go version

We only really want to do that once a day, otherwise every time you change directory to another Go project it will unnecessarily downloads dependencies you already have and that takes time.