Not sure how to structure your Go web application?

My new book guides you through the start-to-finish build of a real world web application in Go — covering topics like how to structure your code, manage dependencies, create dynamic database-driven pages, and how to authenticate and authorize users securely.

Take a look!

Continuous integration with Go and GitHub Actions

Published on:

In this post we're going to walk through how to use GitHub Actions to create a continuous integration (CI) pipeline that automatically tests, vets and lints your Go code.

For solo projects I usually create a pre-commit Git hook to carry out these kinds of checks, but for team projects or open-source work — where you don't have control over everyone's development environment — using a CI workflow is a great way to flag up potential problems and help catch bugs before they make it into production or a versioned release.

And if you're already using GitHub to host your repository, it's nice and easy to use their built-in functionality to do this without any need for additional third-party tools or services.

To demonstrate how it works, let's run through a step-by-step example.

If you'd like to follow along, please create a new repository and clone it to your local machine. For the purpose of this post I'm going to use the private repository alexedwards/example.

$ git clone
Cloning into 'example'...
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Receiving objects: 100% (3/3), done.

Then let's scaffold a simple Go application along with a (failing) test like so:

$ cd example/
$ touch main.go main_test.go
$ go mod init
File: main.go
package main

import "fmt"

func main() {
    msg := sayHello("Alice")

func sayHello(name string) string {
    return fmt.Sprintf("Hi %s", name)
File: main_test.go
package main

import "testing"

func Test_sayHello(t *testing.T) {
    name := "Bob"
    want := "Hello Bob"

    if got := sayHello(name); got != want {
        t.Errorf("hello() = %q, want %q", got, want)

If you run this application it should compile correctly and print "Hi Alice", but executing go test . will result in a failure. Similar to this:

$ go test .
--- FAIL: Test_sayHello (0.00s)
  main_test.go:10: hello() = "Hi Bob", want "Hello Bob"
FAIL	0.002s

Creating a workflow file

The next thing that we want to do is create a workflow file which describes what we want to do in our CI checks, and when we want them to run. By convention this file should be stored in a .github/workflow directory in the root of your repository and should be in YAML format.

Let's create this directory along with an audit.yml workflow file.

$ mkdir -p .github/workflows
$ touch .github/workflows/audit.yml

There's an excellent introduction to the workflow file syntax here, and there's also a collection of templates for different languages and frameworks that you can use as a starting point.

But for now, let's jump in and update the workflow file so that it looks like this:

File: .github/workflows/audit.yml
name: Audit

    branches: [main]
    branches: [main]


    runs-on: ubuntu-20.04
    - uses: actions/checkout@v2

    - name: Set up Go
      uses: actions/setup-go@v2
        go-version: 1.17

    - name: Verify dependencies
      run: go mod verify

    - name: Build
      run: go build -v ./...

    - name: Run go vet
      run: go vet ./...

    - name: Install staticcheck
      run: go install

    - name: Run staticcheck
      run: staticcheck ./...

    - name: Install golint
      run: go install

    - name: Run golint
      run: golint ./...

    - name: Run tests
      run: go test -race -vet=off ./...

Let's quickly step through this and explain what the different parts of the file do.

  • First we use the on keyword to define when we want the workflow to run. In this case, I've configured the workflow so that it runs when a new commit is made to the main branch, or a pull request is submitted.
  • Then we use the jobs keyword to define a list of the jobs that are to be run. At the moment our workflow only contains one job called audit, but you can specify multiple jobs if you want and (by default) they will be executed in parallel.
  • An independent runner will be spun up for each job. This is essentially a virtual machine that will execute the steps for the job. In the file above we use the runs-on keyword to specify that we want the runner to use Ubuntu 20.04 as a base OS, but others operating systems are available. It's also worth noting that the runner has a lot of useful software and tooling pre-installed.
  • In the first step for our audit job we use the uses keyword to execute the community action actions/checkout@v2. This action will checkout our project repository to the runner so that the following steps access the code.
  • Then we use the actions/setup-go@v2 action to install Go version 1.17 on the runner.
  • Once that's done, in the remaining steps we use the run keyword to execute specific commands on the runner. In this case we build our code and then audit it using the standard go build|vet|test commands and the additional golint and staticcheck tools.

Now that's in place, let's commit everything and push the changes to your repository:

$ git add .
$ git commit -m "Initial commit"
$ git push  

Once the push has completed, head to your repository and select the Actions tab. You should see that the CI 'Audit' workflow is running, similar to the screenshot below.

You can click through on the workflow name to see more details while it's running, and after a minute or two you should see that the workflow is terminated due to our failing test.

Additionally, as the owner of the repository, you should also get an email notification to tell you that the workflow failed, and everyone who browses the repository will see a red cross symbol next to the commit in the Git history.

Fixing the code

Let's fix our codebase by updating the sayHello() function to return the correct output, like so:

File: main.go
package main

import "fmt"

func main() {
	msg := sayHello("Alice")

func sayHello(name string) string {
	// Change this to "Hello %s" instead of "Hi %s".
	return fmt.Sprintf("Hello %s", name)

If you want, you can commit this change and push it…

$ git add .
$ git commit -m "Fix sayHello() to return the correct value"
$ git push

… and you should see that the 'Audit' job in our workflow file now completes successfully and everything has a nice green check mark next to it.

Great! That's working really well and, from now on, any time someone makes a push or pull request to the main branch, the tests and vetting and linter checks will be automatically run.

From here, you can extend the workflow to carry out more checks or send additional notifications if you want to — or even expand it to act as a continuous deployment (CD) pipeline that builds and deploys your binaries. To give you some ideas, here are a couple of slightly more complicated workflows from my own projects: