Continuous Integration

One pipeline per push: gofmt, go vet, go test -race, go build, then an image tagged with the commit.

Syntaxgo vet ./... && go test -race ./... && go build ./...

Continuous integration means a machine runs the same checks on every push, so a broken change is found before a person deploys it. For a Go service the pipeline is short.

The steps

  1. test -z "$(gofmt -l .)". gofmt -l lists files that are not formatted; an empty list is the pass condition. Formatting stops being an argument in review.
  2. go vet ./... reports mistakes the compiler accepts, such as a %d placeholder handed a string. ./... means this package and every package beneath it.
  3. go test -race ./... runs the tests with the race detector, which reports two goroutines touching the same memory with no synchronisation between them.
  4. go build ./..., then build the image and push it tagged with the commit hash, so a running image can always be traced back to source.

Why vet separately when test already vets

go test runs a high-confidence subset of vet before building the test binary. That subset is atomic, bools, buildtag, directive, errorsas, ifaceassert, nilfunc, printf, stdversion, stringintconv and tests; stdversion, which reports standard library symbols newer than the version in your go.mod, was added to the subset in Go 1.27. go vet ./... runs the full default set, and it also covers packages that have no tests at all.

What trips people up

The race detector requires cgo enabled, and a C compiler on everything except macOS, so a pipeline that sets CGO_ENABLED=0 for every step cannot even build the test binary. Set it to 0 on the release build step only. Tests under -race are slower and use more memory. Results are also cached: go test ./... may print (cached) and run nothing, and -count=1 is the documented way to force a real run.

Example

Example · go
// This file compiles. `go vet ./...` rejects it, which is the point:
// the second step of the pipeline catches what the first one cannot.
package main

import "fmt"

type order struct {
	id    int
	total string // money as a string, to avoid binary floating point
}

func main() {
	o := order{id: 7, total: "19.99"}
	// %d asks for a number and total is a string. The compiler is happy.
	// go vet: fmt.Printf format %d has arg o.total of wrong type string
	fmt.Printf("order %d total %d\n", o.id, o.total)
}

// Output:
// order 7 total %!d(string=19.99)

When to use it

  • Block a merge until formatting, vet, race-enabled tests and the build all pass.
  • Publish an image tagged with the commit so a rollback can name an exact version.
  • Catch a data race on a shared map in CI instead of in production traffic.

More examples

The check job

go-version-file reads the version from the go directive in go.mod, so the pipeline and your machine cannot drift apart. The steps are ordered cheapest first: formatting fails in seconds, the race-enabled test run is the slow one. setup-go caches the module and build caches between runs on its own.

Example · yaml
name: ci
on: [push]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod   # one source of truth for the Go version
      - run: test -z "$(gofmt -l .)"
      - run: go vet ./...
      - run: go test -race ./...
      - run: go build ./...

The image job, gated on the checks

Tagging with github.sha, the commit hash, means a deployment names the exact source it came from; a floating :latest tag cannot. push: true and --output=type=registry are the same instruction in two forms. Naming platforms explicitly avoids publishing whatever architecture the runner happens to be.

Example · yaml
  image:
    needs: check                  # only runs if every check passed
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v7
      - uses: docker/setup-buildx-action@v4
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v7
        with:
          platforms: linux/amd64
          push: true              # shorthand for --output=type=registry
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

The same pipeline on your machine

Running the identical commands locally is how you stop discovering failures from a queue. -count=1 is the documented way to bypass the test cache, so a green run here means the tests actually ran. Keeping CI steps as one-line commands, rather than pipeline-specific syntax, is what makes this copy possible.

Example · make
.PHONY: check
check:
	test -z "$(shell gofmt -l .)"
	go vet ./...
	go test -race -count=1 ./...
	go build ./...

.PHONY: image
image: check
	docker build --platform linux/amd64 -t app:$(shell git rev-parse --short HEAD) .

Discussion

  • Be the first to comment on this lesson.