Skip to content

generate native Connect services and check declarations with buf - #18

Open
hugowetterberg wants to merge 2 commits into
mainfrom
feature/native-connect-generation
Open

generate native Connect services and check declarations with buf#18
hugowetterberg wants to merge 2 commits into
mainfrom
feature/native-connect-generation

Conversation

@hugowetterberg

@hugowetterberg hugowetterberg commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

A service now has one of three shapes:

Shape Serves Implements Streaming
rpc.ShapeDualStack Connect and /twirp/ the plain protobuf service interface no
rpc.ShapeConnect Connect the same plain interface no
rpc.ShapeNative Connect connect-go's own handler interface yes

Only ShapeNative may declare a streaming method: protoc-gen-elephant-rpc and protoc-gen-twirp both fail generation on a stream, since the plain interface returns one response and has no room for one.

The layout picks the default and rpc.Shapes overrides it per service. Flat layout defaults to ShapeDualStack while rpc.Twirp is set and ShapeConnect when it is not; versioned layout defaults to ShapeNative.

The override is not a corner case — it is how an existing service moves, and an earlier revision of this branch got it wrong by deriving the shape from the layout alone. A service's proto package is in its procedure path, and the versioned layout is what puts a version in the package, so under that rule an existing service could reach ShapeNative only by moving directory: adopting connect-go's interface, and therefore streaming, was welded to breaking every caller's path. Retiring Twirp had the smaller version of the same problem, since rpc.Twirp is repository-wide and elephant-api holds six services.

rpc.Twirp = true

rpc.Shapes = map[string]rpc.Shape{
    // Off Twirp and onto connect-go's own interface, where it stands.
    "repository": rpc.ShapeNative,
    // Moved layout, still has Twirp callers.
    "rpc/elephant/collab/v1": rpc.ShapeDualStack,
}

Naming a service there changes what it generates and nothing else — no directory moves, no package changes, no path changes. Going native still changes the generated Go a caller compiles against, which is a library break a consumer adopts on its own schedule; the wire contract does not shift.

Generation is one buf run per shape, since the plugin list and the plugin options both follow the shape: which generator declares the plain service interface is protoc-gen-twirp for dual stack and protoc-gen-elephant-rpc for connect, and neither runs for native. Stale-file removal follows the same rule per service.

rpc.ElephantRPCOptions may no longer set the plugin's interface option — it applies to every service at once and can only contradict a per-service shape. Nothing in the fleet set it.

Discovery now walks the proto root at any depth instead of matching two fixed globs, so a declaration nested under a package prefix is found — elephant.collab.v1 in rpc/elephant/collab/v1. vendor, node_modules, testdata and dot directories are never descended into; testdata for the reason the go command treats it as opaque, since a plugin that keeps a fixture declaration would otherwise have Go generated into it.

rpc:stub scaffolds elephant.<app>.v1 into <proto root>/elephant/<app>/v1 with the Service suffix, so a fresh stub passes buf lint with the STANDARD rules and no exemptions. It did not before.

rpc:lint, rpc:breaking, rpc:format and rpc:formatCheck run the pinned buf over the discovered services, so a vendored declaration is compiled as an import and never checked against rules belonging to the repository it came from.

Breaking, for repositories whose protobuf sources live under rpc

The buf module root moves from the repository root to the proto root, which is what PACKAGE_DIRECTORY_MATCH is checked against and what an import resolves against. Two things follow, both to be dealt with in the commit that bumps this module:

  • An import inside a .proto is now written relative to the proto root. Generation fails with imported file does not exist until it is. No repository has an intra-repo import today.
  • buf names the file greeter/service.proto rather than rpc/greeter/service.proto, and that name is part of what protoc-gen-go writes, so the descriptor variable is renamed and the embedded descriptor bytes in service.pb.go and service.twirp.go change with it.

That rename does not reach the service's callers. A file's name is independent of its package, so message and service full names, the RPC paths and the encoding are all untouched, and the only exported symbol that moves is the File_* descriptor variable — which nothing in the fleet references. The cost is a regeneration diff, not a release coordinated with anybody calling the service.

Affected: newswire and elephant-collab, both of which carry File_rpc_<app>_service_proto today. idapo already generates the new naming and is unaffected, as is elephant-api, whose proto root is the repository root.

rpc:breaking cannot pass on the bump commit of such a repository — the state it compares against has no buf.yaml and names its files from the repository root, so buf reads every declaration as deleted. The target says so rather than reporting the deletions, and the check has to be skipped once on that PR.

Notes for reviewers

A CI job running rpc:breaking needs actions/checkout with fetch-depth: 0. The default shallow checkout has no local main; the target falls back to origin/<branch> where that exists, but a --depth 1 clone has neither and fails with a message saying so.

The CHANGELOG entry carries no PR references — there was no number when it was written.

A service is now one of two shapes, and the layout says which. A declaration in
the flat layout, <proto root>/<application>/service.proto, is dual stack and
generates exactly what it generated before. A declaration whose own directory
is a version is native: protoc-gen-go and protoc-gen-connect-go only, no
adapters, no plain interface, no Twirp — and streaming methods allowed, which
the other shape cannot have because protoc-gen-elephant-rpc and protoc-gen-twirp
both fail generation on a stream. rpc.DualStack names the service directories
that generate dual stack whatever their layout, for a legacy service that moves
layout before its Twirp callers are gone.

Discovery walks the proto root at any depth rather than matching two fixed
globs, so a declaration nested under a package prefix is found:
elephant.collab.v1 in rpc/elephant/collab/v1. vendor, node_modules, testdata
and dot directories are never descended into, testdata for the reason the go
command treats it as opaque — a plugin that keeps a fixture declaration would
otherwise have Go generated into it.

The buf module root moves from the repository root to the proto root, which is
what PACKAGE_DIRECTORY_MATCH is checked against and what an import resolves
against. For a repository whose sources live under rpc that renames the
protobuf file, and the name reaches the generated Go, so the bump is breaking
for those repositories and the CHANGELOG says what has to happen in the same
commit. A repository whose proto root is the repository root, which is
elephant-api, is unaffected and still needs no buf.yaml.

rpc:stub scaffolds elephant.<app>.v1 into <proto root>/elephant/<app>/v1 and
gives the service the Service suffix, so a fresh stub passes buf lint with the
STANDARD rules and no exemptions. It did not before.

rpc:lint, rpc:breaking, rpc:format and rpc:formatCheck run the pinned buf over
the discovered services, so a vendored declaration is compiled as an import and
never checked. rpc:breaking resolves the branch its git input names against the
checkout it runs in and falls back to origin/<branch>, since a CI checkout has
no local main; a shallow clone has neither, and the target says so rather than
blaming a missing first commit.
The layout deciding the shape outright meant an existing service could only
reach the native shape by moving to the versioned layout — which changes its
proto package, and the proto package is in its procedure path. Adopting
connect-go's own interface, and so streaming, was welded to breaking every
caller's path. Retiring Twirp had the smaller version of the same problem:
rpc.Twirp is repository-wide, so a repository holding six services could not
take one of them off the /twirp/ paths without taking all six.

A service now has one of three shapes. ShapeDualStack serves Connect and
/twirp/ on the plain protobuf service interface, ShapeConnect serves Connect
only on that same interface, and ShapeNative serves Connect only on
connect-go's own handler interface and is the one shape that may declare a
streaming method. The layout picks the default — flat is dual stack while
Twirp is on and connect when it is off, versioned is native — and rpc.Shapes
overrides it by service directory, in both directions. Naming a service there
changes what it generates and nothing else: no directory moves, no package
changes, no path changes.

That replaces rpc.DualStack, which only went one way and is not in a release.

The plugin list and the plugin options both follow the shape, so generation is
one buf run per shape rather than two: which generator declares the plain
service interface is protoc-gen-twirp for dual stack and
protoc-gen-elephant-rpc for connect, and neither runs for native. Stale file
removal follows the same rule per service, so changing a shape cleans up
whichever declaration of the interface is no longer generated.

rpc.ElephantRPCOptions may no longer set the plugin's interface option, since
it applies to every service at once and can now only contradict a per-service
shape. Generation refuses it and names rpc.Shapes instead. Nothing in the
fleet set it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant