Protoc plugin pins — Runbook
Status: active — 2026-09-08. Owner: devops-engineer.
Related: core#3233 (this change), core#3229 (the ejected PR), core#3201 (the
golang-migrate 504, same fragility class), core#3210 (merge_group parity).
Why this exists
buf generate used remote: plugins, so every generation step called
buf.build's plugin execution service. On 2026-09-07 a BSR blip
(the server hosted at that remote is unavailable) failed the required
Proto Generation & Go Compilation context and ejected #3229 — a PR with no
proto changes at all — from the merge queue.
Generation is now hermetic. The four plugins are built from in-repo pins into
.proto-tools/bin, and the buf.gen.*.yaml templates name them with local:.
buf generate makes no network call.
Where the pins live
| Plugin | Version pin | Checksum pin | Enforced by |
|---|---|---|---|
protoc-gen-go | tools/proto/go.mod | tools/proto/go.sum | go build |
protoc-gen-go-grpc | tools/proto/go.mod | tools/proto/go.sum | go build |
protoc-gen-connect-go | tools/proto/go.mod | tools/proto/go.sum | go build |
protoc-gen-es | tools/proto/package.json | tools/proto/package-lock.json | npm ci |
protoc-gen-es is npm-only — there is no Go build of it — which is why
tools/proto/ carries two manifests. npm rather than the pnpm used by
ui/caveman: npm ships with node, so the required lane needs no extra
package-manager setup step.
No version number is repeated anywhere else. scripts/proto-pins.sh is the one
reader; install-proto-plugins.sh and proto-gen-remote.sh both source it.
bash scripts/proto-pins.sh # what is pinned, right now
Everyday use
make proto # builds the toolchain if needed, then generates
make proto-tools # just (re)build .proto-tools/bin
Requires go >= 1.25.4 and node >= 20. The toolchain is git-ignored; the
manifests are the committed source of truth.
Bumping a plugin version
-
See what a bump would change, without making it:
make proto-remote-latestgit diff --stat pkg/contextpb pkg/runtimepb pkg/mlpb ui/caveman/genThis regenerates through BSR at latest. A diff is expected — it is exactly what the bump would do to the committed tree.
-
Restore the tree (
git checkout -- pkg/ ui/caveman/gen/) and move the pin:cd tools/protogo get google.golang.org/protobuf@vX.Y.Z && go mod tidy # Go pluginsnpm install --package-lock-only @bufbuild/protoc-gen-es@X.Y.Z # TS plugin -
make protoand commit the regenerated output alongside the manifest change. The lane'sVerify no diff in generated filesstep is what holds you to it.
Changing any manifest changes the CI cache key, so the next run cold-builds. That is intended: the cache can never serve a toolchain that disagrees with the pins.
Checking the vendored toolchain against BSR
make proto-remote # BSR at the PINNED versions — expect a ZERO diff
A diff means the local build and BSR's published plugin of the same version
disagree; investigate before shipping anything. Neither proto-remote target is
wired into any workflow.
Expect Failure: too many requests if you run it repeatedly — BSR rate-limits
unauthenticated plugin execution after a handful of calls. That is itself part of
why the CI path no longer depends on it.
When CI says the toolchain is wrong
Verify the plugin toolchain, and put it on PATH failing looks like:
WRONG protoc-gen-go reports 'protoc-gen-go v1.36.11', pinned at 'v1.36.12'
That is a stale or partial cache restore, not a code problem. It fails closed on
purpose: without it you would instead get Generated proto code is out of date. Run 'make proto' locally three steps later, which sends the author chasing a
phantom. Re-run the job; the key is content-addressed, so a genuine mismatch
cannot survive a cold build.
tests/scripts/test_proto_plugin_pins.sh is the regression guard — it asserts no
CI-path template has drifted back to remote:, and mutation-tests the verifier
against wrong versions, missing binaries and an empty bin/.
Scope note
tools/proto/ is a nested Go module. Root-level ./... patterns
(go build, go vet, golangci-lint, govulncheck) do not descend into it, so
the plugin dependencies are not covered by the nightly vulnerability sweep. They
are build-time-only and never ship in an image; if that changes, add the module
to daily-security-sweep.yml.