GitHub Actions is an alternative to CI services like Travis or CircleCI. It supports building Go projects hosted on GitHub.
This article describes how to use actions to build and test Go projects.
What do Actions give you?
With actions you can run code on every checkin (and many other GitHub events).
Most common use for actions is Continuous Integration (CI) where you build and test code to make sure that new changes didn’t introduce bugs.
When things fail you get notified by an e-mail.
You can see the logs of a particular run. Here’s how the UI for browsing the logs looks like
GitHub actions support running arbitrary code so you can do much more than just build and test the code.
Basic workflow for Go
A workflow defines one or more jobs,. Each job consists of multiple steps, like checking out source code, installing Go toolchain, building and testing your Go code etc.
Workflows run on computers operated by GitHub. The service is free for public repos and pay-as-you-go for private repos.
This is the simplest workflow that builds and tests a Go package. Create .github/workflows/go.yml
file in your GitHub repository:
name: Build and test Go
on: [push, pull_request]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.13
uses: actions/setup-go@v1
with:
go-version: 1.13
- name: Check out source code
uses: actions/checkout@v1
- name: Build
env:
GOPROXY: "https://proxy.golang.org"
run: go build .
- name: Test
env:
GOPROXY: "https://proxy.golang.org"
run: go test -v .
Let’s deconstruct this.
Define the name of a workflow
name
defines a name of the workflow, visible in Actions
tab in GitHub’s repository view.
name: Build and test Go
Specify what triggers a workflow
Workflow is triggered by an event in your GitHub repository. There are
many event types, including a checkin, a creation of pull request etc.
on: [push, pull_request]
This means: execute workflow on checkin (well, when it’s actually pushed to GitHub) and pull request.
This is a good default from most projects.
[push, pull_request]
is an YAML array with 2 elements.
Specify OS executing the workflow
runs-on
defines the OS of the machine running the workflow. Most common:
ubuntu-latest
: ubuntu 18.04 (could be newer in the future)
macos-latest
windows-latest
Define a job
A job is a sequence of steps:
jobs:
build:
name: Build
We define only one job with id build
whose human-visible name is Build
.
We can define multiple jobs. Jobs run in parallel but they start in an empty environment, so if you have multiple steps, like build and test, it’s faster to have them as part of a single job.
- name: Set up Go 1.13
uses: actions/setup-go@v1
with:
go-version: 1.13
The beauty of actions is that they are open-sourced and can be shared.
actions/setup-go
is an action hosted in
https://github.com/actions/setup-go. If you need to tweak how an action works, you can fork it and improve it.
Actions can be either JavaScript (node.js) scripts or docker images. actions/setup-go
is a node.js action.
name
is human-readable name of the step.
Actions can accept arguments, which we provide using with
. go-version
is an argument that actions/setup-go
understands. It defines which version of Go we want to install.
Checkout source code
- name: Check out source code
uses: actions/checkout@v1
action/checkout
is a built-in GitHub action (which means we can’t see how it’s implemented).
It checks out your repository to /home/work/<repo_name>
directory e.g. repository [github.com/kjk/notionapi](http://github.com/kjk/notionapi)
would be checked out into /home/work/notionapi
directory.
You can over-ride checkout directory with
path
argument (see full
list of arguments).
Build and test Go code
- name: Build
env:
GOPROXY: "https://proxy.golang.org"
run: go build .
- name: Test
run: go test -v .
We can execute arbitrary commands in steps.
The most common steps for Go code are
- building the code
- running tests
Working directory when executing commands is the source code directory.
When a step fails, the workflow stops and you get an email.
For projects using module, go build
will also download dependencies recorded in go.mod
and go.sum
.
Downloading modules via
proxy is much faster than cloning repositories with
git
.
We explicitly specify GOPROXY
for Go 1.12. In 1.13 it’s set by default.
Module proxy is only supported by Go 1.12 and later.
Advanced features
Using secrets
Sometimes you need to use a secret that you can’t expose to public. In this example we have a project that is deployed to a Netlify account.
In GitHub UI we defined a secret NETLIFY_TOKEN
needed to authenticate with Netlify:
Here’s how to use it:
- name: Netlify deploy
env:
NETLIFY_TOKEN: ${{ secrets.NETLIFY_TOKEN }}
run: |
./netlifyctl -A ${NETLIFY_TOKEN} deploy || true
cat netlifyctl-debug.log || true
We can access secrets in workflow .yml
file as ${{ secrets.<SECRET_NAME> }}
. We can pass as an argument to a command executed with run
or, as in this example, set an environment variable use env
.
Remember that stdout and stderr of executed commands is logged so be careful to not print secrets.
Matrix builds
Sometimes you want to test on multiple operating systems or with using different versions of Go.
You can specify that with matrix builds.
Here’s a way to run workflow on every supported OS:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
What happened here is:
- we defined a variable
os
which is an array of 3 values
- the variable is under
strategy/matrix
key which tells actions to run the workflow 3 times, setting variable ${{ matrix.os }}
- we can reference that variable in values. In this case
runs-on
uses ${{ matrix.os }}
to specify which OS to use to run the action
Here’s a way to run workflow with multiple Go versions:
runs-on: ubuntu-latest
strategy:
matrix:
goVer: [1.11 1.12 1.13]
steps:
- name: Set up Go ${{ matrix.goVer }}
uses: actions/setup-go@v1
with:
go-version: ${{ matrix.goVer }}
We defined goVer
variable with 3 values and we use it to tell actions/setup-go@v1
which version of Go to install.
Those can be combined:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
goVer: [1.12 1.13]
steps:
- name: Set up Go ${{ matrix.goVer }}
uses: actions/setup-go@v1
with:
go-version: ${{ matrix.goVer }}
This will execute 6 different builds for a combination of 3 x 2 values.
Speeding up builds by caching dependencies
Re-downloading dependencies on each build is wasteful, even when using Go modules cache.
Caching action (currently in preview mode) allows to download dependencies just once. As long as dependencies don’t change, they’ll be re-used between builds.
To use it for Go:
- name: Cache Go modules
uses: actions/cache@preview
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-build-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.OS }}-build-${{ env.cache-name }}-
${{ runner.OS }}-build-
${{ runner.OS }}-
The key parts:
path
defines which directory to cache between builds. ~/go/pkg/mod
is where go
tool stores downloaded modules on the GitHub CI server
${{ runner.os }}-${{ hashFiles('**/go.sum') }}
tells when to clear the cache. In Go we want to do it when we change dependencies. Since the info about dependencies is stored in go.sum
file, we want to clear the cache if any of the go.sum
files changes. This is done with ${{ hashFiles('**/go.sum') }}
which calculates a hash of all go.sum
files. We don’t want to re-use Windows cache on e.g. Linux (as they can differ due to build tags) so we add ${{ runner.os }}
to the key name.
Finding bugs with staticcheck
Static analysis can find bugs in your code, which is great.
To run staticcheck
locally, do:
go get -u honnef.co/go/tools/cmd/staticcheck
staticcheck ./...
To run staticcheck
on GitHub Actions CI:
- name: Staticcheck
run: |
# add executables installed with go get to PATH
# TODO: this will hopefully be fixed by
# https://github.com/actions/setup-go/issues/14
export PATH=${PATH}:`go env GOPATH`/bin
go get -u honnef.co/go/tools/cmd/staticcheck
staticcheck ./...
Uploading artifacts
When a CI job is finished, the server is destroyed. Sometimes we want to preserve files created during CI run. We can do it with a built-in [upload-artifact](https://github.com/actions/upload-artifact)
action:
- uses: actions/upload-artifact@master
with:
name: my-artifact
path: path/to/dir/with/artifacts
Uploaded artifacts will be accessible via web UI:
Cron: running workflows periodically
You can schedule workflows to be executed periodically e.g. once a day.
Define a workflow in a new .yml
file in .github/workflows
directory and schedule it to be periodically executed:
on:
schedule:
# * is a special character in YAML so you have to quote this string
- cron: "0 4 * * *"
The order of fields is:
- minute (valid values: 0-59,
0
in the above example)
- hour (valid values: 0-24,
4
in the example)
- day of the month (valid values: 1-31,
*
means “every day)
- month (valid values: 1-12,
*
means every month)
- day of the week (valid values: 0-6, 0 is Sunday)
In the above example we schedule the workflow to run at 4:00 am every day,
UTC time.
The syntax is quirky so use
crontab guru to create / verify it.
Running multiple commands
You run multiple commands in a single step with |
:
- name: Staticcheck
run: |
# add executables installed with go get to PATH
# TODO: this will hopefully be fixed by
# https://github.com/actions/setup-go/issues/14
export PATH=${PATH}:`go env GOPATH`/bin
go get -u honnef.co/go/tools/cmd/staticcheck
staticcheck ./...
Keep in mind that commands executed with run
are executed in an os-specific default shell (bash
on Linux / Mac OS X and cmd.exe
on Windows).
Simple commands like go build
work the same in both shells, but e.g. export PATH=${PATH}:
go env GOPATH/bin
is specific to bash.
All OS-es have Python and Node installed, so if you need to execute a longer logic, write it in Python or Node.
For example, if you have foo.js
script:
- name: Run foo.js
run: node foo.js
Debugging workflows
Workflows are running on remote servers so if something goes wrong, it might be difficult to figure out why.
Here are few tips:
- read about the setup of the machines (file system layout, environment variables) and double-check assumptions in your script
- read about software installed on the machines
- if you’re uncertain, add more logging. E.g. if you’re not sure if the current directory is the one you expect, log it before error happens (
echo "current directory:
pwd"
on Linux)
More Go resources