Go changes the shape of a CI pipeline in two ways that no other language in this cluster does. The toolchain is a single static binary with formatting, vetting, testing and building all built in, so there is almost nothing to install. And the compiler cross-compiles, so a matrix that produces Linux, macOS and Windows binaries needs one runner rather than three.
The complete workflow is at examples/github-actions/go-ci/ci.yml, validated by
npm run check:workflows.
What setup-go already does for you
Section titled “What setup-go already does for you”The single most common piece of outdated Go CI advice is to add an actions/cache step for the
module cache. That has not been necessary for several major versions.
- uses: actions/setup-go@v7 with: go-version: ${{ matrix.go-version }}What it doesInstalls the requested Go toolchain and restores both the module cache and the build cache, keyed on go.sum.
Why we run itCaching is on by default. A hand-written actions/cache step for ~/go/pkg/mod duplicates it, and a badly keyed one is worse than none — it restores a stale tree that the toolchain then has to invalidate.
Expected resultA `Restore cache` and a `Save cache` group in the step log, with no cache step of your own.
Two caches are involved and they do different jobs:
| Cache | Path | Contents | Keyed on |
|---|---|---|---|
| Module cache | ~/go/pkg/mod | Downloaded dependency source | go.sum |
| Build cache | ~/.cache/go-build | Compiled package archives and test results | go.sum |
The build cache is the one that makes repeat runs fast, and it is also why a Go test job that recompiles nothing can finish in seconds. If you disable caching to debug something, expect both to disappear and the run to get dramatically slower.
To turn it off deliberately — which is occasionally the right call when you suspect a stale cache is
masking a real failure — set cache: false:
- uses: actions/setup-go@v7 with: go-version: "1.25" cache: falseFormatting is a build failure, not a suggestion
Section titled “Formatting is a build failure, not a suggestion”gofmt has no configuration. That is the point: there is one correct formatting of any Go file, so
“formatted correctly” is a fact rather than a team preference, and it can be enforced without anyone
having to agree to anything.
gofmt -l lists files whose formatting differs from canonical. It does not exit non-zero when it
finds them, so a bare run: gofmt -l . always passes — a mistake that survives in a lot of
copied workflows. The list has to be turned into a failure explicitly:
- name: Verify formatting run: | unformatted="$(gofmt -l .)" if [ -n "$unformatted" ]; then echo "::error::these files are not gofmt-formatted:" echo "$unformatted" exit 1 fi::error:: is a workflow command. It writes an annotation that appears at the top of the run summary
and against the job in the pull request, so the reviewer sees the reason without opening the log.
go vet is the second free check. It is not a linter in the style-guide sense — it reports
constructs that compile but are almost certainly wrong, such as a Printf format string that does
not match its arguments, or a mutex copied by value:
- name: Vet run: go vet ./..../... means “this package and everything beneath it”. Nearly every Go CI command takes it.
Testing with the race detector
Section titled “Testing with the race detector”- name: Test with the race detector run: go test -race -coverprofile=coverage.out -covermode=atomic ./...What it doesRuns the test suite with the race detector enabled and writes a coverage profile.
Why we run itData races are timing-dependent. A racy program passes its tests on a quiet laptop and corrupts memory under production load; the race detector makes the bug deterministic by instrumenting every memory access.
Expected resultRoughly 2–10× slower tests and 5–10× more memory. That cost is why you enable it in CI rather than on every local run.
The flags are load-bearing:
-raceinstruments memory access and fails the run if two goroutines touch the same address without synchronisation. It only detects races that actually occur during the run, so it rewards tests that exercise concurrency.-covermode=atomicis required with-race. The defaultsetmode uses non-atomic counter writes, which the race detector correctly reports as races in the instrumentation itself.-coverprofile=coverage.outwrites a profile you can upload, threshold, or convert to HTML withgo tool cover -html=coverage.out.
A CI-only detail worth knowing: go test caches successful results keyed on the package’s inputs. On
a repeat run with no changes the tests do not re-execute; they print (cached). That is usually what
you want, but if you are chasing a flaky test, -count=1 forces re-execution.
Cross-compilation: one runner, many binaries
Section titled “Cross-compilation: one runner, many binaries”For most languages a build matrix means renting one machine per target. Go compiles for any supported platform from any host, so the “matrix” is a pair of environment variables:
build: needs: test runs-on: ubuntu-latest strategy: matrix: include: - goos: linux goarch: amd64 - goos: linux goarch: arm64 - goos: darwin goarch: arm64 steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: "1.25"
- name: Build env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} CGO_ENABLED: "0" run: go build -trimpath -o "dist/app-${GOOS}-${GOARCH}" ./cmd/appmatrix.include without a corresponding matrix axis is the idiom for “these exact combinations, and
no others”. A two-axis goos × goarch product would generate darwin/amd64 and windows/arm64
entries you may not want to ship.
Three details that decide whether the binaries are usable:
CGO_ENABLED: "0" produces a static binary with no libc dependency. With cgo enabled — the default
when a C toolchain is present — the binary links against the runner’s glibc, and a binary built on
ubuntu-latest may refuse to start on an older distribution. It also makes cross-compilation fail
outright, because the runner has no cross C toolchain. Quote the "0": unquoted it is the number
zero, and while Actions coerces environment values to strings, quoting removes the question.
-trimpath strips the build machine’s absolute paths out of the binary. Without it, the panic traces
your users send you contain /home/runner/work/OWNER/REPO/..., and the binary differs between two
otherwise identical builds — which defeats reproducibility.
The -o path uses ${GOOS} shell expansion rather than ${{ matrix.goos }} a second time. Both
work; preferring the shell variable keeps expression substitution out of the command line, which is
the habit that prevents script injection when a value
is not as trustworthy as a matrix entry.
Which Go versions to test
Section titled “Which Go versions to test”Go’s compatibility promise is unusually strong, and the release policy supports the two most recent major versions. A two-entry matrix matching that policy is the honest default:
strategy: fail-fast: false matrix: go-version: ["1.24", "1.25"]fail-fast: false lets every version report. With the default true, the first failure cancels the
others and you learn “it broke on some version” instead of “it broke on 1.24 only” — which is the
answer you actually needed.
Quote the versions. Unquoted, 1.24 is a float and 1.30 would become 1.3. This is the same YAML
trap covered in YAML syntax, and Go’s version numbering
walks straight into it.
Linting beyond vet
Section titled “Linting beyond vet”go vet catches correctness problems. Style, complexity and a long tail of bug patterns need a
dedicated linter, and golangci-lint is the near-universal choice because it runs dozens of
analysers over one parse of the code.
- name: Lint uses: golangci/golangci-lint-action@v9 with: version: v2.13.2Pin the linter version explicitly. A floating version means a linter release can fail a pull request that changed nothing — the classic “CI broke overnight” incident. Upgrading the pin then becomes a deliberate commit with its own diff, which is where the new findings belong.
Two different versions are in play and it is easy to confuse them: golangci-lint-action@v9 is the
action that installs and runs the tool, and version: v2.13.2 is the linter binary it installs.
Bumping one does not bump the other.
Uploading coverage from a matrix
Section titled “Uploading coverage from a matrix”Every matrix leg runs the same steps, so every leg tries to upload an artifact. Artifact names must be unique within a run, and a name collision is a hard error:
- name: Upload coverage if: always() uses: actions/upload-artifact@v7 with: name: coverage-go-${{ matrix.go-version }} path: coverage.outif: always() matters more than it looks. Without it the step inherits the implicit
if: success(), so coverage is uploaded only when the tests passed — precisely the case where you
did not need it. The failing run is the one whose artifacts you want.
Merging profiles across legs is rarely worth the effort. Pick one version as the coverage source of truth and treat the others as pass/fail signals.
Vulnerability scanning that understands your code
Section titled “Vulnerability scanning that understands your code”govulncheck is more useful than a generic dependency scanner because it does not just check which
modules you depend on — it checks whether your code actually reaches the vulnerable function:
- name: Check for known vulnerabilities run: | go install golang.org/x/vuln/cmd/govulncheck@latest govulncheck ./...A generic scanner reports every advisory affecting any version in your module graph, most of which are
in code paths you never call. govulncheck uses call-graph analysis and reports the ones that matter,
which is the difference between a finding people act on and a list people mute.
Pin the tool version rather than using @latest for the same reason you pin a linter: a new release
can fail a pull request that changed nothing.
Private modules
Section titled “Private modules”A build that depends on private repositories needs both a credential and a configuration telling Go not to route those modules through the public proxy:
- name: Configure access to private modules env: TOKEN: ${{ secrets.PRIVATE_MODULES_TOKEN }} run: | git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" go env -w GOPRIVATE="github.com/OWNER/*"GOPRIVATE keeps those module paths away from the public module proxy and checksum database — without
it, the build leaks your private module paths to a third party and then fails the checksum lookup.
The token arrives through env: and is referenced as a shell variable rather than interpolated into
the command, which is the habit that prevents
script injection.
Integration tests and build tags
Section titled “Integration tests and build tags”Go’s convention for separating slow tests from fast ones is a build tag:
//go:build integration
package store_test integration: runs-on: ubuntu-latest services: postgres: image: postgres:18 env: POSTGRES_PASSWORD: ci-only-not-a-real-secret ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: "1.25" - name: Run integration tests run: go test -tags=integration -race ./... env: DATABASE_URL: postgresql://postgres:ci-only-not-a-real-secret@localhost:5432/postgres?sslmode=disableWithout -tags=integration those files are not compiled at all, so the unit test job stays fast and
the integration job opts in. The health check options are what stop the first query racing the
database’s initialisation.
One Go-specific trap: go test caches successful results, and the cache key does not include your
service containers. A test that passed against a database in a previous run can report (cached) and
not re-execute even though the data changed. Use -count=1 in the integration job to disable result
caching.
Stamping version information into the binary
Section titled “Stamping version information into the binary”A binary that cannot tell you which commit built it makes production debugging guesswork:
- name: Build env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} CGO_ENABLED: "0" run: | go build \ -trimpath \ -ldflags "-s -w -X main.version=${GITHUB_REF_NAME} -X main.commit=${GITHUB_SHA}" \ -o "dist/app-${GOOS}-${GOARCH}" ./cmd/app-X importpath.name=value sets a string variable at link time, so main.version and main.commit
are compiled in without the source needing to change. -s -w strips the symbol table and DWARF
debugging information, which meaningfully reduces binary size at the cost of readable stack traces —
worth it for a distributed CLI, usually not for a server you will profile.
Combined with -trimpath, two builds of the same commit produce identical bytes, which is what makes
a build provenance attestation meaningful.
Benchmarks
Section titled “Benchmarks”Go has a benchmark runner built in, and CI can catch performance regressions the same way it catches correctness ones:
- name: Benchmark run: go test -run='^$' -bench=. -benchmem -count=5 ./... | tee bench.txt-run='^$' matches no tests, so only benchmarks run. -count=5 repeats each one, which is the
minimum for the results to mean anything — a single measurement on a shared CI runner is noise.
Comparing runs is where the value is, using benchstat against a baseline from the default branch.
Treat the result as a report rather than a gate: hosted runners are shared machines with variable
neighbours, and a benchmark threshold that fails builds will fail them for reasons unrelated to your
code. Publish the comparison in the job summary and let a human look at large movements.
Merging coverage across the matrix
Section titled “Merging coverage across the matrix”Each matrix leg produces its own profile. To get one number, collect them in a dependent job:
coverage: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: "1.25" - uses: actions/download-artifact@v8 with: pattern: coverage-go-* merge-multiple: true path: profiles/ - name: Report total coverage run: | echo "mode: atomic" > merged.out tail -q -n +2 profiles/*/coverage.out >> merged.out 2>/dev/null || \ tail -q -n +2 profiles/coverage.out >> merged.out go tool cover -func=merged.out | tail -1 | tee -a "$GITHUB_STEP_SUMMARY"The mode: line appears once at the top of a profile, so merging means keeping the first one and
skipping it in every subsequent file — that is what tail -n +2 does. Concatenating the files
directly produces a profile go tool cover refuses to parse.
In practice, picking one matrix leg as the coverage source of truth is simpler and almost always sufficient. Merge only when you genuinely have platform-specific code paths that different legs exercise.
Checking that generated code is current
Section titled “Checking that generated code is current”Go projects generate a lot — mocks, protobuf bindings, string methods from stringer, embedded
assets. Generated files are committed, so they can silently fall behind the source they were generated
from, and the next person to regenerate gets a diff unrelated to their change.
- name: Verify generated code is current run: | go generate ./... if [ -n "$(git status --porcelain)" ]; then echo "::error::generated code is out of date — run go generate ./... and commit the result" git diff exit 1 fiThe same shape works for go mod tidy, which is worth checking separately because an untidied
go.mod is a common source of confusing dependency behaviour:
- name: Verify go.mod and go.sum are tidy run: | go mod tidy git diff --exit-code go.mod go.sumgit diff --exit-code fails the step when there is a difference and prints it, which is the whole
check in one line.
Both of these are instances of a general pattern worth recognising: anything committed that is
derived from something else needs a CI check that it is current. The dist/ verification for
custom actions is the same idea, and so is a lock file
check. Without it, the committed artifact and its source drift, and the drift is discovered by whoever
next touches the area.
The complete pipeline
Section titled “The complete pipeline”-
Test job, per Go version. Checkout,
setup-go, formatting check,go vet,go test -race, upload coverage withif: always(). -
Build job,
needs: test. Cross-compile each target withCGO_ENABLED=0and-trimpath, upload each binary as its own artifact. -
Branch protection. Require the test job. Because the job name includes the matrix value, the check names are
Test (Go 1.24)andTest (Go 1.25)— see jobs for why a matrix job’s name affects what you can require.
The needs: test edge is deliberate. Producing release binaries from a commit whose tests failed
wastes runner time at best, and at worst leaves a downloadable artifact from a known-broken build.
Exercise
Section titled “Exercise”-
Copy
examples/github-actions/go-ci/ci.ymlinto a small Go module at.github/workflows/ci.yml, adjusting./cmd/appto your own main package. -
Push a branch and confirm both matrix legs run.
-
Break the formatting deliberately — add a stray blank line inside a function — and push. Confirm the run fails and the annotation names the file.
-
Replace the formatting step with a bare
run: gofmt -l .and push the same broken formatting. Confirm the run now passes. This is the trap; having seen it once, you will not reintroduce it. -
Restore the real check, then download the coverage artifact and open it locally with
go tool cover -html=coverage.out.
Then what?
Section titled “Then what?”Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.