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.

Diff classification and release routing The base and head specifications are bundled and diffed, and each change is classified as breaking, additive, or cosmetic, which determines the version bump and whether the merge is gated. base spec head spec bundle + diff oasdiff breaking field removed, type narrowed additive new optional field or path cosmetic description, example, order major + approval gate minor patch

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 (stringinteger) 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.
  • oasdiff for classification, and optionally @redocly/cli diff for 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.

One classification, four downstream decisions The severity the diff reports determines the SDK version bump, the changelog section, whether a deprecation notice is required, and whether the merge is gated. severity from the diff SDK semver bump changelog section deprecation required? merge gate one decision, made once, applied consistently

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:

Keeping the diff signal clean Bundling both sides removes structural noise, excluding descriptions and examples removes prose churn, and scoping ignore entries to a path prevents an exception widening silently. raw diff 140 reported changes nobody reads this bundle both sides removes $ref movement exclude description, examples removes prose churn scope ignores to a path stops silent widening clean diff 2 reported changes both are real credibility is the whole asset

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.