The machine that never sleeps
You push a commit. Your laptop says everything works. You merge the pull request. Ten minutes later, the staging server crashes because a dependency changed or a test only fails on Linux. This happens to everyone. The fix isn't to test harder on your machine. The fix is to make a machine that doesn't sleep test your code every time you push. That machine is your CI pipeline.
Continuous Integration in plain words
Continuous Integration means running automated checks on every change. Think of it like a spellchecker that runs automatically when you save a document, except it catches logic errors, not just typos. You write code, push it, and a remote runner checks out your repo, installs Go, builds the binary, runs tests, and reports back. If anything fails, the pipeline stops and you get a notification. You never merge broken code.
Go makes this straightforward. The standard toolchain handles building and testing without external build scripts. You don't need Makefiles or complex configuration. The go command is the build system. The pipeline just runs the commands you already use locally.
Automate the boring checks. Save your brain for hard problems.
The minimal pipeline
Here's the simplest pipeline: trigger on push, check out code, set up Go, build, test.
name: CI
# Trigger on every push and pull request to catch issues early
on: [push, pull_request]
jobs:
test:
# Run on a fresh Ubuntu VM provided by GitHub
runs-on: ubuntu-latest
steps:
# Clone the repository into the runner
- uses: actions/checkout@v4
# Install the stable version of Go
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 'stable'
# Compile all packages to check for syntax and type errors
- name: Build
run: go build -v ./...
# Run all tests with verbose output
- name: Test
run: go test -v ./...
What happens when you push
When you push, GitHub sees the on: [push] trigger. It spins up a virtual machine. The checkout action downloads your code. The setup-go action installs Go. The build step runs go build. If the compiler rejects the code, the job fails immediately. If it passes, go test runs. If a test fails, the job fails. The UI shows a red X or green check.
The ./... pattern tells Go to run the command in the current directory and all subdirectories. This ensures you test every package, not just the root. Running go build separately from go test is a good habit. go test compiles the test binaries, but it doesn't always catch issues in non-test code that isn't imported by tests. A dedicated build step catches unused imports or type errors in code paths that tests might skip.
If you forget to import a package, the compiler rejects the program with undefined: pkg. If you pass the wrong type, you get cannot use x (untyped int constant) as string value in argument. The pipeline stops, and you see the error in the logs.
The pipeline is your safety net. Trust the green check.
Go's deterministic build
Go builds are deterministic. The same source code produces the same binary every time. This is a feature, not a bug. It means your CI pipeline can cache compiled packages and reuse them across runs. The setup-go action supports caching automatically. When you enable cache: true, the action saves the module cache and build cache to a remote store. On the next run, it restores the cache. This speeds up builds significantly. You don't need to configure cache keys manually. The action handles the hashing and restoration.
Commit go.mod and go.sum to your repository. These files lock your dependencies. The pipeline uses them to ensure it installs the exact versions your code expects. The community convention is to track both files. go.sum contains checksums for every dependency. It prevents supply chain attacks by verifying the content matches the expected hash. Never skip go.sum. The pipeline relies on it to reproduce the build environment.
Deterministic builds make reproducibility easy. Cache aggressively. Trust the lock files.
A realistic pipeline
Real projects need more than build and test. You want caching to speed up builds, linting to enforce style, and coverage reports.
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 'stable'
# Cache modules to speed up subsequent runs
cache: true
# Run golangci-lint to check style and common bugs
- name: Lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.54
- name: Test
run: go test -v -coverprofile=coverage.out ./...
# Upload coverage to a service or artifact
- name: Upload Coverage
uses: actions/upload-artifact@v3
with:
name: coverage
path: coverage.out
golangci-lint is the standard linter. It runs gofmt, govet, and many others. Go code should be formatted by gofmt. Don't argue about indentation; let the tool decide. Most editors run it on save. The linter ensures the code matches the community standard. go vet is built into the go tool and checks for suspicious constructs. It runs automatically during go build and go test, but the linter runs additional checks. The linter catches issues like unused variables, format string mismatches, and potential nil pointer dereferences.
Linting catches style issues before code review. Review logic, not formatting.
Pitfalls and how to avoid them
Flaky tests break pipelines. If a test fails randomly, the pipeline becomes noise. Fix the test. Don't disable the check. Slow builds waste time. Without caching, every run downloads modules. Use cache: true in setup-go. Race conditions hide until production. Run go test -race in the pipeline to catch data races.
If you have a data race, the test panics with fatal error: concurrent map writes. The race detector catches this. If you forget to handle an error, the linter warns with error return value not handled. The pipeline fails so you can fix the code.
A red pipeline is a signal. Fix the root cause, don't disable the check.
When to use what
Use a basic build-and-test pipeline when you are starting a new project or working alone. Use a pipeline with caching and linting when the team grows and build times start to matter. Use a pipeline with coverage and artifact upload when you need to track quality metrics over time. Use a separate deployment job when you are ready to push to production automatically.
Start simple. Add complexity only when the pain justifies it.