Detecting Breaking Changes with oasdiff

This walkthrough belongs to Breaking Change Detection in the API Versioning & Deprecation reference. oasdiff compares two OpenAPI documents and classifies what changed between them. This page covers wiring it into a pull request pipeline so the classification is automatic, credible and hard to bypass by accident.

The decision trigger

Symptom Cause
A field rename shipped and a consumer broke no diff gate on pull requests
The diff reports changes for a pure file reorganisation unbundled documents compared
The gate is disabled because it “always fails” diffing the main branch instead of the release
Reviewers cannot see what changed additively only the breaking check runs, no changelog
A suppression added last year still hides a rule no expiry on ignore entries
The SDK version bump was chosen by hand exit status not wired into release derivation

Spec snippet: what a finding looks like

// oasdiff breaking --format json (abridged)
[
  {
    "id": "request-property-became-required",
    "level": 3,
    "operation": "POST",
    "path": "/invoices",
    "source": "openapi.yaml",
    "text": "the request property 'customer_id' became required"
  },
  {
    "id": "response-property-removed",
    "level": 3,
    "operation": "GET",
    "path": "/invoices/{invoiceId}",
    "text": "the response property 'estimated_total' was removed for status '200'"
  }
]

Level 3 is ERR, level 2 is WARN and level 1 is INFO. The id is the stable identifier to use in suppression files and in any policy you write — the text is human wording that may change between releases.

oasdiff in a pull request pipeline The released baseline and the head revision are each bundled, then compared. The breaking check gates the merge, the changelog goes to the review summary, and the exit status derives the SDK version bump. released baseline baselines/current.yaml head revision this pull request bundle + diff self-containedinputs breaking → gate exit non-zero blocks the merge changelog → review summary every change, grouped by severity exit status → version bump major when breaking, else minor

Step-by-step

Step 1 — Run it locally first

# From a blank terminal — Docker needs no local Go toolchain
docker pull tufin/oasdiff:latest

# Every difference, including cosmetic ones
docker run --rm -v "$PWD:/specs" tufin/oasdiff:latest \
  diff /specs/baselines/current.yaml /specs/dist/openapi.yaml --format text | head -40

# Human-meaningful changes, grouped by severity
docker run --rm -v "$PWD:/specs" tufin/oasdiff:latest \
  changelog /specs/baselines/current.yaml /specs/dist/openapi.yaml --format text

# Only the backward-incompatible ones — this is the gate
docker run --rm -v "$PWD:/specs" tufin/oasdiff:latest \
  breaking /specs/baselines/current.yaml /specs/dist/openapi.yaml --fail-on ERR

The three subcommands answer different questions: diff is exhaustive and noisy, changelog is what you show a reviewer, breaking is what you gate on.

Step 2 — Wire the gate into pull requests

# .github/workflows/api-compat.yml
name: API compatibility
on: [pull_request]

jobs:
  compat:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      - name: Bundle the head revision
        run: npx @redocly/cli bundle openapi.yaml -o /tmp/head.yaml --dereferenced=false

      - name: Breaking-change gate
        run: |
          docker run --rm -v /tmp:/head -v "$PWD/baselines:/base" tufin/oasdiff:latest \
            breaking /base/current.yaml /head/head.yaml \
            --format githubactions \
            --fail-on ERR \
            --exclude-elements description,examples,title

      - name: Changelog for reviewers
        if: always()
        run: |
          {
            echo "## API changes in this pull request"
            docker run --rm -v /tmp:/head -v "$PWD/baselines:/base" tufin/oasdiff:latest \
              changelog /base/current.yaml /head/head.yaml --format text
          } >> "$GITHUB_STEP_SUMMARY"

--exclude-elements description,examples,title is worth adding early: prose edits are not contract changes, and a gate that fires on a typo fix in a description gets disabled within a month.

Step 3 — Suppress with a paper trail

# .oasdiff-ignore.yaml — checked by CI for expiry
- id: response-property-removed
  path: /v1/legacy-report
  method: GET
  reason: >
    `estimated_total` has returned null since 2025-03 and is unused by all
    six remaining consumers (verified from access logs, ticket API-2291).
  approved_by: platform-team
  expires: 2026-12-31

- id: request-property-became-enum
  path: /v1/invoices
  method: POST
  reason: >
    `currency` was documented as a free string but the server has always
    rejected non-ISO values; the schema now matches reality.
  approved_by: billing-team
  expires: 2026-09-30
# scripts/check_suppressions.py — run in CI with the current date injected
import datetime, sys, yaml

TODAY = datetime.date.fromisoformat(sys.argv[1])          # e.g. 2026-07-31
entries = yaml.safe_load(open(".oasdiff-ignore.yaml")) or []

problems = []
for e in entries:
    for field in ("id", "path", "reason", "approved_by", "expires"):
        if not e.get(field):
            problems.append(f"suppression missing `{field}`: {e}")
    if e.get("expires") and datetime.date.fromisoformat(str(e["expires"])) < TODAY:
        problems.append(f"suppression for {e['id']} on {e['path']} expired {e['expires']}")

for p in problems:
    print(f"::error::{p}")
print(f"{len(entries)} suppression(s) checked, {len(problems)} problem(s)")
sys.exit(1 if problems else 0)

Step 4 — Understand what it cannot see

oasdiff compares documents, so it inherits their limits. These changes pass cleanly:

Change Why the diff misses it
Default sort order reversed not expressed in the document
Default page size lowered the parameter default may be unchanged in the spec
A validation rule added in code only never appeared in the schema
An error type URI reworded usually only an example
Response latency doubled not a document property
An optional field now always null still optional, still typed

Cover these with the contract tests described in Contract Tests vs Schema Diffs. A diff and a test suite have almost disjoint coverage, which is why running both is not redundant.

Coverage of a spec diff versus contract tests Two overlapping regions. The diff covers removed fields, renamed properties, changed types and removed endpoints across every operation. Contract tests cover defaults, ordering, validation strictness and error identifiers on tested paths. The overlap is required and enum changes. schema diff contract tests removed field renamed property type narrowed endpoint removed every operation required enum changes default value sort order validation strictness error type URIs tested paths only complete coverage of structure · partial coverage of behaviour — run both

Choosing the baseline is the setting that most often decides whether the gate is trusted:

What should the diff compare against? Comparing against the released specification answers the question consumers care about. Comparing against the main branch answers a different question and produces confusing results. compare against what consumers actually hold? yes no the released baseline a series of individually safe merges that combine into a break isstill caught the main branch reverting an unreleased break is reported as breaking; realbreaks slip through in pieces capture baselines/current.yaml at every release and the gate answers the right question

Standard compliance

Concern Standard Note
Document structure compared OpenAPI 3.0 / 3.1 Both are supported inputs
Reference resolution JSON Schema Core 2020-12 §8.2.3 Bundle first for stable results
Compatibility direction not standardised The request/response asymmetry is convention, not RFC
Semantic versioning of SDKs SemVer 2.0.0 Major on breaking findings

There is no RFC that defines “breaking API change”; the classification is a widely shared convention that tools encode slightly differently. That is an argument for writing your own policy document naming which rule ids you gate on, rather than treating any single tool’s defaults as authoritative.

SDK / codegen downstream effect

# release.sh — the diff decides the bump, not the release engineer
if docker run --rm -v "$PWD:/specs" tufin/oasdiff:latest \
     breaking /specs/baselines/current.yaml /specs/dist/openapi.yaml --fail-on ERR >/dev/null 2>&1
then
  BUMP=minor
else
  BUMP=major
fi

echo "next SDK release: $BUMP"
npm --prefix sdk/typescript version "$BUMP" --no-git-tag-version

Deriving the bump mechanically is what makes a consumer’s ^4.2.0 range trustworthy: a major bump means the client surface changed, every time, with no exceptions for a rushed Friday release. The consumer side of that promise is covered in Semver Ranges for Generated SDK Clients.

Common mistakes

Mistake Correct approach
Diffing the main branch instead of the release Keep a baselines/current.yaml updated at each release
Comparing unbundled multi-file specs Bundle both revisions first
Gating on WARN level Gate on ERR; surface warnings to reviewers
Suppressions with no expiry Require owner, reason and expiry; fail on expired entries
Failing on description edits --exclude-elements description,examples,title
Treating the diff as complete coverage Add contract tests for behavioural change

FAQ

What is the difference between oasdiff diff, changelog and breaking?

They answer three different questions from one comparison. diff reports every structural difference between the documents, including reordering and description edits — useful for debugging, far too noisy for a gate. changelog filters that down to changes a human would consider meaningful and groups them by severity, which makes it the right thing to post into a pull request summary so reviewers see additive changes too. breaking reports only the subset classified as backward-incompatible and sets a non-zero exit status, which is what belongs in the CI gate and in your version-bump derivation.

Why does oasdiff report a break when I only moved a schema between files?

Because it compared the documents as written, and in an unbundled multi-file specification the reference targets genuinely differ — ./components/order.yaml#/Order is not the same string as ./schemas/billing.yaml#/Order, even though both resolve to identical content. Bundle both revisions into single self-contained documents before diffing and the reorganisation disappears entirely, because the bundled output is identical. This is the same reason bundling matters for publishing, covered in Reusable Schema Components and $ref Cycles.

Should a warning-level finding fail the build?

Generally no. The value of a breaking-change gate is that it is credible: when it fires, everyone believes something is genuinely wrong. Fail on ERR only, and route WARN findings into the review summary where a human decides. Then, when experience shows that a particular warning-level rule has actually broken your consumers — response enum additions are the usual candidate — promote that specific rule to error rather than raising the global threshold. A gate that fails on everything is a gate that gets bypassed.