From 353d802e64174862b1ee1fc40ca0bfa85e746f94 Mon Sep 17 00:00:00 2001 From: fjmorant Date: Sat, 26 Sep 2026 21:09:39 +0200 Subject: [PATCH] ci: take the release notes from the CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1.0.0 release notes were whatever --generate-notes produced: a list of merged pull requests. Four of the twelve were CI plumbing for the release mechanism itself, so a consumer opening the release read "stop setup-node's .npmrc breaking every yarn step" next to the reason the library got faster, and nothing about the rewrite, the dependency that went away or how to upgrade. Meanwhile the CHANGELOG said all of it. The workflow now builds the notes from the CHANGELOG entry for the version being released, and appends a compare link against the previous release. The v1.0.0 release has already been corrected by hand with exactly what this produces — verified by diffing the two, which match apart from a trailing newline GitHub adds. scripts/changelog-section.mjs extracts one section, dropping the heading since the release carries the version as its title. It exits non-zero when the section is missing or empty, which makes it a release guard as well: a version nobody wrote an entry for is not ready to publish, and that now fails in seconds alongside the other cheap checks rather than after the gate. A rehearsal renders the notes it would publish into the run summary, so the wording can be read before it is public rather than edited afterwards, which is what happened here. Co-Authored-By: Claude Opus 5 --- .github/workflows/publish.yml | 37 +++++++++++++++++++++++- README.md | 9 ++++-- scripts/changelog-section.mjs | 54 +++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 scripts/changelog-section.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c3c6ee..fee0f17 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -85,6 +85,33 @@ jobs: fi echo "${VERSION} is unreleased on both npm and this repository" + # The CHANGELOG entry is what the GitHub release will say. Generated notes + # are a list of merged pull requests, which puts "stop setup-node's .npmrc + # breaking every yarn step" next to the reason the library got faster, and + # leaves the release and the CHANGELOG telling different stories. + # + # It doubles as a guard: the script exits non-zero when the section is + # missing or empty, and a version nobody wrote an entry for is not ready + # to publish. + - name: Build the release notes from the CHANGELOG + env: + VERSION: ${{ steps.version.outputs.version }} + GH_TOKEN: ${{ github.token }} + run: | + node scripts/changelog-section.mjs "$VERSION" > "${RUNNER_TEMP}/notes.md" + # the release being made does not exist yet, so the latest one is the + # previous one + previous="$(gh release view --json tagName --jq .tagName 2>/dev/null || true)" + if [ -n "$previous" ]; then + { + echo + echo '---' + echo + echo "**Full changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${previous}...v${VERSION}" + } >> "${RUNNER_TEMP}/notes.md" + fi + echo "release notes: $(wc -l < "${RUNNER_TEMP}/notes.md") lines from the CHANGELOG" + # A missing token otherwise surfaces as an npm 403 after the whole gate # has run, which reads like a problem with the account rather than a # secret that is not reaching the job. @@ -155,7 +182,7 @@ jobs: gh release create "$TAG" \ --target "$GITHUB_SHA" \ --title "$VERSION" \ - --generate-notes + --notes-file "${RUNNER_TEMP}/notes.md" # The run page otherwise looks identical whether or not anything was # published, which is genuinely confusing when the default is a rehearsal. @@ -178,6 +205,14 @@ jobs: echo "Verified \`${NAME}@${VERSION}\`. No npm release, no tag, no GitHub release." echo echo "To publish for real, run this workflow again with **Rehearse only** unchecked." + if [ -f "${RUNNER_TEMP}/notes.md" ]; then + echo + echo "
Release notes this would publish" + echo + cat "${RUNNER_TEMP}/notes.md" + echo + echo "
" + fi elif [ "${{ job.status }}" != "success" ]; then echo "## Failed while releasing ${VERSION}" echo diff --git a/README.md b/README.md index 540086f..f464909 100644 --- a/README.md +++ b/README.md @@ -258,10 +258,15 @@ yarn check-package # entrypoints and types agree, across all resolution modes Releasing is one button: **Actions → Publish → Run workflow**. It reads the version from `package.json`, refuses to go on if that version is -already tagged or already on npm, runs the whole gate, publishes to npm with +already tagged or already on npm, takes the release notes from that version's +CHANGELOG entry, runs the whole gate, publishes to npm with [provenance](https://docs.npmjs.com/generating-provenance-statements), and creates the GitHub release. +The CHANGELOG entry is required. A version with no `## ` section stops +the run before anything is published, so the release and the CHANGELOG cannot +drift apart — and a rehearsal shows the notes it would publish. + **Rehearse only** is ticked by default, so the default action of that button publishes nothing — it runs every check and stops. Untick it to release for real. Either way the run's summary says plainly what did or did not happen. @@ -270,7 +275,7 @@ Bumping the version stays a pull request, because the CHANGELOG has to be written by a person anyway. Everything after that point is what this automates: ``` -# 1. a PR bumping the version in package.json and moving the CHANGELOG heading +# 1. a PR bumping package.json and adding the CHANGELOG entry for it # 2. merge it # 3. Actions -> Publish -> Run workflow, with Rehearse only unticked ``` diff --git a/scripts/changelog-section.mjs b/scripts/changelog-section.mjs new file mode 100644 index 0000000..9df69b8 --- /dev/null +++ b/scripts/changelog-section.mjs @@ -0,0 +1,54 @@ +/** + * Print the CHANGELOG entry for one version. + * + * GitHub's generated release notes are a list of merged pull requests, which + * for this repository means a consumer reading the release sees "stop + * setup-node's .npmrc breaking every yarn step" next to the reason the library + * got faster. The CHANGELOG already says what changed and why; this hands that + * text to `gh release create --notes-file` so the release and the file agree. + * + * Exits non-zero when the section is missing or empty, which is what makes it + * useful as a release guard: a version nobody wrote a CHANGELOG entry for is + * not ready to publish. + */ +import { readFile } from 'node:fs/promises'; + +const version = process.argv[2]; + +if (!version) { + console.error('usage: changelog-section.mjs '); + process.exit(2); +} + +const changelog = await readFile( + new URL('../CHANGELOG.md', import.meta.url), + 'utf8', +); +const lines = changelog.split('\n'); +const heading = `## ${version}`; +const start = lines.findIndex((line) => line.trim() === heading); + +if (start === -1) { + console.error(`changelog-section: no "${heading}" in CHANGELOG.md`); + process.exit(1); +} + +let end = lines.length; + +for (let i = start + 1; i < lines.length; i++) { + if (lines[i].startsWith('## ')) { + end = i; + break; + } +} + +// The heading itself is dropped: the release already carries the version as +// its title, and repeating it reads like a mistake. +const body = lines.slice(start + 1, end).join('\n').trim(); + +if (!body) { + console.error(`changelog-section: the "${heading}" section is empty`); + process.exit(1); +} + +console.log(body);