Skip to main content

Detecting Breaking Changes with oasdiff in CI

A removed or renamed response field is the most common cause of a silent consumer outage: the provider ships the change, existing clients that deserialize the now-absent field either throw a null-reference exception or silently corrupt their local state, and the failure surfaces in monitoring hours later rather than in the pull request that caused it. This guide is part of Breaking Change Detection and shows exactly how to catch that class of regression automatically by diffing OpenAPI specs on every PR with oasdiff 1.11 in GitHub Actions and failing the check on breaking severity.

Symptom

A consumer integration test or production alert fires with one of these signatures after a provider deploy:

TypeError: Cannot read properties of undefined (reading 'settledAt')
  at TransactionMapper.map (transaction-mapper.ts:42)
HTTP 500 — Unhandled null reference
  downstream: GET /transactions/txn_9a2f
  field: 'settledAt' missing from response body
JSON parse error: field 'currency_code' not found in TransactionResponse
  (renamed to 'currencyCode' in provider commit a3f8b21)

In all three cases the OpenAPI spec changed — a field was removed, a field was renamed — and the change reached production without triggering a build failure. The consumer’s code was never wrong; the contract was changed beneath it.

Root Cause

Schema diff is not performed automatically when a pull request modifies an OpenAPI file. Standard CI pipelines lint the spec for syntactic correctness (Spectral, openapi-generator validate) but do not compare the new spec against the last shipped version. A field removal is syntactically valid YAML; it passes every linting rule. Without an explicit diff step that compares two spec revisions, nothing in the pipeline can distinguish a benign description edit from a wire-breaking field deletion.

The gap is structural: linting validates one document; breaking-change detection requires two documents and a semantic comparison engine. oasdiff fills that gap. It understands the OpenAPI 3.x object model deeply enough to classify every change as additive, non-breaking, or breaking and to exit non-zero on the breaking class, which is the hook CI needs to block the merge.

Understanding the full taxonomy of what constitutes a breaking change is covered in OpenAPI Specification Deep Dive. The short version for this fix: any change that causes a previously valid consumer payload to be rejected, or causes a field the consumer reads to disappear from the response, is breaking.

Step-by-Step Fix

Step 1 — Install oasdiff and verify the baseline reconstruction

Pin a specific version so the result is reproducible across local developer machines and CI runners.

# Install the oasdiff Go binary (Go 1.21+ required).
# Alternatively download the pre-built binary from GitHub releases.
go install github.com/oasdiff/oasdiff@v1.11.0

# Verify the install.
oasdiff --version
# oasdiff version 1.11.0

Before writing CI YAML, confirm the baseline reconstruction command works in your repo. The command reads the root spec from the tip of main and writes it to a temp file:

# Must be run from inside the git repository.
# Requires fetch-depth: 0 on the checkout so origin/main is reachable.
git show origin/main:openapi.yaml > /tmp/baseline.yaml

# Quick sanity check — field counts should match the last shipped spec.
oasdiff diff /tmp/baseline.yaml openapi.yaml --format text

If git show fails with fatal: Path 'openapi.yaml' does not exist in 'origin/main', confirm the spec filename and root path. If the spec is nested (e.g., api/openapi.yaml), adjust the git path accordingly.

Why this works: The diff tool needs two resolved documents. git show reconstructs the exact bytes at the tip of main without checking out the branch, keeping the working tree clean. The PR spec is the file on disk in the checkout.

Step 2 — Run the diff locally to understand the output format

Before wiring CI, run a manual diff between a pair of specs to see exactly what oasdiff reports. The examples below use a spec where settledAt has been removed from the TransactionResponse schema.

# Text report — human-readable, good for local triage.
oasdiff breaking /tmp/baseline.yaml openapi.yaml --format text

# Expected output when a field is removed:
# 1 breaking changes: 1 error, 0 warning
#
# error  [response-property-removed]
#        in API GET /transactions/{id}
#        removed the response property 'settledAt' from the 200 response
# JSON report — machine-readable, good for PR comments and dashboards.
oasdiff breaking /tmp/baseline.yaml openapi.yaml --format json
{
  "breaking": [
    {
      "id": "response-property-removed",
      "text": "removed the response property 'settledAt' from the 200 response",
      "level": 3,
      "operation": "GET",
      "path": "/transactions/{id}",
      "source": "response/200/content/application~1json/schema/properties/settledAt"
    }
  ],
  "info": []
}

Each breaking change carries a stable id (useful for targeted suppression), the HTTP operation and path, and the exact JSON Pointer into the spec. The level field maps to ERR (3) for breaking and WARN (2) for risky-but-not-strictly-breaking.

Why this works: Reviewing the local output before writing CI YAML surfaces mismatches — for instance, a multi-file spec that needs bundling first, or a baseline that does not exist yet on main. Fixing those edge cases locally is faster than debugging a CI job.

Step 3 — Wire the GitHub Actions job

The complete job below runs on every PR that touches the OpenAPI spec. It reconstructs the baseline, installs oasdiff, runs the breaking-change check with an enforcement gate and a report-only bypass for approved exceptions, and posts the structured result as a PR comment.

# .github/workflows/contract-breaking-change.yml
name: OpenAPI Breaking Change Gate

on:
  pull_request:
    paths:
      - "openapi.yaml"
      - "openapi/**"      # if your spec is split across a directory

jobs:
  oasdiff-gate:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write   # needed to post the PR comment
      contents: read

    steps:
      # Full history so git show origin/main:openapi.yaml resolves correctly.
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # Cache the Go module cache so subsequent runs skip the download.
      - uses: actions/setup-go@v5
        with:
          go-version: "1.22"
          cache: true

      - name: Install oasdiff
        run: go install github.com/oasdiff/oasdiff@v1.11.0

      - name: Reconstruct baseline spec from main
        run: git show origin/main:openapi.yaml > /tmp/baseline.yaml

      # Emit the full JSON diff as an artifact for debugging and PR comments.
      - name: Generate diff report (JSON)
        run: |
          oasdiff breaking /tmp/baseline.yaml openapi.yaml \
            --format json > /tmp/breaking.json || true
          cat /tmp/breaking.json

      # Hard block: fails the job when breaking changes are present AND
      # the PR does not carry the 'breaking-change-approved' label.
      - name: Enforce breaking-change gate
        if: >
          !contains(
            github.event.pull_request.labels.*.name,
            'breaking-change-approved'
          )
        run: |
          oasdiff breaking /tmp/baseline.yaml openapi.yaml \
            --format text \
            --fail-on ERR

      # Soft report: when the label IS present, run in report-only mode
      # so the break is still visible in the job log.
      - name: Report breaking changes (approved exception)
        if: >
          contains(
            github.event.pull_request.labels.*.name,
            'breaking-change-approved'
          )
        run: |
          echo "::warning::breaking-change-approved label present — running in report-only mode"
          oasdiff breaking /tmp/baseline.yaml openapi.yaml --format text || true

      # Post the JSON report as an inline PR comment for reviewer visibility.
      - name: Post breaking-change summary as PR comment
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const raw = fs.readFileSync('/tmp/breaking.json', 'utf8');
            const report = JSON.parse(raw);
            const breaking = report.breaking ?? [];
            if (breaking.length === 0) {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: '**oasdiff:** no breaking changes detected.'
              });
              return;
            }
            const lines = breaking.map(c =>
              `- \`${c.id}\` **${c.operation} ${c.path}** — ${c.text}`
            ).join('\n');
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: `**oasdiff: ${breaking.length} breaking change(s) detected**\n\n${lines}`
            });

Why this works: The --fail-on ERR flag makes oasdiff exit 1 when any change at severity ERR is present; GitHub Actions marks the step — and therefore the job — as failed, which branch protection rules can use to block the merge. The label-based bypass (breaking-change-approved) keeps intentional major-version breaks from being permanently blocked while recording the human decision in the PR audit trail. The JSON output feeds the PR comment so reviewers see the exact field and path without reading the job log.

Step 4 — Classify and suppress known-safe exceptions

Not every --fail-on ERR result is genuinely problematic in your context. oasdiff uses stable change IDs you can suppress per-project using a configuration file. Add an oasdiff.yaml at the repo root:

# oasdiff.yaml — project-level breaking-change exceptions
# Each entry exempts a specific change ID for a specific path.
# Do not suppress ERR-level IDs globally; scope to the minimum path.
ignore:
  - id: response-property-added-required
    path: /internal/health
    operation: GET
    # Health endpoints are internal-only; callers do not deserialize the body.

Pass the configuration file to the diff command:

oasdiff breaking /tmp/baseline.yaml openapi.yaml \
  --format text \
  --fail-on ERR \
  --config oasdiff.yaml

For governance rules beyond structural diff — such as requiring a deprecated: true marker before a field can be removed, or enforcing a Sunset response header — layer writing custom Spectral rules for API governance into the same CI job. The two tools are complementary: oasdiff catches structural breaks; Spectral catches process violations.

Before / After

Before — PR merged without a diff gate:

The provider renames settledAt to settlement_time to match a new naming convention. The spec lints cleanly. CI passes. The change ships. Consumer deserialization code throws at runtime.

# openapi.yaml (baseline — what was shipped)
components:
  schemas:
    TransactionResponse:
      type: object
      required: [id, settledAt]
      properties:
        id:
          type: string
        settledAt:
          type: string
          format: date-time
# openapi.yaml (PR — what breaks consumers)
components:
  schemas:
    TransactionResponse:
      type: object
      required: [id, settlement_time]
      properties:
        id:
          type: string
        settlement_time:          # renamed — removes settledAt from the wire
          type: string
          format: date-time

After — oasdiff gate blocks the PR:

$ oasdiff breaking /tmp/baseline.yaml openapi.yaml --format text --fail-on ERR

2 breaking changes: 2 errors, 0 warnings

error  [response-property-removed]
       in API GET /transactions/{id}
       removed the response property 'settledAt' from the 200 response

error  [response-required-property-removed]
       in API GET /transactions/{id}
       removed the required response property 'settledAt' from the 200 response

$ echo $?
1

The PR is blocked. The provider engineer sees the PR comment naming the exact field. The fix is to keep settledAt as a deprecated alias and add settlement_time additively, shipping both for one release cycle, then removing the old name in a subsequent breaking change that goes through the approved exception flow.

Verification

A clean PR (additive-only — new optional response field added, no existing fields removed) produces this output and exit code:

$ oasdiff breaking /tmp/baseline.yaml openapi.yaml --format text --fail-on ERR
No breaking changes.
$ echo $?
0

In GitHub Actions the passing step appears as:

Run oasdiff breaking /tmp/baseline.yaml openapi.yaml --format text --fail-on ERR
No breaking changes.

And the PR comment reads:

oasdiff: no breaking changes detected.

When the gate blocks a PR, the GitHub check oasdiff-gate / oasdiff-gate appears red with the step name Enforce breaking-change gate, and clicking through shows the full text report. Branch protection rules configured to require oasdiff-gate prevent merging until the check is green or the breaking-change-approved label is present.

A PR that adds the breaking-change-approved label produces:

::warning::breaking-change-approved label present — running in report-only mode
2 breaking changes: 2 errors, 0 warnings
...

The job exits 0 (pass), the merge is unblocked, and the PR comment still lists the breaking changes so reviewers have a permanent record of what was approved.

Edge Cases and Caveats

  • Spec does not exist on main yet. If the OpenAPI file is being introduced in the PR itself, git show origin/main:openapi.yaml returns fatal: Path does not exist. Add a guard step: git show origin/main:openapi.yaml > /tmp/baseline.yaml 2>/dev/null || echo '{"openapi":"3.1.0","info":{"title":"empty","version":"0.0.0"},"paths":{}}' > /tmp/baseline.yaml. The empty-spec baseline causes oasdiff to treat every path and schema as additive, which is correct for a brand-new spec.

  • oasdiff flags a field removal that is intentional and already behind a major version path. When your API uses URL versioning (/v1/, /v2/) and the removal only affects a path under /v1/ which is already sunset, use the --config oasdiff.yaml exception file scoped to that path prefix rather than disabling the global gate. This keeps enforcement active on /v2/ and future paths. Also see catching breaking changes with Optic, which has first-class support for versioned paths.

  • Consumer-driven contract tests and oasdiff cover different failure modes. oasdiff operates on the spec document; it catches changes the spec declares. If a provider returns a field that the spec does not document, or omits a field the spec marks as required but the provider never actually sends, oasdiff misses it. Consumer-driven contracts (Pact and its variants) catch the gap between spec and implementation. Run both: oasdiff gates spec changes; Pact gates implementation drift. Neither replaces the other.

Frequently Asked Questions

Does oasdiff work with multi-file OpenAPI specs that use $ref?

Yes. oasdiff resolves $ref pointers automatically when given the root document. If references span remote URLs or an unusual directory structure, bundle the spec first with the Redocly CLI (redocly bundle openapi.yaml -o bundled.yaml) so oasdiff receives a single resolvable document.

What exit code does oasdiff return when it finds breaking changes?

With --fail-on ERR, oasdiff exits 1 if any change classified ERR (breaking) is present and 0 otherwise. Without that flag it always exits 0 and only prints the report, which is useful for generating PR comments without blocking the build.

How do I allow a deliberate breaking change without disabling the gate entirely?

Add a GitHub PR label such as breaking-change-approved and make the enforcement step conditional on its absence. The label records the human decision in the PR audit trail and the gate still runs in report-only mode so the break is fully visible.

Can I use openapi-diff instead of oasdiff?

Yes, but oasdiff is the stronger choice for new pipelines: it is actively maintained, emits stable change IDs, supports JSON output for PR comments, and offers --fail-on ERR out of the box. openapi-diff (Java/Node) remains common in legacy pipelines but its output format is less CI-friendly.

What if my OpenAPI spec is generated from code and the diff is noisy?

oasdiff is key-order-insensitive for most structures, so pure reordering does not produce false positives. If the generator emits nondeterministic enum ordering or description churn, normalize the output (sort keys, strip volatile comments) as a build step before committing the spec or running the diff.