Release Notes and Changelogs from Git

A product's changelog is one of the most-read pages on its documentation site, and one of the least loved by the people who write it. Someone copies merged pull request titles into a Markdown file the day before a release, half of them are meaningless to users ("refactor auth middleware", "bump deps"), the links to new documentation are missing, and the page ends up as a wall of text that nobody can subscribe to or search by version.

This guide builds a changelog for a static documentation site from Git: structured input from changesets written in pull requests, a draft generated at release time and edited by a writer, one page per release with a stable URL, links checked against the docs build, and a feed. It is part of Content Workflows for Documentation Teams. The product ships monthly releases with 40–80 merged pull requests each, and the docs site is built with Astro.

Prerequisites

  • A product repository with pull requests, and a documentation site built by a static generator.
  • Agreement on release cadence and version numbering.
  • A place where release notes will live: in the docs repository, or generated into it from the product repository.

Step 1: Structured Input at Pull Request Time

The best time to describe a change for users is when it is made, by the person making it. Changesets are small Markdown files added in the pull request:

<!-- .changeset/quiet-owls-sing.md -->
---
type: feature
area: search
docs: /guide/search/filters/
---
Search results can now be filtered by product version. The filter remembers your last choice.

A pull request template prompts for one, and a CI check requires a changeset for pull requests labelled user-facing — refactors, tests and dependency updates are exempt with an internal label. The body is written for users, in one or two sentences, and docs points at the page that explains the change.

Teams that already use conventional commits (feat(search): filter by version) can generate the same structure from history with tools such as git-cliff; the text is usually weaker, because commit messages are written for reviewers, but it is a reasonable start.

Pipeline from changesets to published release pages Each user-facing pull request adds a changeset with type, area, docs link and a user-facing sentence. At release time a script collects changesets into a draft release page grouped by type and area. A writer edits the draft in a pull request to the docs repository. The build checks that every docs link exists, generates the release page, the index and the feed, and deploys. From pull request to published release notes Changeset per PR type, area, docs link Draft at release grouped by type and area Writer edits docs pull request Build checks links every docs link exists Page, index, feed deployed with the docs
The writer edits a draft that is already structured and linked, instead of starting from a list of pull request titles.

Step 2: Generate a Draft at Release Time

When the release branch is cut, a script collects the changesets merged since the last release tag, groups them, and writes a draft release page into the docs repository as a pull request:

// scripts/draft-release.mjs
const since = execSync('git describe --tags --abbrev=0').toString().trim();
const files = execSync(`git diff --name-only ${since}..HEAD -- .changeset/`).toString().split('\n').filter(Boolean);
const entries = files.map((f) => matter(readFileSync(f, 'utf8'))).map(({ data, content }) => ({ ...data, text: content.trim() }));
const order = ['breaking', 'feature', 'improvement', 'fix'];
const groups = order.map((type) => ({ type, items: entries.filter((e) => e.type === type) })).filter((g) => g.items.length);
const body = groups.map((g) => `## ${titles[g.type]}\n\n` + g.items
  .sort((a, b) => a.area.localeCompare(b.area))
  .map((e) => `- **${areaNames[e.area]}:** ${e.text}${e.docs ? ` See [the guide](${e.docs}).` : ''}`).join('\n')).join('\n\n');
writeFileSync(`src/content/releases/${version}.md`, `---\nversion: ${version}\ndate: ${today}\nsummary: ''\n---\n\n${body}\n`);

The draft for a typical release had 34 entries in four groups. A writer then edits it: adds a two-sentence summary at the top, merges related entries, rewrites anything unclear, and highlights the two or three changes most readers care about. Editing a structured draft took about 40 minutes per release, against roughly three hours of assembling notes from pull request titles.

Step 3: One Page per Release

Each release is a content collection entry with frontmatter (version, date, summary, optional breaking: true), rendered at /changelog/4-12/. An index page lists releases newest first with their summaries, and a filter by area uses the area values in each entry. Stable per-release URLs matter: support replies, forum posts and upgrade guides link to "the 4.12 release notes", and a single long page with anchors breaks those links when it is split or trimmed.

Breaking changes get extra treatment: a callout at the top of the release page, a link to the upgrade guide, and inclusion in a separate "breaking changes by version" page that people upgrading across several versions read first.

Pages and files generated for the changelog Four outputs from the release entries. A page per release at a stable URL with summary and grouped changes. An index page listing releases with summaries and an area filter. A breaking changes page across versions linking to upgrade guides. An Atom feed with one entry per release. Changelog pages the build produces /changelog/4-12/ one page per release stable URL for links /changelog/ index with summaries and area filter newest first /changelog/breaking/ breaking changes across versions links to upgrade guides /changelog/feed.xml one Atom entry per release for readers and integrations
All four are generated from the same release entries; nothing is maintained by hand in two places.

Every docs link in a release entry must point to a page that exists in the build. The site's link checker covers this, but a specific check with a clear message helps writers: "4.12: link /guide/search/filters/ does not exist — has the guide been merged?" Release notes and their documentation are often merged the same day, and the check catches the order-of-merge mistakes.

An Atom feed with one entry per release, full content and stable IDs lets readers subscribe, and lets teams pipe releases into Slack or Discord with standard RSS integrations. The feed follows the practices in RSS and Atom Feeds for Static Blogs. Within two months, the feed had 1,400 subscribers, and the support team's internal channel received releases automatically.

Step 5: Publish on Release Day

The release page must go live when the product release does, not when someone remembers. The docs pull request containing the release page carries a publish-on-release label; a workflow in the product repository, triggered by publishing the GitHub release, merges it via the API (after its checks pass) and triggers a production docs build. For releases scheduled at a set time, the same approach as Scheduling Content Publication with Cron-Triggered Builds works: the entry has a future date and the build excludes it until then.

Effort and quality of release notes before and after the changeset workflow Paired bars. Writer time per release fell from 180 minutes assembling notes from pull request titles to 40 minutes editing a structured draft. Entries with a link to documentation rose from 9 to 31 per release on average. Release notes before and after Pull request titles by hand Changesets + draft Writer minutes per release 180 40 Entries linking to docs 9 31
Averages over six releases before and six after the change.

Writing Notes Readers Use

The structure only helps if the entries are written for the people reading them. The style guide for changesets fits on one screen: lead with what the reader can now do or what changed for them, not with what the code does ("Search results can now be filtered by version", not "Add version facet to search index"); name the feature as it appears in the product; mention breaking changes explicitly with what the reader must do; and keep each entry to one or two sentences, linking to the guide for detail. Vale enforces a few of these mechanically — banned words like "refactor" and "bump" in changesets, sentence length — as in Editorial Checks with Vale in CI. Reader behaviour confirmed the approach: on release pages, 38% of sessions clicked through to a linked guide, which told the team which changes readers cared about most.

Measured Impact

MeasureHand-assembled notesChangesets + generated draft
Writer time per release~3 h~40 min
Entries per release linking to docs931
Release notes published on release day4 of 6 releases6 of 6
Internal-only entries in public notes11 per release0
Changelog feed subscribers after two months—1,400
Support tickets asking "what changed in X" per month3817

Pitfalls & Rollback

  • Publishing commit messages as release notes. Keep a writer in the loop; drafts are a starting point.
  • One giant changelog page. Per-release pages give stable links and make the index scannable.
  • Changesets that are optional. Without a CI check, they are forgotten exactly on the busiest pull requests.
  • Docs links to pages that are not merged yet. Check links in the release entry against the build.
  • Rollback: release pages are ordinary content; reverting the merge unpublishes one, and the draft script can be dropped without affecting existing pages.

Conclusion

Good release notes come from capturing the user-facing description when a change is made, not reconstructing it from history on release day. Changesets in pull requests, a generated draft grouped by type and area, a writer's edit, per-release pages with checked docs links, and a feed turned a dreaded monthly chore into a forty-minute task — and gave readers something they actually subscribe to.

FAQ

Should a changelog be generated from commit messages?

Partly. Commit messages written for developers are rarely good release notes for users. A useful pattern generates a draft from structured commits or changesets, grouped by type and area, and lets a writer edit it into user-facing notes before publishing.

What is the difference between conventional commits and changesets?

Conventional commits encode the change type in each commit message, such as feat or fix, and tools derive the changelog from history. Changesets are small Markdown files added in pull requests that describe the change for users, which tools collect at release time. Changesets usually produce better user-facing text.

How should a changelog be structured on a documentation site?

One page per release, with a stable URL, grouped into new features, improvements, fixes and breaking changes, linking to the relevant documentation pages, plus an index page and an RSS or Atom feed that readers and integrations can subscribe to.

Include the documentation URL in the changeset or the pull request template, and have the generator check that each linked page exists in the build. Readers then go directly from a release note to the updated guide.

Filed underHow-tos