From 8a8ee2029b5a44f9843d1b0ba30be8ff7a40b954 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 2 Sep 2026 09:52:22 +0200 Subject: [PATCH] deps: raise everything that was behind, and watch what nothing was watching An audit of every pinned thing in the repository, prompted by yesterday's migration, where two CI tools turned out to be on versions that could not read Go 1.27 at all. Raised, all of them current as of today and each checked against its own release source rather than recalled: golangci-lint v2.13.1 -> v2.13.2 semgrep 1.175.0 -> 1.176.0 deploy-pages v5.0.0 -> v5.0.1 sbom-action v0.24.0 -> v0.24.2 x/image v0.43.0 -> v0.45.0 x/image is the only one that could have moved bytes, and it does not: every format at two sizes, byte for byte identical across the bump, so D11 holds and no version is owed. The module graph is unchanged at 68, and the notices file carries the new number because those licences ask for the version that actually ships. The comment in webp.go citing what x/image contains was re-read at v0.45.0 rather than carried across with the number changed - it still holds no encoder. The rest of the audit came out clean: ten of twelve actions were already current, five of six direct modules were, and all three Dependabot alerts are fixed with govulncheck finding nothing. The finding is what was NOT being watched. CLAUDE.md says Dependabot keeps these current, and that is true of go.mod and of `uses:` only. Four tool versions live as shell commands inside workflow steps, where it does not look - measured, thirty pull requests from that bot and not one about any of them. Nobody had ignored a warning yesterday. There was never going to be one. Semgrep moves to .github/requirements-semgrep.txt, which is an ecosystem Dependabot reads, so it will now arrive as a pull request. The three Go tools cannot be handled that way, twice over: a token cannot push changes under .github/workflows, so no bot can edit the line they sit on, and putting them in go.mod as tool directives was measured at 69 modules becoming 439 - against immutable rule 11, which asks for a licence check on every one, and against two gates that pin that graph deliberately. So they get a weekly job that reports instead. It reads the pins out of the workflows rather than keeping a copy, and asks the Go module proxy rather than the GitHub releases API - the first version asked GitHub and raised two false alarms out of three, because staticcheck releases under two names at once and x/vuln answered with an older tag than the one pinned. A gate that cries wolf is worse than none. Rolled staticcheck back to v0.7.0 to confirm it goes red, which is the failure it exists for. The semgrep guard was split rather than relaxed: the job has to name the requirements file, and the file has to pin an exact version, so an empty file cannot pass. Co-Authored-By: Claude Opus 5 --- .github/dependabot.yml | 25 ++++++++ .github/requirements-semgrep.txt | 25 ++++++++ .github/scripts/tool_versions.py | 88 +++++++++++++++++++++++++++++ .github/workflows/ci.yml | 21 ++++--- .github/workflows/pages.yml | 2 +- .github/workflows/tool-versions.yml | 50 ++++++++++++++++ THIRD-PARTY-NOTICES.md | 2 +- go.mod | 2 +- go.sum | 4 +- internal/format/webp/webp.go | 7 ++- internal/guard/semgrepgate_test.go | 23 +++++++- 11 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 .github/requirements-semgrep.txt create mode 100644 .github/scripts/tool_versions.py create mode 100644 .github/workflows/tool-versions.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f3ddf2f..2117006 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -61,3 +61,28 @@ updates: cooldown: default-days: 7 open-pull-requests-limit: 5 + + # The scanner CI installs, which used to be a version written inside a + # workflow step and therefore watched by nothing at all. + # + # Measured 2026-09-02: thirty pull requests from this bot, not one of them + # about staticcheck, govulncheck, golangci-lint or semgrep. Dependabot reads + # go.mod for Go modules and `uses:` for actions. It does not read shell + # commands, and nobody had missed a warning about it, because there was never + # going to be one. The bill came due on 2026-09-01, when staticcheck turned + # out to be on a version that could not read Go 1.27 at all and govulncheck + # on one that panicked. + # + # Semgrep could be moved into a file this bot reads. The three Go tools next + # door could not, twice over: a token cannot push changes under + # .github/workflows, so a bot cannot edit the line they live on, and putting + # them in go.mod as tool directives was measured at 69 modules becoming 439 - + # against immutable rule 11, which asks for a licence check on every one, and + # against two gates that pin this graph on purpose. They are watched by a + # scheduled job instead, which reports rather than proposes. O170. + - package-ecosystem: pip + directory: "/.github" + schedule: + interval: weekly + cooldown: + default-days: 7 diff --git a/.github/requirements-semgrep.txt b/.github/requirements-semgrep.txt new file mode 100644 index 0000000..58c8ac5 --- /dev/null +++ b/.github/requirements-semgrep.txt @@ -0,0 +1,25 @@ +# The scanner CI runs, pinned, in a file a bot can edit. +# +# It lived as `pip install semgrep==X` inside a workflow step until 2026-09-02, +# and that is a place nothing watches. Dependabot reads go.mod for Go modules +# and `uses:` for actions, and it does not read shell commands - measured on +# this repository, thirty pull requests, none of them about a pinned tool. The +# cost of that was paid on 2026-09-01: staticcheck sat on a version that could +# not read Go 1.27 at all, and nobody had missed a warning, because there was +# never going to be one. +# +# A requirements file is an ecosystem Dependabot does read, so a new semgrep +# release now arrives as a pull request against THIS file. It can, because this +# file is not a workflow: a token cannot push changes under .github/workflows, +# which is why the three Go tools next door still cannot be handled this way. +# Written up as O170. +# +# Pinned rather than floating for the reason the workflow gives: an unpinned +# analyser turns somebody else's release into a red build on a commit that +# changed nothing. +# +# Licence read from the pinned version rather than recalled, on 2026-08-27: +# LGPL-2.1-or-later, stated by the wheel metadata as a License-Expression and +# carried in full beside it. It analyses the tree and is never linked into +# either binary. +semgrep==1.176.0 diff --git a/.github/scripts/tool_versions.py b/.github/scripts/tool_versions.py new file mode 100644 index 0000000..875dec0 --- /dev/null +++ b/.github/scripts/tool_versions.py @@ -0,0 +1,88 @@ +"""Are the tool versions pinned in ci.yml still the current ones? + +Reads the pins out of the workflows rather than carrying its own copy, because +a list copied into a checker stops describing the thing it checks the moment +somebody edits the original. + +Asks the Go MODULE PROXY, not the GitHub releases API, and that is the whole +correctness of this file. The first version asked GitHub and produced two false +alarms out of three: staticcheck releases under two names at once, so v0.8.1 was +reported behind "2026.2.1" when they are the same thing, and golang.org/x/vuln +answered with v1.1.4 as its latest while the pin was already on v1.7.0. A pin is +a MODULE VERSION - `go run module@version` - so the module system is the thing +that knows what the newest one is, and it answers in the same words the pin is +written in. + +That matters more than tidiness here. A gate that cries wolf gets ignored, and +an ignored gate is what this whole file was written to replace. + +Exits non-zero when a pin is behind, and also when it finds no pin at all: a +checker that quietly checks nothing is the same failure wearing a green tick. +""" + +import json +import pathlib +import re +import sys +import urllib.request + +WORKFLOWS = pathlib.Path(__file__).resolve().parents[1] / "workflows" +PROXY = "https://proxy.golang.org/{}/@latest" + + +def pins(): + """Every `go run module/path/cmd/x@version` this repository's workflows run.""" + found = {} + for path in sorted(WORKFLOWS.glob("*.yml")): + text = path.read_text(encoding="utf-8") + for m in re.finditer(r"go run ([\w./-]+?)/cmd/[\w-]+@(v[\w.+-]+)", text): + found[m.group(1)] = m.group(2) + return found + + +def latest(module): + """What the module proxy calls the newest version of this module.""" + # Module paths are case encoded for the proxy: an upper case letter becomes + # "!" and the lower case letter. None of ours have one today, and doing it + # anyway costs a line and stops a silent 404 the day one does. + encoded = re.sub(r"[A-Z]", lambda c: "!" + c.group(0).lower(), module) + with urllib.request.urlopen(PROXY.format(encoded), timeout=30) as r: # noqa: S310 - fixed https host + return json.load(r)["Version"] + + +def main(): + found = pins() + if not found: + print("FAIL: no pinned tool was found in the workflows, so this checked nothing") + return 1 + + behind = [] + for module, pinned in sorted(found.items()): + try: + now = latest(module) + except Exception as exc: # noqa: BLE001 - any failure here has to be loud + print(f"FAIL: could not ask the module proxy about {module}: {exc}") + return 1 + state = "current" if now == pinned else f"BEHIND, latest is {now}" + print(f" {module:<42} {pinned:<10} {state}") + if now != pinned: + behind.append((module, pinned, now)) + + print() + if not behind: + print(f"all {len(found)} pinned tool(s) are current") + return 0 + + print(f"{len(behind)} pinned tool(s) are behind:") + for module, pinned, now in behind: + print(f" {module} {pinned} -> {now}") + print() + print("Nothing else watches these. Dependabot reads go.mod and `uses:`, and these are shell") + print("commands, so this job is the only thing that will ever say so. Raise them in .github/") + print("workflows/ci.yml, and check the new version against this tree before merging - the last") + print("two of these to go stale had stopped working with the compiler entirely.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6317de..774fb4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -397,7 +397,7 @@ jobs: # this refuses. A key that does not exist, or a value outside what the # schema allows, is otherwise ignored in silence - and a linter reading # a setting nobody applied is a gate that reports what it feels like. - run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 config verify + run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 config verify - name: golangci-lint # Run rather than imported, so it never enters go.mod and the @@ -411,7 +411,7 @@ jobs: # .golangci.yml. Measured before switching this on: zero findings from # ineffassign and one from misspell, which turned out to be a comment # written in the wrong language rather than a typo. - run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 run ./... + run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run ./... semgrep: name: semgrep @@ -429,12 +429,15 @@ jobs: # unpinned analyser turns somebody else's release into a red build on a # commit that changed nothing. # - # Licence read from the pinned version rather than recalled, on - # 2026-08-27: LGPL-2.1-or-later, stated by the wheel metadata as a - # License-Expression and carried in full beside it. It analyses the tree - # and is never linked into either binary, so it is the same kind of - # dependency as the two above. - run: pip install semgrep==1.175.0 + # The version lives in .github/requirements-semgrep.txt rather than on + # this line, and the reason is in that file: a version written into a + # workflow step is watched by nothing, which is how staticcheck came to + # be two releases behind a compiler it could not read. A requirements + # file is an ecosystem Dependabot reads, so this one now arrives as a + # pull request when it moves. + # + # The licence note and the reason for pinning moved there with it. + run: pip install -r .github/requirements-semgrep.txt - name: scan # No account and no token. p/default is fetched anonymously from the @@ -526,7 +529,7 @@ jobs: # Pinned by commit like every other action here. The scan is evidence, # not the document: its job is to ask whether the built binaries contain # something the registry does not know about. - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.0 with: path: dist format: syft-json diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5142b3c..818310e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -85,4 +85,4 @@ jobs: path: web/public - id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.0 diff --git a/.github/workflows/tool-versions.yml b/.github/workflows/tool-versions.yml new file mode 100644 index 0000000..45f7b2c --- /dev/null +++ b/.github/workflows/tool-versions.yml @@ -0,0 +1,50 @@ +# Are the tools CI pins still the current ones? +# +# Dependabot cannot answer this. It reads go.mod for Go modules and `uses:` for +# actions, and staticcheck, govulncheck and golangci-lint are none of those - +# they are shell commands inside a workflow step. Measured 2026-09-02: thirty +# pull requests from that bot, not one about any of them. +# +# The cost of nobody watching was paid on 2026-09-01. staticcheck was pinned to +# a version that could not read Go 1.27 at all, and govulncheck to one that +# panicked on it. Both surfaced in the middle of a migration, as failures, and +# neither was a warning anybody had ignored - there was never going to be one. +# +# This reports rather than proposes, and that is a limit rather than a choice. +# A workflow token cannot push changes under .github/workflows, so no bot can +# open a pull request against the line these versions live on. Moving them +# where a bot could reach was measured too: as go.mod tool directives the graph +# went from 69 modules to 439, against immutable rule 11 and against two gates +# that pin that graph deliberately. So a human is told, weekly, and does it. +# Written up as O170. +# +# Weekly and on request only. Never on push: a new release upstream is not a +# fault in the commit somebody just wrote, and failing their build for it is +# how a gate gets ignored. +name: tool versions + +on: + schedule: + # Monday morning, before the week's work rather than after it. + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + pinned-tools: + name: the pinned tools are the current ones + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: compare each pin with its latest release + env: + GH_TOKEN: ${{ github.token }} + # The pins are read out of the workflow rather than repeated here. A + # list copied into a checker stops describing the thing it checks the + # moment somebody edits the original, and this repository has been + # caught by exactly that more than once. + run: python .github/scripts/tool_versions.py diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 93a4a9b..1f6af9e 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -264,7 +264,7 @@ rymdport project. | `github.com/srwiley/oksvg` | v0.0.0-20221011165216-be6e8873101c | BSD-3-Clause | (c) 2018, Steven R Wiley | | `github.com/srwiley/rasterx` | v0.0.0-20220730225603-2ab79fcdd4ef | BSD-3-Clause | (c) 2018, Steven R Wiley | | `github.com/yuin/goldmark` | v1.8.2 | MIT | (c) 2019 Yusuke Inuzuka | -| `golang.org/x/image` | v0.43.0 | BSD-3-Clause | 2009 The Go Authors. | +| `golang.org/x/image` | v0.45.0 | BSD-3-Clause | 2009 The Go Authors. | | `golang.org/x/net` | v0.57.0 | BSD-3-Clause | 2009 The Go Authors. | | `golang.org/x/sys` | v0.47.0 | BSD-3-Clause | 2009 The Go Authors. | diff --git a/go.mod b/go.mod index d5e157d..42e0ae5 100644 --- a/go.mod +++ b/go.mod @@ -86,7 +86,7 @@ require ( github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/yuin/goldmark v1.8.2 // indirect - golang.org/x/image v0.43.0 + golang.org/x/image v0.45.0 golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index fa0dd0c..e5bfe42 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,8 @@ github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= -golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= diff --git a/internal/format/webp/webp.go b/internal/format/webp/webp.go index 9b951fc..0c75318 100644 --- a/internal/format/webp/webp.go +++ b/internal/format/webp/webp.go @@ -7,8 +7,11 @@ // there why it compresses nothing. // // Written by hand rather than taken from a library, and the reason is -// measured rather than assumed: x/image/webp at v0.43.0 holds decode.go and -// doc.go and nothing else, so the ecosystem offers no encoder to take. Pure Go +// measured rather than assumed: x/image/webp holds decode.go and doc.go and +// nothing else, so the ecosystem offers no encoder to take. Checked at v0.43.0 +// and again at v0.45.0 on 2026-09-02, when the module was raised - a claim +// about what somebody else ships has to be re-read when their version moves, +// not carried across with the number changed. Pure Go // encoders exist outside it, and taking one would have put somebody else's // release inside the byte stability contract D11 - their next version would // move the hashes in our users' test suites. See docs/STACK.md section 4.2. diff --git a/internal/guard/semgrepgate_test.go b/internal/guard/semgrepgate_test.go index e4e43b2..96fb976 100644 --- a/internal/guard/semgrepgate_test.go +++ b/internal/guard/semgrepgate_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "testing" ) @@ -148,7 +149,7 @@ func TestCIRunsSemgrepAndDecidesFromTheReportRatherThanTheExitCode(t *testing.T) text := withoutYamlComments(string(body)) for _, want := range []struct{ needle, why string }{ - {"semgrep==", "an unpinned analyser turns somebody else's release into a red build"}, + {"requirements-semgrep.txt", "the version has to come from somewhere, and that file is where it is"}, {"--config p/default", "the rules come from the registry rather than from this repository"}, {"--metrics=off", "this repository sends no telemetry about its own source"}, {".github/scripts/semgrep_gate.py", "the report is what decides, not the scanner's exit code"}, @@ -157,4 +158,24 @@ func TestCIRunsSemgrepAndDecidesFromTheReportRatherThanTheExitCode(t *testing.T) t.Errorf("the semgrep job never mentions %q, and %s", want.needle, want.why) } } + + // And the file that name points at really pins a version. + // + // This used to be one check: the job itself had to carry "semgrep==". The + // pin moved out on 2026-09-02 so that Dependabot could see it - a version + // written into a workflow step is watched by nothing, which is how + // staticcheck came to sit two releases behind a compiler it could not read. + // + // Splitting it in two rather than dropping it, because the thing being + // guarded never changed: an unpinned analyser turns somebody else's release + // into a red build on a commit that changed nothing. Asking only for the + // file name would let an empty file pass. + req, err := os.ReadFile(filepath.Join(repoRoot(t), ".github", "requirements-semgrep.txt")) + if err != nil { + t.Fatalf("the semgrep job installs from a requirements file that is not here: %v", err) + } + if !regexp.MustCompile(`(?m)^semgrep==\d`).Match(req) { + t.Errorf("requirements-semgrep.txt does not pin semgrep to an exact version, and %s\n %s", + "an unpinned analyser turns somebody else's release into a red build", req) + } }