Diffing OpenAPI Specs in CI with TypeScript to Block Breaking Changes

Diffing OpenAPI Specs in CI with TypeScript to Block Breaking Changes

pr0h0•
openapitypescriptapi-contractsci-cdbreaking-changes
AI Usage (86%)

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 a oneOf branch 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, or pattern — 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

ChangeVerdictWho fixes it
Path or operation removedbreakingproducer
New required request propertybreakingproducer (coordinate with consumers)
Response property removedbreakingproducer
Enum member removedbreakingproducer
Type narrowedbreakingproducer
Added optional response propertyriskyconsumers (strict decoders)
New enum memberriskyconsumers (exhaustive switches)
Changed defaultriskyproducer
Tightened maxLength/minimum/patternriskyproducer
Added optional request propertysafenobody
New optional path or operationsafenobody
Description, summary, tag editssafenobody

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, dedicated breaking and changelog subcommands, 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 $ref resolution, allOf merging, 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

scripts/generate-specs.sh
#!/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
done

Make 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.

Share this post

More posts

Comments