
Diffing OpenAPI Specs in CI with TypeScript to Block Breaking Changes
Why OpenAPI Spec Diffing Belongs in CI
What a silent contract break looks like
Diffing OpenAPI specs in TypeScript and running the check in CI stops contract-breaking changes before consumers see them. I have watched this failure mode play out twice. A producer renames customer_id to customerId, updates the handler, updates openapi.yaml in the same pull request, and ships. The spec file was updated. Nobody compared it to the previous revision. Next morning the billing job writes zero rows, because the consumer reads order.customer_id against a payload that no longer has that field. In TypeScript that is not even a compile error when the property is optional. You get undefined, a silent write, and a week of reconciliation.
Code review discipline is not the fix here. Reviewers cannot diff two 4,000-line YAML files by eye, and they should not be asked to.
What this post builds
A CI job that generates the OpenAPI document from the base branch inside a throwaway git worktree, generates the PR's document from the working tree, diffs the two with a dedicated engine, feeds the machine-readable output into a small TypeScript classifier driven by its own policy file, and exits non-zero on contract-breaking changes. Around that core: a waiver file with mandatory expiry, GitHub annotations that name the exact JSON path that broke, and fixture tests for the gate itself.
What is verified here, and what is not
The seed for this post was a SitePoint guide on detecting breaking API changes with TypeScript and OpenAPI. I only ever had the aggregator redirect, so there is no canonical URL to link, and I did not take tool behavior from it. The concrete claims below come from the OpenAPI Specification plus the tools' own repositories and docs. Where I show command output, it is the documented or expected shape rather than a capture from this article; the few things I could not confirm, I flag as inference.
Deciding What "Breaking" Means Before You Diff Anything
Why "breaking" is a policy decision, not a spec property
The OpenAPI Specification describes structure. It has no concept of "breaking," and it cannot have one, because breakingness is a claim about consumers, and the document does not know who yours are or which fields they actually read. So you write the rule down yourself and keep it in version control next to the code.
Changes that are unambiguously breaking
- a path or operation removed
- a response property removed, or an optional response property that consumers read removed
- a new required request property or parameter
- an enum member removed
- a type narrowed:
string→integer,number→integer, or aoneOfbranch deleted - an optional parameter that becomes required
Changes that are breaking in practice but arguable
- Added optional response property — breaks strict decoders, snapshot tests,
Object.keys()iteration, and strict Zod/io-ts schemas. - New enum member — fine for a
default:branch, fatal for exhaustive switches. - Changed default value — silently changes behavior for every caller who omitted the field.
- Tightened
maxLength,minimum, orpattern— the schema still validates, but inputs that used to be accepted now 400. - Changed
format, e.g.int32→int64— structurally identical, but JavaScript numbers lose precision past 2^53, so the wire contract effectively changed.
Changes that are almost never breaking
A new optional path or operation, an added optional request property, and edits to descriptions, summaries, or tags. Each one widens the surface without changing an existing response shape or rejecting input that used to be valid.
A reference table of change type → verdict → owner
| Change | Verdict | Who fixes it |
|---|---|---|
| Path or operation removed | breaking | producer |
| New required request property | breaking | producer (coordinate with consumers) |
| Response property removed | breaking | producer |
| Enum member removed | breaking | producer |
| Type narrowed | breaking | producer |
| Added optional response property | risky | consumers (strict decoders) |
| New enum member | risky | consumers (exhaustive switches) |
| Changed default | risky | producer |
Tightened maxLength/minimum/pattern | risky | producer |
| Added optional request property | safe | nobody |
| New optional path or operation | safe | nobody |
| Description, summary, tag edits | safe | nobody |
The last four rows are team policy, not spec-level fact. A team whose consumers all switch with default: branches can legitimately call new enum members safe. Put the decision in a file where it can be reviewed, instead of leaving it in the head of whoever last edited the CI config.
Treating added enum values as safe only holds if every consumer switches with a default branch. TypeScript will not catch the ones that don't: if the generated type is a string union and the value arrives over the network, an exhaustive switch with no default compiles fine and falls through at runtime. If you cannot verify every consumer, classify new enum members as risky and post a warning.
Choosing an OpenAPI Diff Engine for CI
OpenAPI diff engines I evaluated
- oasdiff (
Tufin/oasdiff) — Go, single binary, dedicatedbreakingandchangelogsubcommands, JSON output. - OpenAPITools/openapi-diff — JVM, mature, needs a JVM or a container in a Node pipeline.
- openapi-changes (
pb33f/openapi-changes) — Go, oriented toward human-readable changelog reports. - Hand-rolled JSON Schema comparison — you would be reimplementing
$refresolution,allOfmerging, and discriminator handling before you found your first real bug.
Why I picked machine-readable output plus an exit code
Parsing another tool's pretty-printed output is a regex farm that breaks on every minor release. I went with oasdiff because its breaking subcommand emits JSON, and its exit code already separates "breaking changes found" from "clean" and from "tool error." That split lets the classifier own policy while the engine owns structural comparison.
The trade-off: a Go binary in a Node repo
A Go binary in a Node repo is one more pinned download plus a checksum check, and the release asset names change per release. Verify them on the release page for the exact version you pin rather than copying a string out of a blog post. openapi-diff skips the binary download but pulls a JVM into the job; I would expect a heavier cold start in CI, but I have not measured it, so treat that as inference.
What I am not claiming
I am not making ordering or performance claims about these tools beyond what their own docs state. If a post shows a speed comparison without the machine, the command, and the numbers, ignore it.
Producing a Trustworthy Baseline Spec
Generate the spec from code, not by hand
The spec has to be generated from the running code — NestJS decorators, tsoa, Fastify schemas, a Zod-to-OpenAPI bridge — not hand-edited. If it is hand-maintained, your diff measures how disciplined people were under deadline, which is the exact variable you are trying to remove. Add a CI check that regenerating produces no diff; that check alone catches more drift than the diff engine will.
Generating the base spec from the base ref
#!/usr/bin/env bash
set -euo pipefail
SPEC="${1:?usage: generate-specs.sh <spec-name>}" # public | internal
BASE_REF="${BASE_REF:?set BASE_REF to the PR target branch}"
OUT=$(pwd)
WORKTREE=$(mktemp -d)
trap 'git worktree remove --force "$WORKTREE" >/dev/null 2>&1 || true' EXIT
## Base side: the target branch, checked out cleanly and never touching your PR tree.
git fetch --no-tags --depth=1 origin "$BASE_REF"
git worktree add --detach "$WORKTREE" FETCH_HEAD
(cd "$WORKTREE" && npm ci --ignore-scripts && npm run "build:openapi:$SPEC" -- --out "$OUT/openapi.base.$SPEC.json")
## Head side: the PR working tree you are already in.
npm ci --ignore-scripts
npm run "build:openapi:$SPEC" -- --out "$OUT/openapi.head.$SPEC.json"
## Same dialect on both sides, or the diff is noise.
for f in "$OUT/openapi.base.$SPEC.json" "$OUT/openapi.head.$SPEC.json"; do
jq -e '.openapi | startswith("3.1")' "$f" > /dev/null
doneMake the Spec Diff a Required Check
The gate only works if it blocks merges, so make the job a required status check and keep the policy file in version control alongside the spec generator. oasdiff handles the structural comparison and the machine-readable exit code; your TypeScript classifier decides which differences your API contracts actually forbid. Classify the unambiguous cases first, mark everything arguable as risky so it warns rather than blocks, and tighten the policy as you learn what your consumers break on. A diff that is advisory is the same as no diff at all.


