diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92640d8..65e087c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,19 +36,60 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + # `--new-from-merge-base` needs the base branch's history to find the + # common ancestor; a shallow clone has none of it. + fetch-depth: 0 - uses: actions/setup-go@v6 with: 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: Resolve the diff this change introduces + # Everything below runs against this range and nothing else — no + # `--all-files` anywhere in CI. A branch create or a force-push sends an + # all-zero `before`, and an unresolvable ref makes both pre-commit and + # golangci-lint report nothing while exiting 0, which is the silent pass + # this whole job exists to prevent. So: fall back to the merge base with + # main, then verify every ref actually resolves and fail loudly if not. + id: range + env: + BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + base="$BASE" + if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "${base}^{commit}" 2>/dev/null; then + echo "No usable base ref ('${base:-empty}') — falling back to the merge base with main" + base="$(git merge-base origin/main "$HEAD_SHA")" + fi + git rev-parse --verify "${base}^{commit}" >/dev/null + git rev-parse --verify "${HEAD_SHA}^{commit}" >/dev/null + # The manual hook below diffs against origin/main; if that is missing + # it reports `0 issues` and exits 0 rather than erroring. + git rev-parse --verify origin/main >/dev/null + echo "base=$base" >> "$GITHUB_OUTPUT" + echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "Linting ${base}..${HEAD_SHA}" - 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. + # checkout. The step below is what gates the change itself. + uses: ./ + with: + extra_args: --from-ref ${{ steps.range.outputs.base }} --to-ref ${{ steps.range.outputs.head }} + - name: Lint what this change introduces + # Same config, same pin, same hook — asked the narrower question, and + # told not to fix (`--fix=false` in the hook's args): a linter that + # repairs its own findings exits 0 and reports nothing, which is a green + # check over unchanged, still-broken committed code. uses: ./ + with: + extra_args: --from-ref ${{ steps.range.outputs.base }} --to-ref ${{ steps.range.outputs.head }} --hook-stage manual golangci-lint-new # Gating happens inside the jobs, never with a workflow-level `paths:` # filter: a required check that never runs reports as *pending forever* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d62148..8f78965 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,18 +41,46 @@ repos: rev: v2.12.2 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. - + # repository's whole backlog. # - # Keep `rev` in lockstep with go.mod's tool pin. + - id: golangci-lint + # The same upstream hook, told which base to diff against and told not + # to fix. Both flags are appended to the entry, and both override it + # (measured): + # + # `--new-from-merge-base` beats the entry's `--new-from-rev HEAD`, + # which on a clean checkout compares the tree against an identical + # HEAD and so finds nothing. + # + # `--fix=false` beats the entry's `--fix`. This one is not a + # preference. Measured on a committed misspelling: with `--fix`, the + # hook reported **Passed** while rewriting the runner's working copy, + # because a linter that repairs its own findings has nothing left to + # report. Green check, no output, committed code still wrong. + # + # And pre-commit's usual safety net does not cover it. A fixing hook + # normally fails with "files were modified by this hook" — that is why + # end-of-file-fixer and friends stay honest in CI. But that check works + # from the filenames pre-commit handed the hook, and this one is + # `pass_filenames: false`, so there are none to compare. Measured both + # ways: end-of-file-fixer failed on a file it fixed; golangci-lint + # passed on one it fixed. That is what makes this hook the exception. + # + # Fixing belongs to the hook above, which runs against your working + # tree where the repair is the point. CI only reports. + # + # `stages: [manual]` keeps it out of every local commit. CI asks for + # this one by name. - id: golangci-lint + alias: golangci-lint-new + name: golangci-lint (issues this change introduces) + stages: [manual] + args: [--new-from-merge-base=origin/main, --fix=false] + # Secret scanning - repo: https://github.com/gitleaks/gitleaks rev: v8.30.1 @@ -60,5 +88,7 @@ repos: - id: gitleaks exclude: ^build/ -# Note: the whole-repo golangci-lint run stays in CI — the hook above only -# lints what changed since HEAD, which is fast enough for every commit. +# Note: CI runs the `golangci-lint-new` alias above at the manual stage, so a +# pull request is gated on the issues it introduces. Nothing runs the whole +# repository; whole-module linters like `unused` are reported only for code +# the change touched.