A version number on a client library is a machine-readable compatibility promise. Package managers act on it without asking: npm resolves ^2.3.0 to the newest 2.x, Go refuses to upgrade across a major boundary without a source-level import change, and a Dependabot PR merges on green because the range said the change was safe. When an SDK ships a breaking change under a minor bump, that promise fails silently in every consumer's build pipeline at once. The thesis of this playbook is narrow: for generated API SDKs, the correct version bump is a property of the API contract diff, not of the commit log, and every durable automation follows from computing it that way.
TLDR:
- Semantic versioning is a contract with dependency resolvers, not a changelog aesthetic. The bump determines whether a consumer's build upgrades automatically.
- A breaking change in an SDK and a breaking change in an API are different sets. Adding an enum value is additive over HTTP and breaking in a closed-enum deserializer.
- Commit-convention tools like semantic-release and release-please infer the bump from human-authored commit messages, which do not exist in a bot-generated SDK repository.
- The reliable signal is a diff: either the OpenAPI document against its last published state, or the generated code surface against the last released package.
- Majors should be gated, not blocked. Deprecate with RFC 9745 and RFC 8594 headers, keep the previous major on a maintenance branch, and publish a migration path.
- Fern computes the bump from a diff of the API definition, exits non-zero in CI when the bump is major, and publishes the resulting versions across nine languages.
What semantic versioning actually promises an SDK consumer
The Semantic Versioning 2.0.0 rules are short; their consequences are not, because dependency resolvers treat the number as an assertion and act on it unattended. A caret range in package.json, a ~= compatible-release clause under PEP 440, a Maven version range, a Cargo ^ default — each delegates an upgrade decision to the publisher's judgment about their own change. Getting the bump wrong does not produce a confusing changelog. It produces a green CI run in someone else's repository that installs code their application cannot compile against.
Two rules matter disproportionately for API clients:
0.y.zmeans nothing is promised. Under semver, major version zero is initial development and anything may change at any time. Publishing a client at0.xis a legitimate choice, but it opts out of the automation: resolvers treat^0.4.1as>=0.4.1 <0.5.0, so every minor is a wall. Shipping1.0.0is the moment the version number starts doing work.- The "public API" is whatever consumers can reach. For a generated client, that includes method names, parameter ordering and optionality, exported type names, enum members, error class hierarchies, and the module paths it is all imported from. Anything a consumer can bind to is in scope, whether or not it corresponds to something in the OpenAPI document.
Why a breaking change in an SDK is not the same as a breaking change in an API
The most common versioning failure in generated clients is treating "is this a breaking API change?" and "is this a breaking SDK change?" as one question. They overlap, but neither contains the other, and the difference is where the incidents live.
Changes that are additive over the wire and breaking in a typed client:
- A new enum value. The HTTP contract is unchanged. A Java or C# client deserializing into a closed enum throws on the unrecognized member; a TypeScript client with a string-literal union fails to typecheck in a consumer's exhaustive
switch. The same change is a patch in a language with permissive deserialization and a major in one without, which is why single-version-across-all-languages policies produce either over-bumping or silent breakage. - A response property changing from required to optional. Additive from the server's perspective, and a nullability change in every statically typed client.
- A new required field on a request body. Breaking everywhere, but detectable only if the diff tooling inspects request schemas rather than just the endpoint list.
Changes that are breaking in the SDK with no API change at all:
- Generator configuration edits. Renaming a method, regrouping endpoints into a different client namespace, or changing an exported type's location alters the public surface of the package while the wire contract stays identical. Fern exposes these as explicit controls —
x-fern-sdk-method-nameandx-fern-sdk-group-namein the API definition — so each one is a deliberate decision, and each one is a major bump. - Generator version upgrades. A new generator release can change idioms, dependency floors, or minimum runtime versions. Raising the minimum supported Python or Node version is a breaking change for consumers even though no endpoint moved.
The reverse case is the one worth engineering for: a change that is breaking over the wire but not in a client built to absorb it. Clients that deserialize unknown response fields instead of rejecting them, and that model enums as open types with an unknown fallback, convert a whole category of major bumps into minors. That is a generator property rather than a versioning policy, and it is the cheapest available lever on major-version frequency. Evergreen SDKs depend on it more than on any release process.
Why commit-driven versioning breaks down for generated clients
The mainstream release-automation stack infers the bump from the commit log. Conventional Commits encodes intent in the message: fix: maps to patch, feat: to minor, a BREAKING CHANGE: footer or ! marker to major. semantic-release reads those messages and publishes without human intervention. release-please reads the same convention but accumulates changes into a long-lived release pull request. Changesets drops the convention entirely and asks contributors to commit an intent file alongside their change, which is the most explicit option and the best fit for monorepos with independently versioned packages.
Every one of these is a good tool, and none of them has the input it needs in a generated SDK repository. The commits there are produced by a bot that regenerated a tree of files from a specification. There is no human at the keyboard to write feat!:, and the commit that lands is a wholesale replacement of the generated directory. Bolting Conventional Commits onto the pipeline means writing an inference step that reads the spec change and synthesizes a commit message, then a second step that parses that message back out — a lossy intermediate representation of a diff that was already available.
These tools exist because a hand-written library's public surface is only knowable from its source, so the author has to declare intent. A generated client's public surface is a function of the specification and the generator configuration, both versioned files. The intent does not need to be declared, because it can be computed.
The practical consequence: use Conventional Commits and Keep a Changelog as output formats for the release commit and the changelog entry, and a diff as the input that decides the bump. Reversing those two is the mistake.
Computing the bump from the contract diff
There are two places to take the diff, and they catch different things.
Diffing the specification compares the OpenAPI document in the pull request against the version the last package was published from. oasdiff is the reference implementation: it classifies changes across the whole document, assigns each a stable ID and severity, and reports the file and line where it occurred. Its GitHub Action compares against the base branch on every pull request and surfaces breaking changes as inline annotations in the Files changed tab, so the classification lands where review already happens rather than in a CI log nobody opens.
Diffing the generated surface compares the code the generator produced against the last published package. This is strictly broader, because it sees generator-configuration changes, generator-version upgrades, and language-specific consequences a spec diff cannot know about. It is also more expensive, since generation has to run before the bump is known.
Fern supports both shapes. The deterministic path runs the API definition through the intermediate representation and diffs two snapshots:
fern ir old-ir.json # from the last released spec
fern ir new-ir.json # from the current spec
fern diff --from old-ir.json --to new-ir.json --from-version 1.4.2fern diff returns JSON carrying a bump of major, minor, patch, or no_change along with the computed nextVersion, and exits with a non-zero status when the bump is major — the whole CI gate in one exit code. The alternative path, fern generate --local --version AUTO, analyzes the full generated-output diff and classifies it, producing the changelog entry, pull request description, and conventional commit message in the same pass. It requires an AI provider configured in generators.yml, which is the tradeoff against the deterministic route. Both are documented in Fern's self-hosted versioning guide.
| Approach | Input signal | Catches generator-config changes | Deterministic | Best for |
|---|---|---|---|---|
| Conventional Commits + semantic-release | Human commit messages | No | Yes | Hand-written libraries |
| Changesets | Contributor-authored intent files | Yes, if declared | Yes | Monorepos with human contributors |
| Spec diff (oasdiff) | OpenAPI document delta | No | Yes | Gating the API change in review |
IR diff (fern ir + fern diff) | API definition delta | Partially | Yes | Computing the bump in CI without an LLM |
Generated-output diff (--version AUTO) | Full generated code delta | Yes | No | Catching language-specific breakage |
The two diff points are complementary rather than competing. A spec diff in the API repository blocks the breaking change at design review; a surface diff in the SDK repository sets the number after the change has been approved. Teams running API governance across many services generally want both gates, because the first is about whether the change should happen and the second is about how it gets communicated.
Versioning across nine languages at once
A single-language SDK has one version number. A multi-language SDK program has one per language, and the distribution channels do not agree about what a version is.
- Go enforces semantic import versioning: from v2 onward, the module path must carry a matching
/v2suffix, soexample.com/modbecomesexample.com/mod/v2. A major bump in Go is a source-level rename of every import in every consumer, not a number in a manifest. Automation that treats it as a manifest edit produces a module that cannot be resolved. - Maven Central and crates.io are append-only. Sonatype prohibits modifying or removing a published component, and a crates.io publish is permanent —
cargo yankpulls a version from the index without deleting the code. A mis-bumped release can only be superseded, never corrected in place. - npm and PyPI offer
npm deprecateand PEP 592 yanking, but neither retroactively fixes a resolver that already upgraded.
That leaves a policy question: lockstep or independent. Lockstep publishes every language at the same number on every release, which makes support conversations trivial ("you are on 4.2.1 everywhere") at the cost of shipping empty majors to languages a change did not touch. Independent versioning keeps each number honest and forces the docs and support matrix to track nine trajectories. Most API programs converge on lockstep for the major digit and independence below it, because the major is what consumers actually reason about.
Whichever policy applies, it belongs in the pipeline rather than a runbook. Fern's generator configuration drives automated publishing to npm, PyPI, Maven Central, NuGet, RubyGems, Packagist, crates.io, and pkg.go.dev from one definition, which is what makes a uniform policy enforceable instead of aspirational.
A CI pipeline for automated semantic versioning
The ordering below is the design: nothing generates before the spec is valid, and nothing publishes before the tests generated from that same spec have passed.
1. Validate the definition
Lint the specification before anything reads it. Spectral for OpenAPI rulesets, fern check for the API definition and generator configuration. An invalid document produces a meaningless diff.
2. Diff against the last published state
Compare the current definition against the one the last release was generated from, not against the previous commit. The published package is the baseline consumers hold, and the gap between it and HEAD may span many merges.
3. Classify and compute
Map the diff to major, minor, or patch, then compute the next version from the last published tag. Store the classification, not just the number — the changelog needs the reasons.
4. Gate majors on human approval
This step separates automated versioning from automatic versioning. A major should halt the pipeline and require an engineer to approve it with a justification, because it commits every consumer to a migration cost. fern diff exiting non-zero on a major turns that into a standard required-check configuration, and Fern's release automation likewise requires human approval before finalizing a bump and publishing.
5. Generate, then test
Regenerate the clients at the computed version. Fern generates unit tests and mock-server tests from the same definition and runs them on every pull request and release, which is the cheapest way to confirm that nine languages actually compile and round-trip after a regeneration. Broader API testing practice applies here too: the SDK test suite is a compatibility test, not a functional one.
6. Stage the release
Accumulating several spec changes into one open pull request usually beats publishing on every merge, because it lets the release cadence differ from the merge cadence. Fern's GitHub integration supports mode: release, mode: pull-request, and mode: push, and in pull-request mode it updates the existing bot-opened PR rather than opening a new one per regeneration, so the accumulated diff stays reviewable in one place.
7. Publish and tag
Publish to each registry, tag the commit, and record the version that each language shipped. That record is the baseline for the next run's diff, which closes the loop.
Shipping a major version without stranding consumers
Automated bumping only solves the arithmetic. The expensive part of a breaking change is the migration, and the tooling for that is separate.
Signal the deprecation over the wire first. RFC 9745 defines the Deprecation response header, carrying a date that may be in the past or the future. RFC 8594 defines the companion Sunset header for the point at which the resource is expected to stop responding, and RFC 9745 requires that the sunset timestamp not precede the deprecation one. Together they give integrators a machine-readable window that a changelog entry cannot.
Separate the API version from the SDK version. Stripe is the clearest public example of the two axes being managed independently: since the 2024-09-30.acacia release it ships monthly dated API versions with no breaking changes and a named breaking release twice a year, with minor SDK versions tracking the monthly versions and major SDK versions tracking the twice-yearly ones. Consumers are pinned to a dated API version on their first request and upgrade deliberately. The lesson is the decoupling, not the cadence: an SDK major communicates a client-side migration and an API version communicates a server-side one, and conflating them forces every consumer onto the faster of the two clocks.
Mark stability in the client, not just the docs. Endpoints marked beta carry looser guarantees, so promoting one to generally available becomes an acceptable breaking change with a clear upgrade path rather than an unannounced one. Fern supports beta endpoint markers across all generated languages, and mixed-stability clients that expose beta and GA endpoints from the same instance with the stability level visible at the call site.
Keep the previous major alive. A maintenance branch for N-1 taking security and correctness patches for a defined window turns "upgrade now" into "upgrade this quarter." Publish the window as policy so consumers can plan against it; the API design best practices guide covers how versioning and deprecation fit the wider contract.
Reduce the migration cost mechanically. Automated code transformations, preserved method signatures for the highest-traffic calls, and a migration guide do more for adoption than a longer deprecation window. Fern provides migration tooling for this case, including embedding a legacy SDK inside a new generated one so existing consumers keep working while new code moves over.
Automating the changelog, not just the number
The bump tells a consumer that something broke. The changelog tells them what, and it determines whether the upgrade takes an hour or a sprint.
Keep a Changelog supplies the grouping most tooling already expects: Added, Changed, Deprecated, Removed, Fixed, and Security. Generating those groups from the same classified diff that produced the bump keeps them consistent by construction — every entry traces to a specific change, and the justification for the bump type is derivable rather than asserted. Fern generates structured changelogs and pull request descriptions from detected SDK changes, consolidating multi-part diffs, deduplicating entries, grouping them under Keep a Changelog headers, and attaching a one-sentence justification for the chosen bump.
The entry that matters most names the exact symbol that moved. "Renamed client.users.list() to client.users.listAll()" is actionable. "Improved API consistency" is not, and it is what a hand-written release-day changelog reliably produces.
How Fern automates semantic versioning for API SDKs
Fern generates idiomatic SDKs in nine languages — TypeScript, Python, Go, Java, C#, PHP, Ruby, Swift, and Rust — from a single API definition, and treats the version number as an output of that definition rather than a manual step. fern diff compares the current definition against the last released version, returns the computed bump and next version, and exits non-zero on a major so CI can require review; fern generate --local --version AUTO derives the same classification from the full generated-output diff and produces the changelog and pull request description alongside it. Publishing runs from the same generators.yml configuration to each language's package ecosystem, with the generated unit and mock-server tests gating the release. The comparison of multi-language SDK generation tools covers how the wider category differs on release automation.
Final thoughts on automating semantic versioning for API clients
The decision that determines whether SDK versioning works is where the bump comes from. Commit messages are a declaration of intent that a generated repository cannot produce honestly; the spec and the generated surface are facts already sitting in version control. Compute the bump from a diff of those, gate majors on a human, and let the changelog and the publish step fall out of the same classification. Everything else — registry idiosyncrasies, deprecation windows, maintenance branches — is policy layered on a number that is finally trustworthy.
Fern's docs-as-code workflow applies the same principle to the reference documentation that ships beside the client. To see how automated versioning, generation, and publishing work against a real API definition, book a demo.
FAQ
What counts as a breaking change in an SDK versus an API?
They are overlapping but distinct sets. An API breaking change alters the wire contract: removing an endpoint, adding a required request field, or changing a response type. An SDK breaking change alters anything a consumer imports, which additionally includes method names, exported type locations, error class hierarchies, and the minimum supported language runtime. Adding an enum value is the canonical divergence — additive over HTTP, and a compile-time break in any client that deserializes into a closed enum.
Can semantic-release or release-please automate versioning for a generated SDK?
Both work well for hand-written libraries and poorly for generated ones, because both infer the bump from Conventional Commits written by a human. A generated SDK repository receives bot commits that replace a directory tree, so there is no authored intent to parse. The workable pattern is to compute the bump from a spec or generated-surface diff and then emit a Conventional Commit and a Keep a Changelog entry as outputs of that classification.
How is automated breaking change detection wired into CI?
Compare the current API definition against the state the last package was published from, not the previous commit, then fail the job when the classification is major. oasdiff's GitHub Action does this against the base branch and annotates breaking changes inline on the pull request. Fern's fern diff returns the bump and next version as JSON and exits non-zero on a major, which makes it usable directly as a required status check.
Should SDKs in different languages share one version number?
Lockstep versioning keeps support conversations simple but ships empty major releases to languages a change did not affect. Independent versioning keeps each number accurate at the cost of tracking several release trajectories in the docs. Most multi-language API programs settle on lockstep major versions with independent minors and patches, since the major digit is what consumers pin and reason about.
How should a major SDK version be released without breaking existing integrations?
Announce it over the wire before the code changes, using the RFC 9745 Deprecation and RFC 8594 Sunset headers to give a dated window. Keep the previous major on a maintenance branch with a published support period, ship a migration guide naming the specific symbols that moved, and provide automated code transformations where the rename is mechanical. Decoupling the API version from the SDK version, as Stripe does with dated API versions and separately numbered clients, keeps consumers from being forced through two migrations at once.