feat(release): auto-sync release PR changelog when main moves

When a release PR sits open and new commits merge to main, the release
binary includes those commits (the tag lands on the merge commit) but
the changelog doesn't mention them. The new sync-release-changelog
workflow fires on every push to main, finds any open same-repo
version-bump/* PR, and regenerates the CHANGELOG.md section from
<last-tag>..origin/main — keeping the PR's changelog accurate until it
merges.

Uses origin/main as the log range upper bound (not HEAD) to exclude the
release commit and any prior sync commits on the PR branch. No merge
commit is created; only CHANGELOG.md is touched. Fails loudly when
multiple release PRs are open to avoid targeting the wrong one.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
This commit is contained in:
Will Pfleger
2026-06-03 12:57:36 -04:00
parent 56c504a4c7
commit 15aadbd3c2
2 changed files with 161 additions and 2 deletions

View File

@@ -0,0 +1,154 @@
name: Sync Release PR Changelog
on:
push:
branches: [main]
# Collapse rapid successive main pushes into one sync run.
concurrency:
group: sync-release-changelog
cancel-in-progress: true
permissions:
contents: write
pull-requests: write
jobs:
sync:
name: Sync changelog on open release PR
runs-on: ubuntu-latest
steps:
- name: Find open release PR
id: find-pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Filter to same-repo PRs only (exclude forks) with version-bump/<semver> branches.
PRS=$(gh pr list \
--repo block/sprout \
--state open \
--json number,headRefName,headRepository \
--jq '[.[] |
select(.headRepository.nameWithOwner == "block/sprout") |
select(.headRefName | test("^version-bump/[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$"))
]')
COUNT=$(echo "$PRS" | jq 'length')
if [[ "$COUNT" -eq 0 ]]; then
echo "No open release PR found — nothing to sync"
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "$COUNT" -gt 1 ]]; then
echo "::error::Multiple open release PRs found — cannot auto-sync. Resolve manually."
echo "$PRS" | jq -r '.[].headRefName'
exit 1
fi
BRANCH=$(echo "$PRS" | jq -r '.[0].headRefName')
NUMBER=$(echo "$PRS" | jq -r '.[0].number')
VERSION="${BRANCH#version-bump/}"
echo "found=true" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "number=$NUMBER" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Found release PR #${NUMBER} (${BRANCH})"
- name: Check out release PR branch
if: steps.find-pr.outputs.found == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ steps.find-pr.outputs.branch }}
fetch-depth: 0
fetch-tags: true
- name: Regenerate changelog section
if: steps.find-pr.outputs.found == 'true'
env:
VERSION: ${{ steps.find-pr.outputs.version }}
PR_NUMBER: ${{ steps.find-pr.outputs.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
# Refresh origin/main so the log range reflects what's actually on main now.
git fetch origin main
# Most recent release tag reachable from origin/main (i.e., the previous release).
# Using origin/main — not HEAD — so the release commit on this PR branch is excluded.
LAST_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' origin/main 2>/dev/null || echo "")
if [[ -z "$LAST_TAG" ]]; then
NEW_ENTRIES="Initial release"
else
# Entries since the last tag up to current main tip, excluding merge commits.
# Upper bound is origin/main, NOT HEAD, so release/sync commits on this PR
# branch never appear in the changelog.
NEW_ENTRIES=$(git log "${LAST_TAG}..origin/main" --oneline --no-merges || echo "")
fi
echo "Changelog range: ${LAST_TAG:-<beginning>}..origin/main"
echo "New entries:"
echo "${NEW_ENTRIES:- (none)}"
# Locate the ## v<VERSION> section in CHANGELOG.md.
START=$(grep -n "^## v${VERSION}$" CHANGELOG.md | head -1 | cut -d: -f1)
if [[ -z "$START" ]]; then
echo "::error::Section '## v${VERSION}' not found in CHANGELOG.md — cannot sync"
exit 1
fi
# Line number of the next ## v section after our section (may be empty if last).
NEXT=$(awk -v start="$START" 'NR > start && /^## v[0-9]/{print NR; exit}' CHANGELOG.md)
TMPFILE=$(mktemp)
# Lines before the version section (the "# Changelog" header block).
if [[ "$START" -gt 1 ]]; then
head -n $((START - 1)) CHANGELOG.md >> "$TMPFILE"
fi
# Write the refreshed version section.
printf '## v%s\n\n' "$VERSION" >> "$TMPFILE"
if [[ -n "$NEW_ENTRIES" ]]; then
printf '%s\n' "$NEW_ENTRIES" >> "$TMPFILE"
else
printf 'No changes.\n' >> "$TMPFILE"
fi
printf '\n' >> "$TMPFILE"
# Append the remainder of the changelog (previous releases).
if [[ -n "$NEXT" ]]; then
tail -n "+${NEXT}" CHANGELOG.md >> "$TMPFILE"
fi
mv "$TMPFILE" CHANGELOG.md
# Skip commit if nothing changed.
if git diff --quiet CHANGELOG.md; then
echo "Changelog already up to date — nothing to commit"
exit 0
fi
git add CHANGELOG.md
git commit -m "chore(release): sync changelog for v${VERSION}"
git push origin "HEAD:refs/heads/version-bump/${VERSION}"
# Update the PR body with the refreshed commit list.
if [[ -n "$LAST_TAG" ]]; then
PR_HEADER="### Changes since ${LAST_TAG}:"$'\n\n'"${NEW_ENTRIES}"
else
PR_HEADER="Initial release."
fi
PR_BODY="## Release v${VERSION}"$'\n\n'"${PR_HEADER}"$'\n\n'"**To release:** merge this PR. The tag and build will happen automatically."
gh pr edit "$PR_NUMBER" --body "$PR_BODY"
echo "Changelog synced and PR #${PR_NUMBER} body updated"

View File

@@ -24,9 +24,11 @@ This creates a `version-bump/<version>` PR that bumps all version manifests, reg
1. **`just release`** runs locally on `main` — computes the next version, creates a `version-bump/<version>` branch, bumps versions in all manifests, regenerates lockfiles, generates a changelog entry, commits, pushes, and opens a PR.
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the `version-bump/*` branch merge and pushes a `v<version>` tag.
2. **The release PR stays in sync** — if other PRs merge to `main` while the release PR is open, the `sync-release-changelog` workflow automatically updates the changelog on the release PR branch so the final release notes reflect everything that will be in the binary. No manual action needed.
3. **Tag triggers `release.yml`** — the existing release workflow builds, signs, notarizes, and publishes the desktop app for macOS and Linux.
3. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the `version-bump/*` branch merge and pushes a `v<version>` tag.
4. **Tag triggers `release.yml`** — the existing release workflow builds, signs, notarizes, and publishes the desktop app for macOS and Linux.
---
@@ -121,3 +123,6 @@ The version string must be valid semver: `MAJOR.MINOR.PATCH` with an optional pr
### Auto-updater reports "no update available"
Verify that the `sprout-desktop-latest` release exists and contains a valid `latest.json`.
### `sync-release-changelog` workflow fails with "Multiple open release PRs"
More than one `version-bump/*` PR is open. Close or merge the stale one before the sync can resume.