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..48644d0 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.2.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.2.0", + "resolved": "git+ssh://git@github.com/datocms/release-toolchain.git#5fbd5d5aa750b8ce833a05bc26aa86a6400c4d0d", + "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..9c9ba4d 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.2.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); -}