Skip to content

ci: guard the exported API against accidental breaking changes - #1297

Merged
baptmont merged 5 commits into
mainfrom
apidiff-ci
Aug 11, 2026
Merged

ci: guard the exported API against accidental breaking changes#1297
baptmont merged 5 commits into
mainfrom
apidiff-ci

Conversation

@baptmont

Copy link
Copy Markdown
Contributor

What

Adds an apidiff check that fails a pull request on incompatible changes to the module's exported API. Adding to the API is always allowed.

Why

The build and the tests do not catch every break. Adding a trailing variadic parameter to an exported function changes that function's type: every ordinary call site still compiles, so nothing goes red, while any caller that held the function as a value stops compiling.

That is not hypothetical. It happened to NewRuntimeAPIController, NewPubSubController and NewEventarcController, and reached a release. A test written specifically to pin backward compatibility did not catch it either, because a plain call expression is exactly the form a trailing variadic can never break.

Two details worth reviewing

  • It compares against the merge base, not the target tip. My first draft compared against origin/main and produced two convincing false positives on a branch that had been cut before fix(session): give Event a consistent JSON encoding #1252 landed, reporting it as having removed the methods that PR added. A check that cries wolf gets ignored, so this seemed worth getting right.
  • internal/ packages are excluded, since they are not public API.

Testing Plan

Verified end to end against real commits rather than a synthetic case:

  • Against the commit that introduced the variadics, it reports both constructors and exits 1.
  • Against the commit that restores the signatures, it exits 0 with no findings.
  • The pinned apidiff version resolves and installs from a clean module.
  • Workflow YAML parses; the script passes bash -n.

Local run on the breaking commit:

=== google.golang.org/adk/v2/server/adkrest/controllers/triggers ===
Incompatible changes:
- NewPubSubController: changed from func(..., TriggerConfig) *PubSubController
  to func(..., TriggerConfig, ...ControllerOption) *PubSubController

Notes

This is independent of the context-compaction stack (#1231 to #1236), though that is where the motivating break was found and fixed.

The build and the tests do not catch every break. Adding a trailing
variadic parameter to an exported function changes that function's type:
every ordinary call site still compiles, so nothing goes red, while any
caller that held the function as a value stops compiling. That reached a
release, and a test written specifically to pin backward compatibility
did not catch it either, because it was an ordinary call expression.

The check runs apidiff over every exported package and fails on
incompatible changes. Adding to the API is always allowed.

Two details that matter. It compares against the merge base rather than
the target branch tip, because a branch cut before a recent change to the
target would otherwise be reported as removing whatever the target gained
in the meantime; the first draft did exactly that and produced two
convincing false positives. And internal packages are excluded, since
they are not public API.

Verified end to end rather than assumed: run against the commit that
introduced the variadics it reports both constructors and exits 1, and
against the commit that restores them it exits 0 with no findings.
zizmor's template-injection audit flagged the run step, correctly. A
${{ }} expansion inside run: is substituted into the shell text before
the shell sees it, so whatever the value holds becomes code rather than
an argument. Passing it as an environment variable and quoting it in the
script keeps it data.

Caught by CI on the pull request that added the workflow, which is a
reasonable advertisement for the check that job performs.
The check shipped with no way to land an intended break. The failure
message said a maintainer could override it, but the only mechanism was
disabling a required check, which is how checks get removed rather than
overridden.

A pull request labelled breaking-change is still compared and still
prints what changed, so the break is on the record instead of waved
through invisibly. It just stops failing.
The check took eleven minutes warm and over fifteen cold. It invoked
apidiff once per exported package, and every invocation reloads and
type-checks the entire dependency graph. apidiff -m reads a whole module
in one pass and reports the same findings in seven seconds.

Module mode also reaches plugin/agentanalytics, which go list ./... never
saw. That module is public API and was going unchecked. And it skips
internal/ on its own, so the grep filtering is gone with it.

Separately, a module that cannot be read now fails. Every apidiff call
carried a trailing || true, so a base revision that failed to build wrote
no snapshots, every package was then skipped for want of one, and the job
went green having compared nothing at all. apidiff exits non-zero only
when it could not do its job, so that case is easy to separate from a
run that found a break, and the breaking-change label does not cover it:
a comparison that did not happen is not a break anyone decided to make.

Verified against the same cases as before. A trailing variadic added to
runner.New and to NewBatchProcessor is reported and exits 1, a clean tree
exits 0, and a module that does not compile fails even with the label set.
@baptmont

Copy link
Copy Markdown
Contributor Author

Pushed 3c1d2e2, which changes how the comparison is driven. The description above still describes the per-package version, so, in short:

One apidiff -m per module instead of one per exported package. The per-package loop took 11m16s on a warm cache and over 15 minutes cold — 87 invocations, each reloading and type-checking the whole dependency graph. Module mode reports the same findings in 22s.

It now covers plugin/agentanalytics. go list ./... only ever saw the root module, so the submodule — which is public API — was going unchecked. Module mode also skips internal/ by itself, so the grep filtering is gone.

A module that cannot be read now fails the check. Every apidiff call carried a trailing || true. A base revision that failed to build wrote no snapshots, every package was then skipped for want of one, and the job went green having compared nothing — the one outcome worse than not having the check. apidiff exits non-zero only when it could not do its job (reporting a break is a successful run), so the two cases separate cleanly. The breaking-change label deliberately does not cover this one: a comparison that did not happen is not a break anyone decided to make.

Re-verified the same cases: clean tree exits 0; a trailing variadic on runner.New and on NewBatchProcessor is reported and exits 1; the label still downgrades a break to a report; a module that does not compile fails even with the label set.

Backport to v1 is #1304 — both files there are byte-identical to these.

Generated with CloudCode, session ses_00f468febffe7dsyc1CNVAgbg7.

kdroste-google pushed a commit that referenced this pull request Aug 11, 2026
#1304)

Backport of #1297 to the 1.x maintenance branch, where the guarantee it
enforces matters more than it does on main: v1 is a maintenance line, so
every break in it is by definition unintended.

The build and the tests do not catch every break. Adding a trailing
variadic parameter to an exported function changes that function's type:
every ordinary call site still compiles, so nothing goes red, while any
caller that held the function as a value stops compiling. That reached a
release, and a test written specifically to pin backward compatibility
did not catch it either, because a plain call expression is exactly the
form a trailing variadic can never break.

The check runs apidiff over every module and fails on incompatible
changes. Adding to the API is always allowed. A deliberate break can be
landed by labelling the pull request breaking-change, which still
compares and still prints what changed, so the break stays on the record.

The workflow already listed v1 among its trigger branches, but a
pull_request workflow runs from the head of the branch under test, so it
could never fire on a v1 pull request until it existed here. Both files
are byte-identical to the ones on main.
@kdroste-google
kdroste-google self-requested a review August 11, 2026 13:50

@kdroste-google kdroste-google left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@baptmont
baptmont merged commit 362e529 into main Aug 11, 2026
14 checks passed
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.

2 participants