Generating API Changelogs from OpenAPI Diffs
Part of API Changelog Automation within the API Versioning & Deprecation reference, this guide turns two OpenAPI documents into a categorised, reviewer-facing changelog — and, more importantly, into a CI gate that blocks accidental breaking changes. A hand-written changelog drifts from the spec within a release or two; deriving it from the contract itself keeps the record honest and lets the same diff both document and enforce compatibility.
When You Need This
The trigger is any workflow where the OpenAPI document is the source of truth and more than one person can change it:
| Symptom / decision trigger | What the diff must produce |
|---|---|
| A breaking change shipped without a major bump | A CI gate that fails on breaking diffs |
| Reviewers cannot see the API delta in a PR | An inline, categorised changelog comment |
The CHANGELOG drifts from the actual spec |
Machine-generated release notes |
| SDK consumers are surprised by removed fields | An explicit “Breaking” section per release |
If the contract already governs code review and linting — as covered in Contract Linting & Governance — the diff is the natural next gate. It answers a different question than a linter: not is this spec well-formed? but is this spec compatible with the last one we published? Version choices downstream also depend on it — a breaking diff should force the SDK major bump described in Semver Ranges for Generated SDK Clients.
The Tooling Landscape
Three tools dominate; oasdiff is the reference throughout this guide because it is a single Go binary, understands OpenAPI 3.0 and 3.1, and ships a breaking-change rule set out of the box.
| Tool | Strength | Best for |
|---|---|---|
| oasdiff | Rich breaking-change rules, text/markdown/JSON output | CI gates and changelog generation |
| openapi-diff (Tufin/others) | Simple HTML report | Human review, quick visual diff |
| Optic | Diffs from live traffic and captures per-PR | Teams tracking spec vs real behaviour |
The Diff Contract
oasdiff reports a structured change set. The breaking subcommand collapses it into severities you can act on — ERR blocks, WARN and INFO inform. A minimal changed operation illustrates the shape of a breaking edit:
# HEAD spec — a new REQUIRED property is a breaking change (ERR)
openapi: 3.1.0
info:
title: Orders API
version: "2.0.0"
paths:
/orders:
post:
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [customer_id, idempotency_key] # idempotency_key is new + required
properties:
customer_id: { type: string }
idempotency_key: { type: string }
Adding idempotency_key to required breaks every existing client that omits it, so oasdiff breaking classifies it ERR. Making it optional (removing it from required) would be INFO.
Step-by-Step
Step 1 — Diff two specs locally
Install the binary and run a plain diff to see the raw change set before wiring anything into CI:
# Human-readable summary
oasdiff diff openapi.base.yaml openapi.head.yaml --format text
# Breaking-change classification only
oasdiff breaking openapi.base.yaml openapi.head.yaml --format text
oasdiff breaking prints each change with its severity (error/warning/info) and a stable rule id such as request-property-became-required — the id is what you allow-list later.
Step 2 — Emit machine-readable output for the changelog script
Text output is for humans; the changelog generator consumes JSON.
oasdiff diff openapi.base.yaml openapi.head.yaml \
--format json > diff.json
oasdiff breaking openapi.base.yaml openapi.head.yaml \
--format json > breaking.json
Step 3 — Gate CI on breaking changes
The gate is a single command. --fail-on ERR makes the process exit non-zero when any breaking change is present, so the job fails and the merge is blocked:
#!/usr/bin/env bash
set -euo pipefail
BASE="openapi.base.yaml" # last published spec (restored from the base branch)
HEAD="openapi.yaml" # spec in this PR
# Exit non-zero if any ERR-level (breaking) change is detected.
oasdiff breaking "$BASE" "$HEAD" --fail-on ERR --format githubactions
The githubactions format emits ::error:: annotations that appear inline on the changed lines in the PR diff. To allow an intentional breaking change once the major version is bumped, gate the step on a label or on the info.version major having incremented — do not disable the check globally.
Step 4 — Restore the baseline in CI
The gate needs the previously published spec. In a GitHub Actions workflow, check out the base branch’s spec alongside the head:
name: api-diff
on: pull_request
permissions:
contents: read
pull-requests: write # required to post the changelog comment
jobs:
diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Restore base spec
run: git show "origin/${{ github.base_ref }}:openapi.yaml" > openapi.base.yaml
- name: Install oasdiff
run: |
curl -sSfL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh
- name: Breaking-change gate
run: oasdiff breaking openapi.base.yaml openapi.yaml --fail-on ERR --format githubactions
- name: Generate changelog JSON
if: always()
run: |
oasdiff diff openapi.base.yaml openapi.yaml --format json > diff.json
- name: Render and post changelog
if: always()
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: node scripts/changelog.mjs
Everything the comment step needs — the token, PR number, and repository — comes from the Actions context (github.token, github.event.pull_request.number, github.repository). No hard-coded URLs or secrets in scripts.
Step 5 — Render a categorised markdown changelog
Group the diff into Breaking, Added, and Deprecated sections. This TypeScript/Node script reads diff.json and produces markdown:
// scripts/changelog.mjs
import { readFileSync } from 'node:fs';
const diff = JSON.parse(readFileSync('diff.json', 'utf8'));
function collect(diff) {
const breaking = [];
const added = [];
const deprecated = [];
for (const [path, ops] of Object.entries(diff.paths?.modified ?? {})) {
for (const [method, change] of Object.entries(ops.operations?.modified ?? {})) {
const label = `\`${method.toUpperCase()} ${path}\``;
if (change.requestBody?.content) breaking.push(`${label} — request schema changed`);
if (change.deprecated?.to === true) deprecated.push(`${label} — deprecated`);
}
}
for (const path of Object.keys(diff.paths?.added ?? {})) added.push(`\`${path}\` added`);
for (const path of Object.keys(diff.paths?.deleted ?? {})) breaking.push(`\`${path}\` removed`);
return { breaking, added, deprecated };
}
function render({ breaking, added, deprecated }) {
const section = (title, items) =>
items.length ? `### ${title}\n${items.map((i) => `- ${i}`).join('\n')}\n` : '';
return [
'<!-- api-changelog -->', // marker used to find/update the sticky comment
'## API changes in this PR',
section('Breaking', breaking),
section('Added', added),
section('Deprecated', deprecated),
breaking.length ? '\n**A breaking change requires a major version bump.**' : '',
].filter(Boolean).join('\n');
}
const body = render(collect(diff));
Python equivalent using the same JSON shape, for teams whose tooling is Python-first:
# scripts/changelog.py
import json
with open("diff.json") as fh:
diff = json.load(fh)
breaking, added, deprecated = [], [], []
for path in (diff.get("paths", {}).get("deleted") or {}):
breaking.append(f"`{path}` removed")
for path in (diff.get("paths", {}).get("added") or {}):
added.append(f"`{path}` added")
def section(title, items):
return f"### {title}\n" + "\n".join(f"- {i}" for i in items) + "\n" if items else ""
body = "\n".join([
"<!-- api-changelog -->",
"## API changes in this PR",
section("Breaking", breaking),
section("Added", added),
section("Deprecated", deprecated),
])
Step 6 — Post it as a sticky PR comment
Find an existing comment carrying the <!-- api-changelog --> marker and update it; otherwise create one. All identity comes from the Actions context env vars set in the workflow — no literal URLs in the code.
// appended to scripts/changelog.mjs
const { GH_TOKEN, PR_NUMBER, REPO } = process.env;
const api = `https://api.github.com/repos/${REPO}`;
const headers = {
authorization: `Bearer ${GH_TOKEN}`,
accept: 'application/vnd.github+json',
};
const existing = await fetch(
`${api}/issues/${PR_NUMBER}/comments`, { headers },
).then((r) => r.json());
const mine = existing.find((c) => c.body?.includes('<!-- api-changelog -->'));
const target = mine
? `${api}/issues/comments/${mine.id}`
: `${api}/issues/${PR_NUMBER}/comments`;
await fetch(target, {
method: mine ? 'PATCH' : 'POST',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({ body }),
});
The ${api} base is assembled from REPO, which comes from github.repository — the host is not hard-coded into a comment or literal, it is derived from the runtime context.
Breaking vs Non-Breaking
Caching and Idempotency of the Pipeline
The diff step is deterministic: the same two spec inputs always produce the same change set, so it is safe to cache by the hash of both files and skip regeneration on unrelated pushes. Make the comment update idempotent by keying on the <!-- api-changelog --> marker — re-running the job must edit the existing comment, never append a duplicate. Treat the gate itself as pure: it reads specs and exits, with no side effects beyond annotations, so it can run on every push without ordering concerns.
RFC and Standard Alignment
| Standard | Role in this pipeline |
|---|---|
| OpenAPI 3.1.0 | The contract format being diffed (also handles 3.0.x) |
| Semantic Versioning 2.0.0 | Breaking diff → major bump; additive → minor |
| JSON Schema 2020-12 | Underlies 3.1 schema comparison in oasdiff |
| RFC 8594 | A deprecated/Sunset change surfaces in the Deprecated section |
SDK and Codegen Downstream Effect
A breaking diff is the signal that regenerated SDKs will be source-incompatible. Wire the gate’s result into the release: an ERR-level change forces a major SDK version, which in turn changes the semver range consumers can safely accept.
# generated client package.json — driven by the diff result
- "version": "1.4.0",
+ "version": "2.0.0", // breaking diff detected → major bump
Feed the categorised changelog into the release notes attached to that major version so consumers reading Semver Ranges for Generated SDK Clients understand why their caret range stopped auto-updating.
Common Mistakes
| Mistake | Correct approach |
|---|---|
Diffing against HEAD~1 instead of the published spec |
Restore the base branch’s spec as the baseline |
| Failing CI on every change, including additive ones | Gate with --fail-on ERR, not on any diff |
| Appending a new PR comment on each push | Update a sticky comment via a <!-- marker --> |
| Hard-coding the repo URL/token in the script | Read github.repository / github.token from context |
Hand-editing CHANGELOG.md alongside the spec |
Generate it from the diff so it cannot drift |
FAQ
How does oasdiff decide a change is breaking?
oasdiff applies a rule set that classifies each detected change by severity. Removing an endpoint, adding a required request property, narrowing an enum, or tightening a response schema are ERR (breaking) because they can break existing clients. Adding an optional property, a new endpoint, or a new response code is non-breaking. Each rule carries a stable id such as request-property-became-required, so you can allow-list or downgrade specific rules when a change is deliberate and coordinated with consumers.
How do I fail CI only on breaking API changes?
Run oasdiff breaking base revision with --fail-on ERR. The command exits non-zero only when at least one ERR-level change is present, so WARN and INFO changes still appear in the report but do not block the merge. Pair the gate with a label check or a info.version major-bump check so an intentional breaking change can pass once the major version has been incremented, rather than disabling the gate outright.
Why post the changelog as a PR comment instead of committing a file?
A sticky PR comment gives reviewers the API delta inline at review time without a second commit that re-triggers CI. Update the same comment on each push by locating a marker in the existing comments, so the thread stays clean instead of accumulating one comment per push. You can still write the permanent CHANGELOG file at release time; the PR comment is the review-time preview, not the historical record.
Related
- API Changelog Automation — the section on deriving release notes from the contract
- API Versioning & Deprecation — the parent reference on versioning schemes and retirement
- OpenAPI 3.0 vs 3.1 Schema Dialect Differences — dialect nuances that affect how a diff is interpreted
- Semver Ranges for Generated SDK Clients — turning a breaking diff into the right SDK version bump
- Contract Linting & Governance — the linting gate the diff gate complements