Skip to content

Commit 5981771

Browse files
aksOpsclaude
andauthored
ci(release): derive release notes from CHANGELOG.md (#52)
Previously `release.yml` ran `gh release create --generate-notes`, which produces GitHub's raw PR-list auto-summary. OpenSSF BestPractices `release_notes` explicitly disqualifies that form — release notes must be a human-readable curated summary of major changes and upgrade impact. This ties the release workflow to CHANGELOG.md as the single source: 1. After artifact download, a new "Resolve release notes" step: - Looks for `## [vX.Y.Z]` or `## [X.Y.Z]` in CHANGELOG.md. - If missing, promotes non-empty `## [Unreleased]` → `## [X.Y.Z] — <today>`, inserts a fresh `## [Unreleased]` above it, commits the rename to main as github-actions[bot], and re-extracts. - If neither [Unreleased] nor [X.Y.Z] have content, fails with a clear instruction to populate CHANGELOG.md. - Emits the extracted section as a multi-line step output. 2. The "Create GitHub release" step consumes that output via `--notes-file` (prepended to the existing cosign Verify footer). The old draft-then-edit dance is gone. CHANGELOG.md gets a short contributor note explaining the flow so the promotion is discoverable. One acceptable consequence: the binary's embedded Commit SHA refers to the pre-rename commit (github.sha captured by the build job), while the tag points to the post-rename commit. The binary bytes are unaffected; only the CHANGELOG.md doc differs. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ab01676 commit 5981771

2 files changed

Lines changed: 78 additions & 11 deletions

File tree

.github/workflows/release.yml

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,72 @@ jobs:
159159
pattern: binary-*
160160
path: downloaded/
161161

162+
- name: Resolve release notes from CHANGELOG.md
163+
id: notes
164+
env:
165+
TAG: ${{ needs.tag.outputs.tag }}
166+
run: |
167+
set -eu
168+
169+
# Extract the block under `## [<heading>]` up to the next `## [` heading.
170+
extract_section() {
171+
awk -v h="$1" '
172+
$0 ~ "^## \\[" h "\\]" { found=1; next }
173+
found && /^## \[/ { exit }
174+
found { print }
175+
' CHANGELOG.md
176+
}
177+
178+
has_content() { printf '%s' "$1" | grep -Eq '[^[:space:]]'; }
179+
180+
# The tag arrives as vX.Y.Z; CHANGELOG convention is [X.Y.Z] (no v).
181+
TAG_STRIPPED="${TAG#v}"
182+
183+
body=""
184+
for candidate in "$TAG" "$TAG_STRIPPED"; do
185+
body=$(extract_section "$candidate")
186+
if has_content "$body"; then break; fi
187+
done
188+
189+
if ! has_content "$body"; then
190+
# Promote [Unreleased] → [TAG_STRIPPED] — YYYY-MM-DD and insert a fresh empty Unreleased.
191+
unreleased=$(extract_section 'Unreleased')
192+
if ! has_content "$unreleased"; then
193+
echo "::error::CHANGELOG.md has neither a '## [${TAG}]' / '## [${TAG_STRIPPED}]' section nor a non-empty '## [Unreleased]' section."
194+
echo "::error::Add a '## [${TAG_STRIPPED}] — YYYY-MM-DD' section (or populate Unreleased) before releasing."
195+
exit 1
196+
fi
197+
198+
echo "Promoting [Unreleased] → [${TAG_STRIPPED}] in CHANGELOG.md"
199+
date_iso=$(date -u +%Y-%m-%d)
200+
python3 - <<PY
201+
import pathlib
202+
p = pathlib.Path('CHANGELOG.md')
203+
t = p.read_text()
204+
needle = '## [Unreleased]'
205+
replacement = f'## [Unreleased]\n\n## [${TAG_STRIPPED}] — ${date_iso}'
206+
if needle not in t:
207+
raise SystemExit("no '## [Unreleased]' heading in CHANGELOG.md")
208+
p.write_text(t.replace(needle, replacement, 1))
209+
PY
210+
211+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
212+
git config user.name 'github-actions[bot]'
213+
git add CHANGELOG.md
214+
git commit -m "docs: release ${TAG}"
215+
git push origin HEAD:main
216+
217+
body=$(extract_section "$TAG_STRIPPED")
218+
fi
219+
220+
# Emit to GITHUB_OUTPUT (heredoc form for multi-line).
221+
{
222+
echo 'body<<__RN_EOF__'
223+
printf '%s' "$body"
224+
echo
225+
echo '__RN_EOF__'
226+
} >> "$GITHUB_OUTPUT"
227+
162228
- name: Assemble versioned binaries + SHA256SUMS
163229
env:
164230
TAG: ${{ needs.tag.outputs.tag }}
@@ -201,19 +267,11 @@ jobs:
201267
env:
202268
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
203269
TAG: ${{ needs.tag.outputs.tag }}
270+
NOTES_BODY: ${{ steps.notes.outputs.body }}
204271
run: |
205272
set -eu
206-
# Draft first so we can get the auto-generated body, then edit
207-
# the body to append the cosign Verify footer before publishing.
208-
gh release create "$TAG" \
209-
--title "$TAG" \
210-
--generate-notes \
211-
--draft \
212-
dist/docsiq-* dist/SHA256SUMS dist/SHA256SUMS.sig dist/SHA256SUMS.pem
213-
214-
body=$(gh release view "$TAG" --json body -q .body)
215273
{
216-
printf '%s\n\n' "$body"
274+
printf '%s\n\n' "$NOTES_BODY"
217275
printf '### Verify\n\n'
218276
printf 'All artifacts are signed with [cosign](https://github.com/sigstore/cosign) keyless via Sigstore.\n\n'
219277
printf '```sh\n'
@@ -226,7 +284,10 @@ jobs:
226284
printf '```\n'
227285
} > release-notes.md
228286
229-
gh release edit "$TAG" --notes-file release-notes.md --draft=false
287+
gh release create "$TAG" \
288+
--title "$TAG" \
289+
--notes-file release-notes.md \
290+
dist/docsiq-* dist/SHA256SUMS dist/SHA256SUMS.sig dist/SHA256SUMS.pem
230291
231292
- name: Generate SLSA build provenance
232293
id: attest

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
Each release ships signed binaries (cosign keyless + Rekor), a signed
1111
`SHA256SUMS`, and SLSA build provenance.
1212

13+
> **Contributors:** add bullets under `## [Unreleased]` as part of any
14+
> PR worth mentioning in release notes. When the release workflow runs,
15+
> it promotes `[Unreleased]``[vX.Y.Z] — YYYY-MM-DD` automatically and
16+
> uses that section as the GitHub release body. If no non-empty
17+
> `[Unreleased]` section exists at release time, the workflow fails.
18+
1319
## [Unreleased]
1420

1521
### Added

0 commit comments

Comments
 (0)