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