GitHub Actions Changelog Workflow: Copy-Paste Example
The short version: a single GitHub Actions workflow, triggered on tag push, fetches the PRs merged since the previous tag, groups them by label into Added / Fixed / Breaking sections, writes the result into CHANGELOG.md, and publishes a GitHub Release. The full YAML below is copy-paste ready; the only setup you need is four labels.
The four labels the workflow depends on
Create these on your repo (Settings → Labels):
| Label | Meaning | Maps to |
|---|---|---|
feature | New user-visible capability | Added |
fix | Bug fix | Fixed |
breaking-change | Anything that requires migration or retraining | Breaking |
skip-changelog | Internal work that should not appear | (filtered out) |
The workflow treats an unlabeled PR as feature when it matches feat: in the title and drops everything else. You can tighten that rule in the jq below; do not skip the labels, because the labels are what keep the output from becoming a merge queue.
The workflow
# .github/workflows/changelog.yml
name: changelog
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Find previous tag
id: prev
run: |
PREV=$(git tag --list 'v*' --sort=-version:refname | grep -xv "$GITHUB_REF_NAME" | head -1)
echo "prev=${PREV:-}" >> "$GITHUB_OUTPUT"
- name: Build changelog entry
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
REPO="${GITHUB_REPOSITORY}"
PREV="${{ steps.prev.outputs.prev }}"
# PRs merged since the previous tag (or all merged PRs for the first release)
if [ -n "$PREV" ]; then
SINCE=$(git log -1 --format=%ci "$PREV")
else
SINCE=""
fi
curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$REPO/pulls?state=closed&sort=updated&direction=desc&per_page=100" \
> prs.json
# group into sections; breaking beats everything, skip-changelog wins
jq -r '
map(select(.merged_at != null)
| select(.merged_at >= (env.SINCE // "1970-01-01T00:00:00Z")))
| map({title: .title, n: .number, labels: [.labels[].name]})
| group_by(
if any(.labels; . == "skip-changelog") then "skip"
elif any(.labels; . == "breaking-change") then "breaking"
elif any(.labels; . == "fix") or (.[0].title | startswith("fix:")) then "fix"
else "feat" end)
| to_entries
| map(
if .key == "breaking" then "### Breaking changes"
elif .key == "fix" then "### Fixed"
else "### Added" end
+ "\n"
+ (.value | map("- " + .title + " (#" + (.n|tostring) + ")") | join("\n"))
)
| select(length > 0) | join("\n\n")
' env=SINCE prs.json > entry.md 2>/dev/null || \
jq -r '
map(select(.merged_at != null))
| map({title: .title, n: .number, labels: [.labels[].name]})
| map("- " + .title + " (#" + (.n|tostring) + ")")
| join("\n")
' prs.json > entry.md
- name: Prepend to CHANGELOG.md and commit
run: |
{
echo "## v${GITHUB_REF_NAME#v} — $(date +%Y-%m-%d)"
echo ""
cat entry.md
echo ""
if [ -f CHANGELOG.md ]; then cat CHANGELOG.md; else echo "# Changelog"; fi
} > CHANGELOG.new && mv CHANGELOG.new CHANGELOG.md
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
git commit -m "changelog: v${GITHUB_REF_NAME#v}" || echo "nothing to commit"
git push
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${GITHUB_TOKEN}
run: |
gh release create "${GITHUB_REF_NAME}" --notes-file entry.md --title "v${GITHUB_REF_NAME#v}"
Two notes on the jq: the first pass assumes SINCE filtering; if your jq build chokes on the env interpolation (it varies by version), the fallback on the second line just lists all merged PRs — the grouping is a v2. Start with the simple version, then add the date filter once the basic flow works. That sequencing saved us an hour of debug.
Pitfalls that make the output unreadable
- PR titles are not release notes. "feat: auth" is not a sentence your users will understand. Either enforce "feat: Add SSO login" at PR time, or accept a rewording pass. This is the gap between a changelog file and a changelog product; see how to write release notes for multiple audiences for what the user-facing version looks like.
- Backports and cherry-picks. A fix backported to a maintenance branch shows up in two tags. Label backport PRs
skip-changelogand add a one-liner manually to the backport release. - Monorepos. Filter by the files the PR touched if the changelog is per-service; otherwise every shared-infra PR pollutes every service's log.
- Rate limits.
GITHUB_TOKENallows 5,000 requests/hour, which is plenty for a single repo. If you fan this out across a hundred repos, use a fine-grained token with read-only pulls. - Tag discipline. This workflow is only as good as your tags. If you ship without tagging, nothing happens — which is arguably correct, but make sure your deploy pipeline and your changelog pipeline agree on what a release is.
Where the file ends and the product begins
A CHANGELOG.md in your repo is for your team: it lives next to the code, survives tool changes, and is grep-able. It is also invisible to your users, which is why the second half of this pipeline usually exists — a hosted page, RSS, or email digest that mirrors the same entries. You can serve the same entry.md to any of those targets; the grouping logic above is the part worth reusing, and it is the part every hosted service reimplements for you.
ShipChangelog
ShipChangelog runs this exact trigger as a hosted service: merged PRs become draft entries, BREAKING CHANGE markers and major-version bumps are flagged, and each release publishes developer, customer, and stakeholder versions to a public page with RSS. The workflow above stays useful as your in-repo record; the hosted page is what your users actually see. Free for one repo with 5 entries a month.
FAQ
Does the workflow need a PAT, or is GITHUB_TOKEN enough?
GITHUB_TOKEN is enough: it has contents: write for creating the commit and release, and read access to pulls by default. Set the permissions: block at the top to keep the scope explicit and minimal. A PAT is only needed if you trigger the workflow from a fork or from an external CI system.
Why trigger on tag instead of on every merged PR?
Tag triggers batch the release: one entry per release, in release order, matching what a version bump means. Per-PR triggers create one entry per merge, which is a merge queue with extra steps and the main reason auto-generated changelogs read like noise.
Can I keep the workflow and still use a hosted changelog?
Yes — that is the recommended split. The workflow maintains the authoritative CHANGELOG.md in the repo; the hosted service (or a small script) mirrors the same entries to a page, feed, or email. Do not let two systems write the same file.
ShipChangelog writes these changelogs for you — start free with 1 repo.