From 4042ea3b7102d1555323dee1c4cb596b40ab054b Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Tue, 25 Aug 2026 13:12:58 +0200 Subject: [PATCH] fix(release): keep the release notes to what a human wrote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems found while backporting this script to js-rest-api-clients, where nine packages move in lockstep instead of two. `packages()` asked npm for the workspace locations. `npm query` resolves them through node_modules, which during a release rehearsal is a symlink to another checkout — so it answered with paths pointing outside the copy being rehearsed, and the changelog lookup found nothing. The rehearsal is a documented step, so this would have looked like a broken script to whoever ran it. The list is now read straight off the filesystem, which needs no node_modules at all. The bigger one: every package in a `fixed` group accumulates a changelog entry saying the other packages moved. With two packages that's one wasted section; with nine it was 46 lines of release notes carrying a single line of actual content. A section is now included only if it has a bullet that isn't `Updated dependencies` or a bare `pkg@version`, and a footer lists every package published at that version so none of them becomes invisible. Checked against the real v2.2.7 changelogs and against a nine-package rehearsal, plus the case of an entry whose prose happens to begin with a `pkg@version`. --- AGENTS.md | 2 +- bin/publish.sh | 46 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a2fa9e0..327e60d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Use it instead of `npm link` (a symlinked React library breaks with duplicate-Re - Every user-visible change needs a changeset (`npx changeset`) in the same PR, or it ships with no release note. `patch` is for bug fixes only; new API surface is `minor`. - Releasing (maintainers only): `npm run publish` from the root, on a clean `master`. It builds and tests, applies the pending changesets, publishes to npm, then tags `vX.Y.Z`, pushes, and opens the GitHub release. An interrupted release is resumed by re-running it, never undone. See `bin/publish.sh`. -- One `vX.Y.Z` tag per release, as always — `changeset publish` runs with `--no-git-tag` so it doesn't tag each package separately. The tag carries a GitHub release whose body is assembled from both `CHANGELOG.md`s. +- One `vX.Y.Z` tag per release, as always — `changeset publish` runs with `--no-git-tag` so it doesn't tag each package separately. The tag carries a GitHub release whose body is assembled from the `CHANGELOG.md`s: a section for each package that actually has something to say, and a footer listing everything that shipped at that version. ## More detail diff --git a/bin/publish.sh b/bin/publish.sh index a2c6e57..1ce7143 100755 --- a/bin/publish.sh +++ b/bin/publish.sh @@ -28,10 +28,26 @@ fail() { printf '\n\033[31mAborted: %s\033[0m\n' "$1" >&2; exit 1; } BRANCH="$(git rev-parse --abbrev-ref HEAD)" -# Every workspace package, as "name version location" triples. +# Every publishable workspace package, as "name version location" triples. +# +# Read off the filesystem rather than asked of npm: `npm query` resolves +# workspace locations through node_modules, which during a release rehearsal is +# a symlink to another checkout, so it answers with paths outside the copy you +# are actually rehearsing. packages() { - npm query .workspace --no-workspaces-update 2>/dev/null \ - | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{for(const p of JSON.parse(s))console.log(p.name,p.version,p.location)})' + node -e ' + const fs = require("node:fs"), path = require("node:path"); + for (const pattern of require("./package.json").workspaces) { + const dir = path.dirname(pattern); + for (const entry of fs.readdirSync(dir).sort()) { + const location = path.join(dir, entry); + let pkg; + try { pkg = JSON.parse(fs.readFileSync(path.join(location, "package.json"), "utf8")); } catch { continue; } + if (pkg.private) continue; + console.log(pkg.name, pkg.version, location); + } + } + ' } version() { node -p "require('./packages/sdk/package.json').version"; } pending_changesets() { find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' '; } @@ -53,15 +69,31 @@ changelog_section() { # $1 = package location, $2 = version awk -v want="## $2" '$0 == want { found = 1; next } found && /^## / { exit } found' "$1/CHANGELOG.md" } -# The body of the GitHub release: every package's entry for this version, under -# its own heading. The packages move in lockstep, so one release covers them all. +# True when a changelog section says something a human wrote, as opposed to the +# dependency bookkeeping every package in a fixed group accumulates: +# +# - Updated dependencies [5b90e51] +# - datocms-plugin-sdk@2.2.7 +# +# A bullet counts as prose unless it is exactly one of those lines. +has_prose() { + grep -vE '^- Updated dependencies( \[[0-9a-f]+\])?$|^ *- [^ ]+@[0-9][^ ]*$' <<<"$1" | grep -qE '^- ' +} + +# The body of the GitHub release. The packages move in lockstep, so one release +# covers them all — but only the ones with something to say get a section, or +# the page fills up with each package restating that the others moved too. The +# footer keeps every published package visible. release_notes() { - local name ver loc section + local name ver loc section shipped="" while read -r name ver loc; do + shipped="$shipped +- $name@$ver" section="$(changelog_section "$loc" "$VERSION")" - [ -n "$section" ] || continue + has_prose "$section" || continue printf '## %s\n%s\n\n' "$name" "$section" done < <(packages) + printf -- '---\n\nReleased in lockstep:%s\n' "$shipped" } # ---------------------------------------------------------------------------