GitHub Actions
A repository that takes go-tools from its go.mod needs one job in CI: the tasks a developer
runs, one a step, so what CI does and what a laptop does are the same commands. The two files
below are that repository’s. They are files in go-tools, not copies: a test fails when
taskgate would refuse them, actionlint reads the workflow on every change, and
Dependabot keeps its pins current.
# A repository that runs go-tools from its go.mod, as the site's CI page shows it. CI runs these# tasks and nothing else, so a developer runs exactly what CI does. TestTheSitesCIExamplePasses# holds it to taskgate, and task lint:workflows its workflow to actionlint.version: '3'
tasks: generate: desc: The generated code, and a failure when what is committed is not it. cmds: # go generate, with each mockgen directive answered from gencache's cache when nothing its # mock is made from changed. - go tool gencache ./... - git diff --exit-code
lint: desc: golangci-lint at the version versions.yaml pins, and go-tools' gates. cmds: # Its cache in the tree, where CI's actions/cache can keep it. - mkdir -p _output/golangci-lint - >- docker run --rm -u "$(id -u):$(id -g)" -v {{.ROOT_DIR}}:/work -w /work -v "$(go env GOMODCACHE)":/go/pkg/mod -v "$(go env GOCACHE)":/cache/go-build -e GOMODCACHE=/go/pkg/mod -e GOCACHE=/cache/go-build -e GOLANGCI_LINT_CACHE=/work/_output/golangci-lint -e HOME=/tmp -e GOTOOLCHAIN=local $(go tool versions ref golangci-lint) golangci-lint run ./... - go tool ctxlife - go tool testquality ./... - go tool refs - go tool mutate -stale-exempts - go tool sfw check - go tool taskgate - go tool vulngate ./...
test: desc: The tests, under the race detector, with their coverage in coverage.out. cmds: - go test -race -coverprofile=coverage.out ./...
mutate: desc: Break what the change touched and fail on an edit no test refuses (BASE=origin/main by hand). cmds: # A pull request against what it merges into; a push against what the branch was before it, # or the commit before when that is not in the clone. Only a pull request keeps the edits # already refused (-cache), for its own next run: the cache's key does not cover a file a # test reads from outside its package, so a run from scratch asks everything. - | base="{{.BASE}}" cache="" if [ -z "$base" ] && [ "${GITHUB_EVENT_NAME:-}" = pull_request ]; then base="origin/$GITHUB_BASE_REF" cache="-cache _output/mutate" elif [ -z "$base" ] && [ -n "${GITHUB_ACTIONS:-}" ]; then base="${BEFORE:-}" git rev-parse --verify -q "$base^{commit}" >/dev/null || base=HEAD~1 fi go tool mutate -base "${base:-origin/main}" -max 200 $cache# The gate on every change, as the site's CI page shows it: the tasks a developer runs, one a step.name: CI
on: push: branches: [main] pull_request:
permissions: contents: read
# sfw's binary and versions' downloads are asked of GitHub with this: anonymous ones are the# first it sheds under load.env: GITHUB_TOKEN: ${{ github.token }}
jobs: gate: name: Gate runs-on: ubuntu-latest # A ceiling: a test that deadlocks would otherwise hold the runner for six hours. timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # mutate diffs against the branch point, which a shallow clone does not have. fetch-depth: 0
# Go's module and build caches. Task is a tool of go.mod's, so it comes with them. - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod
# gencache's mocks and golangci-lint's analysis, each keyed by content inside, so any older # copy is safe to start from: saved under the commit, restored from the newest - main's for # a pull request. - name: Mocks and lint findings, from the last run uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cache/go-tools/gencache _output/golangci-lint key: answers-${{ github.sha }} restore-keys: answers-
# The edits mutate saw refused, for this pull request's next run only (task mutate says why). - name: Refused edits, from this pull request's last run if: github.event_name == 'pull_request' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: _output/mutate key: mutate-${{ github.head_ref }}-${{ github.sha }} restore-keys: mutate-${{ github.head_ref }}-
- name: Generated code run: go tool task generate
- name: Lint run: go tool task lint
- name: Test run: go tool task test
# After the tests are green, what they would have refused. - name: What this change broke, and whether a test noticed env: BEFORE: ${{ github.event.before }} run: go tool task mutateAdd the tools it runs, Task among them:
go get -tool github.com/go-task/task/v3/cmd/taskfor t in ctxlife gencache mutate refs sfw taskgate testquality versions vulngate; do go get -tool github.com/spin-stack/go-tools/cmd/$tdoneWhy each part is there
Section titled “Why each part is there”The whole history (fetch-depth: 0). mutate breaks what the change touched,
and finds it by diffing against the branch point, which a shallow clone does not have.
Task from go.mod. go tool task is the version the module pins, built from Go’s caches,
which setup-go restores. Nothing else is installed.
What a run already answered. A runner starts empty, and setup-go keeps only Go’s module and build caches, so without the cache steps every run redoes work no change asked for:
- gencache keys each mock by what it is made from, and golangci-lint each package’s analysis by its content. An entry that no longer matches is never used, so any older copy is safe: they are saved under the commit and restored from the newest, which for a pull request is main’s.
- mutate’s
-cachekeeps the edits the tests refused. Its key does not cover a file a test reads from outside its package, so it is only for the same change asked again: the Taskfile passes it on a pull request alone, and the workflow keeps it under that pull request’s branch.
Generated code first. gencache writes back a cached mock whose file differs, so
git diff --exit-code after it still fails on generated code nobody regenerated.
Lint, then the tests, then mutate. Lint is the quick gates: ctxlife,
testquality, refs, mutate’s -stale-exempts, sfw,
taskgate and vulngate. mutate asks the tests about the change,
so it runs once they pass. Its base is what a pull request merges into, or on a push what the
branch was before it (BEFORE), or the commit before when that is not in the clone, a branch’s
first push.
What else goes in CI
Section titled “What else goes in CI”- A release: provenance and an SBOM for what it publishes, and a gate that fails on a workflow publishing without them. Provenance and SBOMs says what each writes and what attestgate refuses.
- A commit CI already answered: proven finds the run that passed for the same tree, so a rebase that changed nothing is not run again.
- Pins: versions says which are behind; its test fails on a pin written
outside
versions.yaml.

