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 thego
binary to install other versions they will simply create an alias (manually) or have a Makefile accept aGO_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 usedgolatest
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.