From 05a78dc23ab02f3a81c7a77d2c5f7394fabd8a4b Mon Sep 17 00:00:00 2001 From: Fadion Dashi Date: Thu, 27 Aug 2026 22:32:40 +0200 Subject: [PATCH] ci: make every check report, and scan the dependencies Protecting master is what turned this up. A required status check waits on a check run, and a workflow that a path filter stops from triggering produces no check run at all -- so the moment these checks are required, a docs-only pull request waits forever on six checks that will never arrive. The paths-ignore added in #57 to keep documentation off the matrix would have made master unmergeable for exactly the changes it was meant to speed up. A job skipped by an `if` does report, as "skipped", and that satisfies the requirement. So the filtering moves off the triggers and into the jobs: the workflow always runs, a `changes` job decides whether the rest is worth it, and the four heavy jobs gate on its answer. `changes` asks the API which files the pull request touches rather than checking out and diffing, so there is no clone, no fetch-depth to get wrong, and no third-party action in the supply chain for something this small. A push to master always runs everything: it is the check after the merge, it is rare, and making it conditional only adds a way to be wrong. The test is written as "is there a file that is not ignorable" rather than the inverse, so an unfamiliar path counts as code and the jobs run. Checked against docs-only, README-only, examples-only, mixed, an empty list, and the two lookalikes -- CLAUDE.md.bak and docsy/ -- both of which correctly run everything. govulncheck joins the lint job, unpinned where golangci-lint is pinned. The reason to pin a linter is that a new check turning a branch red says nothing about this repo; a new vulnerability is the opposite, a fact about this repo nobody knew yesterday. It reports only what this code can actually reach, so the three vulnerabilities currently sitting in imported packages and the six in required modules do not fail the build, and something reachable would. Dependabot opens a pull request monthly for the Go modules and the actions. Everything here is pinned on purpose, which is right and is also how a repo sits on a three-year-old toolchain without noticing. Co-Authored-By: Claude Opus 5 --- .github/dependabot.yml | 24 +++++++++++ .github/workflows/ci.yml | 86 ++++++++++++++++++++++++++++++++++------ 2 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3d47198 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +# Keeps the pinned versions from silently ageing. +# +# Everything here is pinned deliberately -- actions to a major, golangci-lint to +# an exact version -- which is the right default and also a way to sit on a +# three-year-old toolchain without noticing. These open a pull request instead, +# so an update is a thing somebody decided rather than a thing that drifted. +# +# Monthly, not weekly: this repo has two direct dependencies and a handful of +# actions, and a pull request nobody reads is worse than no pull request. +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: monthly + commit-message: + prefix: "deps" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + commit-message: + prefix: "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f90a3a..57ea1d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,21 +4,15 @@ on: # Only master on push. A branch with a PR open is covered by pull_request, # and listening for both ran every one of them twice. # - # paths-ignore lists the files nothing here reads. README.md is deliberately - # not among them: the language job runs every one of its code blocks through - # the interpreter, so it is executable and a change to it can fail CI. - # Anchors would say this once, but Actions does not expand them. + # No paths filter on either trigger, deliberately. A workflow that a path + # filter stops from triggering reports no check run at all, and a required + # status check waits for it forever -- so the moment master requires these + # checks, a docs-only pull request becomes unmergeable. A job skipped by an + # `if` does report, as "skipped", which satisfies the requirement. So the + # filtering moved from the trigger into the jobs, below. push: branches: [master] - paths-ignore: - - 'docs/**' - - 'CLAUDE.md' - - 'LICENSE' pull_request: - paths-ignore: - - 'docs/**' - - 'CLAUDE.md' - - 'LICENSE' workflow_dispatch: permissions: @@ -30,8 +24,58 @@ concurrency: cancel-in-progress: true jobs: + # Which of the jobs below are worth running. A change confined to files + # nothing here reads should not pay for six jobs across three platforms, and + # this is where that is decided now that the triggers no longer decide it. + # + # README.md is deliberately not in the list: the language job runs every one + # of its code blocks through the interpreter, so it is executable and a change + # to it can fail CI. Same for examples/. + # + # A push to master always runs everything. It is the check after the merge, + # it is rare, and making it conditional would only add a way to be wrong. + changes: + name: changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + # The API rather than a checkout and a diff: no clone, no fetch-depth to + # get wrong, and no third-party action in the supply chain for something + # this small. + - id: filter + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number }} + run: | + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "code=true" >> "$GITHUB_OUTPUT" + echo "not a pull request, running everything" + exit 0 + fi + + files="$(gh api "repos/$GH_REPO/pulls/$NUMBER/files" --paginate --jq '.[].filename')" + echo "changed:" + echo "$files" | sed 's/^/ /' + + # Anything outside the ignorable set means run everything. Written as + # "is there a file that is not ignorable" rather than the inverse, so + # an unfamiliar path is treated as code and the jobs run. + if echo "$files" | grep -qvE '^(docs/|CLAUDE\.md$|LICENSE$)'; then + echo "code=true" >> "$GITHUB_OUTPUT" + else + echo "code=false" >> "$GITHUB_OUTPUT" + echo "documentation only, skipping the rest" + fi + # The Go side, on every platform we ship a binary for. test: + needs: changes + if: needs.changes.outputs.code == 'true' name: test (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: @@ -73,6 +117,8 @@ jobs: # service is retired -- its badge now renders the word "retired" -- and its # own site points at golangci-lint instead. lint: + needs: changes + if: needs.changes.outputs.code == 'true' name: lint runs-on: ubuntu-latest steps: @@ -89,9 +135,23 @@ jobs: with: version: v2.13.1 + # Known vulnerabilities in the dependencies, and in the toolchain itself. + # Unpinned on purpose, unlike golangci-lint above: the reason to pin a + # linter is that a new check turning a branch red says nothing about this + # repo, and the reverse is true here -- a new finding is exactly a fact + # about this repo that nobody knew yesterday. govulncheck also reports + # only what is actually reachable from this code, so a vulnerability in an + # unused corner of a dependency does not stop the build. + - name: govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + # The language side. Ubuntu only: both scripts need bash 4 (mapfile) and GNU # coreutils (timeout), and the macOS runner ships bash 3.2 with neither. language: + needs: changes + if: needs.changes.outputs.code == 'true' name: language runs-on: ubuntu-latest steps: @@ -117,6 +177,8 @@ jobs: # Cheap, and both targets have found real bugs. 30s each, as in CLAUDE.md. fuzz: + needs: changes + if: needs.changes.outputs.code == 'true' name: fuzz runs-on: ubuntu-latest steps: