Skip to main content

Semantic Versioning for OpenAPI Documents

Problem: the info.version field in your OpenAPI document says 1.0.0 and has said so for eighteen months, or it tracks the service’s release number and jumps by a patch on every deploy. Either way, nobody can look at two documents and tell whether the newer one is safe to adopt. This guide is part of API Versioning and Deprecation Policies and shows how to make that field mean something, computed automatically rather than remembered.

Two Numbers, Two Questions

The confusion at the root of this is that an API has two version numbers with different jobs.

Two version numbers, two jobs The document version changes on every published edit and answers which description you are reading. The API version changes only on a breaking change and answers which guarantees apply. info.version — the document "which description am I reading?" 2.14.3 changes on every published edit dozens of times per quarter semver applies here /v2 — the compatibility contract "which guarantees apply to me?" v2 changes only on a breaking change once every year or two a single integer is enough

Keep both, and let them move independently. The document version is what a consumer quotes in a bug report; the API version is what determines whether their client still compiles.

What Major, Minor and Patch Mean for a Contract

Semver’s definitions translate cleanly once you decide who the “consumer” is: the code generated from the document.

  • Major — a change that can break a generated client or a live caller: a removed response field, a newly required request property, a narrowed enum, a changed status code.
  • Minor — a change that adds capability without breaking anyone: a new endpoint, an optional response field, a new optional parameter, a loosened response constraint.
  • Patch — a change no client can observe: a corrected description, a new example, a reordered tag list, a typo in a summary.

The useful test for a patch is mechanical: regenerate a client from both documents and diff the output. If the generated code is byte-identical, the change was a patch.

# Is this change really a patch? Generate from both and compare.
npx openapi-typescript /tmp/baseline.yaml -o /tmp/before.d.ts
npx openapi-typescript openapi.yaml       -o /tmp/after.d.ts
diff -q /tmp/before.d.ts /tmp/after.d.ts && echo "patch" || echo "at least minor"

Computing the Bump in CI

Do not ask a human to classify the change; ask the diff tool, then check the human’s number against it. The classification comes from the same breaking-change detection run you already have.

#!/usr/bin/env bash
# scripts/check-spec-version.sh — fails when the committed version is understated.
set -euo pipefail

git show origin/main:openapi.yaml > /tmp/baseline.yaml
OLD=$(yq '.info.version' /tmp/baseline.yaml)
NEW=$(yq '.info.version' openapi.yaml)

# oasdiff exits 1 when it finds a breaking change; 0 otherwise.
if ! oasdiff breaking /tmp/baseline.yaml openapi.yaml --fail-on ERR >/dev/null; then
  REQUIRED=major
elif [ -n "$(oasdiff diff /tmp/baseline.yaml openapi.yaml --format json | jq -r '.paths // empty')" ]; then
  REQUIRED=minor
else
  REQUIRED=patch
fi

# Compare the two dotted versions field by field.
IFS=. read -r OM ON OP <<< "$OLD"
IFS=. read -r NM NN NP <<< "$NEW"
case "$REQUIRED" in
  major) [ "$NM" -gt "$OM" ] || { echo "::error::breaking change needs a major bump ($OLD -> $NEW)"; exit 1; } ;;
  minor) [ "$NM" -gt "$OM" ] || [ "$NN" -gt "$ON" ] || { echo "::error::additive change needs a minor bump"; exit 1; } ;;
  patch) [ "$NEW" != "$OLD" ] || { echo "::error::document changed but info.version did not"; exit 1; } ;;
esac
echo "version $NEW satisfies the computed minimum ($REQUIRED)"

Why this works: the tool decides the floor and the author decides the number. An author may bump further than required — publishing 3.0.0 for a release they want to signal loudly — but never less.

From diff severity to a minimum version The diff produces one of three verdicts, each implying a minimum bump; the committed version is then compared against that floor and the build fails if it is lower. breaking found paths or fields added prose only major minor patch committed version compared lower than the floor to red build The author still chooses the number — the gate only refuses to let it understate the change.

Publishing Every Released Document

A version number is only useful if the document it names can be retrieved. Publish each released document to a stable address:

# .github/workflows/publish-spec.yml (excerpt)
- name: Publish the released document
  run: |
    VERSION=$(yq '.info.version' openapi.yaml)
    npx @redocly/cli@1.25 bundle openapi.yaml -o "dist/openapi-${VERSION}.yaml"
    aws s3 cp "dist/openapi-${VERSION}.yaml" "s3://api-specs/orders/openapi-${VERSION}.yaml"
    # 'latest' is a pointer, never the only copy.
    aws s3 cp "dist/openapi-${VERSION}.yaml" "s3://api-specs/orders/openapi-latest.yaml"

Publishing the bundled document matters: a consumer retrieving version 2.14.3 should get one self-contained file, not a root document with references to files that have since moved.

Every version addressable, plus a pointer Each released document is stored under its own version, and a latest alias points at the newest; a consumer debugging an old incident retrieves the exact document that deployment served. openapi-2.12.0.yaml openapi-2.13.0.yaml openapi-2.14.2.yaml openapi-2.14.3.yaml openapi-latest.yaml immutable once published — a consumer can always fetch the contract a deploy served

Verification

$ ./scripts/check-spec-version.sh
version 2.15.0 satisfies the computed minimum (minor)

$ git checkout -b remove-field && yq -i 'del(.components.schemas.Order.properties.settledAt)' openapi.yaml
$ ./scripts/check-spec-version.sh
::error::breaking change needs a major bump (2.14.3 -> 2.14.3)

And the published archive should answer for an arbitrary past version:

curl -s https://specs.example.com/orders/openapi-2.13.0.yaml | yq '.info.version'
# 2.13.0

Edge Cases and Caveats

  • Pre-1.0 documents. Semver treats everything below 1.0.0 as unstable, which is honest during design but stops being true the moment a consumer integrates. Move to 1.0.0 when the first external consumer ships, not when the API feels finished.
  • Prerelease identifiers. 2.15.0-rc.1 is useful for a document published for review before the implementation lands. Make sure the publishing step excludes prereleases from the latest pointer.
  • Generated documents. In a code-first workflow the document is a build output, so the version has to come from somewhere in the source. Deriving it from the package version couples the contract to the release again; a dedicated constant in the annotation configuration keeps them separate.
  • Multiple documents per service. A service exposing both a public and an internal API should version the two documents independently. Sharing one number means an internal-only change forces a public version bump that consumers will investigate for nothing.

Using the Version Number in Practice

A version number earns its keep only if something reads it. Four consumers of the number are worth wiring up deliberately.

Consumer build pipelines. A consumer that vendors the document should record the version it generated from and compare it against the published latest on a schedule. That comparison is only meaningful when the number moves for a reason, which is what the computed-bump gate guarantees.

Support conversations. “Which version are you generating from?” resolves a class of integration question in one message, provided every published document is retrievable by version. Without an archive, the question has no useful answer.

Change logs. Generating the changelog entry from the diff and the version bump keeps the two consistent: the entry says 2.15.0 and lists exactly the additive changes the diff found, rather than whatever the author remembered.

Incident review. When a client broke at a particular time, the question is which document the deployment served. A version recorded in the deployment metadata plus an addressable archive turns that from an archaeology exercise into a lookup.

None of these require ceremony beyond what the gate already computes, but each of them stops working the moment the number becomes decorative — which is why enforcing the floor matters more than choosing a philosophy about what a major bump signifies.

Common Objections, and What They Miss

Three objections come up whenever this gate is proposed, and each contains a grain of truth worth addressing rather than dismissing.

“The version is just a number, nobody reads it.” True today, and it is the reason to fix it rather than to abandon it. A number nobody reads costs nothing to maintain and provides nothing; a number that reliably distinguishes a compatible document from an incompatible one becomes the thing consumers pin against. The gate is what moves it from the first state to the second.

“Computing the bump slows down every pull request.” The computation is a diff you already run for the breaking-change gate, so the marginal cost is a version comparison in shell — measured in milliseconds. What genuinely slows a pull request is discovering after merge that the version was wrong.

“Our releases and our contract change together anyway.” Sometimes, early on. The moment a release ships with no contract change — a performance fix, a logging improvement — the two numbers separate, and a scheme that assumed they never would starts producing version bumps that mean nothing. Separating them from the start costs one extra field.

Frequently Asked Questions

Is info.version the same as the API version?

No, and conflating them is the root of most confusion. info.version identifies the document — this description of the API. The API version, whether in the URL or a header, identifies the compatibility contract. A document can go through dozens of minor versions inside one API major version.

What counts as a patch change to an OpenAPI document?

Anything a client cannot observe: a corrected description, a new example, a reordered tag, a typo fixed in a summary. If regenerating a client from the document produces byte-identical output, it is a patch.

Should the document version be bumped automatically?

Compute the required bump automatically from the diff, then have CI fail if the committed version is lower than the computed one. Fully automatic bumping hides the decision; fully manual bumping gets skipped. Computing and enforcing keeps the number honest without taking the choice away.

How does document semver interact with the URL version?

A major bump of the document should coincide with a new API version, because both mean the same thing: existing consumers can break. Minor and patch bumps happen freely inside the current API version.

Do I need to publish every document version?

Publish every version that ships, and keep them addressable — a consumer debugging an incident needs the document as it was three releases ago. A versioned artifact store or a git tag per release is enough; a registry is nicer but not required.