Continuous Integration
One pipeline per push: gofmt, go vet, go test -race, go build, then an image tagged with the commit.
go 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
test -z "$(gofmt -l .)".gofmt -llists files that are not formatted; an empty list is the pass condition. Formatting stops being an argument in review.go vet ./...reports mistakes the compiler accepts, such as a%dplaceholder handed a string../...means this package and every package beneath it.go test -race ./...runs the tests with the race detector, which reports two goroutines touching the same memory with no synchronisation between them.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
// 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.
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.
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.
.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