Protobuf and gRPC Codegen on GitHub Actions

Protobuf codegen on GitHub Actions: cache buf modules and protoc plugin binaries, fail pull requests on generated-code drift, and size warp- runner labels.

Last verified:

Protobuf codegen on GitHub Actions splits into two jobs: one that regenerates the stubs and fails the pull request when the committed output has drifted, and a matrix that fans generation out across every language you publish. Cache the buf module cache and the compiled protoc-gen-* plugin binaries, then point runs-on at a warp- label sized for the compile step rather than for generation itself.

Overview

A .proto file set is a dependency graph before it is code. buf generate resolves imports across modules, orders the inputs, and hands each file set to one plugin process per language target, which is the same shape described in the task graph glossary entry. Generation is cheap. Everything around it is where GitHub Actions minutes go.

Two models exist, and the pipeline has to pick one and enforce it.

Committing generated code keeps consumers free of the toolchain: a Go service imports gen/go/... and never installs protoc. The cost is drift. A schema change merged without regenerating leaves the repository in a state where the .proto files and the checked-in stubs disagree, and the failure surfaces at runtime in whichever service deserializes the wrong field number. The fix is a job that regenerates into a clean directory and runs git diff --exit-code.

Generating at build time removes drift by construction and moves the toolchain requirement into every downstream build, including local developer machines. Most repositories that publish stubs to several package registries end up committing the output and running the drift check, because the registries need artifacts regardless.

Either way, four caches decide the job duration:

PathWhat it holdsKey input
~/.cache/bufResolved schema modules and the buf build cache, under $XDG_CACHE_HOME/bufbuf.lock
~/go/binCompiled protoc-gen-go, protoc-gen-go-grpc, and other plugin binariestools/go.sum
~/go/pkg/modModule source the plugins were compiled fromtools/go.sum
~/.gradle/caches or ~/.m2/repositoryJVM dependencies for compiling the generated Java or Kotlin stubsgradle.lockfile or pom.xml

All drop-in for GitHub-hosted labels, with shapes and per-minute rates in the cloud runners documentation.

Configuration

The first job lints the schema, checks it for breaking changes against main, regenerates, and fails on any diff against the committed output.

name: proto

on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: bufbuild/buf-action@v1
        with:
          setup_only: true

      - uses: actions/setup-go@v5
        with:
          go-version: "1.24"
          cache: false

      - name: Restore buf and plugin caches
        uses: WarpBuilds/cache@v1
        with:
          path: |
            ~/.cache/buf
            ~/go/bin
            ~/go/pkg/mod
          key: proto-${{ runner.os }}-${{ hashFiles('buf.lock', 'buf.gen.yaml', 'tools/go.sum') }}
          restore-keys: |
            proto-${{ runner.os }}-

      - name: Install codegen plugins
        working-directory: tools
        run: |
          go install \
            google.golang.org/protobuf/cmd/protoc-gen-go \
            google.golang.org/grpc/cmd/protoc-gen-go-grpc

      - run: buf lint
      - run: buf breaking --against '.git#branch=main'

      - name: Regenerate stubs
        run: |
          rm -rf gen
          buf generate

      - name: Fail when generated code is stale
        run: |
          git add --intent-to-add gen
          git diff --exit-code -- gen || {
            echo "gen/ is out of date. Run 'buf generate' and commit the result."
            exit 1
          }

Four details in that file carry the weight.

Plugin versions live in a tools module. go install inside tools/ resolves the versions pinned in that module's go.mod instead of taking whatever is newest. Unpinned plugins are the most common cause of a drift check that fails on a pull request touching no schema, because a plugin release changed a generated comment or a build tag.

The regenerate step starts from an empty directory. rm -rf gen before buf generate makes deletions visible. Without it, a message removed from the schema leaves its orphaned stub file in place and git diff stays clean.

git add --intent-to-add makes new files count. git diff --exit-code ignores untracked files, so a newly added service would pass the check silently. Staging the intent first puts new paths in the diff.

The breaking-change check needs history. buf breaking --against '.git#branch=main' reads the previous schema state out of the local clone, which requires fetch-depth: 0 on the checkout. A shallow clone makes the step fail or, worse, compare against nothing.

The second job fans generation out per language. Each target gets its own cache entry, because a Java plugin run shares nothing with a TypeScript one:

  generate:
    needs: check
    runs-on: warp-ubuntu-latest-x64-4x
    strategy:
      fail-fast: false
      matrix:
        language: [go, java, python, typescript, csharp]
    steps:
      - uses: actions/checkout@v4

      - uses: bufbuild/buf-action@v1
        with:
          setup_only: true

      - uses: WarpBuilds/cache@v1
        with:
          path: ~/.cache/buf
          key: buf-${{ matrix.language }}-${{ hashFiles('buf.lock') }}
          restore-keys: |
            buf-${{ matrix.language }}-

      - run: buf generate --template buf.gen.${{ matrix.language }}.yaml

      - uses: actions/upload-artifact@v4
        with:
          name: stubs-${{ matrix.language }}
          path: gen/${{ matrix.language }}

WarpBuilds/cache@v1 takes the same path, key, and restore-keys inputs as actions/cache@v4 and is enabled by default on Linux runners. Two behaviors from the caching documentation shape the keys above: entries are scoped to key, version, and branch, so seed the cache on main and let branch builds reach it through restore-keys; and an entry expires 7 days after its last use, so an abandoned language target stops costing storage on its own. hashFiles follows the GitHub Actions expression semantics, hashing every match in glob order.

Sizing

Rates below come from the pricing page and the shapes from the runner catalog.

Runner labelvCPURAMStoragePrice per minute
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032

Generation is a sequence of short plugin processes reading a resolved file set, so it consumes cores in bursts and finishes before a wide machine has warmed up. warp-ubuntu-latest-x64-4x is the right default for the check job and for every generate leg. The 2x label fits a lint-only job on a schema-only repository. The 8x label earns its rate when the same job compiles what it generated, which is where a Gradle build of grpc-java stubs or a TypeScript project reference build spends its minutes. Width comes from the matrix, and the matrix sharding guide covers how to keep the fan-out from outgrowing its merge step.

Cost at five languages

Take a schema repository publishing stubs for five languages, running 600 pipelines a month:

JobJobs per pipelineMinutes eachMinutes per pipeline
Lint and breaking-change check111
Drift check (generate plus git diff)122
Per-language generate and publish5420
Total723

23 minutes per pipeline across 600 pipelines is 13,800 runner minutes a month.

MachineRate per minuteMonthly minutesMonthly cost
warp-ubuntu-latest-x64-4x$0.00813,800$110.40
GitHub-hosted 4-core Linux larger runner$0.01213,800$165.60

Stated as list-price arithmetic: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13, from the GitHub Actions billing reference. At the same wall clock, the pipeline above comes to $55.20 a month less.

Cache usage is metered separately at $0.20 per GB-month of storage and $0.0001 per write or restore operation. A schema repository with a 600MB combined buf and plugin cache and 4,000 operations a month adds about $0.52.

Bottlenecks

Recompiling plugins on every job. go install builds each protoc-gen-* binary from source, which is the largest fixed cost in a cold codegen job. Caching ~/go/bin alongside ~/go/pkg/mod turns the install step into a no-op on a hit. The same module-cache reasoning applies to the service builds downstream, covered on the Go builds page.

One cache key across all languages. Five matrix legs writing a single key evict each other for the whole run and the next pipeline still starts cold. Put matrix.language in the key, as the generate job does.

Drift failures caused by tooling, not by schema. A drift check that fails on unrelated pull requests trains the team to rerun it until it passes. Pin the buf version in buf-action, pin the plugins in the tools module, and pin the generator options in buf.gen.yaml, so the only input that can change the output is the schema.

Shallow clones under buf breaking. The default checkout depth of 1 gives the breaking-change step nothing to compare against. Set fetch-depth: 0 on the check job only, and leave the generate legs shallow, since they read the working tree.

Serialized publishing. Pushing five language packages from one job makes the slowest registry set the pipeline duration, and one registry outage fails all five. Publish per language inside the matrix leg that produced the artifact.

Separating a slow generate from a slow publish is a measurement problem. The observability Jobs report shows duration, queue time, CPU, and memory per job, which tells a cache miss apart from a registry that is slow to respond. When a drift check fails only inside GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, so you can run buf generate and git status on the machine that produced the diff. Repository-wide fan-out strategy is on the monorepo pipelines page.

Proof

The rest is checkable in your own repository. Point the check job at a warp- label, leave the generate matrix where it is, and compare job duration at P75 rather than on a single run, since one run hides queue time and cache-miss variance. Every cost number on this page comes from a published rate with a source link and a checked-on date, and no build-time number is claimed for your schema, because your language count and cache hit rate decide it.

FAQ

Should generated protobuf code be committed to the repository?

Either model works as long as the pipeline enforces it. If you commit the generated code, add a drift job that regenerates and runs git diff --exit-code so a stale stub fails the pull request. If you generate at build time, every consumer needs the toolchain and the plugin versions pinned, which moves the same problem into each downstream build.

Which directories should a protobuf codegen job cache?

The buf module cache under $XDG_CACHE_HOME/buf, which defaults to ~/.cache/buf on Linux, and the compiled plugin binaries in ~/go/bin along with the ~/go/pkg/mod source they were built from. Key the entry on buf.lock, buf.gen.yaml, and the go.sum of the tools module, so the cache rolls when the schema dependencies or the plugin versions change.

What runner size does protobuf codegen need?

Start on warp-ubuntu-latest-x64-4x at $0.008 per minute for 4 vCPU and 16 GB. Generation itself spawns one short plugin process per file set and rarely saturates more cores than that, so add width through matrix jobs rather than vCPU. Move to warp-ubuntu-latest-x64-8x only when the job also compiles the generated stubs, which is where JVM and TypeScript targets spend their time.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.