How the Go toolchain builds, tests and secures Go services, which files it needs, how GoReleaser and ko package them, and a complete GitHub Actions pipeline.
Posted
DevOps pipeline loop motif with the title Go Build and GoReleaser
Go ships its build system inside the language toolchain: go build, go test, go vet and Go modules cover what other languages need three or four tools for. DevOps work in Go is mostly about reproducibility, static binaries, vulnerability checks and packaging releases for many platforms.
In CI, each stage of the generic build pipeline maps to a Go toolchain command, plugin or scanner. Press play, or step through it:
Source + go.mod: A commit triggers CI. go.mod lists the module requirements and go.sum records their checksums.
Download and verify modules: go mod download fetches modules through GOPROXY (proxy.golang.org or a private proxy), and go mod verify checks them against go.sum.
Lint and format: gofmt -l must print nothing, then go vet, staticcheck and golangci-lint catch suspicious code.
Compile: CGO_ENABLED=0 go build -trimpath produces a static binary, with the version stamped in through -ldflags.
Test and coverage: go test -race -coverprofile=cover.out ./... runs the tests with the race detector; go tool cover summarises coverage.
Security scans in parallel: gosec checks our code, govulncheck reports only vulnerabilities in code paths we actually call, and Gitleaks scans for secrets.
Quality gate: Formatting, vet, lint, tests and both scanners are required checks; any failure stops the release.
Fail the build (fail): A failed check stops the pipeline at the gate, so nothing is packaged, signed or published.
Cross-compile and package (pass): GoReleaser cross-compiles for each OS and architecture and builds archives and checksums from .goreleaser.yaml.
SBOM and signing (pass): GoReleaser produces SBOMs and signatures from the same config; cyclonedx-gomod is the standalone SBOM tool.
Store the release (pass): Binaries and archives are kept as release assets or in an artifact repository; the CI job stores them with actions/upload-artifact.
Container image (pass): ko builds a minimal container image for the Go binary without a Dockerfile, and the registry scans it again.
Minimal version selection picks the lowest version that satisfies every requirement, so builds are reproducible without a separate lockfile; go.sum stores the checksums.
The build cache makes repeated builds and tests fast; cache it in CI.
CGO_ENABLED=0 produces a fully static binary that runs in scratch or distroless images.
Cross-compiling is built in: GOOS=linux GOARCH=arm64 go build ./cmd/app.
Build files
File
Purpose
Commit?
go.mod
Module path, Go version, toolchain line, requirements
go mod download && go mod verify # fetch and verify modulesCGO_ENABLED=0 go build -trimpath-ldflags="-s -w -X main.version=1.0.0"-o bin/app ./cmd/app # build
go vet ./... && golangci-lint run # lint
gosec ./... # SAST
govulncheck ./... # SCA (only vulnerable code you actually call)
go test-race-coverprofile=cover.out ./... # tests + coverage
go tool cover -func=cover.out # coverage summary
cyclonedx-gomod app -json-output sbom.json ./cmd/app # SBOM
goreleaser release --snapshot--clean# multi-platform release locally
Minimal project
1
2
3
4
5
6
// go.mod
module github.com/example/demo
go 1.24
require github.com/go-chi/chi/v5 v5.2.0
1
2
3
4
5
6
7
8
9
10
11
12
# Makefile (recipes must be indented with tabs)
VERSION?=$(shell git describe --tags--always).PHONY:build lint test vulnbuild:CGO_ENABLED=0 go build -trimpath-ldflags="-s -w -X main.version=$(VERSION)"-o bin/app ./cmd/app
lint:
golangci-lint run
test:
go test-race-cover ./...
vuln:
govulncheck ./...
Frameworks
Framework
Start a project
net/http (standard library)
go mod init example.com/api and use http.HandleFunc
Gin
go get github.com/gin-gonic/gin
Echo
go get github.com/labstack/echo/v4
chi
go get github.com/go-chi/chi/v5
Cobra (CLI)
go install github.com/spf13/cobra-cli@latest && cobra-cli init
name:go-cion:[push,pull_request]permissions:contents:readjobs:build:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v4-uses:actions/setup-go@v5with:go-version-file:go.mod# caches module + build cache automatically-run:go mod verify-run:test -z "$(gofmt -l .)"# fail on unformatted files-run:go vet ./...-run:go run honnef.co/go/tools/cmd/staticcheck@latest ./...-run:go run github.com/securego/gosec/v2/cmd/gosec@latest ./...-run:go run golang.org/x/vuln/cmd/govulncheck@latest ./...-run:go test -race -coverprofile=cover.out ./...-run:CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${{ github.sha }}" -o bin/app ./cmd/app-uses:actions/upload-artifact@v4with:name:apppath:bin/app
Pin tool versions (staticcheck@2025.1, gosec@v2.22.0) instead of @latest once the pipeline is stable, so a new linter release cannot break unrelated builds.
Best practices
Commit go.sum and run go mod verify; use go mod tidy -diff in CI to catch an untidy go.mod.
Set GOFLAGS=-mod=readonly so builds never silently modify go.mod.
Use GOPRIVATE and a private GOPROXY for internal modules instead of committing tokens.
Build static, stripped binaries (CGO_ENABLED=0, -trimpath, -ldflags="-s -w") and inject the version with -X.
Prefer govulncheck as the gate: it reports only vulnerabilities in code paths you call, so it has far fewer false positives than manifest-only scanners.
Release with GoReleaser to get archives, checksums, SBOMs and signed images from one config.