Release notes are a translation problem. The source material is a commit range written by engineers for engineers; the audience is usually somebody deciding whether to upgrade.
AI does the mechanical part of that translation well — grouping, categorising, rewriting terse subjects into sentences. It cannot do the editorial part, which is deciding what matters, and the difference between useful notes and generated noise is entirely in that second half.
The short answer
Section titled “The short answer”Get the range, get the pull requests, ask for grouping — then edit.
git log --oneline --no-merges v1.4.0..v1.5.0What it doesLists commits reachable from the new tag but not the previous one, excluding merge commits.
Why we run itThis is the release's content. Merge commits usually carry no information beyond 'branches joined'.
Expected resultOne line per commit: abbreviated hash and subject.
{/* Richer source: the pull requests, with labels */}gh pr list --state merged --limit 100 \ --json number,title,labels,author \ --jq '.[] | "\(.number) \(.title)"'Pull request titles are usually better source material than commit subjects, because they were written knowing somebody would read them.
Getting the range right
Section titled “Getting the range right”The most common defect in generated release notes is a wrong range, and it produces notes that are confidently missing things.
{/* The previous tag, by version ordering rather than date */}git describe --tags --abbrev=0 v1.5.0^Three traps:
Tag ordering is not date ordering. A patch release cut from a release branch can be tagged after a
later minor. --sort=-v:refname orders by version rather than time.
Cherry-picked commits appear twice. A fix picked onto a release branch and also merged to main is two commits with different hashes and the same subject. Notes generated naively list it twice.
Reverts are not netted out. A commit and its revert both appear in the range. A summary lists both as changes, and a reader concludes a feature shipped that did not.
The check worth running before generating anything:
{/* Anything reverted in this range? */}git log --oneline --grep='Revert' v1.4.0..v1.5.0None of these are AI problems. They are Git problems that AI will faithfully reproduce.
Listing tags in version order rather than the default lexicographic order:
git tag --sort=-v:refnameThat matters more than it sounds. Lexicographically, v1.10.0 sorts before v1.9.0, so a script
picking “the previous tag” without version sorting starts producing wrong ranges the moment you reach
a double-digit minor version — and the symptom is release notes that silently omit or duplicate work.
What to feed it
Section titled “What to feed it”Three sources, in increasing order of quality.
Commit subjects. Always available, often terse, written for other engineers mid-task.
git log --oneline --no-merges v1.4.0..v1.5.0Commit subjects with bodies. Where your team writes bodies, this is substantially richer — the body is where the why lives.
git log --no-merges --format='%h %s%n%b' v1.4.0..v1.5.0Merged pull requests with labels. Usually the best source. Titles were written knowing somebody would read them, and labels give you categorisation you control rather than categorisation inferred from prose.
gh pr list --state merged --limit 100 \ --search 'merged:>=2026-08-01' \ --json number,title,labels,author \ --jq '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"'The labelled form is what makes categorisation reliable rather than probabilistic, and it is the argument for labelling pull requests at all. See AI pull request automation for automating the labels themselves.
Categorisation
Section titled “Categorisation”The useful structure for almost every audience:
## Breaking changes## New features## Bug fixes## Security## Performance## Deprecations## InternalWhere a repository uses Conventional Commits, categorisation is close to
mechanical — feat to features, fix to bug fixes, ! or BREAKING CHANGE to the first section.
Where it does not, a model categorises from the subject text and gets it mostly right, with two
systematic errors.
Security fixes described neutrally. A commit saying “validate input length” may be a hardening change or a fix for a reported vulnerability. Only you know which, and the distinction determines whether it belongs under Security with an advisory reference.
Internal work promoted to features. A refactor described enthusiastically reads like a feature. If users cannot observe it, it is internal — and a features list padded with internal work trains readers to skip it.
Two audiences, two documents
Section titled “Two audiences, two documents”The single most useful distinction on this page, and the one most often collapsed.
Customer-facing notes answer: should I upgrade, what do I gain, what might break, what do I need to do. Written in the language of the product. Internal refactors are omitted entirely.
Engineering notes answer: what changed, where, by whom, referencing what. Written in the language of the codebase. Internal changes matter because somebody debugging next month needs them.
The same commit appears differently in each:
| Commit | Customer-facing | Engineering |
|---|---|---|
fix: handle null customer in invoice totals | “Fixed an error when generating invoices for accounts with no billing contact.” | “Fix NPE in InvoiceTotals.compute when customer is null (#482).” |
refactor: extract retry policy | (omitted) | “Extract RetryPolicy from HttpClient (#491).” |
Generate them as separate passes with different instructions. Asking for one document that serves both produces a document that serves neither — too much internal detail for customers, too little precision for engineers.
Changelogs and release notes are different
Section titled “Changelogs and release notes are different”Both derive from the same history and serve different purposes, and conflating them produces a document that is a poor version of each.
A changelog is cumulative, versioned, in the repository, and complete. CHANGELOG.md following
Keep a Changelog or similar. Its value is that somebody upgrading across
four versions can read all four entries in one place.
Release notes are per-release, published where the release is, and selective. Their value is answering “should I take this one”.
The practical relationship: the changelog entry is generated and comprehensive; the release notes are edited down from it and lead with what matters. Generating the changelog entry first and then cutting it is less work than writing both.
Where a project maintains an Unreleased section, there is a further option: append to it as pull
requests merge rather than reconstructing at release time. That produces better entries — written by
the author, with context — and reduces release-day generation to reorganising and editing.
Upgrade notes are not in the diff
Section titled “Upgrade notes are not in the diff”The section readers most need and generation cannot produce.
What action is required. Run a migration, change a configuration value, update a dependency, regenerate something. None of it is inferable from a commit range; a migration file’s presence is visible, the fact that it must run before the deploy is not.
What behaviour changes silently. A changed default is the classic case — nothing errors, and behaviour differs. Consumers who never set the value explicitly are affected and will not know why.
What is deprecated and when it goes. A deprecation with no removal timeline is a deprecation nobody acts on.
Ordering constraints. If this release must be deployed before or after another component, that is the most important sentence in the document.
A useful prompt is narrow rather than open:
From this diff, list anything that changes a default value, alters an error type, tightens validation, or adds a migration. For each, say what a consumer relying on the old behaviour would experience.
That produces candidates for the upgrade section. Deciding which are real, and what a user should do about them, is editorial work.
Contributor credit
Section titled “Contributor credit”Worth getting right, and easy to get wrong.
{/* Contributors in this range, by commit count */}git shortlog -sn --no-merges v1.4.0..v1.5.0{/* First-time contributors, via the API */}gh pr list --state merged --json author,number --jq '.[].author.login' | sort -uTwo cautions. Commit counts are not contribution — one commit can be a month of work. Use the list
for acknowledgement, not for ranking. And check the names: Co-authored-by trailers, bots, and
people who have changed their display name all produce lists that need a human pass before publication.
If your project thanks first-time contributors, that is a genuinely nice thing to automate and a genuinely bad thing to get wrong.
GitHub’s own generation
Section titled “GitHub’s own generation”GitHub can generate release notes from merged pull requests, configured with
.github/release.yml — categorising by label, excluding authors or labels.
That is deterministic and worth using as the base layer: it will not hallucinate, it groups by labels you control, and it links every entry to its pull request.
The productive combination:
GitHub generates the structured list — accurate, linked, complete, and dull.
AI writes the summary and the upgrade notes — the paragraph at the top saying what this release is about, and the “what you need to do” section.
You review both, particularly the breaking-change classification.
Generated-from-labels notes are only as good as your labelling, which is an argument for label automation rather than against the approach.
Automating it in a release workflow
Section titled “Automating it in a release workflow”Where release notes are generated in CI, the shape that works keeps the human in the loop by default.
name: draft release notes
on: push: tags: ["v*"]
permissions: contents: write
jobs: draft: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0
- name: Determine the previous tag id: prev run: | PREV=$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || echo "") echo "tag=${PREV}" >> "$GITHUB_OUTPUT"
- name: Collect the range run: | git log --oneline --no-merges "${PREV}..${GITHUB_REF_NAME}" > range.txt env: PREV: ${{ steps.prev.outputs.tag }}
- name: Create a draft release run: gh release create "${GITHUB_REF_NAME}" --draft --generate-notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Three deliberate choices.
fetch-depth: 0. A shallow clone has no tags and no history, so the range is empty and the notes
are silently blank.
--draft. The release is created unpublished. Somebody edits and publishes. An automation that
publishes directly removes the editorial step this whole page argues for.
--generate-notes uses GitHub’s deterministic generation as the base. An AI pass over the draft
adds the summary and upgrade sections; it does not replace the linked, accurate list.
Note the interaction with immutable releases: once published, an immutable release cannot have its assets changed, so the draft-first pattern is required there for a different reason as well.
Editing is the job
Section titled “Editing is the job”Generated notes are a first draft. The edit that turns them into something worth publishing:
-
Delete most of it. A release with sixty commits does not need sixty bullets. Readers want the handful that affect them.
-
Lead with what matters. The most significant change first, not the first alphabetically or chronologically.
-
Verify every breaking-change classification. Both directions — missed ones and over-flagged ones.
-
Check security entries against the actual advisories, and link them. See vulnerability alerts.
-
Add upgrade instructions where anything is required. This is never in the diff.
-
Cut anything a user cannot observe from customer-facing notes.
-
Read it as somebody deciding whether to upgrade. Does it answer that question?
Step 1 is the highest-value one and the hardest to delegate — a model asked to summarise a release will summarise all of it, because omitting things looks like failure.
What makes notes worth reading
Section titled “What makes notes worth reading”A useful test, applied to the finished document: could a reader who is two versions behind decide, in thirty seconds, whether this release is worth taking?
That standard rules out most of what generation produces by default.
Sixty bullets fails it. So does any document where the significant change is item forty-one.
Pure enumeration fails it. “Updated dependencies”, “refactored the parser”, “fixed a bug” tell a reader nothing about whether it affects them.
A missing risk statement fails it. If there is nothing about what might break, a cautious reader assumes there is something and waits.
What passes: a short paragraph saying what the release is about, an explicit breaking-change section even when it says “none”, the handful of changes a user could observe, and a clear statement of any action required.
The uncomfortable implication is that good release notes are mostly short, and a generation workflow naturally produces long ones. The editing step is not polish; it is the majority of the value, and it is the step under time pressure at the end of a release.
One way to protect it: draft the notes before the release is cut, from the range as it stands. Editing a draft you wrote yesterday is a different task from writing a document while people wait for the tag.
Common mistakes
Section titled “Common mistakes”A wrong range. Cherry-picks duplicated, reverts listed as changes, the previous tag chosen by date rather than version.
Publishing the first draft. Generated notes are a source list, not a document.
One document for two audiences. Too much detail for customers, too little for engineers.
Missing a breaking change. The failure that costs the most, and the one generation is worst at.
Security fixes buried in bug fixes. They need their own section and an advisory link.
Every commit as a bullet. Length is not thoroughness; it is a reason not to read.
Unverified contributor lists. Bots, renamed accounts and co-authors need a human pass.
Notes that are downstream of vague commits. Generated notes inherit the quality of the messages they summarise.
Verifying the notes against the release
Section titled “Verifying the notes against the release”A final check that takes two minutes and catches the errors that matter.
Does every claimed change exist in the range? Pick three entries and find their commits. A generated note describing something that is not in the release is rare and extremely damaging to trust.
Does every significant commit appear somewhere? The reverse direction. Scan the range for anything touching a public interface, a default, or a security-relevant path and confirm it is represented.
Do the links resolve? Pull request and issue numbers, advisory references, documentation links. A model asked to include references will produce plausible ones.
Does the version number match the content? A release containing a breaking change tagged as a minor version is a semantic versioning error that the notes have just documented.
That last check is the one worth automating if you automate any of them, because the tag is usually already cut by the time somebody reads the notes — and correcting it afterwards means either an immutable release you cannot change or a moved tag, which is its own problem.
Mental model
Section titled “Mental model”Release notes are a translation from commits to a decision. AI does the grouping and the rewriting; you decide what matters, what breaks, and what to leave out — which is the part the reader is actually paying for.
What you learned
Section titled “What you learned”- Generate from a verified range; cherry-picks and reverts distort it silently
- Pull request titles are usually better source material than commit subjects
- Conventional Commits make categorisation near-mechanical; without them expect two systematic errors
- Breaking-change classification is a claim about consumers and must be reviewed
- Customer-facing and engineering notes are different documents from the same source
- GitHub’s
.github/release.ymlgeneration is deterministic and a good base layer - Contributor lists need a human pass for bots, co-authors and renames
- Editing — mostly deleting — is what turns a generated list into release notes
Exercise
Section titled “Exercise”Use a repository with at least two tags.
-
Generate notes from
git log --oneline v_PREV..v_CURRENT. Count the bullets. -
Check the range for reverts and cherry-picks. Predict: does the generated output account for them?
-
Regenerate from
gh pr listoutput instead. Predict: which source produces better wording? -
Ask specifically: “which of these changes could break an existing consumer?” Compare against the breaking-change section it produced unprompted.
-
Generate a customer-facing version and an engineering version with different instructions. Compare lengths.
-
Cut the customer-facing version to five bullets. Predict: does it lose anything a user would miss?
-
Add
.github/release.ymlwith label categories and compare GitHub’s generated notes with the AI draft.