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.
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) =>
({ '<': '<', '>': '>', '&': '&' }[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
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:
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.
Related
- Automated Changelog Tools — the parent guide on generating change data.
- Generating API changelogs from OpenAPI diff — producing the entries this publishes.
- Detecting breaking changes with oasdiff — the diff behind the breaking flag.
- Sunset header and deprecation policy docs — the other channel a deprecation must reach.