Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 129 additions & 77 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,127 +1,179 @@
name: CI/CD

env:
GO_VERSION: "1.26"
name: CI

# One workflow holds everything that gates a merge, so there is one file to
# read and one check to require. Splitting pre-commit or tests into their own
# workflow bought nothing and cost a second place to keep in sync.
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
check-latest: true # setup-go's manifest lags fresh Go patch releases; resolve via go.dev
cache: true

- name: Run tests
run: make test

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# `synchronize` is every push to a branch with an open pull request, which
# is the feedback that matters. A draft is treated exactly like any other
# pull request: the distinction bought a little runner time and cost a
# second set of rules to hold in your head.
types: [opened, reopened, synchronize, ready_for_review]
push:
# Everything runs here too, not just pre-commit. A squash merge lands a
# tree no pull request ever built — the branch was tested before the
# squash, and `main` may have moved under it since — so the merge result
# gets its own run rather than inheriting the branch's green tick.
branches: [main]

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
check-latest: true # setup-go's manifest lags fresh Go patch releases; resolve via go.dev
cache: true
permissions:
contents: read

- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
args: --timeout=10m
concurrency:
# A superseded push is not worth finishing; only the newest commit matters.
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
# Runs for every commit that reaches GitHub — every push to a pull request,
# draft or not, and every push to main. It is the only job that looks at
# *every* file rather than at Go: trailing whitespace, licence headers,
# secrets, the toolchain pin. None of that is worth detecting changes for,
# and a secret committed to a draft is committed.
pre-commit:
name: Pre-commit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
- uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
check-latest: true # setup-go's manifest lags fresh Go patch releases; resolve via go.dev
cache: true

- name: Install gofumpt
run: go install mvdan.cc/gofumpt

go-version-file: go.mod
check-latest: true
cache: true # go-mod-tidy-repo resolves the whole graph; uncached it is slow and flaky
- name: Run pre-commit hooks
# Nothing is skipped here, unlike every other repo, which skips the
# HEAD-relative `golangci-lint`. Building a `language: golang` hook is
# this project's product, and the hook reporting nothing against a clean
# checkout is beside the point — that it *builds and runs* is the thing
# under test, and this is the one job that runs the action from the
# checkout. The Lint job still gates the change itself.
uses: ./

build:
# Gating happens inside the jobs, never with a workflow-level `paths:`
# filter: a required check that never runs reports as *pending forever*
# rather than as passed, which makes a docs-only pull request unmergeable.
# Anything unrecognised counts as code, so when this is wrong it is wrong in
# the direction of running the tests.
# Only after the cheap universal checks have passed is it worth asking the
# expensive question, so this waits on pre-commit.
changes:
name: Detect changed files
needs: pre-commit
runs-on: ubuntu-latest
needs: [ test, lint ]
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-depth: 0 # the diff needs the base commit, not just HEAD
- id: filter
name: Is any of this code?
env:
# On a merge to main there is no pull request; `before` is the commit
# main pointed at, which makes the diff the merge itself.
BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: |
code=false
if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
# A merge runs everything. Not because the merge itself changed code
# — it may be pure prose — but because this is the first time this
# tree is tested *as main*, and what it protects against is an
# earlier merge that broke something nobody has run since.
echo "push to $GITHUB_REF_NAME — running everything"
code=true
elif ! git cat-file -e "$BASE" 2>/dev/null; then
# No usable base commit — a force push, or a shallow fetch that did
# not reach it. `git diff` would fail here and print nothing, and an
# empty diff is indistinguishable from "only prose changed", so this
# would silently skip every test. The safe answer is to run them.
echo "base commit $BASE is not present — running everything"
code=true
else
while IFS= read -r f; do
[ -n "$f" ] || continue
case "$f" in
docs/*|*.md|LICENSE|LICENSE.*|*.png|*.jpg|*.gif|*.svg) ;; # prose
*) code=true; break ;;
esac
done <<<"$(git diff --name-only "$BASE"...HEAD)"
fi
echo "code=$code" >> "$GITHUB_OUTPUT"
echo "changed files are $([ "$code" = true ] && echo 'code — full run' || echo 'prose only — skipping the code jobs')"

- name: Set up Go
uses: actions/setup-go@v6
test:
name: Build and test
needs: changes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
if: needs.changes.outputs.code == 'true'
- uses: actions/setup-go@v6
if: needs.changes.outputs.code == 'true'
with:
go-version: ${{ env.GO_VERSION }}
check-latest: true # setup-go's manifest lags fresh Go patch releases; resolve via go.dev
go-version-file: go.mod
check-latest: true
cache: true
- run: make test
if: needs.changes.outputs.code == 'true'

- name: Build
run: make build

# Parity is this project's entire claim, so a change that breaks it fails
# before review rather than after a merge to main.
language-integration:
name: Parity with Python pre-commit
needs: changes
runs-on: ubuntu-latest
needs: [ test ]
# Runs on every PR. Parity is this project's entire claim, so a change that
# breaks it should fail before review, not after a merge to main.
steps:
- uses: actions/checkout@v6
if: needs.changes.outputs.code == 'true'
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v6
- uses: actions/setup-go@v6
if: needs.changes.outputs.code == 'true'
with:
go-version: ${{ env.GO_VERSION }}
check-latest: true # setup-go's manifest lags fresh Go patch releases; resolve via go.dev
go-version-file: go.mod
check-latest: true
cache: true

- name: Set up Python
uses: actions/setup-python@v6
- uses: actions/setup-python@v6
if: needs.changes.outputs.code == 'true'
with:
python-version: '3.13'

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'

- name: Install Python pre-commit
if: needs.changes.outputs.code == 'true'
# Pinned to the line our version number claims parity with. The harness
# rejects any other line, so a bump here is a deliberate parity decision.
run: pip install 'pre-commit==4.6.2'

- name: Run parity tests
if: needs.changes.outputs.code == 'true'
# PARITY_REQUIRE turns "no Python found" and "zero checks ran" from a
# green skip into a failure. This job exists to make the comparison; a
# run that makes no comparison has not done its job.
env:
PARITY_REQUIRE: '1'
run: go test -v -tags=integration -timeout=600s ./test/integration/

- name: Upload parity report
if: always()
uses: actions/upload-artifact@v4
with:
name: parity-report
path: test/integration/parity_report.json

# No `if:` needed: a job that `needs` a skipped job is itself skipped.
build:
name: Build
needs: [changes, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
if: needs.changes.outputs.code == 'true'
with:
fetch-depth: 0
- uses: actions/setup-go@v6
if: needs.changes.outputs.code == 'true'
with:
go-version-file: go.mod
check-latest: true
cache: true
- run: make build
if: needs.changes.outputs.code == 'true'
10 changes: 10 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ repos:
hooks:
- id: golangci-lint-fmt

# The diff-scoped hook (`--new-from-rev HEAD`), deliberately: this runs on

# every commit and should report what you just wrote, not the

# repository's whole backlog. CI lints the change against its base.

#

# Keep `rev` in lockstep with go.mod's tool pin.

- id: golangci-lint
# Secret scanning
- repo: https://github.com/gitleaks/gitleaks
Expand Down
Loading