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

Breaking vs non-breaking OpenAPI changes Two columns. The left column lists breaking changes that fail the CI gate: remove endpoint, add required request field, narrow enum. The right column lists non-breaking changes that pass: add optional field, add endpoint, add response code. Breaking (ERR) Non-breaking Remove an endpoint Add a required request field Narrow an enum or type fail --fail-on ERR Add an optional field Add a new endpoint Add a new response code pass the gate

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.