diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e58733a..92640d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de5133e..0d62148 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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