Automated Changelog Tools for OpenAPI

Hand-written API changelogs drift the moment a spec changes faster than a human can summarise it. This guide — part of SDK Generation & Changelog Automation — shows how to derive changelogs directly from the OpenAPI document using oasdiff for breaking-change detection, openapi-changes from pb33f for human-readable history, and conventional-commit release tooling to stitch the result into a versioned release note. The scope here is the diff-and-classify pipeline: comparing two spec revisions, deciding which changes are breaking, and emitting a changelog on every pull request. It does not cover hand-authoring the spec itself or generating client libraries — those belong to the schema-authoring and SDK clusters linked at the end.

The hardest part is not running a diff; it is classifying each change as breaking or safe and wiring that verdict into a gate that blocks merges. The diagram below shows the flow this guide builds.

Spec diff to classified changelog Two spec revisions are diffed, each change is classified as breaking or non-breaking, breaking changes gate the build, and a Markdown changelog is posted on the pull request. base spec openapi.yaml PR spec openapi.yaml oasdiff diff classify breaking? fail build (breaking) post changelog (PR comment)

Prerequisites & Environment Setup

Pin tool versions so a runner upgrade does not silently change how a diff is classified. The pipeline in this guide uses three tools.

Tool Pinned version Role Install
oasdiff v1.11.x Breaking-change detection, Markdown changelog, CI gate Go install or release binary
openapi-changes (pb33f) v0.0.7x Human-readable Git-history diff, HTML report, TUI Go install or release binary
git-cliff v2.x Conventional-commit release notes that wrap the spec changelog cargo install or release binary

Install oasdiff from a pinned release binary rather than go install @latest, which is unreproducible:

# oasdiff v1.11.x — pinned, reproducible
curl -sSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh -s -- v1.11.2
oasdiff --version
# oasdiff version: 1.11.2

Install openapi-changes (pb33f) for human-readable reports and git-cliff for conventional-commit release notes:

# openapi-changes v0.0.7x — produces HTML/console diffs over Git history
go install github.com/pb33f/[email protected]

# git-cliff v2.x — conventional-commit changelog generator
cargo install git-cliff --version 2.6.1

You need two artifacts to diff: a base spec (the version currently shipped) and a candidate spec (the version in the pull request). In CI, the base is usually git show origin/main:openapi.yaml and the candidate is the working-tree file. Keep both as a single bundled file — if your spec is split across $ref files, bundle it first with @redocly/[email protected] so the diff sees a resolved document. Authoring conventions that keep diffs clean live in Defining JSON Schema Components.

npx @redocly/[email protected] bundle openapi.yaml --output dist/bundled.yaml

Core Configuration

The classification rule set is the part you actually configure. oasdiff ships a maintained list of change checks, each with a default severity of error (breaking), warning, or info. Override individual rules in a YAML severity file when your team treats a change differently — for example, deprecating a header may be a warning for you rather than a hard break.

# oasdiff-severity.yaml — override default change classifications
# Each key is an oasdiff check id; value is one of: error | warn | info
api-removed-without-deprecation: error          # removing an operation outright stays a hard break
response-property-removed: error                # dropping a response field breaks deserializers
request-property-became-required: error         # newly required input breaks existing callers
new-required-request-property: error            # same class, additive-but-mandatory field
response-optional-property-became-not-write-only: info  # cosmetic, downgrade to info
api-deprecated-sunset-parse: warn               # malformed sunset date is a warning, not a block

Run the breaking-change gate against the severity file. oasdiff breaking exits non-zero only when at least one error-level (breaking) change is present, which is exactly the signal a CI gate needs:

oasdiff breaking dist/base.yaml dist/candidate.yaml \
  --severity-levels oasdiff-severity.yaml \
  --fail-on ERR \
  --format text

Expected output when a response field is removed:

1 changes: 1 error, 0 warning, 0 info
error	[response-property-removed] at dist/candidate.yaml
	in API GET /users/{id}
		removed the optional property 'lastLogin' from the response with the '200' status

The --fail-on ERR flag sets the threshold for a non-zero exit. Use --fail-on WARN to also block on warnings. Treat the severity file as code: review changes to it in the same PR that relaxes a rule, so the relaxation is auditable.

To produce the changelog itself, use oasdiff changelog, which emits a grouped, severity-ordered list of every change — breaking and additive alike — and never fails the build on its own:

oasdiff changelog dist/base.yaml dist/candidate.yaml \
  --severity-levels oasdiff-severity.yaml \
  --format markdown > CHANGELOG_DELTA.md

The Markdown output groups entries by endpoint:

### What's Changed

#### GET /users/{id}
- :warning: removed the optional property 'lastLogin' from the response with the '200' status

#### POST /users
- added the new optional request property 'referralCode'

Three flags govern how this output reads. Use --exclude-elements examples,description to suppress noise from documentation-only edits when you only want contract changes in the changelog. Use --flatten-allof so that schemas composed with allOf are compared as their resolved shape rather than as nested fragments — without it, a refactor that moves a property between allOf branches without changing the effective schema shows up as a spurious removal plus addition. Use --lang (for example --lang ru) only if you need localised text; the default English output is what most teams commit. Pin the same flag set in CI and in local runs so a developer’s preview matches what the pipeline posts.

For conventional-commit release tooling, the changelog command pairs with a cliff.toml that recognises the commit types your team uses. Mark API-breaking commits with ! so the version bump and the spec reality agree:

# cliff.toml — git-cliff v2.x, minimal config for API release notes
[changelog]
header = "# Release notes\n"
body = """
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group }}
{% for commit in commits %}- {{ commit.message }}
{% endfor %}{% endfor %}
"""

[git]
conventional_commits = true
filter_unconventional = false
commit_parsers = [
  { message = "^feat!", group = "Breaking changes" },   # feat! → major bump
  { message = "^feat", group = "Features" },             # feat  → minor bump
  { message = "^fix", group = "Fixes" },                 # fix   → patch bump
]

The split is deliberate: oasdiff decides what is breaking at the contract level, while git-cliff decides the human-facing release-note grouping at the commit level. A robust release step fails fast if the two disagree — for instance, if oasdiff breaking flags a removal but no feat! commit exists, the author forgot to signal the major bump.

Integration Pattern

Wire the two commands into a pull-request workflow: extract the base spec from the merge target, diff against the candidate, gate on breaking changes, and post the Markdown changelog as a comment. This is the canonical CI integration for this topic; the step-by-step build of a complete workflow file lives in Generating API changelogs from OpenAPI diff.

# .github/workflows/api-changelog.yml
name: API Changelog
on:
  pull_request:
    paths:
      - 'openapi.yaml'          # only run when the spec actually changes
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        # need full history to read the base spec
      - name: Install oasdiff
        run: |
          curl -sSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh \
            | sh -s -- v1.11.2
      - name: Resolve base and candidate specs
        run: |
          mkdir -p dist
          git show "origin/${{ github.base_ref }}:openapi.yaml" > dist/base.yaml
          cp openapi.yaml dist/candidate.yaml
      - name: Generate changelog (never fails)
        run: |
          oasdiff changelog dist/base.yaml dist/candidate.yaml \
            --severity-levels oasdiff-severity.yaml \
            --format markdown > delta.md
      - name: Post changelog comment
        uses: peter-evans/create-or-update-comment@v4
        with:
          issue-number: ${{ github.event.pull_request.number }}
          body-path: delta.md
      - name: Gate on breaking changes (may fail)
        run: |
          oasdiff breaking dist/base.yaml dist/candidate.yaml \
            --severity-levels oasdiff-severity.yaml \
            --fail-on ERR

The comment step runs before the gate so reviewers see the changelog even when the breaking-change gate fails the job. This same gate sits naturally alongside SDK regeneration — when a spec change is additive, tools like OpenAPI Generator, Fern, and Speakeasy can publish a minor SDK release; when it is breaking, the gate forces a major version bump first.

Advanced Options

Human-readable history with openapi-changes. Where oasdiff answers “is this PR safe?”, openapi-changes answers “how did this endpoint evolve?”. It walks Git history and renders a navigable HTML report or an interactive terminal view:

# Self-contained HTML report across the full spec history
openapi-changes html-report openapi.yaml --no-logo

# Interactive terminal explorer of the same history
openapi-changes console openapi.yaml

Attach the generated report.html as a CI artifact for reviewers who want the visual diff rather than the Markdown list.

Wrapping spec changes in a conventional-commit release note. When you cut a release, fold the spec delta into a full release note driven by commit messages. git-cliff reads feat:/fix:/feat!: commits; append the oasdiff Markdown so API consumers see both the code-level and contract-level changes:

git-cliff --tag v2.0.0 --output RELEASE_NOTES.md
cat CHANGELOG_DELTA.md >> RELEASE_NOTES.md

Treat any oasdiff breaking entry as the trigger for a major-version feat!: commit so the conventional-commit version bump and the contract reality stay aligned.

Machine-readable output for custom gates. Use --format json (or --format yaml) with either command to feed a custom policy engine. The JSON groups changes under breaking and non-breaking arrays, so a small script can enforce rules oasdiff does not model — for example, blocking any change to a /payments path regardless of severity:

oasdiff changelog dist/base.yaml dist/candidate.yaml --format json \
  | jq -e '[.[] | select(.path | startswith("/payments"))] | length == 0'

Verification & Testing

Verify the gate behaves correctly before trusting it on real PRs. Construct a deliberately breaking change and confirm a non-zero exit:

# Remove a response property in the candidate, then check the exit code
oasdiff breaking dist/base.yaml dist/broken.yaml --fail-on ERR
echo "exit code: $?"
# exit code: 1

Confirm an additive change does not trip the gate but still appears in the changelog:

oasdiff breaking dist/base.yaml dist/additive.yaml --fail-on ERR
echo "exit code: $?"
# exit code: 0

oasdiff changelog dist/base.yaml dist/additive.yaml --format text
# 1 changes: 0 error, 0 warning, 1 info
# info	[new-optional-request-property] ... added the new optional request property 'referralCode'

Validate that both specs bundle cleanly first — a diff against an unresolved $ref produces misleading “removed” entries. Run @redocly/[email protected] lint on both files and confirm zero errors before diffing. When identical specs report changes, the cause is almost always whitespace or key-order noise; normalise by bundling both through the same @redocly/cli version.

Add a regression test for the gate itself so a future oasdiff upgrade cannot silently reclassify a change. Keep two fixture specs in the repo — fixtures/breaking.yaml and fixtures/additive.yaml — and assert their exit codes in CI:

# fixtures-check.sh — run in CI to verify the classifier still behaves
set -e
oasdiff breaking dist/base.yaml fixtures/additive.yaml --fail-on ERR \
  && echo "additive: pass (exit 0, correct)"
if oasdiff breaking dist/base.yaml fixtures/breaking.yaml --fail-on ERR; then
  echo "breaking fixture did NOT fail — classifier regressed" && exit 1
else
  echo "breaking: correctly failed (exit 1)"
fi

This turns “we trust the tool” into an explicit, version-pinned contract: if a runner picks up a different oasdiff build that changes a default severity, the fixture check fails loudly in a controlled PR instead of letting a real breaking change through unflagged. Run it on a schedule as well as on PRs so dependency drift surfaces even on quiet weeks.

Troubleshooting

oasdiff reports changes between two identical-looking files. The two files differ in $ref resolution or key ordering, not content. Bundle both through the same @redocly/[email protected] version into dist/ before diffing so each side is a fully resolved, consistently ordered document.

The CI gate never fails even on an obvious break. You omitted --fail-on ERR (or used oasdiff changelog, which always exits 0). The changelog command is for reporting; the gate must be oasdiff breaking --fail-on ERR. Confirm with echo $? immediately after the command.

git show origin/<base>:openapi.yaml fails with “fatal: path does not exist”. The checkout used a shallow clone, so the base ref is missing, or the spec path differs on the base branch. Set fetch-depth: 0 in actions/checkout@v4 and verify the file existed at that path on the target branch before the PR.

The changelog comment is empty or the step errors with 403. The job lacks pull-requests: write permission, or the diff genuinely found no spec changes. Add the permission block shown in the integration workflow, and use paths: ['openapi.yaml'] so the job only runs when the spec changed.

FAQ

What is the difference between oasdiff and openapi-changes?

oasdiff is a Go CLI focused on machine-readable breaking-change detection and changelog generation with stable exit codes for CI gating. openapi-changes from pb33f produces human-friendly diffs over Git history with an HTML report and a terminal TUI, so the two complement each other rather than replace one another.

Does oasdiff understand what counts as a breaking change?

Yes. oasdiff ships a built-in classifier that marks each change as breaking, non-breaking, or a warning based on a maintained rule set covering request and response schemas, parameters, enums, and security. You can downgrade or disable individual rules with a YAML severity-levels file when your team treats a change differently.

Can I generate a Markdown changelog automatically from two spec versions?

Yes. Run oasdiff changelog against the old and new spec with the -f markdown flag and redirect the output to a file. The command groups entries by endpoint and severity, which you can commit to the repo or post as a pull-request comment.

How do I fail a build only on breaking changes but still record everything else?

Use oasdiff breaking for the gate because it exits non-zero only when breaking changes exist, and run oasdiff changelog separately to record the full set of changes. Keeping the two commands distinct lets the pipeline block regressions while still publishing additive updates.