From 5826859c4dfadcd089a4d6beb68642c857192b74 Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Tue, 25 Aug 2026 13:01:46 +0200 Subject: [PATCH 1/2] feat(release): one v-tag per release, carrying the GitHub release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `changeset publish` tags every package separately (`datocms-plugin-sdk@2.2.7`, `datocms-react-ui@2.2.7`), which is the right default when packages move independently. Ours don't: they're a `fixed` group, so the two tags always point at the same commit and say the same thing — and the `vX.Y.Z` tag this repo used for its first 222 tags stopped being created. Publish now runs with `--no-git-tag` and the script tags `vX.Y.Z` itself, *after* the publish returns. That keeps the invariant that made this rewrite worth doing — a tag can only exist for a version that is actually on the registry — and makes it slightly stronger: a partial publish now leaves no tag at all, rather than one tag per package that made it. The tag then carries the release notes. Only 6 of the 222 tags ever had a GitHub release, because writing one meant composing it by hand; changesets has already written that prose into the `CHANGELOG.md`s, so the script reads each package's section for this version and posts them under one release. A prerelease is marked as such, so a `next` publish can't take over the repository's "Latest release" badge. `gh` is checked in preflight, alongside npm auth, so a missing GitHub CLI stops the release before anything is mutated rather than after npm has been published. --- .changeset/README.md | 3 ++ AGENTS.md | 4 +-- README.md | 6 ++-- bin/publish.sh | 65 ++++++++++++++++++++++++++++++++----- packages/react-ui/README.md | 2 +- packages/sdk/README.md | 2 +- 6 files changed, 68 insertions(+), 14 deletions(-) diff --git a/.changeset/README.md b/.changeset/README.md index c5b8835..2ec6bc9 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -34,5 +34,8 @@ untouched. It works in two modes: recorded in `.changeset/pre.json`, which you commit. Run `npx changeset pre exit` when the line is done. +Either way the GitHub release is marked as a prerelease, so it never becomes +the repository's "Latest release". + `npm run publish` refuses to run while `.changeset/pre.json` exists, so a forgotten pre mode can't quietly turn a real release into a prerelease. diff --git a/AGENTS.md b/AGENTS.md index 590b473..a2fa9e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,8 @@ Use it instead of `npm link` (a symlinked React library breaks with duplicate-Re ## Gotchas - 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 and pushes. An interrupted release is resumed by re-running it, never undone. See `bin/publish.sh`. -- Git tags are per-package now (`datocms-plugin-sdk@2.2.7`), not the single `vX.Y.Z` Lerna used to create. +- 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. ## More detail diff --git a/README.md b/README.md index 386a4c4..8e5c893 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,16 @@ released together. 2. **Release.** From an up-to-date, clean `master`, run `npm run publish`. It builds and tests first, then applies the pending changesets (bumping the versions and writing the `CHANGELOG.md`s), publishes to npm, and only then - tags and pushes to GitHub. + tags `vX.Y.Z`, pushes, and publishes the GitHub release — its notes are the + changelog entries changesets just wrote. If a release is interrupted, **do not undo anything**: run `npm run publish` again. It detects that some package is still missing from the registry and resumes the publish instead of starting a new release. `npm run publish-next` does the same under the `next` dist-tag, leaving -`latest` untouched. +`latest` untouched; its GitHub release is marked as a prerelease, so it doesn't +become the repository's "Latest release" either. ## License diff --git a/bin/publish.sh b/bin/publish.sh index ff37a78..137851b 100755 --- a/bin/publish.sh +++ b/bin/publish.sh @@ -28,10 +28,10 @@ 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" pairs. +# Every workspace package, as "name version location" triples. 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)})' + | 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)})' } version() { node -p "require('./packages/sdk/package.json').version"; } pending_changesets() { find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' '; } @@ -40,13 +40,30 @@ pending_changesets() { find .changeset -maxdepth 1 -name '*.md' ! -name 'README. # the registry. Checking a single package would be wrong — a release can die # after publishing the first one. unpublished() { - local name ver missing="" - while read -r name ver; do + local name ver loc missing="" + while read -r name ver loc; do npm view "$name@$ver" version >/dev/null 2>&1 || missing="$missing $name@$ver" done < <(packages) echo "${missing# }" } +# The section of a package's CHANGELOG for one version, without its "## x.y.z" +# heading — changesets has already written exactly the prose we want. +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. +release_notes() { + local name ver loc section + while read -r name ver loc; do + section="$(changelog_section "$loc" "$VERSION")" + [ -n "$section" ] || continue + printf '## %s\n%s\n\n' "$name" "$section" + done < <(packages) +} + # --------------------------------------------------------------------------- # Preflight: no mutations, just refuse to start from a state we can't finish. # --------------------------------------------------------------------------- @@ -68,6 +85,9 @@ git fetch --quiet origin "$BRANCH" npm whoami >/dev/null 2>&1 || fail "you are not logged in to npm. Run 'npm login'." +command -v gh >/dev/null 2>&1 || fail "the GitHub CLI is not installed, so the release notes can't be published." +gh auth status >/dev/null 2>&1 || fail "you are not logged in to GitHub. Run 'gh auth login'." + echo "on $BRANCH, in sync with origin, npm user: $(npm whoami)" # --------------------------------------------------------------------------- @@ -121,20 +141,49 @@ fi VERSION="$(version)" # --------------------------------------------------------------------------- -# The irreversible step. npm first; changeset creates the git tags only for the -# packages it actually managed to publish. +# The irreversible step, npm first. +# +# --no-git-tag: changesets would tag every package separately +# (datocms-plugin-sdk@2.2.7, datocms-react-ui@2.2.7). The two move in lockstep, +# so we tag the release once, below, the way this repo always has. Tagging after +# the publish keeps the property that matters: a tag can only exist for a +# version that is actually on the registry. # --------------------------------------------------------------------------- step "Publishing v$VERSION to npm" if [ -n "$DIST_TAG" ]; then - npx changeset publish --tag "$DIST_TAG" + npx changeset publish --no-git-tag --tag "$DIST_TAG" else - npx changeset publish + npx changeset publish --no-git-tag fi # --------------------------------------------------------------------------- # git follows npm. # --------------------------------------------------------------------------- +step "Tagging v$VERSION" +if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then + echo "v$VERSION already tagged" +else + git tag -a "v$VERSION" -m "v$VERSION" +fi + step "Pushing to GitHub" git push --follow-tags origin "$BRANCH" +# --------------------------------------------------------------------------- +# The release notes. Last, because it's the only step a human can redo by hand +# from the changelog if it goes wrong. +# --------------------------------------------------------------------------- +step "Publishing the release notes" +if gh release view "v$VERSION" >/dev/null 2>&1; then + echo "the v$VERSION release already exists, leaving it alone" +else + # A prerelease must not become the repo's "Latest release": that's reserved + # for whatever is on the `latest` dist-tag. + PRERELEASE="" + case "$VERSION" in *-*) PRERELEASE="--prerelease" ;; esac + [ -z "$DIST_TAG" ] || PRERELEASE="--prerelease" + + release_notes | gh release create "v$VERSION" --title "v$VERSION" --notes-file - $PRERELEASE +fi + printf '\n\033[32mReleased v%s\033[0m\n' "$VERSION" diff --git a/packages/react-ui/README.md b/packages/react-ui/README.md index 87cdff3..f4cde72 100644 --- a/packages/react-ui/README.md +++ b/packages/react-ui/README.md @@ -44,6 +44,6 @@ rm -rf node_modules/datocms-react-ui node_modules/.vite && npm install Every user-visible change needs a changeset: run `npx changeset` from the repo root in the same PR, pick the bump level (`patch` is for bug fixes only, new API surface is `minor`) and commit the file it writes under `.changeset/`. -To release, from an up-to-date, clean `master`, run `npm run publish` from the repo root. It builds and tests, applies the pending changesets — bumping **both** packages to the same version (fixed group) and writing the `CHANGELOG.md`s — publishes to npm, and only then tags (`datocms-react-ui@X.Y.Z`) and pushes. An interrupted release is resumed by re-running it, never undone. Use `npm run publish-next` for a prerelease under the `next` dist-tag. +To release, from an up-to-date, clean `master`, run `npm run publish` from the repo root. It builds and tests, applies the pending changesets — bumping **both** packages to the same version (fixed group) and writing the `CHANGELOG.md`s — publishes to npm, and only then tags the release `vX.Y.Z`, pushes, and publishes the GitHub release, whose notes come straight from those changelog entries. An interrupted release is resumed by re-running it, never undone. Use `npm run publish-next` for a prerelease under the `next` dist-tag. For deeper architectural notes (CSS Modules pipeline, dual CJS/ESM output, theming via `ctx`), see [`AGENTS.md`](https://github.com/datocms/plugins-sdk/blob/master/packages/react-ui/AGENTS.md) in this directory. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index aae8c16..8ef3dee 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -44,4 +44,4 @@ rm -rf node_modules/datocms-plugin-sdk node_modules/.vite && npm install Every user-visible change needs a changeset: run `npx changeset` from the repo root in the same PR, pick the bump level (`patch` is for bug fixes only, new API surface is `minor`) and commit the file it writes under `.changeset/`. -To release, from an up-to-date, clean `master`, run `npm run publish` from the repo root. It builds and tests, applies the pending changesets — bumping **both** packages to the same version (fixed group) and writing the `CHANGELOG.md`s — publishes to npm, and only then tags (`datocms-plugin-sdk@X.Y.Z`) and pushes. An interrupted release is resumed by re-running it, never undone. Use `npm run publish-next` for a prerelease under the `next` dist-tag. +To release, from an up-to-date, clean `master`, run `npm run publish` from the repo root. It builds and tests, applies the pending changesets — bumping **both** packages to the same version (fixed group) and writing the `CHANGELOG.md`s — publishes to npm, and only then tags the release `vX.Y.Z`, pushes, and publishes the GitHub release, whose notes come straight from those changelog entries. An interrupted release is resumed by re-running it, never undone. Use `npm run publish-next` for a prerelease under the `next` dist-tag. From 8667d9e146fa70ae296e89157e5f5f562d460fe3 Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Tue, 25 Aug 2026 13:04:32 +0200 Subject: [PATCH 2/2] feat(release): print the release URL when the publish finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link is read back with `gh release view` instead of being parsed out of `gh release create`, so it's the same whether the release was just created or was already there from an interrupted run — and a lookup that fails leaves the success message intact rather than killing the script at the very last line. --- bin/publish.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bin/publish.sh b/bin/publish.sh index 137851b..a2c6e57 100755 --- a/bin/publish.sh +++ b/bin/publish.sh @@ -186,4 +186,9 @@ else release_notes | gh release create "v$VERSION" --title "v$VERSION" --notes-file - $PRERELEASE fi +# Asked for rather than parsed out of `gh release create`, so the link is the +# same whether we just created the release or found one already there. +RELEASE_URL="$(gh release view "v$VERSION" --json url --jq .url 2>/dev/null || true)" + printf '\n\033[32mReleased v%s\033[0m\n' "$VERSION" +[ -z "$RELEASE_URL" ] || printf '%s\n' "$RELEASE_URL"