Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

API stability and the semver gate

quality / semver-checks was red on every run, including on master and on pull requests that touched no Rust at all (#1213). The failure was real but it said nothing anyone could act on:

--- failure struct_pub_field_missing: pub struct field removed or renamed ---
  field allow_direct_provider_keys of struct SecuritySettings, previously in
  rolter-store-0.1.0/src/postgres/models.rs:726

A check that is red on a clean tree is worse than no check. Everybody learns to scroll past it, and the one time it catches something real it is indistinguishable from the noise.

Why it was always red

Two causes stacked.

The baseline was the published crate. cargo-semver-checks diffed the working tree against rolter-store-0.1.0 on crates.io, so every pre-1.0 change to a public item in a published crate was reported.

The version on master is the released version. release-plz bumps versions at release time, in the release PR — so between two releases the tree always claims to be the version it is being compared against. cargo-semver-checks reads that as “no change; assume minor”, and under 0.x semantics a minor bump does not license a breaking change. Any breaking change is therefore red from the moment it merges until the next release, no matter how deliberate it was.

That is not a bug in the tool. It is what happens when a gate is pointed at a surface that has not been declared stable yet.

What the check covers now

The gate is scoped instead of silenced. It compares each change against the commit it lands on, not against the published crate, and it checks only the crates listed in GUARDED, the env of the cargo semver-checks (guarded crates) step of the rust build job in .github/workflows/quality.yml:

CrateGuardedWhy
rolter-authyesvirtual keys, roles and access checks; consumed by both planes
rolter-balanceryesin-tree strategies all implement one LoadBalancer trait, so a change to it reaches every strategy at once
rolter-gatewayyesdata-plane surface; changes here are behavioural, not structural
rolter-proxyyesprovider dialect adapters
rolter-corenoconfig types churn with every new provider kind and strategy
rolter-storenorepo signatures and row structs track the schema
rolter-controlnoCRUD payload structs track the dashboard
rolternolauncher binary, no library surface

The guarded crates are checked at --release-type patch — the strict reading. On those crates a change to a public item is surfaced rather than passing unremarked: the check goes red, and the pull request either keeps the API or says in its description that moving it was the point.

The unguarded three are unguarded because their public items exist purely so the two binaries can share code, and they change shape whenever the schema, the config or the dashboard payloads change. Checking them produced the permanent red this page opens with.

The list is about review value, not about a promise. No rolter crate has a stable Rust API — ADR-0032 decides that explicitly, and 1.0 does not change it. A crate is on the list because an unintended change to its API is likely enough to be an unintended change in behaviour that a second look is worth the noise, and off it when it is not. Adding or removing one is that judgement, made in the same pull request, with a line in the table saying why.

Why the baseline is the change’s own base

Until #2204 the baseline was the previous release tag. That removed the first cause above and kept the second: a deliberate break stayed red on every later run until the next release. From #1863 on, every pull request failed over two RouteContext fields that none of them had touched (#1969), so a red run once again said nothing about the change it was reported on.

The resolve baseline step now picks the commit the change lands on:

RunBaseline
pull requestthe base branch tip, the first parent of the test merge GitHub checks out
merge queuethe queue entry’s base_sha
push to masterthe tip the push replaced (github.event.before)
dispatch (e.g. the release PR)git merge-base HEAD origin/master

A red run therefore means this change moved a guarded crate’s API. A change that touches nothing under crates/, Cargo.toml or Cargo.lock has nothing to diff, so the check is skipped and the report says why. Nothing compares against the last release any more; release-plz does not run the tool either (see below), because rolter’s version follows commit types alone.

The baseline is checked out as a git worktree under the runner’s temp directory and reaches the tool as --baseline-root. --baseline-rev would copy the whole tree into target/semver-checks/, which rust-cache walks when it prunes the target directory, and it reports every tests/ directory in that copy as an ENOENT error. To reproduce a run locally, where no cache walks target/, --baseline-rev is fine:

cargo semver-checks -p rolter-balancer --baseline-rev "$(git merge-base HEAD origin/master)" --release-type patch

What this does not cover

The surfaces rolter’s users actually depend on are not Rust APIs:

  • the OpenAI- and Anthropic-compatible /v1/* gateway surface,
  • the control-plane REST API under /api/v1/*,
  • the configuration file keys and environment variables,
  • the database schema and its migrations.

None of those are checked here — cargo-semver-checks cannot see them. What each of them guarantees is decided in ADR-0032 (#922), and the user-facing statement of it is Versioning & compatibility.

The configuration-file half already has one property that a 1.0 promise will have to keep: rolter’s config types carry no deny_unknown_fields, so a rolter.toml written for any build loads on any other build of the same major, including an older one it is rolled back onto. That is why an unknown key can only ever be a warning, and why the warning lives in rolter check rather than in the deserializer — Unrecognised keys in rolter.toml covers what it reports and how --strict turns it into a CI gate.

The other half of what #922 needs is the list of subsystems the promise does not cover. That is a separate axis, set per subsystem rather than per crate, and it lives in Stability markers: an experimental marker is a documented exemption saying the subsystem may change shape or be removed in a minor release.

Renaming a public item on a guarded crate

The job reports a rename as inherent_method_missing — it cannot tell a rename from a deletion, and both read the same to anything outside the crate. So on a guarded crate a rename is not a free refactor, and it has exactly two honest endings:

  • Keep the old name as a deprecated shim. Leave the pub item in place, mark it #[deprecated(note = "use `<new name>`")], and have it delegate to the new one. The symbol is still in the API, the check stays green, and every in-tree caller moves to the new name in the same pull request. This is what rolter_proxy::Forwarder::forward_bearer is: #1446 renamed it to forward_mcp, which left the job red on every pull request until #1473 put the shim back.
  • Drop the old name and say so. Delete it outright and state in the pull request description that moving the API was the point. The job goes red, and that red is the record.

What is not an option is renaming quietly. The job is continue-on-error, so nothing blocks the merge, and it diffs against the commit the change lands on, so the red shows on that one pull request and never again. Left unexplained, it reads as noise and the move goes unrecorded. Do not add a since to the #[deprecated] unless the release that deprecates it is already known; release-plz picks the version from commit types, so a guessed one is wrong as often as not.

A shim is removed in a pull request whose title carries the ! and a BREAKING CHANGE: footer, once no caller is left.

Why the deprecation lint is allowed off

#[deprecated] is itself a minor-level change to cargo-semver-checks (type_method_marked_deprecated), and the guarded crates run at --release-type patch. Taken literally that makes the first ending above impossible: adding the attribute fails the same job that restoring the symbol was meant to fix, so the only green move would be a silent shim with no marker saying it is going away.

That reading is backwards here. The lint protects downstream crates from an unexpected compiler warning against a Rust API ADR-0032 says was never stable, while deprecate-then-delegate is precisely the behaviour worth encouraging on a guarded crate. So rolter-proxy turns that one lint off, in its own Cargo.toml:

[package.metadata.cargo-semver-checks.lints]
type_method_marked_deprecated = "allow"

Per-crate and per-lint on purpose. Nothing else is silenced — a removal, a signature change or a visibility change on any guarded crate still fails — and the next crate to deprecate something adds its own line rather than inheriting a workspace-wide exemption it never asked for.

At 1.0 — the check stays advisory

An earlier version of this page planned to promote the job at 1.0: drop continue-on-error, add it to ci-ok’s needs:, and revisit GUARDED against whatever 1.0 declared stable. ADR-0032 withdraws that plan, because 1.0 declares no Rust API stable. Every published crate is internal implementation detail, said so in its Cargo.toml description, its //! docs and the README, and a gate enforcing a promise the ADR disclaims would be worse than either choice alone.

So the check is permanently advisory:

  • Its three steps (the install, the baseline and the check) keep continue-on-error: true and run last in rust build, whose report turns a failure into a warning, never an error, so the check can never turn ci-ok red. A red run is a review signal — “this pull request moved a public item in a crate both binaries share” — and is often the correct outcome, as it is for the behaviour-preserving refactors in #1041 and #1042.
  • GUARDED keeps its four crates for the same reason: those are where an unintended API change is most likely to be an unintended behaviour change worth a second look. Adding or removing one is a judgement about review value, not about a promise, and still wants a line in the table above saying why.
  • release-plz has semver_check = false at the workspace level in .github/config/release-plz.toml. Left at its default it would run cargo-semver-checks itself and propose a major bump of the shared workspace version — every binary, the wheel, the Docker tag — because a shared helper changed shape. rolter’s version is driven by commit types and nothing else.

The check is green on a clean tree, and a red run means a guarded crate’s API moved. That is information, not a verdict.