Fast Local Checks, Trusted CI: Pre-commit and GitHub Actions for Go
For small Go projects, it's easy to tell yourself that gofmt, go mod tidy, and golangci-lint are habits, not policy. That usually works right up until a pull request fails because generated files changed, go.sum drifted, or a formatter was only run on one laptop.
The pattern I keep coming back to is simple: define the validation contract once, usually in a Makefile or Taskfile.yml, then let pre-commit provide fast local feedback and GitHub Actions enforce the same expectations for everyone else. Local hooks keep the loop short. CI keeps the rules shared, visible, and hard to accidentally bypass.
This isn't the only way to validate a Go repository, and it isn't always necessary. But for Go projects with generated code, multiple contributors, or public pull requests, it's a very practical baseline.
Why This Pattern Works Well for Go¶
Go repositories tend to have a few checks that are small on their own, but noisy when they drift:
go mod tidygo generategofmtgolangci-lint
None of these are exotic. That's exactly why they are good candidates for automation. They are predictable, repeatable, and easy to turn into repository policy.
When you combine local hooks with CI, each layer does a different job:
makeortaskdefines the shared validation contractpre-commitcatches mistakes before they become commits- GitHub Actions proves the same checks pass in a clean environment
- Pull request status checks give maintainers a shared enforcement point
That combination is usually more useful than either layer by itself.
Use Make or Task as the Common Contract¶
If pre-commit runs one set of commands and GitHub Actions runs another, drift starts to creep in. The cleanest pattern is to put the real validation logic behind a thin project contract, usually a Makefile (make) or Taskfile.yml (task), and let everything else call into that.
For example, a small Makefile might look like this:
.PHONY: mod-tidy fmt-check lint generate
mod-tidy:
go mod tidy
git diff --exit-code
fmt-check:
@test -z "$$(gofmt -l .)" || \
(echo "Run: gofmt -w ."; gofmt -l .; exit 1)
lint:
golangci-lint run
generate:
go generate ./...
git diff --exit-code
If your project prefers Taskfile.yml, the idea is the same. The important part is having one shared definition of what a clean repository looks like.
What Each Check Actually Protects¶
A workflow like this is not really about "linting." It's about preventing four kinds of repository drift.
go mod tidy¶
This catches module graph drift. If a developer adds or removes imports and forgets to tidy the module, the repository stops reflecting the actual dependency state of the code.
In CI, that usually looks like this:
If go mod tidy rewrites go.mod or go.sum, the pull request is incomplete.
go generate or make generate¶
If the project expects checked-in generated artifacts, CI should verify that running the generation step, whether that is go generate directly or a project wrapper like make generate, produces no new changes:
If that step changes tracked files, the pull request isn't done yet. This is the check that keeps generated artifacts from drifting away from the source that produced them.
gofmt¶
Formatting should be the most boring rule in the repository. That's a compliment.
Using gofmt -l . in CI gives you a simple yes-or-no answer:
You could let a local hook rewrite files automatically, but CI should only verify the committed result.
golangci-lint¶
This covers the static analysis layer. I like golangci-lint because it gives Go projects one well-known entry point for a wider set of checks. For a deeper setup discussion, see Getting Started with golangci-lint in Go Projects.
One caution here: I would not float on version: latest forever in a mature repository. For an early project, it may be fine. For a stable shared codebase, pinning an explicit linter version usually creates fewer surprises over time.
Local Feedback and CI Enforcement Are Different Jobs¶
Hooks are fast feedback. They're also optional: nobody has to install pre-commit, and --no-verify still exists. Local tool versions wander. Generated output can depend on things your laptop has and CI doesn't.
CI is where the repository proves the intended state, not just the developer's last local run.
GitHub Actions Example¶
Here is a straightforward workflow shape for a Go repository:
---
name: Go Validate
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
validate:
name: check-${{ matrix.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: mod-tidy
target: mod-tidy
install_golangci_lint: false
- name: lint
target: lint
install_golangci_lint: true
- name: fmt
target: fmt-check
install_golangci_lint: false
- name: generate
target: generate
install_golangci_lint: false
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Install golangci-lint
if: matrix.install_golangci_lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: v2.11.3
install-only: true
- name: Run ${{ matrix.name }} check
run: make ${{ matrix.target }}
The matrix adds a small bit of workflow machinery, but the structure is still straightforward: each check validates one repository contract, and the command logic remains centralized in the Makefile. The golangci-lint action uses install-only: true to put a pinned binary on PATH, while make lint remains the actual contract. Pin the version input to whatever your repository standardizes on, and bump it deliberately like any other dependency. On larger modules, a prior go mod download or a quick go build can keep first-run lint failures from looking like vague package-load noise. The tradeoff is less repeated YAML while keeping the jobs easy to review and debug.
Pin Action References
If you publish a workflow like this, pin GitHub Actions to commit hashes rather than floating tags. I covered the reasoning in Why You Should Pin GitHub Actions to Commit Hashes.
Pair with Pre-commit¶
If CI is the enforcement layer, pre-commit is the developer experience layer.
A local configuration does not need to reproduce every CI detail perfectly. It only needs to catch the common issues early enough that a developer does not discover them five minutes later in a pull request check.
A simple pattern might look like this:
repos:
- repo: local
hooks:
- id: gofmt
name: make fmt-check
entry: make fmt-check
language: system
pass_filenames: false
- id: go-mod-tidy
name: make mod-tidy
entry: make mod-tidy
language: system
pass_filenames: false
- id: golangci-lint
name: make lint
entry: make lint
language: system
pass_filenames: false
- id: go-generate
name: make generate
entry: make generate
language: system
pass_filenames: false
That's enough to catch a lot of routine drift before the commit lands. If you want the broader framework setup and hook model, see Elevate Your Git Workflow: A Guide to Using pre-commit.
When the Pattern is Most Useful¶
I think this pattern is most useful when at least one of these is true:
- The repository has multiple contributors.
- Pull requests come from forks or outside contributors.
- Generated code is committed to the repository.
- The project has enough activity that review time is expensive.
- Maintainers want predictable status checks before merge.
For a tiny one-person utility with no generated code and no meaningful lint policy, this may be more structure than you need. That's fine. The goal is not to maximize ceremony. The goal is to remove avoidable drift.
There's a Tradeoff¶
The cost is duplication: local hooks and CI both need to exist, even when they call the same make targets. That's not elegant, but it is often practical if both layers stay anchored to one contract so neither drifts alone.
That's the version of this pattern I trust most:
- Fast feedback locally.
- Clear enforcement in CI.
- One shared definition of what "clean" means.