Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions .github/requirements-semgrep.txt
Original file line number Diff line number Diff line change
@@ -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
88 changes: 88 additions & 0 deletions .github/scripts/tool_versions.py
Original file line number Diff line number Diff line change
@@ -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())
21 changes: 12 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 50 additions & 0 deletions .github/workflows/tool-versions.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
7 changes: 5 additions & 2 deletions internal/format/webp/webp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 22 additions & 1 deletion internal/guard/semgrepgate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
Expand Down Expand Up @@ -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"},
Expand All @@ -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)
}
}
Loading