
Catch Breaking API Changes in CI with OpenAPI Diff
An API can keep returning HTTP 200 while quietly breaking every generated client that depends on it. Renaming a response field, making an optional request property required, or removing a status code may look small in a pull request, but each change alters the contract consumers compiled against.
An OpenAPI document gives that contract a machine-readable form. In this tutorial, you will validate the document and compare each pull request against the base branch with the current oasdiff GitHub Actions integration. The result is a fast CI gate that catches accidental incompatibilities before merge.
The current OpenAPI specification is 3.2.0, while many production tools and APIs still publish 3.0.x or 3.1.x documents. The workflow below does not require you to upgrade an existing valid specification just to add change detection.
What Counts as a Breaking API Change?
A breaking change is one that can make an existing consumer fail without changing its own code. Common examples include:
- Removing an operation or response status.
- Adding a required request parameter to an existing operation.
- Making an optional request property required.
- Narrowing an accepted enum or numeric range.
- Removing a response property that clients may read.
- Changing a schema type, format, or authentication requirement incompatibly.
Not every diff is breaking. Adding an optional response field or a new endpoint is usually compatible. A semantic diff tool understands OpenAPI structure, so it is much more useful than a line-by-line YAML comparison.
Keep the Contract in Version Control
Place the source-of-truth specification in the repository. This tutorial uses openapi.yaml at the root:
openapi: 3.1.0
info:
title: Orders API
version: 1.4.0
paths:
/orders/{orderId}:
get:
operationId: getOrder
parameters:
- name: orderId
in: path
required: true
schema:
type: string
responses:
'200':
description: Order found
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'404':
description: Order not found
components:
schemas:
Order:
type: object
required: [id, status]
properties:
id:
type: string
status:
type: string
enum: [pending, paid, shipped]
Generate the document from code if your framework owns the schema, or treat the document as design-first input. Either model works, but CI must compare the artifact that will actually be published. Commit generated output and fail CI when regeneration creates an uncommitted diff, or generate it deterministically before the validation step.
Add Validation and Breaking-Change Jobs
Create .github/workflows/openapi-contract.yml:
name: OpenAPI contract
on:
pull_request:
branches: [main]
paths:
- openapi.yaml
- .github/workflows/openapi-contract.yml
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Validate OpenAPI document
uses: oasdiff/oasdiff-action/validate@v0
with:
spec: openapi.yaml
fail-on: ERR
breaking-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Fetch the pull request base
run: git fetch --depth=1 origin "${{ github.base_ref }}"
- name: Reject breaking contract changes
uses: oasdiff/oasdiff-action/breaking@v0
with:
base: "origin/${{ github.base_ref }}:openapi.yaml"
revision: "HEAD:openapi.yaml"
fail-on: WARN
review: false
The validation job checks one document against OpenAPI and JSON Schema rules. The second job compares the base-branch version with the pull request version. fail-on: WARN is strict: both warning- and error-level breaking findings fail the job.
The action's documented moving major tag is @v0; it advances only to stable v0.x.y releases. Teams that require immutable dependencies can pin the action to a reviewed release tag or full commit SHA and update it deliberately.
review: false keeps the specifications inside the runner. If you leave the default review behavior enabled, the action can create an encrypted side-by-side report. Choose that tradeoff explicitly based on whether your API description contains information you consider sensitive.
Understand the Base and Revision Inputs
Comparison direction matters. The base is the contract consumers already use; the revision is the proposed contract.
base: "origin/${{ github.base_ref }}:openapi.yaml"
revision: "HEAD:openapi.yaml"
The explicit git fetch makes the target branch available without downloading full history. This works for pull requests aimed at branches other than main because github.base_ref contains the actual target branch name.
If the specification is generated during CI, write both versions to separate paths and compare those paths instead. Never compare two files generated from the same checkout; that produces a reassuring but meaningless zero diff.
Prove That the Gate Works
Create a test branch and make orderId optional by removing required: true, or create a clearer breaking change by deleting shipped from the response enum:
status:
type: string
enum: [pending, paid]
Open a pull request. The breaking-change job should report the narrowed enum and fail. Restore shipped, push again, and confirm the job passes.
Also test a compatible change, such as adding a new optional property:
createdAt:
type: string
format: date-time
A useful CI rule is one the team has deliberately seen fail and pass. That exercise verifies the workflow path filter, branch fetch, file path, and repository branch protection—not only the tool itself.
Make the Check Required Before Merge
After the workflow has run at least once, open the repository's branch rules and require both jobs for the protected branch. The exact GitHub interface varies by repository and organization settings, but the policy is simple:
- Require a pull request before changes reach the production branch.
- Require the OpenAPI validation and breaking-change checks.
- Require the branch to be current before merge if your team commonly stacks dependent changes.
- Restrict bypass permission to a small, accountable group.
CI detection without a required check is advisory. It can still help reviewers, but it cannot reliably prevent an accidental merge.
Decide How Intentional Breaking Changes Ship
A breaking finding is not always a bug. Sometimes the API needs a new major version or an agreed migration. Do not solve that case by adding a blanket ignore list.
A safer process is:
- Introduce a versioned endpoint or media type when old and new contracts must coexist.
- Deprecate the old operation and publish a removal date.
- Record the migration in release notes and a consumer-facing changelog.
- Update examples, SDKs, mocks, and contract tests together.
- Approve a narrow, documented exception only when the compatibility impact is understood.
Keep exceptions reviewable. If you use .oasdiff.yaml to adjust severity or ignore a known finding, include that configuration in the same pull request and require an API owner to review it.
Optional Repository Configuration
For repeated settings, add .oasdiff.yaml at the repository root:
fail-on: WARN
exclude-elements:
- description
- title
- summary
The action automatically reads this file, and explicit workflow inputs take precedence. Excluding documentation-only elements keeps policy focused on executable contract changes. Avoid suppressing request or response categories broadly; that can hide the exact incompatibilities the gate exists to catch.
If your document uses remote $ref URLs, the action defaults to not resolving them. That protects untrusted pull-request runs from server-side request forgery. Prefer local, repository-controlled references. Enable external references only when you trust and control the locations involved.
Troubleshooting
The base specification cannot be found
Confirm the fetch step ran and the file exists on both branches:
git show "origin/main:openapi.yaml" >/dev/null
git show "HEAD:openapi.yaml" >/dev/null
For a non-main target, replace main with the pull request's base branch. File renames need special handling because the old and new paths differ.
The workflow never runs
Check the paths filter. A generated contract may change because application source changed while openapi.yaml is not committed. In that setup, include the generator's source paths or remove the filter.
Validation passes but the API behaves differently
The specification and implementation have drifted. Add a deterministic generation check or integration tests that exercise real responses against the contract. OpenAPI diffing protects the document's compatibility; it cannot prove that production matches the document.
Too many warnings block useful work
Review each check ID and change severity narrowly in .oasdiff.yaml. Start strict for error-level removals and required-input changes, then tune documented false positives. Do not switch the entire job to continue-on-error unless the team explicitly wants report-only behavior.
Fork pull requests have limited permissions
The workflow above requires only contents: read and does not post comments, so it is friendly to read-only fork tokens. If you later enable pull-request comments, grant only the documented permission and account for the reduced token permissions on forked contributions.
Production Checklist
- The published OpenAPI document is version-controlled or generated deterministically in CI.
- Validation runs on every contract-changing pull request.
- The comparison uses the target branch as base and the pull request as revision.
- A deliberate breaking test fails, and a compatible test passes.
- Both jobs are required by the production branch rule.
- Workflow permissions are limited to what the selected reporting mode needs.
- External references remain disabled unless every target is trusted.
- Intentional breaking changes use versioning, migration notes, and owner review.
- Ignore rules are narrow, documented, and stored with the code.
- The team also tests implementation behavior so the live API cannot silently drift from its contract.
Official Sources
A contract gate will not replace integration tests or thoughtful versioning. It does give every API pull request an immediate compatibility review, turning a class of production surprises into ordinary, fixable CI feedback.