diff --git a/.changeset/README.md b/.changeset/README.md index 98e80c0..fdeee4f 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 version, updates the `package.json`s and the `CHANGELOG.md`s, and deletes the files. @@ -40,7 +40,7 @@ contradict each other, like a `datocms-react-ui@2.2.8` that requires ## 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 `2.3.0`) @@ -53,5 +53,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/AGENTS.md b/AGENTS.md index 5d95d39..65adbd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ 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, pushes, and opens the GitHub releases. An interrupted release is resumed by re-running it, never undone. See `toolchain/publish.mjs`. +- Releasing (maintainers only): `npm run release` from the root, on a clean `master`. It builds and tests, applies the pending changesets, publishes to npm, then tags, pushes, and opens the GitHub releases. An interrupted release is resumed by re-running it, never undone. The script is [`@datocms/release-toolchain`](https://github.com/datocms/release-toolchain), shared by every DatoCMS repository and pinned here by git tag. - One `name@X.Y.Z` tag and one GitHub release **per package**. `changeset publish` does the publishing and the tagging itself, in that order and only for the packages npm accepted, so a tag can't outlive a failed publish. What is being released comes from `changeset publish-plan --output`, not from anything the script reconstructs. Each release's body is that package's own `CHANGELOG.md` section — when a package moved only because the other one did, that section is just its dependency bump, which is the honest thing to show. Releases up to v2.2.7 used a single `vX.Y.Z` tag instead; those tags stay where they are. ## More detail diff --git a/README.md b/README.md index 4957a85..370e6cd 100644 --- a/README.md +++ b/README.md @@ -57,20 +57,24 @@ released together. patch/minor/major, then writes a small markdown file under `.changeset/` which you commit. `patch` is for bug fixes only; new API surface is `minor`. See [`.changeset/README.md`](.changeset/README.md). -2. **Release.** From an up-to-date, clean `master`, run `npm run publish`. +2. **Release.** From an up-to-date, clean `master`, run `npm run release`. 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 each package `name@X.Y.Z`, pushes, and publishes one GitHub release per tag — its notes are the changelog entries changesets just wrote. -If a release is interrupted, **do not undo anything**: run `npm run publish` +If a release is interrupted, **do not undo anything**: run `npm run release` 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 +`npm run release:next` does the same under the `next` dist-tag, leaving `latest` untouched; its GitHub releases are marked as prereleases, so they don't become the repository's "Latest release" either. +The script itself is +[`@datocms/release-toolchain`](https://github.com/datocms/release-toolchain), +shared with every other DatoCMS repository and pinned here by tag. + ## License This repository is published under the [MIT](LICENSE.md) license. diff --git a/package-lock.json b/package-lock.json index f923835..d52b415 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "devDependencies": { "@biomejs/biome": "1.6.4", "@changesets/cli": "^3.0.1", - "@manypkg/get-packages": "^3.1.0", + "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.2.0", "@types/jest": "^29.2.1", "@typescript-eslint/eslint-plugin": "^5.42.0", "@typescript-eslint/parser": "^5.42.0", @@ -1097,6 +1097,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", @@ -11169,6 +11187,14 @@ } } }, + "@datocms/release-toolchain": { + "version": "git+ssh://git@github.com/datocms/release-toolchain.git#5fbd5d5aa750b8ce833a05bc26aa86a6400c4d0d", + "dev": true, + "from": "@datocms/release-toolchain@github:datocms/release-toolchain#v1.2.0", + "requires": { + "@manypkg/get-packages": "^3.1.0" + } + }, "@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 49e0a32..0424625 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "devDependencies": { "@biomejs/biome": "1.6.4", "@changesets/cli": "^3.0.1", - "@manypkg/get-packages": "^3.1.0", + "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.2.0", "@types/jest": "^29.2.1", "@typescript-eslint/eslint-plugin": "^5.42.0", "@typescript-eslint/parser": "^5.42.0", @@ -28,8 +28,8 @@ "build": "turbo run build", "lint": "biome ci packages", "changeset": "changeset", - "publish": "node toolchain/publish.mjs", - "publish-next": "node toolchain/publish.mjs --tag next", + "release": "release-toolchain", + "release:next": "release-toolchain --tag next", "format": "biome check toolchain packages --apply && biome format --write toolchain packages", "prepare": "husky" }, diff --git a/packages/react-ui/README.md b/packages/react-ui/README.md index 3505c17..d09d176 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 whichever packages changed, and writing their `CHANGELOG.md`s — publishes to npm, and only then tags each package `name@X.Y.Z`, pushes, and publishes 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. +To release, from an up-to-date, clean `master`, run `npm run release` from the repo root. It builds and tests, applies the pending changesets — bumping whichever packages changed, and writing their `CHANGELOG.md`s — publishes to npm, and only then tags each package `name@X.Y.Z`, pushes, and publishes 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 release: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 fa84061..7c07b41 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 whichever packages changed, and writing their `CHANGELOG.md`s — publishes to npm, and only then tags each package `name@X.Y.Z`, pushes, and publishes 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. +To release, from an up-to-date, clean `master`, run `npm run release` from the repo root. It builds and tests, applies the pending changesets — bumping whichever packages changed, and writing their `CHANGELOG.md`s — publishes to npm, and only then tags each package `name@X.Y.Z`, pushes, and publishes 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 release:next` for a prerelease under the `next` dist-tag. 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 6f08480..0000000 --- a/toolchain/publish.mjs +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env node -// -// Releases datocms-plugin-sdk and datocms-react-ui. -// -// 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 = 'master'; - -/** 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'); - // A `linked` group shares one version across whatever it releases - // together, so `release: v3.0.0` is right when both packages move — but a - // release can now carry one package alone, and calling that "v2.2.8" would - // claim the other moved too. Name it instead when it is the only one. - 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.join(', ')}`; - run('git', ['add', '-A']); - run('git', ['commit', '-m', subject]); - } - - // ------------------------------------------------------------------------- - // 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); -}