A space elevator cable rises from a cloud-covered Earth at night toward a ring-shaped orbital station, with a climber car ascending the tether.
Guide

GitHub Auto-Generated Release Notes: release.yml Setup, Categories, and Fixes

Felix Macx · · Updated · 12 min read

GitHub can write your release notes for you. Not the customer-facing kind, but the developer-facing kind: a list of every pull request merged since the last release, grouped by label, with a compare link at the bottom. It’s built into every repository, needs no tool, no bot, and no Conventional Commits, and it has a few sharp edges that are easy to hit.

This guide covers the whole feature — what GitHub actually generates, the three ways to trigger it, the .github/release.yml configuration line by line, how to make the output good instead of merely present, and the fixes for the two problems behind the community thread that ranks for this feature: empty notes and the wrong previous tag. It closes with how this compares to Release Please, semantic-release, and Changesets, so you know when to graduate.

Jump to: What GitHub generates · Three ways to trigger it · release.yml line by line · Make the input good · Troubleshooting · Versus the release tools

What GitHub actually generates

Press the button on a repository with a handful of merged pull requests and you get something like this — the example is the real body of actions/checkout v4.2.2, which uses the generator with no custom configuration:

## What's Changed
* `url-helper.ts` now leverages well-known environment variables by @jww3 in https://github.com/actions/checkout/pull/1941
* Expand unit test coverage for `isGhes` by @jww3 in https://github.com/actions/checkout/pull/1946

**Full Changelog**: https://github.com/actions/checkout/compare/v4.2.1...v4.2.2

Three things are in play, and GitHub’s documentation names all three: a list of merged pull requests, a list of contributors to the release, and a link to a full changelog.

  • What’s Changed is one line per pull request: the PR title verbatim, the author’s handle, and the PR link. If you’ve configured categories in release.yml, the pull requests are organized under your category headings instead.
  • New Contributors appears when someone merged their first pull request to the repository in this range. It’s absent in the example above because the only author had contributed before.
  • Full Changelog is a compare link from the previous tag to this one. It’s the only place direct commits show up.

The range is “everything merged between the previous tag and this tag”. The previous tag defaults to auto — GitHub picks it — and that choice is where most of the trouble starts, so hold that thought.

Two things the output tells you about the input: the list is built from merged pull requests, so a direct push to main never becomes a line item, and nothing is rewritten. The PR title is the note. That constraint is the whole design, and it’s why the second half of this guide is about pull request hygiene rather than configuration.

Three ways to generate the notes

1. The button

Releases → Draft a new release → choose or create the tag → optionally open the Previous tag dropdown (it reads “auto” until you change it) → click Generate release notes above the description. GitHub fills in the title and body; edit anything you like before you publish or save as a draft. This is the right path for a human-paced release, and the Previous tag dropdown is the fix for the wrong-range problem below.

2. The gh CLI

gh release create v1.2.0 --generate-notes

That creates the tag from the default branch if it doesn’t exist, generates the title and body through the same API, and publishes. The flags that matter, from the gh release create reference:

  • --notes-start-tag v1.1.0 sets the previous tag explicitly instead of letting GitHub guess.
  • --notes "text" prepends your own paragraph to the generated body, so you can write a human summary on top and keep the generated list underneath.
  • --verify-tag aborts if the tag doesn’t already exist on the remote, which is what you want in CI so a typo doesn’t mint a new tag.
  • --draft leaves the release unpublished for review.
  • --fail-on-no-commits refuses to create a duplicate release when nothing changed since the last one.

3. The REST API, and GitHub Actions

Two endpoints. POST /repos/{owner}/{repo}/releases/generate-notes returns a name and a Markdown body without creating anything, which is useful when you want to post the notes somewhere else or edit them programmatically. Its parameters are tag_name (required), target_commitish (required only if the tag doesn’t exist yet), previous_tag_name, and configuration_file_path, which defaults to .github/release.yml or .github/release.yaml.

curl -L -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/OWNER/REPO/releases/generate-notes \
  -d '{"tag_name":"v1.2.0","previous_tag_name":"v1.1.0"}'

That example assumes v1.2.0 already exists as a tag. For a tag that doesn’t exist yet, target_commitish (the branch or commit to tag) is required. Either way this endpoint only returns the notes; it creates nothing.

The second path is the create-release endpoint with generate_release_notes: true, which does both steps at once. If you pass a body alongside it, your text is prepended to the generated notes, the same behaviour as the CLI’s --notes. The token needs Contents: write on the repository.

In a workflow, the CLI is the least code. This runs whenever you push a v* tag:

# .github/workflows/release.yml
name: Release
on:
  push:
    tags: ["v*"]
permissions:
  contents: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Create GitHub Release with generated notes
        run: gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

gh is preinstalled on GitHub-hosted runners and reads GH_TOKEN from the environment. The permissions block is required: the default GITHUB_TOKEN is read-only in many repositories, and creating a release is a write. If you want a review step, add --draft and publish by hand.

One trap: if the tag itself is pushed by another workflow using the built-in GITHUB_TOKEN, this workflow won’t start, because GitHub doesn’t trigger new workflow runs from events created with that token (it’s how it prevents recursive runs). Push the tag from your machine, or have the tagging step authenticate with a GitHub App token or a personal access token. And if gh release create fails with a 403 under the built-in token, check the permissions block first, then the repository’s Actions settings for workflow permissions.

.github/release.yml line by line

Without a configuration file you get the flat What’s Changed list. With one you get categories, and you can drop noise. The file lives at .github/release.yml (or .yaml) and has exactly six options, all under a top-level changelog key. From the docs:

Key Required What it does
changelog.exclude.labels no Pull requests with any of these labels are left out of the notes entirely.
changelog.exclude.authors no Pull requests by these user or bot logins are left out entirely.
changelog.categories[*].title yes The heading for the category. Markdown and emoji are fine.
changelog.categories[*].labels yes Labels that put a pull request in this category. * is the catch-all.
changelog.categories[*].exclude.labels no Labels that keep a pull request out of this category.
changelog.categories[*].exclude.authors no Logins that keep a pull request out of this category.

Here’s a configuration that does what most product teams want — breaking changes on top, features, fixes, dependency bumps at the bottom, and release-chore PRs hidden:

# .github/release.yml
changelog:
  exclude:
    labels:
      - skip-changelog
      - release
  categories:
    - title: "⚠️ Breaking changes"
      labels:
        - breaking-change
        - semver-major
    - title: "🚀 New features"
      labels:
        - feature
        - enhancement
        - semver-minor
    - title: "🐛 Bug fixes"
      labels:
        - bug
        - fix
    - title: "📦 Dependencies"
      labels:
        - dependencies
    - title: "Other changes"
      labels:
        - "*"

Four rules that aren’t obvious from the table:

  1. Order is precedence. A pull request lands in the first category whose labels match and nowhere else. Put the specific categories before the broad ones.
  2. Add a catch-all. The docs note that a '*' category is required to display a catch-all, which means a pull request that matches none of your labelled categories has nowhere to appear. If your notes are missing pull requests, check this first.
  3. Top-level exclude beats everything. A pull request labelled skip-changelog never appears, even if it also carries feature.
  4. Quote the asterisk. '*' or "*". A bare * is a YAML alias and the file fails to parse.

If your team doesn’t label pull requests at all, the docs’ second example is the useful one: a single catch-all category that excludes the dependencies label, and a second category that collects it. That separates Dependabot from human work with no labelling discipline required, because Dependabot applies the dependencies label by default (if you’ve customized the labels option in your Dependabot config, match whatever you set). The exclude.authors key is the other route to the same outcome; the docs describe it as a list of user or bot login handles.

Commit the file before you generate the next release’s notes. The docs specify the file’s path, not which branch GitHub reads it from, so if a release targets a branch other than your default, keep the file on that branch too. The API’s configuration_file_path parameter lets you point at a different file if you keep several, for example one per release train.

The notes are only as good as your pull request titles

Because the generator copies the PR title verbatim, the title is the release note. Three habits turn the output from a merge log into something a developer on another team can read.

Write the title for the reader, not the reviewer. “Fix flaky test in auth suite” is a fine PR title and a useless release note. “Fix: sessions no longer expire early when the clock skews” tells a consumer what changed. A one-line rule that works: the title should still make sense when it appears, alone, under a heading called Bug fixes.

Make the PR title the thing you review. The release note is the PR title whatever the merge method, so the title is the artifact that matters. If you squash-merge, set the repository’s default squash commit message to the pull request title (Settings → General → Pull Requests); by default a single-commit pull request uses the commit’s own message instead, so the subject your reviewers approved and the subject in history can differ. Add “title reads as a release note” to your review checklist, or enforce a shape with commitlint on PR titles if the team already uses Conventional Commits.

Label at creation, not at release. Categories only work if labels are present when you generate. A pull request template with a label checklist, an auto-labeler action keyed on changed paths, or a required label check in branch protection all work; what doesn’t work is labelling at release time, because by then there are forty PRs to triage. Dependabot labels itself, which is why the dependencies category is free.

Do that and the generated notes are good enough to ship as the GitHub Release body. They are still not customer-facing notes, and the comparison section explains why that split matters.

Troubleshooting: empty notes and the wrong previous tag

The notes are empty, or missing pull requests

Work through these in order.

  1. Direct commits. If the changes went to the branch without a pull request, they aren’t listed; only the Full Changelog compare link reflects them. Check the compare link: if it shows commits but What’s Changed is empty, direct commits are one explanation and an exclusion rule in release.yml is the other, so check both. The fix for the first is process (merge through PRs) or a different generator that reads commits, such as git-cliff or Release Please.
  2. No catch-all category. Open .github/release.yml. If every category has specific labels and none has '*', pull requests that match no category have nowhere to appear. Add the catch-all as the last category.
  3. Wrong range. The previous tag was auto-selected and it’s the wrong one, so the range between it and your new tag contains nothing. See the next section.
  4. Merged outside the range. A pull request appears in the release whose tag range contains its merge. If it was merged before the previous tag, or into a different branch than the one you tagged, it won’t be in this release’s notes.

This is the problem behind the community thread that ranks for this feature. A repository of GitHub Actions keeps a moving v4 tag that’s force-pushed to the latest release, as GitHub’s own guidance for action authors recommends. Draft v4.7.0, press Generate release notes, and the compare link reads v4...v4.7.0 instead of v4.6.0...v4.7.0, because the generator chose the major-version tag as the previous release. Once v4 is moved to the new commit, both ends of the compare point at the same commit and the changelog is empty. A second reporter found every generated changelog in their action repository was empty for the same reason, and noted that some of GitHub’s own actions show it.

In August 2026 GitHub staff replied in that thread that the generator had been improved to prefer tags more likely to remain static over major and minor tags. That’s a heuristic, not a guarantee, so the reliable control is still to set the range yourself:

  • Set the previous tag explicitly. In the UI, use the Previous tag dropdown. In the CLI, --notes-start-tag v4.6.0. In the API, previous_tag_name. In a workflow, read the latest published release with gh release view --json tagName -q .tagName and pass it to --notes-start-tag, after checking that it belongs to the same release line as the tag you’re cutting; a repository with maintenance branches can’t take that answer blindly.
  • Stop moving tags. The thread’s author argues that tags are meant to be immutable and a process that requires a regular force-push is a broken process, and suggests pointing v4 at a branch instead. GitHub’s own immutable releases, which lock a release’s tag and assets once published, are the documented route to the same end.
  • Keep release tags distinct from floating pointers. The same author proposed that the generator consider only annotated tags, which would let lightweight major-version pointers be ignored. GitHub hasn’t documented that behaviour, so treat annotated release tags as hygiene that gives the heuristic less to guess about, not as a fix.

The configuration seems ignored

Check the path and extension: GitHub looks for .github/release.yml or .github/release.yaml by default, or whatever configuration_file_path you passed to the API. Check that the file is on the branch the release targets, and run it through a YAML linter: an unquoted * is the usual parse failure. A category whose labels entries don’t match your repository’s label names exactly is the second.

Versus Release Please, semantic-release, and Changesets

GitHub’s generator is the “no tool” option, and it’s worth being precise about what it doesn’t do, because the other three in this series each cover one of the gaps.

  GitHub generated notes Release Please semantic-release Changesets
Decides the version number No, you choose the tag Yes, from commits Yes, from commits Yes, from changeset files
Source of the text Pull request titles Commit messages (or PR titles with changelog-type: github) Commit messages Hand-written changeset files
Needs structured commits No Yes, Conventional Commits Yes, Angular convention by default (configurable) No
Writes CHANGELOG.md No Yes Optional plugin Yes
Creates the GitHub Release Yes Yes Yes Via its action
Publishes a package No No Yes Yes
Human review before release In the UI or with --draft The release PR None by design The Version Packages PR
Language Any Many JavaScript first JavaScript

So: use the built-in generator when you tag by hand, your changes arrive as pull requests, and the GitHub Release is the only artifact you need. Graduate to Release Please when you want the version computed and a CHANGELOG.md committed, with a review step. Graduate to semantic-release when the branch is the release and you want push-to-publish. Choose Changesets when the people writing the code should also write the human-readable entry at PR time, which is the one thing none of the generators can do. The longer comparison, including the AI-scripted and managed options, is in how to automate release notes from GitHub commits.

Where ReleasePad fits

Everything above produces developer-facing output: PR titles under headings, on the Releases tab, for people who already live in the repository. That’s a finished product for library consumers and your own team. It isn’t a finished product for customers, who don’t have the context to read “Expand unit test coverage for isGhes” and don’t visit GitHub to find out what shipped.

ReleasePad starts from the same input — your merged pull requests and commits — and drafts the customer-facing version: plain-language entries grouped by what changed for the user, ready to review and publish to a public changelog page and an in-app widget. Keep the generated notes on the release; let the GitHub integration draft the ones your users read.

Setup checklist

  • Add .github/release.yml with your categories, a '*' catch-all last, and top-level excludes for dependencies, skip-changelog, or whatever your noise is.
  • Decide how pull requests get labels at creation: template, auto-labeler, or branch protection.
  • Put “title reads as a release note” in the PR review checklist.
  • Pick a trigger: the button, gh release create --generate-notes, or the workflow above.
  • If you keep moving major-version tags, set the previous tag explicitly on every release, or move to branches or immutable releases.
  • Prepend a two-sentence human summary with --notes or the body field when a release deserves one.

Further reading

Frequently Asked Questions

What are GitHub auto-generated release notes?

They're the release description GitHub writes for you when you click Generate release notes on a new release, or pass --generate-notes to the gh CLI, or call the generate-notes REST endpoint. GitHub lists every pull request merged since the previous tag under a What's Changed heading, with the PR title, author, and link, adds a New Contributors section for first-time contributors, and ends with a Full Changelog compare link. A .github/release.yml file lets you group those pull requests into labelled categories and exclude labels or authors.

How do I automatically generate release notes in GitHub?

Three ways. In the browser, draft a new release, pick the tag, and click Generate release notes above the description field. From a terminal, run gh release create v1.2.0 --generate-notes. From CI or a script, POST to /repos/{owner}/{repo}/releases/generate-notes with the tag_name, or create the release with generate_release_notes set to true. All three read the same .github/release.yml configuration if the file exists.

How do I customize GitHub release notes with release.yml?

Create .github/release.yml in the repository. Under changelog, list categories, each with a required title and a required list of labels; a pull request lands in the first category whose labels match. Use the label '*' on the last category as a catch-all, because a pull request that matches none of your categories has nowhere to appear. Under changelog.exclude you can list labels and author login handles to leave out entirely, and each category accepts its own exclude block too.

Why are my GitHub generated release notes empty or missing pull requests?

The four usual causes: the changes were pushed as direct commits rather than merged pull requests, which the generator never lists; GitHub chose the wrong previous tag, so the range contained nothing, which happens in repositories with moving major-version tags like v4; your release.yml has categories but no '*' catch-all, so unmatched pull requests have no category to appear in; an exclude rule is hiding them; or the pull requests were merged outside the tag range. Fix the range with the Previous tag dropdown, --notes-start-tag in the CLI, or previous_tag_name in the API.

Does GitHub's release notes generator include commits that weren't in a pull request?

No. The generator is built from merged pull requests. A direct push to the release branch appears only inside the Full Changelog compare link, not as a line in What's Changed. If part of your team commits directly, either move them to pull requests or use a commit-based generator such as git-cliff or Release Please, which read commit messages instead.

Is the Generate release notes button the same as asking Copilot to write release notes?

No. The button assembles pull request titles, authors, and links into a fixed structure; GitHub's docs describe the output as a list of merged pull requests, the contributors, and a compare link. The output is only as readable as your PR titles. That's the right tool for a developer-facing GitHub Release. For customer-facing notes that explain what changed in plain language, you still need a writing step, whether that's a person, an AI drafting tool, or both.

github release-notes release.yml github-actions automation changelog

Ready to put this into practice?

Your changelog shouldn't be an afterthought.

ReleasePad makes it easy to publish great release notes — from a public changelog page to an in-app widget, GitHub integration, and analytics. Free to get started.

Get started — it's free
Try me now!