API Changelog Automation

Part of the API Versioning & Deprecation reference. This page covers how to generate a changelog directly from your OpenAPI documents: diffing two versions of the spec, classifying each change as breaking, additive, or informational, gating continuous integration on the result, and publishing release notes that machines as well as humans can act on.

Problem framing

A hand-written changelog is a promise that someone will remember to update it. In practice, that promise breaks. An engineer renames a field, tightens a required list, or narrows a type to fix a bug, ships the pull request, and forgets the CHANGELOG.md entry — or writes one that describes a different change than the code actually made. Over a few releases the changelog drifts from the real contract, and consumers stop trusting it. Once they stop trusting it, they pin to a version and never upgrade, or they upgrade blind and file support tickets when their integration breaks.

The failure that actually costs money is the silent breaking change. A minLength added to a request field, a property moved from optional to required, an enum value removed from a response — none of these throw a compiler error in the service, so they sail through review and reach production, where they break every client that was relying on the old shape. The changelog said “bug fixes and improvements.” The reality was a major-version break shipped as a patch.

The fix is to stop treating the changelog as prose and start treating it as a derived artifact. Your OpenAPI document already is the contract. If two versions of that document are diffed structurally, every meaningful change is visible, and each one can be classified against a fixed ruleset. That makes the changelog a build output — the same class of thing as a compiled binary or a generated client. This page walks through generating API changelogs from OpenAPI diffs as a CI step, and it connects to two adjacent practices: Contract Linting & Governance, which keeps the spec itself well-formed, and SDK Version Pinning, which is what consumers do downstream once your version numbers actually mean something.

What a spec diff looks like

The input is two OpenAPI documents — the spec as it exists on the base branch, and the spec on the pull request. The engine resolves $ref pointers in both, walks the resolved trees, and emits a structured list of differences. Below, the base declares email optional; the head makes it required and removes a response enum value.

# base: openapi.yaml @ main
paths:
  /users:
    post:
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [name]           # email is optional here
              properties:
                name:  { type: string }
                email: { type: string }
      responses:
        "201":
          content:
            application/json:
              schema:
                properties:
                  status: { type: string, enum: [active, pending, invited] }
# head: openapi.yaml @ pull request
paths:
  /users:
    post:
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [name, email]    # email now REQUIRED — breaking for input
              properties:
                name:  { type: string }
                email: { type: string }
      responses:
        "201":
          content:
            application/json:
              schema:
                properties:
                  status: { type: string, enum: [active, pending] }  # removed "invited" — breaking for output

A structural differ reduces those two documents to a normalized, machine-readable diff. The exact JSON shape varies by tool, but the useful ones carry a location, a change kind, and a severity per entry:

{
  "diff": [
    {
      "path": "/users",
      "operation": "POST",
      "location": "request.body.required",
      "kind": "property-became-required",
      "detail": "email",
      "severity": "breaking"
    },
    {
      "path": "/users",
      "operation": "POST",
      "location": "response.201.status.enum",
      "kind": "enum-value-removed",
      "detail": "invited",
      "severity": "breaking"
    }
  ],
  "summary": { "breaking": 2, "non_breaking": 0, "informational": 0 }
}

Two documents in, one classified list out. Everything downstream — the version bump, the CI gate, the rendered markdown — is a pure function of that list.

Change classification diagram

API changelog automation pipeline Two OpenAPI documents (base and head) flow into a diff engine, which classifies each change as breaking, non-breaking, or informational. Breaking maps to a major bump and fails the CI gate; non-breaking maps to a minor bump; informational maps to a patch. All three feed the rendered changelog. base spec head spec diff engine oasdiff / Optic breaking required, type, enum- non-breaking new path, new field informational description, example major + fail gate minor + patch + rendered changelog CHANGELOG.md + diff.json + PR comment

Standard alignment: semver and OpenAPI change classes

Automated classification only works if “breaking” has a precise definition. Two standards supply it. Semantic Versioning 2.0.0 fixes what each version component means, and OpenAPI 3.1 supplies the concrete keywords whose changes map to those components. The table below is the ruleset a diff engine encodes; direction (request vs response) matters as much as the keyword itself.

Change class Semver bump OpenAPI 3.1 signal Direction note
Breaking major (X.0.0) new required property; type narrowed; enum value removed; format tightened; additionalProperties changed truefalse; property removed On requests, tightening breaks producers; on responses, removing breaks consumers
Breaking major path or operation removed; required parameter added; security scheme added to an operation Any removal from the response contract or new obligation on the request is breaking
Non-breaking minor (0.X.0) new path/operation; new optional property; new response code; new optional parameter; enum value added to a request Additive to input is safe — old clients still send valid values
Non-breaking minor additionalProperties changed falsetrue; maxLength relaxed; property changed required→optional Loosening a constraint accepts a superset of prior inputs
Informational patch (0.0.X) description, summary, example, externalDocs, x- extension edits No wire-visible effect on request or response bytes
Ambiguous — classify by direction major or minor enum value added to a response; format added where absent Output enum growth breaks strict consumers; treat as breaking unless the field is documented as open

The rule that trips people up is the last one. Adding invited to a request enum is additive — existing callers never sent it, and the server now accepts more. Adding invited to a response enum is breaking for any consumer that wrote an exhaustive switch over the old values. A good differ classifies enum growth by the schema’s request/response position, and defaults response-side enum additions to breaking unless an x-enum-open: true marker declares the field extensible.

Implementation step 1: run a structural diff in CI

Run the diff on every pull request. The job below uses oasdiff, which resolves $ref internally and has a dedicated breaking subcommand with a non-zero exit code, making the CI gate a one-liner. Optic (optic diff) is an equivalent choice with an interactive review UX; both emit JSON.

#!/usr/bin/env bash
# scripts/api-diff.sh — run in CI on every pull request
set -euo pipefail

BASE_SPEC="$1"   # e.g. specs/base/openapi.yaml (checked out from the base branch)
HEAD_SPEC="$2"   # e.g. openapi.yaml (this pull request)
OUT_DIR="${3:-./diff-out}"
mkdir -p "$OUT_DIR"

# 1. Full classified diff as JSON — consumed by the changelog renderer.
oasdiff diff "$BASE_SPEC" "$HEAD_SPEC" \
  --format json > "$OUT_DIR/diff.json"

# 2. Breaking-changes-only report. Exit code is non-zero when any breaking
#    change is present, which is what fails the gate below.
set +e
oasdiff breaking "$BASE_SPEC" "$HEAD_SPEC" \
  --format json > "$OUT_DIR/breaking.json"
BREAKING_EXIT=$?
set -e

BREAKING_COUNT=$(jq 'length' "$OUT_DIR/breaking.json")
echo "breaking_count=$BREAKING_COUNT" >> "${GITHUB_OUTPUT:-/dev/stdout}"

# 3. Fail unless the PR is explicitly labelled for a major release.
if [ "$BREAKING_COUNT" -gt 0 ] && [ "${ALLOW_BREAKING:-false}" != "true" ]; then
  echo "::error::$BREAKING_COUNT breaking change(s) without a 'major-release' label"
  exit 1
fi

exit 0

The pattern that matters: fetch the base spec from the target branch, not from a cached copy. In GitHub Actions that means git fetch origin "$GITHUB_BASE_REF" and git show "origin/$GITHUB_BASE_REF:openapi.yaml" so the comparison is always PR-against-target, never against a stale snapshot that would miss or invent changes.

Implementation step 2: render a categorised changelog and post it

The diff JSON is the source of truth; the renderer turns it into grouped markdown and posts it as a pull request comment so reviewers see the contract impact inline. Keep the transform pure — read JSON, emit markdown — so it is trivially unit-testable.

#!/usr/bin/env python3
# scripts/render_changelog.py — diff.json -> categorised markdown
import json
import os
import sys
from pathlib import Path

SEVERITY_ORDER = ("breaking", "non_breaking", "informational")
HEADINGS = {
    "breaking": "### Breaking changes (major)",
    "non_breaking": "### Additions (minor)",
    "informational": "### Editorial (patch)",
}
BUMP = {"breaking": "major", "non_breaking": "minor", "informational": "patch"}


def load_entries(diff_path: Path) -> list[dict]:
    data = json.loads(diff_path.read_text())
    # oasdiff --format json emits an object; normalise to a flat entry list.
    return data.get("diff", data) if isinstance(data, dict) else data


def classify(entry: dict) -> str:
    sev = entry.get("severity")
    if sev in SEVERITY_ORDER:
        return sev
    # Fall back to keyword heuristics for differ outputs without a severity field.
    kind = entry.get("kind", "")
    if any(k in kind for k in ("removed", "became-required", "type-", "enum-value-removed")):
        return "breaking"
    if any(k in kind for k in ("added", "new-")):
        return "non_breaking"
    return "informational"


def render(entries: list[dict], version: str) -> tuple[str, str]:
    buckets: dict[str, list[str]] = {s: [] for s in SEVERITY_ORDER}
    for e in entries:
        loc = f"`{e.get('operation', '')} {e.get('path', '')}`".strip()
        detail = e.get("detail", e.get("location", ""))
        buckets[classify(e)].append(f"- {loc}{e.get('kind', 'changed')}: {detail}")

    highest = next((s for s in SEVERITY_ORDER if buckets[s]), "informational")
    lines = [f"## {version}{BUMP[highest]} release", ""]
    for sev in SEVERITY_ORDER:
        if buckets[sev]:
            lines.append(HEADINGS[sev])
            lines.extend(sorted(buckets[sev]))
            lines.append("")
    return "\n".join(lines).rstrip() + "\n", BUMP[highest]


def main() -> int:
    diff_path = Path(sys.argv[1])
    version = sys.argv[2] if len(sys.argv) > 2 else "Unreleased"
    entries = load_entries(diff_path)
    if not entries:
        Path("changelog-fragment.md").write_text(f"## {version}\n\n_No contract changes._\n")
        return 0

    markdown, bump = render(entries, version)
    Path("changelog-fragment.md").write_text(markdown)
    # Expose the computed bump so the release job can tag the right version.
    with open(os.environ.get("GITHUB_OUTPUT", "/dev/stdout"), "a") as fh:
        fh.write(f"bump={bump}\n")
    print(markdown)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Posting the fragment as a pull request comment is a thin wrapper — pipe changelog-fragment.md to gh pr comment "$PR" --body-file changelog-fragment.md, and update rather than append by deleting the bot’s previous comment first so the thread never accumulates stale duplicates.

Edge cases that break naive diffing

The difference between a changelog developers trust and one they ignore is how it handles the changes that look breaking but are not — and the ones that look harmless but are not.

Spec refactors that are not contract changes. Splitting one giant openapi.yaml into $ref-linked files, or hoisting an inline schema into components/schemas, changes the document’s syntax without changing a single byte on the wire. A text diff screams; a structural diff on the resolved documents shows nothing. Always bundle and dereference before diffing. oasdiff does this internally; a custom differ must run redocly bundle --dereference on both sides first.

$ref restructuring and renamed components. Renaming #/components/schemas/User to #/components/schemas/UserResource while every reference is updated is a no-op for consumers — the resolved shapes are identical. A differ that compares reference strings reports a removal plus an addition and misclassifies both as breaking. Comparing resolved schema content by structural equality collapses the pair to zero diff. This is the single most common source of false positives.

Additive enum values, request vs response. Covered in the alignment table, but it is the edge case that causes real incidents, so it bears repeating in code terms: an added value under requestBody is minor; the same value under responses is major for exhaustive consumers. A differ that ignores request/response position will either under-report (missing a real break) or over-report (blocking a safe change). Encode direction into the classifier.

Constraint tightening that hides in numbers. minLength: 0minLength: 1, maximum: 1000maximum: 500, adding a pattern — none remove a field, so a shallow differ misses them, yet each rejects inputs that used to be valid. Treat any tightening of a validation keyword on a request schema as breaking.

Nullability flips. Under OpenAPI 3.1 / JSON Schema 2020-12, dropping "null" from a type array (["string","null"]"string") is breaking for responses — a consumer that received null before now gets a schema that forbids it. This maps to the same rule as type narrowing; make sure the differ walks type arrays, not just scalar type.

Validation and testing

Two gates, both cheap. First, the fail-on-breaking gate from step 1 — a non-empty breaking bucket fails the job unless a major-release label is present. Second, an assertion that every release actually carries a changelog entry, so the automation can never silently no-op.

# tests/test_changelog_gate.py — run in CI after render_changelog.py
import json
import re
import subprocess
from pathlib import Path


def test_breaking_change_requires_major_bump():
    """A diff containing a breaking entry must resolve to a major bump."""
    diff = {"diff": [
        {"path": "/users", "operation": "POST",
         "kind": "property-became-required", "detail": "email", "severity": "breaking"}
    ]}
    Path("diff.json").write_text(json.dumps(diff))
    out = subprocess.run(
        ["python", "scripts/render_changelog.py", "diff.json", "v2.0.0"],
        capture_output=True, text=True, check=True,
    )
    assert "major release" in out.stdout
    assert "### Breaking changes" in out.stdout


def test_changelog_entry_exists_for_release():
    """Every tagged release must have a matching CHANGELOG.md heading."""
    version = subprocess.check_output(
        ["git", "describe", "--tags", "--abbrev=0"], text=True
    ).strip()
    changelog = Path("CHANGELOG.md").read_text()
    # e.g. tag v2.1.0 must appear as a "## v2.1.0" (or "## 2.1.0") heading.
    pattern = rf"^##\s+v?{re.escape(version.lstrip('v'))}\b"
    assert re.search(pattern, changelog, re.MULTILINE), \
        f"No CHANGELOG.md entry found for release {version}"

The second test is what closes the drift loop: a release cannot ship without a corresponding, generated entry. Pair it with the diff gate and the changelog cannot lie about a release, because the same commit that changes the spec is the commit that regenerates the entry. This is the CI-side counterpart to Contract Linting & Governance, which keeps the spec well-formed in the first place — lint the spec, then diff it.

SDK generation impact

The changelog and the SDKs must be generated from the same resolved spec at the same commit, or they diverge — and a diverged changelog is worse than none, because it is confidently wrong. If SDKs are built from openapi.yaml but the changelog is hand-written or derived from a different artifact (a Postman export, a separate docs spec), you will eventually publish release notes describing a v2 that does not match the v2 client your users actually installed.

Wire all three consumers — the diff engine, the SDK generator, and the docs build — to one canonical spec resolved once:

#!/usr/bin/env bash
# scripts/release.sh — one resolved spec, three consumers
set -euo pipefail

# Resolve ONCE from this exact commit — every downstream step reads this file.
redocly bundle --dereference openapi.yaml -o dist/openapi.resolved.yaml

# 1. Changelog + version bump, from the resolved spec.
BUMP=$(python scripts/render_changelog.py diff-out/diff.json "$NEXT_VERSION")

# 2. SDKs, from the SAME resolved spec.
openapi-generator-cli generate -i dist/openapi.resolved.yaml -g typescript-fetch -o clients/ts
openapi-generator-cli generate -i dist/openapi.resolved.yaml -g python        -o clients/py

# 3. The generated SDK's package version is the changelog's computed bump.
echo "Publishing clients at $NEXT_VERSION (bump: $BUMP)"

Because the version the changelog computes is the version the SDK ships under, a consumer who reads “v3.0.0 — major release: removed invited from status” and then installs @your-api/client@3.0.0 gets exactly the code that matches those notes. That guarantee is the whole point, and it is what makes SDK Version Pinning meaningful downstream: pinning to ^2.0.0 is only safe if your 2.x line genuinely never contained a breaking change, which the diff gate is what enforces.

Anti-patterns quick-reference

Anti-pattern Correct approach
Hand-written CHANGELOG.md maintained separately from the spec Generate the changelog from a spec diff so it cannot drift from the real contract
Diffing raw spec text or unbundled $ref documents Bundle and dereference both sides first; compare resolved schema content, not reference strings
Treating every enum addition as either always safe or always breaking Classify by request/response direction — additive on input, breaking on strict output consumers
Breaking changes merge and the version bumps by a patch Fail CI on a non-empty breaking bucket unless an explicit major-release label is present
Only catching removed fields, missing constraint tightening Flag minLength/pattern/maximum tightening and type/nullability narrowing as breaking too
Changelog built from a different artifact than the SDKs Resolve one canonical spec per commit and feed the diff, SDKs, and docs from that single file
Comparing the PR against a cached or stale base spec Fetch the base spec from the target branch at diff time so the comparison is always current
Publishing only human markdown Also commit the JSON diff so downstream consumers can act on machine-readable release notes

FAQ

How do I stop a diff tool from flagging a $ref refactor as a breaking change?

Bundle and dereference both specs before diffing so structural equality is compared on the resolved schemas, not the reference syntax. Tools like oasdiff resolve $ref automatically; if you use a custom differ, run redocly bundle --dereference on the base and head documents first so a moved component definition with identical content produces zero diff entries.

Is adding an enum value a breaking change?

It depends on direction. Adding a value to a request (input) enum is additive because existing clients still send valid values. Adding a value to a response (output) enum is breaking for strict clients that treat the enum as exhaustive, because they may receive a value they cannot handle. Classify by request/response position, not by the keyword alone.

Should the changelog be generated from the same spec that generates the SDKs?

Yes. Point the diff engine, the SDK generator, and the documentation build at one canonical openapi.yaml resolved from the same commit. If the changelog is written by hand or derived from a different artifact, it will eventually describe a release that does not match the shipped client, which defeats the purpose of an automated changelog.