Semver Policy for OpenAPI-Driven SDKs
This guide is part of Breaking Change Detection & Spec Diffing within SDK Generation & Changelog Automation. It covers the step after classification: turning “this change is breaking” into the version number a generated client library is published under, and publishing a policy that makes a ^ range safe for consumers to write.
Problem & Context
Semantic versioning makes one promise: a ^1.2.3 range will never install something that breaks your build. Consumers rely on that promise by writing caret ranges and enabling automatic dependency updates, which means a breaking change published as a patch reaches every one of them with no action on their part — and no warning.
For a generated SDK, that promise is harder to keep than it looks, because two independent things can break consumers.
The first is the obvious one: a breaking contract change flows through generation into the client. The second catches teams out repeatedly — a generator upgrade that changes emitted method names, type names, or constructor signatures, with the contract completely untouched. From a consumer’s perspective these are indistinguishable: their code no longer compiles. The fact that your specification did not change is not something they can observe or care about.
A workable policy therefore derives the bump from both inputs, takes the higher of the two, and publishes what each level means so consumers know what they are pinning to.
Step-by-Step Solution
1. Separate the contract version from the SDK version
They answer different questions and forcing them to match makes both wrong. Record the relationship instead:
{
"name": "@acme/sdk",
"version": "3.1.0",
"acme": {
"contractVersion": "2.7.0",
"generatorVersion": "7.9.0"
}
}
Now “which contract does SDK 3.1.0 implement?” is answerable from the package itself, without matching version numbers that legitimately move at different rates.
2. Map the diff classification to a bump
The level computed in Failing CI on breaking OpenAPI changes is the input:
# contract-level: major | minor | patch, from the spec diff
case "${CONTRACT_LEVEL}" in
major) BUMP=major ;;
minor) BUMP=minor ;;
*) BUMP=patch ;;
esac
3. Account for generator upgrades
Compare the generator version recorded in the last release with the one about to be used, and take the higher bump of the two inputs:
PREV_GEN=$(npm view @acme/sdk acme.generatorVersion 2>/dev/null || echo '0.0.0')
CURR_GEN="${GENERATOR_VERSION}"
gen_level() {
local a="${1%%.*}" b="${2%%.*}" # major components
if [ "$a" != "$b" ]; then echo major
elif [ "${1%.*}" != "${2%.*}" ]; then echo minor
else echo patch
fi
}
GEN_LEVEL=$(gen_level "${PREV_GEN}" "${CURR_GEN}")
# The higher of the two wins.
rank() { case "$1" in major) echo 3;; minor) echo 2;; *) echo 1;; esac; }
if [ "$(rank "${GEN_LEVEL}")" -gt "$(rank "${BUMP}")" ]; then BUMP="${GEN_LEVEL}"; fi
echo "bump=${BUMP}"
bump=major
4. Publish the policy consumers pin against
A caret range is only safe if consumers know what your major means. State it plainly, on a stable URL:
https://docs.acme.com/sdk-versioning
MAJOR A change that can break compiling code: a removed or renamed method,
a narrowed type, a new required argument, or a generator upgrade that
alters emitted names or signatures.
MINOR New methods, new optional arguments, new response fields. Existing code
continues to compile and behave identically.
PATCH Documentation, comments, and internal fixes with no API surface change.
We record the contract version each release implements in package metadata.
Majors are announced at least 30 days before publication.
5. Enforce the bump in the release job
The computed level and the version being published must agree, or the policy is aspirational:
CURRENT=$(npm view @acme/sdk version)
EXPECTED=$(npx semver "${CURRENT}" -i "${BUMP}")
if [ "${RELEASE_VERSION}" != "${EXPECTED}" ]; then
echo "Refusing to publish ${RELEASE_VERSION}: a ${BUMP} bump from ${CURRENT} is ${EXPECTED}" >&2
exit 1
fi
echo "OK publishing ${RELEASE_VERSION} (${BUMP} from ${CURRENT})"
Complete Working Example
A release script that computes the bump from both inputs, refuses a mismatch, and records the provenance in the published package:
#!/usr/bin/env bash
# release-sdk.sh — derive the bump, verify it, publish, record provenance.
set -euo pipefail
: "${CONTRACT_LEVEL:?set CONTRACT_LEVEL (major|minor|patch) from the spec diff}"
: "${CONTRACT_VERSION:?set CONTRACT_VERSION, e.g. 2.7.0}"
: "${GENERATOR_VERSION:?set GENERATOR_VERSION, e.g. 7.9.0}"
PKG="${PKG:-@acme/sdk}"
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; *) echo 1 ;; esac; }
# --- input 1: the contract -------------------------------------------------
BUMP="${CONTRACT_LEVEL}"
# --- input 2: the generator ------------------------------------------------
PREV_GEN=$(npm view "${PKG}" acme.generatorVersion 2>/dev/null || echo '0.0.0')
if [ "${PREV_GEN%%.*}" != "${GENERATOR_VERSION%%.*}" ]; then GEN_LEVEL=major
elif [ "${PREV_GEN%.*}" != "${GENERATOR_VERSION%.*}" ]; then GEN_LEVEL=minor
else GEN_LEVEL=patch
fi
[ "$(rank "${GEN_LEVEL}")" -gt "$(rank "${BUMP}")" ] && BUMP="${GEN_LEVEL}"
echo "contract=${CONTRACT_LEVEL} generator=${GEN_LEVEL} → bump=${BUMP}"
# --- compute and verify the version ---------------------------------------
CURRENT=$(npm view "${PKG}" version 2>/dev/null || echo '0.0.0')
NEXT=$(npx --yes semver "${CURRENT}" -i "${BUMP}")
if [ -n "${RELEASE_VERSION:-}" ] && [ "${RELEASE_VERSION}" != "${NEXT}" ]; then
echo "ERROR: requested ${RELEASE_VERSION} but a ${BUMP} bump from ${CURRENT} is ${NEXT}" >&2
exit 1
fi
# A major must be announced before it is published.
if [ "${BUMP}" = major ] && [ "${MAJOR_ANNOUNCED:-false}" != true ]; then
echo "ERROR: major release requires MAJOR_ANNOUNCED=true (30-day notice policy)" >&2
exit 1
fi
# --- record provenance and publish ----------------------------------------
npm version "${NEXT}" --no-git-tag-version
npm pkg set "acme.contractVersion=${CONTRACT_VERSION}"
npm pkg set "acme.generatorVersion=${GENERATOR_VERSION}"
npm publish --access public
echo "Published ${PKG}@${NEXT} (contract ${CONTRACT_VERSION}, generator ${GENERATOR_VERSION})"
Expected output for an additive contract change on an unchanged generator:
contract=minor generator=patch → bump=minor
Published @acme/[email protected] (contract 2.8.0, generator 7.9.0)
The MAJOR_ANNOUNCED guard is the one people leave out and then regret. A major that publishes the day it is decided reaches consumers who had no chance to plan, which erodes the trust that makes the whole versioning scheme useful.
Gotchas & Edge Cases
A generator upgrade sneaks in as a patch. This is the most common way an SDK breaks consumers without anyone intending it. If the generator version is not recorded in the published package, there is nothing to compare against and the check silently degrades to contract-only. Record it on every release from the first one.
Language ecosystems disagree about what breaks. Adding a method to an interface is additive in TypeScript and breaking for any Go consumer that implements it. If you publish for several languages from one contract, compute the bump per language rather than applying the strictest rule everywhere — or accept the strictest and explain why in the policy.
Pre-1.0 versions carry no promise. In semver, 0.x allows anything to break at any time. Sitting at 0.x for years to avoid committing tells consumers you have no stability policy, which is worse for adoption than a real 1.0.0 with a clear compatibility statement.
Automated dependency updates amplify every mistake. A patch published in error reaches consumers within hours because their bots merge it. The version number is not documentation; it is the input to an automated system with real consequences, which is exactly why it should be derived rather than typed.
A contract patch does not always need an SDK release. If the change was documentation-only, regenerating produces byte-identical code. Skip the publish rather than shipping a no-op version, and let the recorded contract version catch up on the next real release.
Publish the policy before you need it, not during an argument. The value of a written versioning policy is almost entirely in having agreed it in advance. When a contentious change appears — the response field that is additive for tolerant readers and breaking for strict ones — a team without a policy holds the same debate every time, under time pressure, and resolves it differently depending on who is in the room. A team with a policy looks it up. Write the contested cases down explicitly rather than only the obvious ones, because the obvious ones were never going to cause trouble.
Record what each release was built from, and you get migration tooling for free. Because the published package carries both the contract version and the generator version, a consumer upgrading across two majors can be told exactly which contract changes they are absorbing: diff the two recorded contract versions and you have the precise list. That turns “SDK 4.0.0 has breaking changes” into a specific, actionable set of endpoints and fields, which is the difference between a migration someone can schedule and one they keep postponing.
FAQ
Should the SDK version match the API version?
No. An SDK can need a major bump for a generator change that did not touch the contract, and a contract patch can require no SDK release at all. Relate them by recording which contract version each release was built from, rather than by forcing the numbers to match.
Is a generator upgrade a breaking change?
It is whenever it changes emitted method names, type names, or constructor signatures — which generator majors routinely do. Consumers experience a compile error, and they neither know nor care that your contract was untouched.
What version should the first SDK release be?
Start at 1.0.0 once you intend to keep compatibility promises, and use 0.x only while you are genuinely willing to break consumers without notice. A long-lived 0.x tells consumers nothing useful about your stability.
Related
- Breaking Change Detection & Spec Diffing — the parent guide and the classification this consumes.
- Failing CI on breaking OpenAPI changes — where the contract level is computed.
- Detecting breaking changes with oasdiff — the diff behind the classification.
- SDK Generation & Changelog Automation — the parent overview of the release pipeline.