Skip to content

Roll up CHANGELOG after release #1

Roll up CHANGELOG after release

Roll up CHANGELOG after release #1

name: Roll up CHANGELOG after release
# Triggered automatically after `release.yml` succeeds. Performs the
# mechanical CHANGELOG roll-up (move `[Unreleased]` entries under a
# `[<version>]` heading, update footer compare links, bump pyproject.toml +
# uv.lock to the next PATCH). Opens a `chore:` PR against develop for
# owner review + admin-squash-merge.
#
# The script logic + edge cases live in
# `.github/scripts/rollup_changelog.py` with full unit-test coverage in
# `tests/test_rollup_changelog.py`.
on:
workflow_run:
workflows: [Release]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: "Released tag (e.g. v0.3.0) — required only for manual replay"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
rollup:
name: Open rollup PR
runs-on: ubuntu-latest
# `workflow_run` fires regardless of conclusion; gate so we only act on
# successful release runs (skip when release.yml itself failed).
if: >
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: develop
# full history needed for `git describe --abbrev=0 --tags <ref>^`
# to resolve the prior tag.
fetch-depth: 0
# Prefer RELEASE_BOT_TOKEN (a non-GITHUB_TOKEN identity) so the
# resulting branch push fires `pull_request` workflows on the
# auto-PR. Falls back to GITHUB_TOKEN when the secret isn't set —
# the auto-PR still opens, but its CI doesn't run until a user
# pushes a commit on top.
token: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
- name: Resolve tags + date
id: resolve
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ inputs.tag }}"
else
# release.yml runs on tag pushes; the head_branch on the
# workflow_run payload carries the tag name in that case.
TAG="${{ github.event.workflow_run.head_branch }}"
fi
if [ -z "${TAG}" ]; then
echo "::error::could not resolve released tag from trigger payload"
exit 1
fi
# Resolve the tag's prior tag. `git describe ... <tag>^` errors when
# there is no prior — catch and treat as empty (first release).
if PRIOR=$(git describe --abbrev=0 --tags --match 'v*.*.*' "${TAG}^" 2>/dev/null); then
echo "prior tag: ${PRIOR}"
else
PRIOR=""
echo "no prior tag (first release)"
fi
# Resolve the release date from the released tag's CHANGELOG.md.
# The release PR is the authoritative source — using `date -u` at
# workflow run time can drift if the workflow fires in a different
# UTC day than the release PR was crafted. Falls back to date -u
# with a warning when the heading isn't found, so a degenerate
# CHANGELOG shape doesn't block the workflow.
VERSION="${TAG#v}"
if DATE=$(git show "${TAG}":CHANGELOG.md \
| grep -oE "^## \[${VERSION}\] - [0-9]{4}-[0-9]{2}-[0-9]{2}" \
| head -1 \
| sed 's/.* - //'); then
if [ -z "${DATE}" ]; then
echo "::warning::could not extract date for ${VERSION} from CHANGELOG.md; falling back to date -u"
DATE=$(date -u +%Y-%m-%d)
else
echo "date from CHANGELOG: ${DATE}"
fi
else
echo "::warning::git show ${TAG}:CHANGELOG.md failed; falling back to date -u"
DATE=$(date -u +%Y-%m-%d)
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "prior=${PRIOR}" >> "$GITHUB_OUTPUT"
echo "date=${DATE}" >> "$GITHUB_OUTPUT"
echo "branch=chore/changelog-rollup-${TAG}" >> "$GITHUB_OUTPUT"
- name: Run rollup script
run: |
python .github/scripts/rollup_changelog.py \
--tag "${{ steps.resolve.outputs.tag }}" \
--prior-tag "${{ steps.resolve.outputs.prior }}" \
--date "${{ steps.resolve.outputs.date }}"
- name: Open rollup PR
env:
GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
BRANCH="${{ steps.resolve.outputs.branch }}"
TAG="${{ steps.resolve.outputs.tag }}"
DATE="${{ steps.resolve.outputs.date }}"
# Idempotent: if the branch already exists from a previous replay,
# bail rather than force-push (the existing PR is the source of truth).
if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then
echo "::warning::branch ${BRANCH} already exists; skipping push to avoid clobbering an in-flight rollup PR"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "${BRANCH}"
git add CHANGELOG.md pyproject.toml uv.lock
git commit -m "chore: roll up CHANGELOG [Unreleased] under [${TAG#v}] - ${DATE}"
git push origin "${BRANCH}"
gh pr create \
--base develop \
--head "${BRANCH}" \
--title "chore: roll up CHANGELOG [Unreleased] under [${TAG#v}] - ${DATE}" \
--body "$(cat <<EOF
Auto-opened by [.github/workflows/changelog-rollup.yml](.github/workflows/changelog-rollup.yml) after the ${TAG} release.
## What this PR does
- Inserts \`## [${TAG#v}] - ${DATE}\` heading after \`## [Unreleased]\` in CHANGELOG.md.
- Updates \`[Unreleased]: ...compare/${TAG}...HEAD\` footer link.
- Adds \`[${TAG#v}]: ...compare/...${TAG}\` footer link.
- Bumps \`pyproject.toml\` \`[project].version\` and \`uv.lock\` self-version one PATCH (so develop's \`[Unreleased]\` can accumulate again).
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the cycle.
## Review
- [ ] CHANGELOG diff matches the released tag's published notes.
- [ ] Version bump is the next PATCH after \`${TAG#v}\` (no skipped bumps).
- [ ] CI green.
After review: \`gh pr merge <N> --admin --squash\`.
EOF
)"