Publishing Changelogs to a Docs Portal

This guide is part of Automated Changelog Tools within SDK Generation & Changelog Automation. It covers the last mile: taking a generated list of contract changes and turning it into something consumers can read, link to, and subscribe to. The gap it closes is common — a diff tool produces an accurate change list every release, and it lands in a CI log that nobody outside the team ever sees.

Changelog publication pipeline A spec diff produces structured entries, which are merged with a hand-written summary and rendered as a release page, an index and an Atom feed. spec diff factual, complete release summary hand-written, why entries.json one stable format /changelog/2026-07-30/ permanent, linkable /changelog/ reverse-chronological index /changelog/feed.xml subscribable by people and tools

Problem & Context

Most API changelogs fail in one of two directions.

Generated-only changelogs are complete and unreadable. response-property-removed at GET /invoices/{id} is accurate and tells a consumer nothing about whether it affects them, why it happened, or what to do. A page of forty such lines gets skimmed and closed.

Hand-written changelogs are readable and incomplete. Somebody writes up the three changes they remember, and the field that was quietly tightened in a bugfix never appears — which is precisely the change most likely to break someone.

There is a third failure that applies to both: publication. A changelog that lives in a repository file, or in release notes on a code host, does not reach the people integrating with your API. They read your documentation portal, and increasingly their tooling watches a feed.

The arrangement that works combines a generated factual list with a written summary, renders both to a permanent URL per release, and publishes a feed so nobody has to remember to check.

Step-by-Step Solution

1. Settle a stable entry format

Fix the shape first, because both the generator and the renderer depend on it:

{
  "version": "2.8.0",
  "date": "2026-07-30",
  "summary": "Adds invoice search and begins the retirement of the legacy search endpoint.",
  "level": "minor",
  "changes": [
    {
      "kind": "added",
      "breaking": false,
      "operation": "GET /invoices",
      "detail": "New `query` parameter for full-text search across invoice numbers and references.",
      "reference": "/api-reference/invoices/list"
    },
    {
      "kind": "deprecated",
      "breaking": false,
      "operation": "GET /invoices/search",
      "detail": "Deprecated in favour of `GET /invoices` with `query`. Sunset 2027-01-31.",
      "reference": "/api-reference/invoices/search-legacy"
    }
  ]
}

2. Generate entries from the spec diff, not the commit log

Commit messages depend on discipline; a contract diff does not:

#!/usr/bin/env bash
# scripts/changelog-entry.sh — build one entry from the diff between two releases.
set -euo pipefail
: "${PREV:?set PREV, e.g. 2.7.0}" "${VERSION:?set VERSION, e.g. 2.8.0}"

curl -fsSL "https://specs.acme.com/specs/${PREV}/api.yaml"    -o /tmp/prev.yaml
curl -fsSL "https://specs.acme.com/specs/${VERSION}/api.yaml" -o /tmp/curr.yaml

oasdiff changelog /tmp/prev.yaml /tmp/curr.yaml --format json \
  --exclude-elements description,examples > /tmp/raw.json

set +e
oasdiff breaking /tmp/prev.yaml /tmp/curr.yaml --format json > /tmp/breaking.json
BREAKING=$?
set -e

node -e '
const fs = require("fs");
const raw   = JSON.parse(fs.readFileSync("/tmp/raw.json", "utf8"));
const brk   = new Set(JSON.parse(fs.readFileSync("/tmp/breaking.json","utf8"))
                        .map(c => `${c.operation} ${c.path} ${c.id}`));
const KIND = { added: "added", deleted: "removed", modified: "changed" };
const entry = {
  version: process.env.VERSION,
  date: process.env.RELEASE_DATE,
  summary: process.env.SUMMARY || "",
  level: process.env.LEVEL || "minor",
  changes: raw.map(c => ({
    kind: KIND[c.section] || "changed",
    breaking: brk.has(`${c.operation} ${c.path} ${c.id}`),
    operation: `${(c.operation||"").toUpperCase()} ${c.path||""}`.trim(),
    detail: c.text,
  })),
};
fs.writeFileSync(`changelog/${process.env.VERSION}.json`, JSON.stringify(entry, null, 2));
console.log(`${entry.changes.length} change(s) recorded for ${entry.version}`);
'
7 change(s) recorded for 2.8.0

The summary comes from the environment because it is the one field a machine cannot produce — it is written by whoever cut the release.

3. Render one page per release plus an index

A permanent URL per release is what makes a changelog linkable from a support ticket six months later:

content/changelog/
├─ index.md                 # reverse-chronological list
├─ 2.8.0/index.md           # /changelog/2.8.0/
└─ 2.7.0/index.md
// scripts/render-changelog.js — JSON entries to portal pages.
const fs = require('fs');
const path = require('path');

const entries = fs.readdirSync('changelog')
  .filter((f) => f.endsWith('.json'))
  .map((f) => JSON.parse(fs.readFileSync(path.join('changelog', f), 'utf8')))
  .sort((a, b) => b.date.localeCompare(a.date));

const ICON = { added: '➕', removed: '➖', changed: '🔁', deprecated: '⚠️' };

for (const e of entries) {
  const dir = path.join('content/changelog', e.version);
  fs.mkdirSync(dir, { recursive: true });

  const breaking = e.changes.filter((c) => c.breaking);
  const body = [
    '---',
    `title: "API changelog ${e.version}"`,
    `description: "${e.summary.replace(/"/g, "'")}"`,
    'type: howto',
    `datePublished: ${e.date}`,
    `dateModified: ${e.date}`,
    '---',
    `# API changelog — ${e.version}`,
    '',
    e.summary,
    '',
    breaking.length
      ? `> **${breaking.length} breaking change${breaking.length > 1 ? 's' : ''}** in this release. ` +
        'Review before upgrading.\n'
      : '',
    '## Changes',
    '',
    ...e.changes.map((c) =>
      `- ${ICON[c.kind] || ''} **${c.operation}** — ${c.detail}` +
      (c.breaking ? ' _(breaking)_' : '')),
    '',
  ].join('\n');

  fs.writeFileSync(path.join(dir, 'index.md'), body);
}

console.log(`rendered ${entries.length} release page(s)`);

4. Publish an Atom feed

The channel that reaches integrations nobody is actively maintaining:

// scripts/render-feed.js
const fs = require('fs');
const SITE = 'https://docs.acme.com';
const entries = JSON.parse(fs.readFileSync('changelog/all.json', 'utf8'));

const esc = (s) => String(s).replace(/[<>&]/g, (c) =>
  ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));

const feed = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Acme API changelog</title>
  <link href="${SITE}/changelog/feed.xml" rel="self"/>
  <link href="${SITE}/changelog/"/>
  <id>${SITE}/changelog/</id>
  <updated>${entries[0].date}T00:00:00Z</updated>
${entries.map((e) => `  <entry>
    <title>${esc(e.version)}${e.changes.some((c) => c.breaking) ? ' (breaking)' : ''}</title>
    <link href="${SITE}/changelog/${e.version}/"/>
    <id>${SITE}/changelog/${e.version}/</id>
    <updated>${e.date}T00:00:00Z</updated>
    <summary>${esc(e.summary)}</summary>
  </entry>`).join('\n')}
</feed>`;

fs.writeFileSync('static/changelog/feed.xml', feed);
console.log(`feed: ${entries.length} entries`);

5. Wire it into the release pipeline

Publishing the changelog in the same job that publishes the contract is what stops the two diverging:

      - name: Build the changelog entry
        env:
          PREV: ${{ vars.PREVIOUS_SPEC_VERSION }}
          VERSION: ${{ steps.v.outputs.version }}
          RELEASE_DATE: ${{ steps.v.outputs.date }}
          SUMMARY: ${{ github.event.inputs.summary }}
        run: ./scripts/changelog-entry.sh

      - name: Render pages and feed
        run: |
          node scripts/render-changelog.js
          node scripts/render-feed.js
Generated facts, written meaning The diff supplies a complete list of what changed and whether it is breaking, while a person supplies the summary explaining why and what to do. the machine supplies every change, none missed breaking or not the affected operation completeness the person supplies why it changed what consumers should do how urgent it is meaning

Complete Working Example

The release workflow, publishing the contract and its changelog together:

# .github/workflows/release.yml
name: Release
on:
  workflow_dispatch:
    inputs:
      summary:
        description: One-sentence summary of this release, for the changelog
        required: true

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci

      - name: Install oasdiff
        run: curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh -s -- v1.11.7

      - name: Derive the version
        id: v
        run: |
          echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
          echo "date=$(date -u +%Y-%m-%d)"    >> "$GITHUB_OUTPUT"

      # Entries come from the contract diff — nothing depends on commit messages.
      - name: Build the changelog entry
        env:
          PREV:         ${{ vars.PREVIOUS_SPEC_VERSION }}
          VERSION:      ${{ steps.v.outputs.version }}
          RELEASE_DATE: ${{ steps.v.outputs.date }}
          SUMMARY:      ${{ github.event.inputs.summary }}
        run: ./scripts/changelog-entry.sh

      - name: Render pages and feed
        run: |
          node scripts/render-changelog.js
          node scripts/render-feed.js

      # A breaking release must carry a summary that says what to do about it.
      - name: Require migration guidance on breaking releases
        run: |
          if jq -e '.changes[] | select(.breaking)' \
               "changelog/${{ steps.v.outputs.version }}.json" >/dev/null; then
            jq -e '.summary | test("(?i)migrat|instead|replace|upgrade")' \
               "changelog/${{ steps.v.outputs.version }}.json" >/dev/null \
              || { echo '::error::Breaking release summary must describe the migration.'; exit 1; }
          fi

      - name: Build and deploy the portal
        run: |
          npm run build
          aws s3 sync dist/ s3://docs-production/ --delete

Expected output:

7 change(s) recorded for 2.8.0
rendered 12 release page(s)
feed: 12 entries

The migration-guidance check is small and disproportionately useful. It cannot judge whether the summary is good, but it does stop a breaking release shipping with a summary like “various improvements” — which is the changelog entry consumers complain about most.

Gotchas & Edge Cases

A generated entry with no summary reads as noise. The diff supplies the facts; without a sentence of context they are a list of rule identifiers. Make the summary a required input to the release, as the workflow does, rather than an optional field someone fills in later.

Feed entry ids must be stable forever. Editing a release page and changing its id makes every subscriber see it as new. Use the permanent release URL as the id and never change it, even if you correct the text.

Changelog pages accumulate and dilute search. After forty releases, searching for an operation returns changelog entries ahead of the reference. Exclude the changelog from the docs search index, or give it its own facet so results stay separable.

The date is not the version. Using dates as page slugs breaks when two releases ship on one day, and makes it impossible to link “the page for 2.8.0”. Slug by version, display the date.

One changelog per audience, not one per artifact. Teams that publish SDKs often end up with an API changelog and a separate changelog per client library, and consumers have to read several to understand one release. Publish one changelog keyed on the contract version, and record within each entry which SDK versions implement it — that gives a consumer a single place to look while still answering the library-specific question.

Silence is a signal too. If a release genuinely changed no contract, publish an entry saying so rather than skipping it. A gap in the sequence makes consumers wonder whether the changelog is being maintained at all.

Publishing to more than one channel is not redundancy — each reaches a different population, and the feed is the only one that reaches software:

Reach by channel The portal page reaches people browsing the docs, the feed reaches subscribers and automation, and direct outreach reaches the accounts still calling a retiring endpoint. portal page people already reading passive Atom feed subscribers and automation the only machine channel direct outreach accounts still calling it targeted by traffic data

FAQ

Should the changelog be generated or written?

Both. Generate the factual list of contract changes so nothing is missed, and hand-write the summary that explains why a change happened and what consumers should do. Generation guarantees completeness; authoring provides the meaning that makes it worth reading.

Why publish a feed as well as a page?

A page requires someone to remember to visit. A feed lets consumers subscribe and lets their automation watch for breaking changes, which is the only channel that reliably reaches an integration nobody is actively working on.

How much detail belongs in an entry?

Enough to decide whether to act: what changed, whether it is breaking, and what to do about it. Link to the reference for the full shape rather than reproducing schemas in the changelog, which ages badly and duplicates the source of truth.