Skip to content

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.

Taskfile.yml
# 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
.github/workflows/ci.yml
# 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 mutate

Add the tools it runs, Task among them:

Terminal window
go get -tool github.com/go-task/task/v3/cmd/task
for t in ctxlife gencache mutate refs sfw taskgate testquality versions vulngate; do
go get -tool github.com/spin-stack/go-tools/cmd/$t
done

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 -cache keeps 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.

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