Skip to main content

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​

PluginVersion pinChecksum pinEnforced by
protoc-gen-gotools/proto/go.modtools/proto/go.sumgo build
protoc-gen-go-grpctools/proto/go.modtools/proto/go.sumgo build
protoc-gen-connect-gotools/proto/go.modtools/proto/go.sumgo build
protoc-gen-estools/proto/package.jsontools/proto/package-lock.jsonnpm 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​

  1. See what a bump would change, without making it:

    make proto-remote-latest
    git diff --stat pkg/contextpb pkg/runtimepb pkg/mlpb ui/caveman/gen

    This regenerates through BSR at latest. A diff is expected — it is exactly what the bump would do to the committed tree.

  2. Restore the tree (git checkout -- pkg/ ui/caveman/gen/) and move the pin:

    cd tools/proto
    go get google.golang.org/protobuf@vX.Y.Z && go mod tidy # Go plugins
    npm install --package-lock-only @bufbuild/protoc-gen-es@X.Y.Z # TS plugin
  3. make proto and commit the regenerated output alongside the manifest change. The lane's Verify no diff in generated files step 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.