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);