Breaking Change Detection & Spec Diffing
This guide is part of SDK Generation & Changelog Automation and covers the gate that sits between a specification edit and a release: an automated diff that classifies every change as breaking, additive, or cosmetic, blocks the accidental breaks, and drives the version number of everything generated downstream. It pairs directly with API Versioning & Deprecation in OpenAPI, which covers what you do once a break is intentional.
The failure this prevents is mundane and expensive. Someone tightens a schema — changes type: string to an enum, marks an optional field required, removes a response property nobody seemed to use — and the change reaches production because no human review reliably catches a semantic break buried in a 900-line YAML diff. Consumers find out when their integration stops working. Nothing about this requires malice or carelessness; a specification is exactly the kind of artifact where a small syntactic edit can be a large semantic one, which is precisely what machines are better at spotting than reviewers.
Quick reference: what breaks and what does not
| Change | Breaking? | Why |
|---|---|---|
| Remove an operation | Yes | Existing calls 404 |
| Remove a response property | Yes | Consumers parsing it get undefined |
| Add a required request property | Yes | Existing requests are now invalid |
| Make an optional request property required | Yes | Same as above |
Narrow a type (string → integer) |
Yes | Previously valid values are rejected |
| Remove an enum value from a request | Yes | Callers sending it are rejected |
| Add an enum value to a response | Yes, usually | Strict client parsers reject unknown values |
Tighten a constraint (maxLength down) |
Yes | Previously valid input is rejected |
| Change a status code for an existing case | Yes | Error handling branches on it |
| Add a new operation | No | Nothing existing changes |
| Add an optional request property | No | Old requests remain valid |
| Add a response property | No, usually | Tolerant readers ignore it |
| Widen a type or relax a constraint | No | Everything previously valid stays valid |
| Change a description or example | No | Not part of the wire contract |
Two rows are genuinely contested. Adding a response property is safe for tolerant readers and breaking for generated clients that validate strictly — which describes most SDKs produced by OpenAPI Generator with validation enabled. Adding an enum value to a response is the same problem in sharper form. Decide your position, write it into the tool’s configuration, and document it in your compatibility policy so consumers know what tolerance you expect of them.
Prerequisites & Environment Setup
- Two resolvable specifications: the base (usually
main) and the head (the branch under review). - A bundler, because diffing unresolved multi-file specs produces noise rather than signal.
oasdifffor classification, and optionally@redocly/cli difffor a human-readable summary.- A written compatibility policy covering the contested rows above.
# oasdiff — a single Go binary, easy to pin in CI
curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh -s -- v1.11.7
oasdiff --version
npm install --save-dev @redocly/cli@2
Always bundle both sides before comparing. This one step removes most false positives:
git show origin/main:openapi/api.yaml > /tmp/base.yaml
npx @redocly/cli@2 bundle /tmp/base.yaml -o /tmp/base.bundled.yaml
npx @redocly/cli@2 bundle openapi/api.yaml -o /tmp/head.bundled.yaml
Core Configuration
oasdiff breaking reports only the changes it classifies as backward-incompatible, and exits non-zero when it finds any:
oasdiff breaking /tmp/base.bundled.yaml /tmp/head.bundled.yaml --format text
A representative finding:
1 breaking changes: 1 error, 0 warning
error [response-property-removed] at /tmp/head.bundled.yaml
in API GET /invoices/{id}
removed the response property 'legacy_total' for the response status '200'
Tune the classification to your policy with an ignore file rather than by disabling checks wholesale. Each entry needs a reason, because an unexplained suppression is indistinguishable from an accident six months later:
# .oasdiff-ignore.yaml
# Response-property additions are additive under our tolerant-reader policy,
# which is stated in the compatibility section of the public docs.
- id: response-property-added
reason: 'Tolerant-reader policy — consumers must ignore unknown fields.'
# The sandbox host changes freely; it is not part of the contract.
- id: api-server-removed
path: /invoices
reason: 'Sandbox server entry only; production servers are covered separately.'
For a summary a human will actually read in a pull request, @redocly/cli diff produces prose rather than rule identifiers:
npx @redocly/cli@2 diff /tmp/base.bundled.yaml /tmp/head.bundled.yaml
Run both. The machine-readable output drives the gate and the version bump; the prose output goes in the pull request comment so a reviewer understands what changed without learning the rule taxonomy.
Integration Pattern
The workflow diffs against the merge base, classifies, comments the result, and gates the merge on a label when the change is breaking. The label is the approval mechanism — deliberate breaks proceed, accidental ones stop.
# .github/workflows/spec-diff.yml
name: Spec diff
on:
pull_request:
paths: ['openapi/**']
permissions:
contents: read
pull-requests: write
jobs:
diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need the base commit to diff against
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Install oasdiff
run: curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh -s -- v1.11.7
- name: Bundle both sides
run: |
git show "origin/${{ github.base_ref }}:openapi/api.yaml" > /tmp/base.yaml
npx @redocly/cli@2 bundle /tmp/base.yaml -o /tmp/base.bundled.yaml
npx @redocly/cli@2 bundle openapi/api.yaml -o /tmp/head.bundled.yaml
- name: Classify the change
id: classify
run: |
set +e
oasdiff breaking /tmp/base.bundled.yaml /tmp/head.bundled.yaml \
--format text --exclude-elements description,examples \
> /tmp/breaking.txt
BREAKING=$?
set -e
oasdiff changelog /tmp/base.bundled.yaml /tmp/head.bundled.yaml \
--format text > /tmp/changelog.txt
if [ "$BREAKING" -ne 0 ]; then
echo "level=major" >> "$GITHUB_OUTPUT"
elif [ -s /tmp/changelog.txt ]; then
echo "level=minor" >> "$GITHUB_OUTPUT"
else
echo "level=patch" >> "$GITHUB_OUTPUT"
fi
- name: Comment the diff
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const level = '${{ steps.classify.outputs.level }}';
const changes = fs.readFileSync('/tmp/changelog.txt', 'utf8').trim() || 'No contract changes.';
const breaking = level === 'major'
? `\n\n**⚠ Breaking changes detected.** Add the \`approved-breaking\` label to proceed.\n\n\`\`\`\n${fs.readFileSync('/tmp/breaking.txt','utf8')}\n\`\`\``
: '';
const body = `<!-- spec-diff -->\n### Contract diff — \`${level}\`\n\n\`\`\`\n${changes}\n\`\`\`${breaking}`;
const {data: comments} = await github.rest.issues.listComments(
{...context.repo, issue_number: context.issue.number});
const prev = comments.find(c => c.body.includes('<!-- spec-diff -->'));
if (prev) await github.rest.issues.updateComment({...context.repo, comment_id: prev.id, body});
else await github.rest.issues.createComment({...context.repo, issue_number: context.issue.number, body});
- name: Gate on approval for breaking changes
if: steps.classify.outputs.level == 'major'
run: |
LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}'
echo "$LABELS" | grep -q 'approved-breaking' \
|| { echo 'Breaking change without the approved-breaking label.'; exit 1; }
The --exclude-elements description,examples flag matters more than it looks. Documentation improvements are the most frequent specification change by far, and a diff that treats every reworded description as a contract event trains people to ignore it. Excluding prose keeps the signal high enough that the gate stays credible.
Advanced Options
Drive the SDK version from the classification. The level output above is exactly the input a release job needs. Passing it to the generator removes the most common release error — a breaking change published as a patch, which reaches every consumer’s ^ range automatically and breaks them without any action on their part. The Semver policy for OpenAPI-driven SDKs covers the mapping and its edge cases.
Generate the changelog from the same diff. oasdiff changelog emits a structured list of every change, which is a far better basis for release notes than a commit log. Generating API changelogs from OpenAPI diff covers turning it into published prose.
Diff against the released contract, not against main. If main accumulates several unreleased changes, diffing against it hides breaks introduced earlier in the same cycle. Diff against the last released tag for the release decision, and against the merge base for the review comment — they answer different questions.
Require a deprecation before a removal. A removal that is correctly classified as breaking is still a bad removal if consumers were never warned. Add a rule that any operation or field being removed must have carried deprecated: true in the previously released contract, which mechanically enforces the notice period described in API Versioning & Deprecation in OpenAPI.
Why the approval gate beats a hard failure
The instinct when adding this check is to make a breaking change simply fail the build, on the grounds that breaking changes are bad. That policy survives about three weeks. Breaking changes are not always mistakes — a major version exists precisely so that some of them can happen deliberately — and a gate with no legitimate path through it teaches people to route around it. The routes are always available: mark the workflow continue-on-error, add a [skip ci] to the merge commit, or make the whole job advisory the first time it blocks something urgent. Once any of those happens the detector is decoration, and the next accidental break ships unnoticed.
An approval gate keeps the useful property — no break reaches a release without a human deciding it should — while removing the incentive to disable it. The label is deliberately low-friction: someone with the authority to make the call adds it, the check passes, and the pull request now carries a permanent record that a breaking change was approved, by whom, and when. That record is worth as much as the block itself, because the question that comes up months later is almost never “why did this break?” but “did anyone decide this, or did it slip?”
Pair the label with two conventions and the process becomes self-documenting. First, require the pull request description to state the migration path when the label is present — a reviewer should not have to reconstruct what consumers must do. Second, have the workflow post the classification result as a status even when it passes, so the additive and cosmetic cases are visible too. Reviewers quickly learn to read the classification line before the diff, which is exactly the habit you want: the machine reads 900 lines of YAML and reports one word, and the human spends their attention on whether that word is acceptable.
Handling the specification’s own churn
A real specification changes for reasons that have nothing to do with the contract. Someone reformats the file, a generator reorders keys, a $ref moves between files during a refactor, descriptions get rewritten in a documentation sprint. None of it changes what a consumer sends or receives, and all of it shows up in a naive diff. If the tool reports these, reviewers learn within a week that the output is mostly noise and stop reading it, at which point a genuine finding buried in the middle is invisible.
Three settings do almost all the work of keeping the signal clean. Bundling both sides eliminates structural noise from $ref movement and file reorganisation, because both documents are reduced to the same canonical shape before comparison. Excluding description and examples removes the largest single category of legitimate but contract-irrelevant change. And scoping ignore entries to a path rather than applying them globally means a deliberate exception for one endpoint does not silently cover the whole API.
What remains after those three is a diff that is almost entirely real, which is the condition under which people actually read it. Treat any recurring false positive as a bug in your configuration rather than as an acceptable cost — every one of them erodes the credibility that makes the gate work. It is worth spending an afternoon early on tuning the exclusions against a few months of historical commits, replaying the diff for each past release and checking that the classification matches what actually happened. That exercise usually surfaces one or two policy questions nobody had answered, which is a better time to answer them than during an urgent release.
Verification & Testing
Test the gate itself, because a detector nobody has seen fire is a detector nobody should trust. Introduce a deliberate break on a scratch branch and confirm the pipeline stops it.
# Remove a response property — an unambiguous breaking change
yq -i 'del(.components.schemas.Invoice.properties.currency)' openapi/api.yaml
npx @redocly/cli@2 bundle openapi/api.yaml -o /tmp/head.bundled.yaml
oasdiff breaking /tmp/base.bundled.yaml /tmp/head.bundled.yaml --format text
echo "exit: $?"
Expected:
1 breaking changes: 1 error, 0 warning
error [response-property-removed] at /tmp/head.bundled.yaml
in API GET /invoices/{id}
removed the response property 'currency' for the response status '200'
exit: 1
Then confirm the inverse — that a purely additive change is classified as minor and does not gate — and that a description-only edit is classified as patch and produces no comment noise. Those three cases exercise every branch of the classification, and running them after any change to the ignore file prevents a suppression from quietly widening.
# Additive: a new optional field should NOT be breaking
yq -i '.components.schemas.Invoice.properties.note = {"type":"string"}' openapi/api.yaml
oasdiff breaking /tmp/base.bundled.yaml /tmp/head.bundled.yaml && echo 'OK additive change is not breaking'
The three configuration choices below are what separate a diff people read from one they learn to ignore. Each removes a distinct source of false positives without weakening the real check:
Troubleshooting
Every pull request reports breaking changes. The specs are being compared unbundled, so $ref resolution differences read as structural change. Bundle both sides first; this fixes the large majority of false positives.
A genuine break was not reported. It is in a category the tool does not check, or an ignore entry is broader than intended. Review .oasdiff-ignore.yaml for entries without a path scope — an unscoped id suppresses that rule everywhere.
The gate blocks a change that is not really breaking. Your compatibility policy is more permissive than the default in one of the contested categories. Encode the policy in the ignore file with a stated reason rather than removing the check or bypassing the gate.
The diff is empty although the file changed. Only excluded elements changed — usually descriptions or examples. This is correct behaviour, and it is the reason the classification reports patch rather than nothing.
git show origin/main:openapi/api.yaml fails in CI. The checkout was shallow. Set fetch-depth: 0, as in the workflow above; a depth-1 clone has no base commit to compare against.
FAQ
What counts as a breaking change in an OpenAPI document?
Anything that invalidates a request a working consumer already sends, or a response they already parse. Removing an operation or response field, adding a required request property, narrowing a type or enum, and tightening a constraint are all breaking. Adding an optional field or a new operation is not.
Should a breaking change fail the build outright?
It should block the merge until someone deliberately approves it. A hard failure with no override pushes people to bypass the check entirely; an approval gate keeps every intentional break visible and recorded while still stopping the accidental ones.
Can I derive the SDK version bump from the spec diff?
Yes, and you should. A diff that reports breaking changes means a major bump, one that reports only additions means a minor bump, and a description-only diff means a patch. Deriving it removes the most common release mistake, which is a breaking change shipped as a patch that consumers pick up automatically.
Why does the diff report a breaking change when I only reordered the file?
The tool is comparing raw documents rather than resolved ones, so a moved $ref or reordered key looks like a structural change. Bundle both sides to a canonical single document before diffing, and the noise disappears.
Related
- SDK Generation & Changelog Automation — the parent overview.
- Detecting breaking changes with oasdiff — the tool in depth.
- Failing CI on breaking OpenAPI changes — the gate and its approval path.
- Semver policy for OpenAPI-driven SDKs — mapping severity to version numbers.
- API Versioning & Deprecation in OpenAPI and Automated Changelog Tools — what happens after a break is approved.