Skip to content

Go CI with GitHub Actions: Complete Pipeline

Lesson 3 of 8Beginner → Intermediate11 min readGitHub Actions & CI/CD · Continuous IntegrationVerified: actions/setup-go v7, actions/upload-artifact v7, August 2026

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.

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:

CachePathContentsKeyed on
Module cache~/go/pkg/modDownloaded dependency sourcego.sum
Build cache~/.cache/go-buildCompiled package archives and test resultsgo.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: false

Formatting 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.

- 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:

  • -race instruments 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=atomic is required with -race. The default set mode uses non-atomic counter writes, which the race detector correctly reports as races in the instrumentation itself.
  • -coverprofile=coverage.out writes a profile you can upload, threshold, or convert to HTML with go 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/app

matrix.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.

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.

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.2

Pin 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.

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.out

if: 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.

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.

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=disable

Without -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.

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.

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.

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
fi

The 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.sum

git 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.

  1. Test job, per Go version. Checkout, setup-go, formatting check, go vet, go test -race, upload coverage with if: always().

  2. Build job, needs: test. Cross-compile each target with CGO_ENABLED=0 and -trimpath, upload each binary as its own artifact.

  3. Branch protection. Require the test job. Because the job name includes the matrix value, the check names are Test (Go 1.24) and Test (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.

  1. Copy examples/github-actions/go-ci/ci.yml into a small Go module at .github/workflows/ci.yml, adjusting ./cmd/app to your own main package.

  2. Push a branch and confirm both matrix legs run.

  3. Break the formatting deliberately — add a stray blank line inside a function — and push. Confirm the run fails and the annotation names the file.

  4. 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.

  5. Restore the real check, then download the coverage artifact and open it locally with go tool cover -html=coverage.out.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.