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) + } }