Skip to content

Commit 0759cf6

Browse files
committed
feat: use CHANGELOG.md [Unreleased] block as GitHub release body
Generated by construct
1 parent eb9f75d commit 0759cf6

4 files changed

Lines changed: 226 additions & 1 deletion

File tree

.github/extract-changelog.sh

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env bash
2+
# extract-changelog.sh <version> [changelog-file]
3+
#
4+
# Extracts the [Unreleased] block from CHANGELOG.md, prints its content to
5+
# stdout (for use as a GitHub release body), and rewrites the changelog:
6+
# - renames ## [Unreleased] → ## [<version>] — <today>
7+
# - inserts a fresh empty ## [Unreleased] block above it
8+
#
9+
# Usage:
10+
# body=$(bash .github/extract-changelog.sh v1.2.3)
11+
# body=$(bash .github/extract-changelog.sh v1.2.3 path/to/CHANGELOG.md)
12+
#
13+
# Exit codes:
14+
# 0 success
15+
# 1 bad usage or no [Unreleased] section found
16+
17+
set -euo pipefail
18+
19+
VERSION="${1:-}"
20+
CHANGELOG="${2:-CHANGELOG.md}"
21+
22+
if [[ -z "$VERSION" ]]; then
23+
echo "usage: extract-changelog.sh <version> [changelog-file]" >&2
24+
exit 1
25+
fi
26+
27+
if [[ ! -f "$CHANGELOG" ]]; then
28+
echo "error: $CHANGELOG not found" >&2
29+
exit 1
30+
fi
31+
32+
TODAY=$(date -u +%Y-%m-%d)
33+
34+
# ---------------------------------------------------------------------------
35+
# Extract the body of the [Unreleased] section.
36+
# The section starts on the line after "## [Unreleased]" and ends just before
37+
# the next "## [" heading or end-of-file.
38+
# ---------------------------------------------------------------------------
39+
body=$(awk '
40+
/^## \[Unreleased\]/ { in_section=1; next }
41+
in_section && /^## \[/ { exit }
42+
in_section { print }
43+
' "$CHANGELOG")
44+
45+
if [[ -z "$(echo "$body" | tr -d '[:space:]-')" ]]; then
46+
echo "error: no content found under ## [Unreleased] in $CHANGELOG" >&2
47+
exit 1
48+
fi
49+
50+
# Trim leading/trailing blank lines and trailing horizontal rules (---) that
51+
# are changelog section separators, not part of the release notes.
52+
body=$(echo "$body" | \
53+
sed -e '/./,$!d' \
54+
-e 's/[[:space:]]*$//' | \
55+
awk '
56+
{ lines[NR] = $0 }
57+
END {
58+
# Strip trailing blank lines and bare "---" separators.
59+
last = NR
60+
while (last > 0 && (lines[last] ~ /^[[:space:]]*$/ || lines[last] ~ /^---[[:space:]]*$/)) {
61+
last--
62+
}
63+
for (i = 1; i <= last; i++) print lines[i]
64+
}
65+
')
66+
67+
# ---------------------------------------------------------------------------
68+
# Rewrite the changelog in-place.
69+
# The existing "## [Unreleased]" line (and everything in its section up to but
70+
# not including the next "## [" heading) is replaced with:
71+
# 1. A fresh empty ## [Unreleased] block (just the heading + blank line)
72+
# 2. A --- separator
73+
# 3. The versioned heading with today's date
74+
# 4. The original section content (minus the trailing ---)
75+
# ---------------------------------------------------------------------------
76+
tmp=$(mktemp)
77+
78+
awk -v version="$VERSION" -v today="$TODAY" '
79+
# State: 0 = before unreleased, 1 = inside unreleased, 2 = after unreleased
80+
state == 0 && /^## \[Unreleased\]/ {
81+
# Emit the new empty Unreleased block.
82+
print "## [Unreleased]"
83+
print ""
84+
print "---"
85+
print ""
86+
# Emit the versioned heading in place of ## [Unreleased].
87+
print "## [" version "] \xe2\x80\x94 " today
88+
state = 1
89+
next
90+
}
91+
state == 1 && /^## \[/ {
92+
# We have left the old unreleased section; switch to passthrough.
93+
state = 2
94+
}
95+
# Print every line that is not the old "## [Unreleased]" heading itself.
96+
state != 0 || !/^## \[Unreleased\]/ { print }
97+
' "$CHANGELOG" > "$tmp"
98+
99+
mv "$tmp" "$CHANGELOG"
100+
101+
# ---------------------------------------------------------------------------
102+
# Print the body to stdout — captured by the caller as the release body.
103+
# ---------------------------------------------------------------------------
104+
printf '%s\n' "$body"

.github/workflows/release.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ jobs:
1313
runs-on: ubuntu-latest
1414
steps:
1515
- uses: actions/checkout@v4
16+
with:
17+
# Need full history and the ability to push the changelog commit back.
18+
fetch-depth: 0
1619

1720
- uses: actions/setup-go@v5
1821
with:
@@ -21,6 +24,20 @@ jobs:
2124
- name: Test
2225
run: go test ./...
2326

27+
- name: Extract changelog and update CHANGELOG.md
28+
run: |
29+
chmod +x .github/extract-changelog.sh
30+
bash .github/extract-changelog.sh "${{ github.ref_name }}" > /tmp/release-body.md
31+
32+
- name: Commit updated CHANGELOG.md
33+
run: |
34+
git config user.name "github-actions[bot]"
35+
git config user.email "github-actions[bot]@users.noreply.github.com"
36+
git add CHANGELOG.md
37+
git commit -m "chore: update changelog for ${{ github.ref_name }}"
38+
# Push to the default branch so the changelog update is not lost.
39+
git push origin HEAD:refs/heads/main
40+
2441
- name: Build (linux/amd64)
2542
run: |
2643
GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w -X main.version=${{ github.ref_name }}" \
@@ -40,4 +57,4 @@ jobs:
4057
uses: softprops/action-gh-release@v2
4158
with:
4259
files: dist/*
43-
generate_release_notes: true
60+
body_path: /tmp/release-body.md

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- **Changelog-driven release notes** — the release pipeline now extracts the
8+
`## [Unreleased]` block from `CHANGELOG.md` and uses it as the GitHub
9+
release body. After tagging, the changelog is automatically updated:
10+
`[Unreleased]` is renamed to the tag version with today's date, and a fresh
11+
empty `[Unreleased]` block is inserted above it. The commit is pushed back
12+
to `main` by `github-actions[bot]`.
13+
514
### Changed
615

716
- Agent commits now carry the host user's real git identity instead of the

docs/spec/changelog-release.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Spec: Changelog-driven release notes
2+
3+
## Problem
4+
5+
The release pipeline previously used GitHub's `generate_release_notes: true`
6+
to auto-generate release body text from merged PR titles. This ignored the
7+
manually curated `CHANGELOG.md` entries that describe changes in human-readable
8+
prose.
9+
10+
## Solution
11+
12+
When a `v*` tag is pushed, the release pipeline:
13+
14+
1. Extracts the `## [Unreleased]` block from `CHANGELOG.md` and uses it
15+
verbatim as the GitHub release body.
16+
2. Rewrites `CHANGELOG.md` in-place:
17+
- Renames `## [Unreleased]``## [<tag>] — <YYYY-MM-DD>` (UTC date).
18+
- Inserts a fresh empty `## [Unreleased]` block above the new versioned
19+
entry.
20+
3. Commits the updated `CHANGELOG.md` back to `main` so the history stays
21+
tidy.
22+
23+
## Behaviour
24+
25+
### Extraction rules
26+
27+
- Everything between `## [Unreleased]` and the next `## [` heading is treated
28+
as the release body.
29+
- Trailing blank lines and `---` horizontal-rule separators (which are
30+
changelog section dividers, not part of the notes) are stripped from the
31+
body before it is used.
32+
- If the `[Unreleased]` section is empty (contains only whitespace and `---`),
33+
the script exits with code 1 and a descriptive error, failing the pipeline
34+
before any files are modified.
35+
36+
### CHANGELOG.md rewrite
37+
38+
Before (tag `v0.4.1` pushed):
39+
40+
```markdown
41+
## [Unreleased]
42+
43+
### Changed
44+
45+
- Some change.
46+
47+
---
48+
49+
## [v0.4.0] — 2026-03-03
50+
```
51+
52+
After:
53+
54+
```markdown
55+
## [Unreleased]
56+
57+
---
58+
59+
## [v0.4.1] — 2026-03-05
60+
61+
### Changed
62+
63+
- Some change.
64+
65+
---
66+
67+
## [v0.4.0] — 2026-03-03
68+
```
69+
70+
### Commit back to main
71+
72+
The changelog commit is authored by `github-actions[bot]` with the message:
73+
74+
```
75+
chore: update changelog for <tag>
76+
```
77+
78+
It is pushed directly to `main`. Branch protection rules that require PRs for
79+
`main` will block this push; if the repo uses such rules, add a bypass for
80+
`github-actions[bot]` or push to a release branch instead.
81+
82+
## Persistence details
83+
84+
No new files are written to the workspace or the repo beyond the updated
85+
`CHANGELOG.md`. The release body is passed via a temp file on the runner
86+
(`/tmp/release-body.md`).
87+
88+
## Files changed
89+
90+
| File | Change |
91+
|---|---|
92+
| `.github/extract-changelog.sh` | New script: extracts unreleased notes, rewrites changelog |
93+
| `.github/workflows/release.yml` | Adds changelog extraction + commit steps; replaces `generate_release_notes: true` with `body_path` |
94+
| `docs/spec/changelog-release.md` | This document |
95+
| `CHANGELOG.md` | Entry under `## [Unreleased]` |

0 commit comments

Comments
 (0)