Skip to content

Go API compatibility gate

The api:public-contract facet, part of the default facet set, adds a hosted check to every repository whose go.mod git tracks. The check compares the exported API of every Go module in the repository with a base revision. A removed or changed exported identifier in any module, a nested one included, fails the required check from v1 on. Before the repository's first v1 release it passes only when a commit in the compared range marks the break: a Conventional Commits ! subject or a BREAKING CHANGE: footer, with a non-empty Migration: footer (the same patterns praetorctl forge check-commits reads); an unmarked pre-v1 break fails the check. The gate is one Go program, tools/apicompat/gate/main.go, run by the workflow .github/workflows/praetor-api.yml under the status context Go API Compatibility.

What adoption writes

While the facet is declared and git tracks a go.mod, praetorctl adopt writes three locked files and praetorctl audit compares them byte for byte, one consistent line-ending style allowed:

File Purpose
tools/apicompat/gate/main.go The gate program. Its //go:build apicompatgate constraint keeps the gate itself out of every ./... pattern, so the repository's own build, tests, linters and API never include it; go run builds it because the command names the file.
tools/apicompat/gate/placeholder.go A stand-in for the gate in a build without the tag: a main that exits 1 and names the command that runs the gate. It gives the directory one buildable package, which a ./... pattern of the repository now builds, vets and lints (a main that prints one line and exits 1, with no exported API), and so go vet ./tools/apicompat/gate and golangci-lint run ./tools/apicompat/gate, which a pre-commit hook may run on the directory of a staged file, find a Go file instead of failing on a package whose files the tag excludes.
.github/workflows/praetor-api.yml One job, Go API Compatibility, on pushes to the repository's default branch and on every pull request; on a draft it fails by design until the draft is marked ready (When the workflow runs). The branch ruleset adoption renders requires it.

Adoption refuses, even with --force, to overwrite a file the repository already had at either path before the gate was adopted. adoption.decline cannot name the api-compatibility-gate step: remove the facet to opt out, which removes both files and, under --force, the required context from the ruleset (internal/adopt/api_compat.go, TestAdoptionAPICompatibilityGateFacetTransitionConverges and TestAdoptionAPICompatibilityGateRefusals in internal/adopt/api_compat_test.go). The Renovate ignore rule and the Prettier inventory that adoption maintains list the gate's files beside the documentation gate's. The actionlint runner labels adoption declares are read from every workflow it writes (adoptedWorkflowFiles in internal/adopt/harness_ci.go), so the runner praetor-api.yml runs on is declared exactly while adoption emits the gate (TestActionlintLabelsFollowTheAPICompatibilityGate in internal/adopt/actionlint_test.go).

When the workflow runs

A newer push to a pull request cancels its older Go API Compatibility run, and the checkout drops the job token after cloning (concurrency and checkout credentials).

The locked workflow also triggers on merge_group, so the Go API Compatibility check reports for a merge group when the repository declares a merge queue (Merge queue); an unedited copy without the trigger refreshes without --force.

Adoption renders the workflow for the repository's default branch, resolved as for the branch ruleset: repository.default_branch in .standards.yaml, else the origin remote's HEAD, else main (forge.RepositoryDefaultBranch in internal/forge/default_branch.go). The rendering is the one audit locks, so a copy rendered for another branch, or edited, fails the audit. A copy rendered for another branch fails naming both branches: a CI checkout usually has no origin HEAD and resolves main, so a repository whose default branch is not main declares repository.default_branch, as for the ruleset (Protected default branch). Adoption warns about an existing manifest that leaves it out, even one that declines the ruleset (TestAdoptWarnsAnUndeclaredBranchTheHostedGatesRenderFor in internal/adopt/workflow_branch_test.go, TestAuditHostedGatesLockTheDefaultBranchRendering in cmd/standardsctl/audit_hosted_gate_branch_test.go).

Event What the job does
Push to the default branch Runs the gate.
Push to any other branch, or of a tag No run starts.
Pull request opened, updated or reopened, not a draft Runs the gate.
Draft marked ready for review Runs the gate (ready_for_review).
Draft opened, updated or reopened Fails by design without running the gate.
Any other pull request activity No run starts.

The job has no condition, so it reports Go API Compatibility on every pull request and the branch ruleset requires it. A job-level draft skip would not do: GitHub reports a job its condition skipped as successful, and a required check accepts that, so a draft marked ready could merge on the skip before the new run reports (using conditions to control job execution). On a draft the job's first step runs alone: it prints an error annotation titled Gate not run on a draft and a step summary line saying that the gate did not run because the pull request is a draft and runs when it is marked ready for review, then exits 1. The step names shell: bash: without it a Windows runner would run the script under PowerShell, and a defaults.run.shell under that shell, and the step would fail without the annotation (TestDraftStepFault in internal/ghworkflow/hostedgate_test.go). Every other step carries if: github.event.pull_request.draft != true. A draft therefore shows a failed check by design. Marking it ready starts the ready_for_review run, which reports the same check on the same head commit and replaces the failure; until it reports, the failed check keeps the pull request from merging.

The compare step sets GOTOOLCHAIN: auto: the gate downloads the toolchain a module's go or toolchain directive names.

hosted_gates.draft: skip in .standards.yaml opts in to a job-level draft skip instead, which the audit accepts only behind a result job that reports Go API Compatibility, needs the gate job and fails when it is skipped. A draft then skips the gate job and fails the required check through the result job (Adoption, TestAuditLocksTheDraftSkipRendering in cmd/standardsctl/audit_draft_skip_test.go).

The trigger and the draft handling are defined once, in internal/ghworkflow/hostedgate.go: both hosted gates render their text from it, and ghworkflow.HostedGateFault checks a workflow against the same shape (TestHostedGateFault_Negative in internal/ghworkflow/hostedgate_test.go). The event table is tested against both hosted gates (TestHostedWorkflowsRunOnlyOnTheDefaultBranchAndReadyPullRequests in internal/managedasset/workflow_branch_test.go). The checkpoint planner reads the failed check of a draft carrying exactly that annotation as draft_pending, not as a failed review (Checkpoint cadence).

An earlier Praetor wrote a workflow that ran on every push to every branch and tag and on every draft (#815), and then one whose draft step named no shell. Plain praetorctl adopt refreshes either text, and a rendering for a former default branch, without --force; a hand-edited copy still needs --force (TestAdoptRefreshesPriorHostedGatesAndKeepsEditedOnes in internal/adopt/workflow_branch_test.go). An earlier text rendered for a default branch other than main refreshes too: the prior lookup reads its one push branch line as main and matches the recorded text (TestPriorTextRenderedForAnotherBranchIsPrior in internal/managedasset/workflow_branch_test.go).

Repositories without Go

The gate compares Go modules only, so adoption writes it only where git tracks a go.mod, at the root or nested, outside the directories a ./... pattern skips. That is the module discovery the gate itself uses (TrackedModuleFiles in tools/apicompat/modules.go, kept equal to the gate by TestGate_Boundary_AdoptionDiscoversTheGatesModules). The index decides, so a go.mod counts once it is staged or committed, and an untracked one does not.

The same discovery decides where the gate's prefetch, security and race-test stages and the Go hooks run (#1353). Those consumers run a complete toolchain once per module, so they refuse more than MaxTrackedModules (256) modules by name (ErrTooManyModules); this gate keeps its own larger bound for comparing revisions.

A repository that declares the facet without such a go.mod, a Python or Rust project for example, gets nothing: no gate program, no workflow, and no Go API Compatibility context in the ruleset adoption renders. The adoption report says no API compatibility checker runs for the repository's languages, and praetorctl audit prints the same as an [INFO] line, neither a pass nor a failure. The facet stays declared and keeps its other policy, including the Migration: footer check.

A repository moves between the two states on the next praetorctl adopt. Tracking its first go.mod adds the gate and the required context. Untracking its last one retires them; while the ruleset still requires the context, an unforced run stops and names the missing go.mod, and --force removes the gate and the requirement, as removing the facet does (TestAdoptionAPICompatibilityGateFollowsTrackedGoModules in internal/adopt/api_compat_test.go). Until that run, audit fails on the gate files or the required context left behind and names the adoption run that retires them.

Modules that need system libraries

The job builds every module on a hosted Linux runner (go list -export ./...), so a package that needs a C library or header at build time, through cgo, fails there when the runner lacks it. The workflow is locked, so the repository declares the need in .standards.yaml and adoption renders the workflow from it.

api:
  system_packages:
    - libudev-dev
    - libopenal-dev

api.system_packages lists Debian package names, validated against the Debian package-name grammar (lower case letters, digits and + - ., two to 64 characters, opening with a letter or digit), at most 64, without repeats; a list outside that fails manifest validation naming the key (internal/config/api_policy.go, TestAPIPolicy_Negative_InvalidPackagesFail). While the list is non-empty the rendered workflow gains one step, Install system packages for the Go build, directly before the step that runs the gate: it carries the draft condition of every gate step, a 15 minute timeout, and runs sudo apt-get update and then sudo apt-get install -y --no-install-recommends with exactly the declared names, one per line so that no line passes yamllint's 80 columns (TestRenderWorkflow_Positive_InstallStepRunsBeforeTheChecker in tools/apicompat/render_test.go). An absent or empty list renders the workflow byte-identical to the text before the key existed (TestRenderWorkflow_Boundary_EmptySettingsKeepTheBytes). Nothing sets CGO_ENABLED: a module is never compared with cgo silently switched off.

The rendering is per repository, like the default-branch rendering above (adopt.FamilyForRepository): audit locks the copy to the rendering for this repository's manifest, so a hand-edited copy fails and so does a copy rendered for other packages (TestAuditLocksTheAPIWorkflowToTheManifestRendering in cmd/standardsctl/audit_api_system_packages_test.go). Plain praetorctl adopt refreshes an unedited earlier rendering, whether the manifest gained, changed or dropped packages, or the branch changed, without --force; an edited one still needs --force (TestAdoptRefreshesUneditedRenderingsWhenTheManifestChanges in internal/adopt/api_system_packages_test.go, TestPriorRendering_RecognisesRenderingsOfOtherSettings in internal/managedasset/manifest_render_test.go).

A module that cannot be built even so

A module whose packages do not build on the runner even with the packages installed takes an expiring exception from the manifest's exceptions list, rule api-compatibility, naming the module's go.mod:

exceptions:
  - rule: api-compatibility
    path: hw/vendor-sdk/go.mod
    reason: Needs a vendor SDK that no Debian package provides.
    expires: 2026-12-31

The entry follows the rules of the exceptions list (one line of reason, an expiry within the category interval (A: stated, at most 90 days; B: 3 days, C: 10 days, D: 120 days; uncategorized: 90 days), no repeats; config.ValidateExceptions) and takes a path to one go.mod, never a glob, and no ${{ in the reason, because the workflow carries the entries to the gate in the environment variable APICOMPAT_EXCEPTIONS; the gate reads no manifest. While the entry holds, through the end of its expires day in UTC, a build failure of that module is not a failure: the gate prints not compared, with the reason and the expiry, in its report and as a warning annotation, compares the other modules and exits as if the module were absent from the comparison. A module that builds is compared whatever the list says. Once the entry expired, or without one, the build failure fails the gate with status 2 as before, naming the expired entry (TestGate_Positive_ExceptedModuleIsReportedNotCompared, TestGate_Negative_ExpiredOrMissingExceptionFails in tools/apicompat/gate_exceptions_test.go). A damaged APICOMPAT_EXCEPTIONS fails the run instead of reading as no exceptions (TestGate_Boundary_DamagedExceptionsFailTheRun).

Adding, renewing or editing an api-compatibility entry changes the rendered workflow, because the entries travel in its APICOMPAT_EXCEPTIONS line. Run praetorctl adopt and commit the refreshed .github/workflows/praetor-api.yml with the manifest change: until then audit fails naming plain adoption (TestAuditLocksTheAPIWorkflowToTheExceptionsAlone in cmd/standardsctl/audit_api_system_packages_test.go, TestAdoptRefreshesTheWorkflowWhenOnlyAnExceptionChanges in internal/adopt/api_system_packages_test.go). The path of an entry is limited to letters, digits and . _ - / for the same reason (TestExceptions_APICompatibilityRuleNamesAModule).

Run it locally

From a clean checkout, with git and go on PATH:

go run tools/apicompat/gate/main.go -base=origin/main

The checker checks each revision out in the working tree and restores HEAD afterwards, and it refuses a dirty tree, so commit or stash first. Without -checker, the gate builds the pinned checker from its build module into a temporary directory, downloading the modules from the Go module proxy; pass -checker=<path> to use a binary you built.

Flag Default Meaning
-base empty Base revision. Empty selects the newest root release tag merged into HEAD.
-policy auto auto rejects from v1 and, before v1, rejects a break no commit marks with ! or BREAKING CHANGE: plus Migration:; warn and reject fix the policy.
-checker empty A go-apidiff binary; empty builds the pinned one.
-repo . Any directory inside the repository.

What it compares

  1. Modules. Every go.mod git tracks at the base and at HEAD, outside the directories a ./... pattern skips (testdata, vendor, and names starting with . or _). A module path with an internal element is listed as not public and not compared. More than 512 modules, or a revision listing more than 2,097,152 paths, fails the gate rather than comparing a part.
  2. Pairs. A module present at both revisions, matched by directory, is compared. One only at HEAD is listed as added. One only at the base is a removed module, every package of it gone, and counts as an incompatible change.
  3. New major versions. A module whose path at HEAD is a later major version of its path at the base, the same path with a higher /vN suffix (.vN for gopkg.in), is listed as a new major version and not compared. Go publishes a new major version under a new module path, so importers of the earlier path keep its releases and lose nothing: an in-place bump to example.com/w/v2 passes from v1 on. Any other change of the module path is compared, and the checker reads every package of the earlier path as removed (isNewMajor; TestGate_Positive_InPlaceMajorVersionIsANewModule, TestGate_Boundary_MajorVersionSuffixes).
  4. Comparison. For each compared module the gate first downloads the module's dependencies (go mod download in its directory, up to 3 attempts with 5 s and 15 s waits, each failed attempt named on stderr), so one proxy stream error does not read as a module that does not build; a download that fails all 3 attempts stops the run with status 2, and no api-compatibility exception excuses it (TestBuildModule_Positive_FirstDownloadFailsThenSucceeds, TestBuildModule_Negative_EveryDownloadFails, TestGate_Negative_ExceptionForModulePlusDownloadFailureStillFailsGate in tools/apicompat/gate/download_retry_test.go). It then runs go list -export ./... at HEAD, so a module whose packages do not build fails instead of reading as empty (unless a live api-compatibility exception excuses it, Modules that need system libraries), and then go-apidiff <base> <HEAD> --repo-path=<root> from that module's directory. go-apidiff loads ./... from its working directory, which is what keeps a nested module from being skipped by a run at the root. When an exported type moves between packages within the module and remains in its original package as an alias (type Bucket = otherpkg.Bucket), the gate verifies that the alias target's declaration at HEAD is identical to the original declaration at base (normalising package qualifiers). Reported changes matching that alias are accepted as compatible (compatible: alias of identical type), while actual signature changes (added or removed methods/fields, parameter type changes, removed names) remain incompatible (filterModuleOutput; TestGate_Positive_IdenticalTypeAliasAccepted). The aliases are collected once from every tracked module at HEAD, and each alias and its target are looked up across the base and HEAD module sets, so a split module whose API names a type another module turned into an alias is matched the same way (#1411; TestGate_Positive_CrossModuleIdenticalAliasAccepted, TestGate_Negative_CrossModuleAliasKeepsRealChangeRejected, TestGate_Boundary_CrossModuleAliasesDoNotChangeUnrelatedTarget). Each module's own files are the ones whose longest owning module directory is that module: the root module's scan leaves nested modules' files out, so a nested module's alias never vetoes an unrelated root signature (#1421; TestGate_Positive_NestedModuleAliasesDoNotPolluteRoot, TestGate_Boundary_SiblingPrefixNotMaskedByNestedModule).
  5. Root vendor directory. go-apidiff loads every module with -mod=vendor when the repository root holds a vendor directory, and with -mod=readonly otherwise (getPackages in its pkg/diff/run.go). The go command vendors a nested module from that module's own directory, so a nested module with requirements would fail with inconsistent vendoring. While the base or HEAD tracks a root vendor entry, the gate compares nested modules in a temporary clone whose two commits drop that entry, and removes the clone afterwards; the root module is still compared in the checkout (vendorFreeClone; TestGate_Positive_ComparesNestedModulesWithoutTheRootVendorDirectory, TestGate_Boundary_VendorFreeCloneScope).

The base is -base when given; the workflow passes the pull request's base commit. Otherwise it is the newest root release tag, vMAJOR.MINOR.PATCH with no directory prefix and no pre-release suffix, merged into HEAD. A nested module's tag (lib/v1.2.3) is never selected. With neither, there is no published API, and the gate passes saying so; a push builds on that.

Exit status and policy

Status Meaning
0 Every compared module is compatible, nothing was there to compare, or incompatible changes were reported under the warn policy.
1 Incompatible changes under the reject policy, or an unmarked pre-v1 break under auto.
2 The comparison did not run: a git or discovery error, a malformed go.mod, module dependencies that could not be downloaded in 3 attempts, a module that does not build at HEAD, a checker that cannot be built, misses the canary, exits with a status other than 0 or 1, or prints an error.

Compatibility policy never relaxes execution policy: status 2 holds under -policy=warn too. go run itself exits 1 for any nonzero status of the program, so a workflow log shows the program's status in the exit status N line. On GitHub Actions the verdict is printed as an ::error:: or ::warning:: workflow command, which renders as an annotation.

The pinned checker

The gate builds github.com/joelanford/go-apidiff from a pinned build module instead of running go install module@version. The module is the text of three files, checkerGoMod, checkerGoSum and checkerTools in tools/apicompat/gate/main.go; the gate writes them to a temporary directory and runs go build -mod=readonly there (buildChecker). The text lives in the gate because the gate ships as embedded bytes and go:embed cannot embed the files of a nested module.

  • go-apidiff is at commit c3e0953fa2fd of its main branch (v0.8.4-0.20260910211158-c3e0953fa2fd). The newest release, v0.8.3, builds against golang.org/x/tools v0.33.0, which cannot decode the export data Go 1.27 writes; go-apidiff ignores the resulting package errors and reports every module as compatible. The pinned commit changes only dependencies, and its CLI and exit statuses (0 compatible, 1 incompatible, 2 failed) match the release.
  • golang.org/x/tools is required at v0.52.0 exactly; the floor is v0.51.0. The commit's own go.mod asks for v0.49.0, which reads export data up to version 4, while Go 1.27.2 writes version 5; built against it the checker exits 0 without comparing anything and the canary fails every run (#1049). The build module raises the floor without a change upstream.
  • go.sum pins the whole closure, and -mod=readonly refuses a build that would change it.

To refresh the module, change the two requirements in checkerGoMod, run go mod tidy in a scratch directory holding the three texts, and copy the results back. TestGatePinsItsChecker (tools/apicompat/checker_pin_test.go) refuses an x/tools below v0.51.0, a requirement that is not exact, a go.sum without both modules' hashes and a tools.go without both imports. Renovate moves the two requirements through the regex manager in renovate.json; the branch then needs the go.sum refresh and the outgoing gate text recorded in priorDigests and internal/managedasset/testdata/shipped.

Before comparing anything, the gate runs the checker on a canary repository whose second commit removes an exported function. A checker that does not report that removal fails the gate with status 2, so a future toolchain the checker cannot read is a failure, not a silent pass (verifyChecker; TestGate_Negative_ExecutionFailuresFailWhateverThePolicy in tools/apicompat/gate_test.go).

Audit

praetorctl audit prints Locked API compatibility gate verified once both files hold their locked texts, git commits them, and the workflow reports Go API Compatibility on every pull request (forge.RequiredStatusContextsOf); the branch ruleset audit then requires that context. In a repository without a tracked go.mod it prints the [INFO] line from Repositories without Go instead. With the facet removed, or with no go.mod tracked, a Praetor file of the gate left behind, or a ruleset still requiring its context, fails the audit unless adoption.decline names branch-ruleset (cmd/standardsctl/audit_api_compat.go, cmd/standardsctl/audit_api_compat_test.go).

Changing the gate

The workflow's action pins move through Renovate's API compatibility gate actions group, which updates tools/apicompat/assets.go and this repository's copy together (renovate.json, TestRenovateUpdatesTemplatePinsWithWorkflowCopy in internal/managedasset/renovate_test.go). Any change to a managed text fails TestShippedTextLedger until internal/managedasset/testdata/shipped/api-compatibility.sha256 records the new digest and priorDigests in tools/apicompat/assets.go records the outgoing one, so an adopter holding the earlier text refreshes without --force:

PRAETOR_UPDATE_SHIPPED_TEXTS=1 go test ./internal/managedasset -run 'TestShippedTextLedger$'

Both Go files are formatted with gofumpt and pass go vet without a tag for the placeholder and with -tags apicompatgate for the gate, because audit locks them byte for byte and an adopter cannot reformat them. TestManagedAssetsAreLintClean in internal/managedasset/lint_clean_test.go holds every managed asset of every family to that kind of default (see Locked assets and linters).

Praetor lints, vets and scans the gate with its build tag (make lint, make sec, .golangci.yml run.build-tags) and tests it by building the embedded bytes (tools/apicompat/gate_test.go). The gate cannot import internal/util, which does not exist in an adopting repository, so the forbidigo rule and the utility-sprawl check of praetorctl dedupe scan exempt it (.golangci.yml, managedAsset in internal/dedupe/dedupe.go); each of its commands runs under a deadline of its own.

Limits

  • Go only. For every other language HISS-14 is enforced by the Migration: footer check alone (HISS compliance matrix), and a repository without a tracked go.mod gets no gate at all (Repositories without Go).
  • The base revision is not built separately: the canary covers a checker that cannot read the toolchain's packages, and go list -export covers HEAD, but a base whose packages no longer load with the current toolchain is compared as the checker reads it.
  • A nested module's own vendor directory is not used: go-apidiff loads a nested module with -mod=readonly, so its requirements come from the module cache or proxy. The vendor-free clone copies or hard-links the repository's object store into the temporary directory.
  • go-apidiff opens the repository with go-git, which does not read a linked worktree (git worktree add): it reports every file as staged and exits 2, so the gate fails there without touching the tree. Run it in a clone, as the hosted job does.
  • The hosted job runs on Linux. The gate itself runs on Linux, macOS and Windows, and its tests run on all three.
  • A nested module whose go language version is newer than the toolchain the checker was built with is type-checked by the checker's go/types and may not be reported; go-apidiff ignores package errors.
  • Type aliases across different modules are not resolved: alias verification reads declarations within the compared module.