Generating API Changelogs from OpenAPI Diff

This task-focused guide is part of Automated Changelog Tools and the wider SDK Generation & Changelog Automation topic. It walks through producing a Markdown changelog from the difference between two OpenAPI versions and failing the build when a change breaks consumers — the situation that arises every time an API spec is edited in a pull request and reviewers need to know, before merge, exactly what changed and whether it is safe.

Problem & Context

A team edits openapi.yaml in a pull request. The reviewer can see the YAML diff, but a raw line diff does not say whether removing a property breaks deserializers, whether a newly required field breaks callers, or whether an enum value was dropped. Manually written changelogs lag behind the spec and quietly omit breaking changes, so a consumer’s client breaks in production with no warning.

The goal is a deterministic pipeline: given the base spec on the target branch and the candidate spec in the PR, emit a human-readable changelog and exit non-zero if any change is breaking. oasdiff provides both halves — oasdiff changelog for the report and oasdiff breaking for the gate — with a maintained classifier so you do not encode “what is breaking” by hand.

Two design decisions make this reliable. First, separate reporting from gating: the changelog command must never fail the build, because reviewers need to see it even when the gate trips; the gate is a distinct command with its own exit code. Second, compare resolved documents, not raw files: bundle both specs through the same tooling so the diff reflects real contract changes rather than $ref layout or key ordering. The broader configuration — severity overrides, JSON output for custom policies, and conventional-commit release notes — is covered in Automated Changelog Tools; this guide focuses on the minimal working pipeline.

Step-by-Step Solution

1. Resolve the two spec versions

The diff needs two fully resolved files. Extract the base from the merge target and copy the candidate, then bundle both through the same @redocly/[email protected] version so $ref resolution and key ordering match on both sides:

mkdir -p dist
git show "origin/main:openapi.yaml" > dist/base.raw.yaml
cp openapi.yaml dist/candidate.raw.yaml
npx @redocly/[email protected] bundle dist/base.raw.yaml      --output dist/base.yaml
npx @redocly/[email protected] bundle dist/candidate.raw.yaml --output dist/candidate.yaml

Expected output from each bundle call:

bundling dist/base.raw.yaml...
📦 Created a bundle for dist/base.raw.yaml at dist/base.yaml

2. Generate the Markdown changelog

Run oasdiff changelog (pin oasdiff v1.11.x). This command never fails the build — it only reports:

oasdiff changelog dist/base.yaml dist/candidate.yaml \
  --format markdown > delta.md

Expected delta.md:

### 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'

3. Gate CI on breaking changes

Run oasdiff breaking with --fail-on ERR. It exits 0 when every change is additive and 1 when at least one breaking change is present:

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

Expected output when a response property was 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
exit code: 1

4. Post the changelog on the pull request

Publish delta.md as a comment before the gate runs, so reviewers see the changelog even on a failing build. In GitHub Actions this is a single step (full workflow below):

- name: Post changelog comment
  uses: peter-evans/create-or-update-comment@v4
  with:
    issue-number: ${{ github.event.pull_request.number }}
    body-path: delta.md

Complete Working Example

Drop this into .github/workflows/api-changelog.yml. It runs only when the spec changes, posts the changelog, then gates on breaking changes.

# .github/workflows/api-changelog.yml
name: API Changelog
on:
  pull_request:
    paths:
      - 'openapi.yaml'              # skip the job when the spec is untouched
permissions:
  contents: read
  pull-requests: write             # needed to post the changelog comment
jobs:
  changelog:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0           # full history so the base spec is reachable

      - name: Install oasdiff v1.11.2
        run: |
          curl -sSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh \
            | sh -s -- v1.11.2

      - name: Resolve and bundle both spec versions
        run: |
          mkdir -p dist
          git show "origin/${{ github.base_ref }}:openapi.yaml" > dist/base.raw.yaml
          cp openapi.yaml dist/candidate.raw.yaml
          npx @redocly/[email protected] bundle dist/base.raw.yaml      --output dist/base.yaml
          npx @redocly/[email protected] bundle dist/candidate.raw.yaml --output dist/candidate.yaml

      - name: Generate Markdown changelog
        run: |
          oasdiff changelog dist/base.yaml dist/candidate.yaml \
            --format markdown > delta.md
          # Guard against an empty file so the comment step always has content
          [ -s delta.md ] || echo "_No API changes detected._" > delta.md

      - name: Post changelog on the PR
        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
        run: |
          oasdiff breaking dist/base.yaml dist/candidate.yaml --fail-on ERR
Diff, report, gate sequence Base and candidate specs are bundled, diffed by oasdiff into a Markdown changelog posted on the PR, and a breaking-change gate decides pass or fail. bundle base + PR oasdiff changelog post delta.md fail on breaking pass if additive

Gotchas & Edge Cases

A shallow checkout hides the base spec. GitHub Actions defaults to a shallow clone, so git show origin/<base>:openapi.yaml fails with fatal: path does not exist. Set fetch-depth: 0 on actions/checkout@v4, as the example does, so the base ref is fully present.

Identical specs report phantom changes. If the base and candidate are bundled with different @redocly/cli versions or one is left unbundled, $ref resolution and key ordering diverge and oasdiff reports spurious “removed”/“added” entries. Always bundle both files through the same pinned CLI version into dist/ before diffing.

The gate passes on a real break because the wrong command was used. oasdiff changelog always exits 0; only oasdiff breaking --fail-on ERR gates. If breaking changes slip through, confirm the final step calls breaking and not changelog, and verify with echo $? locally.

FAQ

Why does oasdiff report no changes when I changed the spec?

The diff ran against an unbundled spec whose $ref pointers resolve differently, or against the wrong base ref. Bundle both files through the same @redocly/cli version and confirm the base ref points at the merge target branch.

Can I produce the changelog in a format other than Markdown?

Yes. Pass --format json, yaml, or text to oasdiff changelog. JSON is best for feeding a custom policy script, while Markdown is best for posting as a pull-request comment.

How do I stop the changelog comment from piling up on every push?

Use a comment action that updates a single comment keyed by a marker instead of creating a new one. peter-evans/create-or-update-comment supports edit mode when you pass the existing comment id.