From c00bd95d940e8a7260bef2f2c77eac0739199fd7 Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Mon, 31 Aug 2026 11:53:46 +0200 Subject: [PATCH 1/3] Release with the shared toolchain instead of a local copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toolchain/publish.mjs here was one of four 88-97% identical copies of the same script. Three of them, this one included, never got the fix that lets an interrupted release resume — which is the scenario the script exists for. @datocms/release-toolchain is that script, once. It derives the repository root, the release branch and the tag shape instead of hard-coding them, so the same code releases a ten-package workspace and a single-package repo. It is pinned by git tag and never published to npm. The commit-subject rule this repo already had is the one the shared script adopted, so releases here keep reading exactly as they did. The scripts are renamed publish -> release, publish-next -> release:next. Harmless in this repo, where the root is private, but 'publish' is a name npm runs itself during 'npm publish', and the singles migrating next would re-enter their own release script if they kept it. Claude-Session: https://claude.ai/code/session_01XaYAhzmiJwysZeq1XC5xrQ --- .changeset/README.md | 6 +- CLAUDE.md | 6 +- README.md | 8 +- package-lock.json | 20 ++- package.json | 6 +- toolchain/README.md | 8 -- toolchain/publish.mjs | 276 ------------------------------------------ 7 files changed, 34 insertions(+), 296 deletions(-) delete mode 100644 toolchain/README.md delete mode 100755 toolchain/publish.mjs diff --git a/.changeset/README.md b/.changeset/README.md index a6955ec..95ed54f 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -6,7 +6,7 @@ Whenever you change something worth mentioning in a release, run `npx changeset` and answer the two prompts (which packages, and whether it's a patch/minor/major). That writes a small markdown file here, which you commit along with your changes. -At release time `npm run publish` consumes every pending file: it computes the +At release time `npm run release` consumes every pending file: it computes the resulting versions, updates the `package.json`s and the `CHANGELOG.md`s, and deletes the files. @@ -39,7 +39,7 @@ though the version numbers will no longer move in lockstep to say so. ## Prereleases -`npm run publish-next` publishes under the `next` dist-tag, leaving `latest` +`npm run release:next` publishes under the `next` dist-tag, leaving `latest` untouched. It works in two modes: - **as-is** — the pending changesets produce a normal version (say `6.1.0`) @@ -52,5 +52,5 @@ untouched. It works in two modes: 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 +`npm run release` 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/CLAUDE.md b/CLAUDE.md index 5e77a7d..3bc7f59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,11 +58,11 @@ npx changeset # Pick the changed packages and the bump level The ten packages version **independently**: a changeset bumps exactly the packages it names, plus any dependent whose declared range the new version falls outside. So the package list inside a changeset carries real weight. `patch` is for bug fixes only; new API surface is `minor`. See `.changeset/README.md`. ```bash -npm run publish # Build, test, version, publish to npm, tag, release notes -npm run publish-next # The same, under the 'next' dist-tag +npm run release # Build, test, version, publish to npm, tag, release notes +npm run release:next # The same, under the 'next' dist-tag ``` -`toolchain/publish.mjs` implements it. The ordering is the point: everything fallible runs before anything irreversible, and npm is published before git is tagged, so a tag can never point at a version nobody can install. There is no rollback — every step is idempotent, so an interrupted release is resumed by re-running it. Tags are per package (`datocms-structured-text-utils@6.1.0`); the historical `vX.Y.Z` tags stay where they are. +[`@datocms/release-toolchain`](https://github.com/datocms/release-toolchain) implements it — one script shared by every DatoCMS repository, pinned here by git tag and never published to npm. The ordering is the point: everything fallible runs before anything irreversible, and npm is published before git is tagged, so a tag can never point at a version nobody can install. There is no rollback — every step is idempotent, so an interrupted release is resumed by re-running it. Tags are per package (`datocms-structured-text-utils@6.1.0`); the historical `vX.Y.Z` tags stay where they are. ## Architecture diff --git a/README.md b/README.md index f206501..1b878fe 100644 --- a/README.md +++ b/README.md @@ -96,13 +96,17 @@ writes under `.changeset/`. See [`.changeset/README.md`](.changeset/README.md) for the details — in particular, the packages version **independently**, so which ones you list matters. -To release, from an up-to-date, clean `main`, run `npm run publish` from the +To release, from an up-to-date, clean `main`, run `npm run release` from the repo root. It builds and tests, applies the pending changesets — bumping only the packages that changed and writing their `CHANGELOG.md`s — publishes to npm, and only then tags each published package `name@X.Y.Z`, pushes, and creates a GitHub release per tag 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. +`npm run release:next` for a prerelease under the `next` dist-tag. + +The script itself lives in +[`@datocms/release-toolchain`](https://github.com/datocms/release-toolchain), +shared with every other DatoCMS repository and pinned here by tag. ## License diff --git a/package-lock.json b/package-lock.json index 01f6464..59afb9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ ], "devDependencies": { "@changesets/cli": "^3.0.1", - "@manypkg/get-packages": "^3.1.0", + "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.0.0", "@types/jest": "^29.5.12", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", @@ -1039,6 +1039,24 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/@datocms/release-toolchain": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/datocms/release-toolchain.git#fe9848cc749b162ebcf14198029eb8bb384790ea", + "dev": true, + "license": "MIT", + "dependencies": { + "@manypkg/get-packages": "^3.1.0" + }, + "bin": { + "release-toolchain": "src/bin.mjs" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@changesets/cli": ">=3" + } + }, "node_modules/@datocms/rest-client-utils": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/@datocms/rest-client-utils/-/rest-client-utils-5.8.0.tgz", diff --git a/package.json b/package.json index fe56054..78dcda2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ ], "devDependencies": { "@changesets/cli": "^3.0.1", - "@manypkg/get-packages": "^3.1.0", + "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.0.0", "@types/jest": "^29.5.12", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", @@ -31,8 +31,8 @@ "test": "npm run lint && cross-env NODE_OPTIONS=--experimental-vm-modules jest", "build": "turbo run build", "changeset": "changeset", - "publish": "node toolchain/publish.mjs", - "publish-next": "node toolchain/publish.mjs --tag next", + "release": "release-toolchain", + "release:next": "release-toolchain --tag next", "lint": "eslint . --ext .ts,.tsx", "prettier": "prettier --write \"**/*.{ts,tsx,js,mjs,json,md,yml}\"", "prettier:check": "prettier --check \"**/*.{ts,tsx,js,mjs,json,md,yml}\"", diff --git a/toolchain/README.md b/toolchain/README.md deleted file mode 100644 index e099a64..0000000 --- a/toolchain/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# toolchain - -Everything that acts on **this repository** rather than shipping to a user. - -`packages/*` is the product. This is the machinery that turns the product into -releases: it reads the repo, writes into it, and produces something outside it — -a version bump, a changelog, a commit, a tag, an npm package, a GitHub release. -Nothing in here is published, and nothing in here is imported by code that is. diff --git a/toolchain/publish.mjs b/toolchain/publish.mjs deleted file mode 100755 index d32bbce..0000000 --- a/toolchain/publish.mjs +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env node -// -// Releases the ten datocms-structured-text-* packages. -// -// The order of the steps is the whole point: everything that can fail (network, -// tests, credentials) runs BEFORE anything irreversible, and the irreversible -// steps go npm first, git second. `changeset publish` does both halves in that -// order by itself — it publishes, then tags only the packages npm accepted — so -// a tag can never outlive a failed publish. -// -// There is deliberately no rollback, because every step is idempotent: the -// publish skips versions already on the registry, the tagging skips tags that -// already exist, and each GitHub release skips itself. A release that dies -// halfway through is resumed by running this again. -// -// It is JavaScript rather than bash because under `set -e` the exit status of -// whatever a loop last evaluated becomes the loop's exit status, and this script -// twice sat one non-matching last package away from dying between `npm publish` -// and `git push`. - -import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { getPackages } from '@manypkg/get-packages'; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -process.chdir(ROOT); - -// Normal releases happen here. Prereleases are routinely cut from a feature -// branch, so --tag only asks that the branch be clean and pushed. -const RELEASE_BRANCH = 'main'; - -/** A refusal we wrote ourselves, as opposed to a step that failed. */ -class Aborted extends Error {} - -const fail = (message) => { - throw new Aborted(message); -}; -const step = (message) => console.log(`\n\x1b[1m==> ${message}\x1b[0m`); - -/** Runs a step the human is watching, and throws if it fails. */ -const run = (file, args, options = {}) => - execFileSync(file, args, { stdio: 'inherit', ...options }); - -/** Runs a command for its output, and throws if it fails. */ -const capture = (file, args) => - execFileSync(file, args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim(); - -/** - * Exit status as a question, for the commands whose failure is an answer rather - * than an error: "am I logged in?", "does this release already exist?". - */ -const succeeds = (file, args) => { - try { - execFileSync(file, args, { stdio: 'ignore' }); - return true; - } catch { - return false; - } -}; - -const pendingChangesets = () => - readdirSync('.changeset').filter( - (entry) => entry.endsWith('.md') && entry !== 'README.md', - ); - -/** - * What this release covers, as `{ kind, name, version }` entries: `publish` for - * a version not yet on the registry, `tag-only` for one that got there before a - * previous run died. Asked of changesets rather than reconstructed here — it is - * the same plan `changeset publish` is about to execute, registry lookups - * included, so the two cannot disagree about what is being released. - */ -const publishPlan = () => { - const file = path.join(tmpdir(), `publish-plan-${process.pid}.json`); - try { - // Captured, not shown: `changeset publish` prints the same registry - // summary again a moment later. - capture('npx', ['changeset', 'publish-plan', '--output', file]); - return JSON.parse(readFileSync(file, 'utf8')).plan.flat(); - } finally { - rmSync(file, { force: true }); - } -}; - -/** - * 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. - */ -const changelogSection = (dir, version) => { - const file = path.join(dir, 'CHANGELOG.md'); - // A package released for the first time has no CHANGELOG.md yet. - if (!existsSync(file)) return ''; - const [, section = ''] = readFileSync(file, 'utf8').split( - `\n## ${version}\n`, - ); - return section.split('\n## ')[0].trim(); -}; - -const main = async () => { - // The only flag: `--tag next` publishes under that npm dist-tag instead of - // `latest`, and marks the GitHub releases as prereleases. - const [flag, distTag = ''] = process.argv.slice(2); - if (flag && flag !== '--tag') fail(`unknown option: ${flag}`); - if (flag && !distTag) fail('--tag needs a value.'); - - // ------------------------------------------------------------------------- - // Preflight: no mutations, just refuse to start from a state we can't finish. - // ------------------------------------------------------------------------- - step('Preflight'); - - const branch = capture('git', ['rev-parse', '--abbrev-ref', 'HEAD']); - - if (!distTag) { - if (branch !== RELEASE_BRANCH) { - fail( - `you are not on ${RELEASE_BRANCH}. Use --tag to publish a prerelease from a branch.`, - ); - } - if (existsSync('.changeset/pre.json')) { - fail( - 'the repo is in changesets pre mode (.changeset/pre.json).\n' + - " Run 'npx changeset pre exit' before cutting a real release.", - ); - } - } - - if (capture('git', ['status', '--porcelain'])) { - fail('working tree is dirty. Commit or stash first.'); - } - - run('git', ['fetch', '--quiet', 'origin', branch]); - if ( - capture('git', ['rev-parse', 'HEAD']) !== - capture('git', ['rev-parse', `origin/${branch}`]) - ) { - fail(`${branch} and origin/${branch} have diverged. Pull (or push) first.`); - } - - if (!succeeds('npm', ['whoami'])) - fail("you are not logged in to npm. Run 'npm login'."); - if (!succeeds('gh', ['auth', 'status'])) { - fail( - "the GitHub CLI is missing or logged out, so the release notes can't be published.", - ); - } - - const npmUser = capture('npm', ['whoami']); - console.log(`on ${branch}, in sync with origin, npm user: ${npmUser}`); - - // ------------------------------------------------------------------------- - // Everything that can fail. Nothing has been mutated yet, so a network - // timeout here costs you nothing but the rerun. - // - // Skipped when there are no changesets to apply, which is what a resumed - // release looks like: the versions were bumped and committed by the run that - // died, and the plan below picks up whatever it didn't finish. - // ------------------------------------------------------------------------- - const bumping = pendingChangesets().length > 0; - - if (bumping) { - step('Building'); - run('npm', ['run', 'build']); - - step('Testing'); - run('npm', ['test']); - - // ----------------------------------------------------------------------- - // Mutations, local only. Still nothing pushed, still nothing published. - // ----------------------------------------------------------------------- - step('Applying pending changesets'); - run('npx', ['changeset', 'version']); - - step('Refreshing the lockfile'); - run('npm', ['install', '--package-lock-only']); - } - - step('Reading the publish plan'); - const plan = publishPlan(); - for (const { kind, name, version } of plan) { - console.log( - ` ${name}@${version}${kind === 'tag-only' ? ' (already on npm)' : ''}`, - ); - } - if (plan.length === 0) { - fail( - 'there is nothing to release: every package is already published and tagged.\n' + - " Describe your changes with 'npx changeset' first.", - ); - } - - if (bumping) { - step('Committing the release'); - // With independent versioning a release moves an arbitrary number of - // packages at an arbitrary number of versions, so the full `name@version` - // list goes in the commit *body*: spelled into the subject, a release - // touching all ten ran to 442 characters. The subject keeps a short exact - // form whenever there is one to state. - const tags = plan.map((entry) => `${entry.name}@${entry.version}`); - const versions = new Set(plan.map((entry) => entry.version)); - const subject = - tags.length === 1 - ? `release: ${tags[0]}` - : versions.size === 1 - ? `release: v${[...versions][0]}` - : `release: ${tags.length} packages`; - run('git', ['add', '-A']); - // A second -m is a paragraph, not a second subject line. - const message = tags.length === 1 ? [subject] : [subject, tags.join('\n')]; - run('git', ['commit', ...message.flatMap((part) => ['-m', part])]); - } - - // ------------------------------------------------------------------------- - // The irreversible step: npm, then one annotated `name@version` tag for each - // package npm accepted. - // ------------------------------------------------------------------------- - step('Publishing to npm and tagging'); - run('npx', ['changeset', 'publish', ...(distTag ? ['--tag', distTag] : [])]); - - step('Pushing to GitHub'); - run('git', ['push', '--follow-tags', 'origin', branch]); - - // ------------------------------------------------------------------------- - // The release notes: one GitHub release per tag, its body the CHANGELOG - // section changesets just wrote. 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'); - const { packages } = await getPackages(ROOT); - const dirOf = new Map( - packages.map((pkg) => [pkg.packageJson.name, pkg.relativeDir]), - ); - - for (const { name, version } of plan) { - const tag = `${name}@${version}`; - if (succeeds('gh', ['release', 'view', tag])) { - console.log(`${tag}: the release already exists, leaving it alone`); - continue; - } - // A prerelease must not become the repo's "Latest release": that's reserved - // for whatever is on the `latest` dist-tag. Decided per package, not once - // for the run, so one prerelease version can't mark the others. - const prerelease = distTag || version.includes('-') ? ['--prerelease'] : []; - const notes = - changelogSection(dirOf.get(name), version) || `Released \`${tag}\`.`; - // --verify-tag: refuse to invent a release for a tag the push didn't carry. - const args = ['--title', tag, '--verify-tag', '--notes-file', '-']; - run('gh', ['release', 'create', tag, ...args, ...prerelease], { - input: notes, - stdio: ['pipe', 'inherit', 'inherit'], - }); - } - - console.log('\n\x1b[32mReleased\x1b[0m'); -}; - -try { - await main(); -} catch (error) { - // A step that failed has already said what it had to say on stderr; all we - // add is which one, and the fact that nothing after it ran. - const summary = [String(error.message).split('\n')[0], error.stderr] - .filter(Boolean) - .join('\n'); - const detail = - error instanceof Aborted - ? error.message - : `${summary}\n The step above printed the details.`; - console.error(`\n\x1b[31mAborted: ${detail}\x1b[0m`); - process.exit(1); -} From 8111f6be51a3ba6e21f938db48d2ca23c13f0414 Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Mon, 31 Aug 2026 12:25:55 +0200 Subject: [PATCH 2/3] Pin release-toolchain v1.1.0 Rehearsing the resume against a real registry and a real GitHub repo turned up a bug that every copy of the old script had: killed between 'changeset publish' and 'git push', the next run reads an empty plan - the packages are on the registry and their tags are local, which is all changesets looks at - and aborts with 'there is nothing to release', leaving the commit unpushed and no GitHub release. v1.1.0 finishes that release. Claude-Session: https://claude.ai/code/session_01XaYAhzmiJwysZeq1XC5xrQ --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 59afb9e..1483e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ ], "devDependencies": { "@changesets/cli": "^3.0.1", - "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.0.0", + "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.1.0", "@types/jest": "^29.5.12", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", diff --git a/package.json b/package.json index 78dcda2..9c7b127 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ ], "devDependencies": { "@changesets/cli": "^3.0.1", - "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.0.0", + "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.1.0", "@types/jest": "^29.5.12", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", From e7ade0186d3c68139b53703000b027057ded32d7 Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Mon, 31 Aug 2026 12:34:58 +0200 Subject: [PATCH 3/3] Pin release-toolchain v1.2.0 v1.2.0 adds a guard a package runs from prepublishOnly to refuse a hand-typed 'npm publish'. This repo does not use it: the root package.json is private, so the same slip publishes nothing. The seven single-package repos carry it, where 'npm publish' at the repo root does reach npm. The lockfile is the substantive part of this commit. Changing the spec alone left the previous SHA resolved, so 'npm ci' kept installing v1.0.0 while package.json claimed otherwise. Claude-Session: https://claude.ai/code/session_01XaYAhzmiJwysZeq1XC5xrQ --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1483e62..48644d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ ], "devDependencies": { "@changesets/cli": "^3.0.1", - "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.1.0", + "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.2.0", "@types/jest": "^29.5.12", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", @@ -1040,8 +1040,8 @@ } }, "node_modules/@datocms/release-toolchain": { - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/datocms/release-toolchain.git#fe9848cc749b162ebcf14198029eb8bb384790ea", + "version": "1.2.0", + "resolved": "git+ssh://git@github.com/datocms/release-toolchain.git#5fbd5d5aa750b8ce833a05bc26aa86a6400c4d0d", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9c7b127..9c9ba4d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ ], "devDependencies": { "@changesets/cli": "^3.0.1", - "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.1.0", + "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.2.0", "@types/jest": "^29.5.12", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",