TLDR: See cristalhq/.github build workflow and how it can be used cristalhq/jsn
Intro
I love open source, and also I love Go. So, a few months ago I decided to build the best CI for Go that I could easily reuse across my projects.
This post shares the results.
Note: Post is based on version v0.5.0
of cristalhq/.github repository.
Github Actions
You probably know what GitHub Actions are, and because they are well-integrated with GitHub, I am too lazy to look at alternatives.
The only thing that I recommend is to look at Workflow syntax which explains what syntax is available for us.
Dependabot
I've noticed that not all developers are happy with Dependabot notifications (pull requests), and they find them noisy. For me, they do more good than bad. Consider just making the interval parameter bigger (a week or longer).
In almost all my projects I use the following Dependabot configuration:
version: 2
updates:
- package-ecosystem: gomod
directory: "/"
schedule:
interval: daily
- package-ecosystem: GitHub-actions
directory: "/"
schedule:
interval: daily
Save the YAML above in .github/dependabot.yml
and it's done.
Basic workflow
The default Github Actions for Go looks like this:
name: Go
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.19
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
In many cases it's ok but we can do better. Let's add more good things to it.
Regular runs
A few times I got stuck with a failing CI because I missed some 3rd party changes and found them too late, running CI regularly prevents that:
on:
schedule:
- cron: '0 10 * * 1' # run "At 10:00 on Monday"
A cron expression is an example, play on crontab guru to find yours.
Runs on and Go version
Most of my projects do not interact with the operating system, so running on Ubuntu is enough.
It's a good practice to keep your code working on the current and previous Go versions. It's not a strict rule, but it might simplify life for you or your users when a new Go version is released.
The actions/setup-go@v4 action names them as stable
and oldstable
:
jobs:
run:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 5 # just in case ¯\_(ツ)_/¯
strategy:
matrix:
go: ['stable', 'oldstable']
First steps:
Just check out the code and install Go. No magic:
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Install Go
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go }}
check-latest: true
check-latest
set to true
will pick the latest Go release for the given Go version. For example, for Go 1.20, it will download Go 1.20.2 (which is the latest at the moment of writing this post).
actions/setup-go@v4
also supports caching out of the box. Please see README section. I don't have this configured because most of my projects are dependency-free or I don't care that much.
Go steps
Enough to setup the CI job, time to do real work!
Formatting and go vet
If code isn't formatted there is no sense to build it (or even to review). Same regarding go vet
. It's the smallest linter but reports good things:
- name: Go Format
run: gofmt -s -w . && git diff --exit-code
- name: Go Vet
run: go vet ./...
Thanks to Daniel Martí gofmt
is super fast now Go #43566.
Check dependencies
I love to write dependency-free packages but sometimes I need something to import. So, the dependencies must be verified.
First, we need to check that go.mod
is correct, download dependencies and verify go.sum
:
- name: Go Tidy
run: go mod tidy && git diff --exit-code
- name: Go Mod
run: go mod download
- name: Go Mod Verify
run: go mod verify
You might think that go mod verify
is an extra step, but this will help you to catch force pushes (or even supply chain attacks) in your dependencies.
I heavily recommend adding this to all your workflows.
Codegen & build
Maybe it's not the most popular feature in Go and there are different ways to manage this but I prefer and recommend you to commit generated files and always treat them as your code (well, it's yours anyway).
And let's build all packages and ignore the executables (if any, anyway we are not going to use them on CI):
- name: Go Generate
run: go generate ./... && git diff --exit-code
- name: Go Build
run: go build -o /dev/null ./...
Test
Okay, this part might sound overengineered but give me a moment to explain. Most of my projects are quite simple and don't require a database or any separate service to run, it's just a Go package that I use in other projects.
Doing just go test
is exactly what I'm doing (ignore the flags and if: ...
, I will explain them in a second):
- name: Go Test
if: ${{ !inputs.skipTests }}
run: go test -v -count=1 -race -shuffle=on -coverprofile=coverage.txt ./...
But as for Redis client that I'm writing (cristalhq/redis), I need to setup Redis to test everything properly which requires additional changes to the workflow file. And I don't want that, I want to reuse as much as possible!
What I did: I added a boolean parameter to the GitHub Actions workflow named skipTests
which is false
by default (because most of the projects are simple).
But for the Redis client repository, I change it to true
and disable tests. I'm running them separately in another workflow file called e2e.yml.
And when tests are disabled I'm only compiling them:
- name: Go Compile Tests
if: ${{ inputs.skipTests }}
run: go test -exec /bin/true ./...
It's a hacky way to compile all packages and delete executables, see Go #15513.
To add a parameter to the workflow just do:
on:
workflow_call:
inputs:
skipTests:
description: 'Skip tests, useful when there is a dedicated CI job for tests'
default: false
required: false
type: boolean
Test flags
Let's take a closer look at the go test
flags:
-v
- I prefer verbose output where I can see everything
-count=1
- prevents caching
go test
results, which might be irrelevant for CI but I prefer to keep it
- prevents caching
-race
- enable race detector, this increases build and run time but it helps to find concurrency bugs earlier, priceless.
-shuffle=on
- run tests in a different order to prevent implicit test order dependencies
- shameless plug: it was my proposal
-coverprofile=coverage.txt
- collect the coverage to upload it later
./...
- just test all the packages
Benchmark
Benchmarking on the CI in many cases is a bad idea due to noisy neighbours (read other processes on the machine) that will skew your results.
But a rare and bad thing: your benchmark might be broken and you might miss that. Well, this happened to me a few times.
To fix that we can run benchmarks once just to verify that they're still passing:
- name: Go Benchmark
run: go test -v -shuffle=on -run=- -bench=. -benchtime=1x ./...
-v
&-shuffle=on
- you know that already
-run=-
- means do not run tests
-bench=.
- means run every benchmark
-benchtime=1x
- tells to run every benchmark once
./...
- do this in every package
I took this suggestion from a good Go issue "Go CI best practices" Go #42119. Again, thanks to Daniel Marti.
Coverage
In the previous step, we collected code coverage, better to keep it somewhere. I prefer Codecov but you might use any other:
- name: Upload Coverage
if: ${{ !inputs.skipTests }} # upload when we really run our tests
uses: codecov/codecov-action@v3
continue-on-error: true # we don't care if it fails
with:
token: ${{secrets.CODECOV_TOKEN}} # set in repository settings
file: ./coverage.txt # file from the previous step
fail_ci_if_error: false
And that's all! This is the build and/or test workflow that I use daily in 100+ Go repositories.
Vulncheck
Go team released govulncheck
tool with Go 1.19. It's a simple and very fast tool to check "Does your code contain vulnerable lines?". Check the announcement for more info go.dev/blog/vuln.
And see vuln.yml
workflow in cristalhq/.github.
Thanks to brandur.org/fragments/govulncheck-ci where I found this originally.
How it can be reused?
Quite easily. GitHub Actions allow us to reuse workflows. To use the described above workflow in any of my projects I need a few lines in .github/workflows/build.yml
:
jobs:
build:
uses: cristalhq/.github/.github/workflows/build.yml@v0.4.0
vuln:
uses: cristalhq/.github/.github/workflows/vuln.yml@v0.4.0
(where cristalhq/.github is a repository for common stuff across the whole cristalhq organisation, like security policies, code of conduct, etc)
One of the projects that use this workflow cristalhq/jsn. See Reusing workflows documentation for more.
Conclusions
Here is the full workflow: build.yml.
That's the basic CI for most of my Go projects. Many things can be added like fuzzing, automatic releases, linting, etc.
But in this post, I wanted to share the core component of my Go CI workflow.
Thanks.