GitHub Actions Changelog Workflow: Copy-Paste Example

2026-09-09 · ShipChangelog

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):

LabelMeaningMaps to
featureNew user-visible capabilityAdded
fixBug fixFixed
breaking-changeAnything that requires migration or retrainingBreaking
skip-changelogInternal 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

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.