diff --git a/.changeset/README.md b/.changeset/README.md index 94efc33..cd2b799 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. @@ -38,7 +38,7 @@ README point at that tag. ## 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 `4.1.0`) @@ -51,5 +51,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 8478995..42ec1c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,14 +50,16 @@ npm run test # Run every package's tests via Turborepo # Releasing npx changeset # Describe a change, in the PR that makes it -npm run publish # Build, test, version, publish, tag, release notes -npm run publish-next # The same, under the `next` dist-tag +npm run release # Build, test, version, publish, tag, release notes +npm run release:next # The same, under the `next` dist-tag ``` Changes worth mentioning in a release need a changeset committed alongside them (`npx changeset`); see `.changeset/README.md`. Note that `changeset version` runs no npm lifecycle hooks, so anything that used to hang off one — the -`oclif readme` regeneration, in particular — lives in `toolchain/publish.mjs`. +`oclif readme` regeneration, in particular — lives in `toolchain/release.mjs`, +which is this repo's `beforeCommit` hook into the shared +[`@datocms/release-toolchain`](https://github.com/datocms/release-toolchain). ### Individual Package Commands @@ -78,7 +80,7 @@ npm run prepack # Build + generate oclif manifest - The WordPress and Contentful import suites talk to live APIs and create real DatoCMS projects, which they delete afterwards. They need a `.env` at the root (see `.env.sample`) and, for WordPress, `docker compose up` in its package. - `npm test` runs them, and so does `npm run publish` — a release cannot be cut + `npm test` runs them, and so does `npm run release` — a release cannot be cut without that setup. Each prerequisite is checked in a `before` hook that says what is missing and how to fix it, so a misconfiguration fails as itself rather than as a 401 halfway through an import diff --git a/README.md b/README.md index 968d1a6..ea93061 100644 --- a/README.md +++ b/README.md @@ -62,21 +62,27 @@ already had. 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 `main`, run `npm run publish`. +2. **Release.** From an up-to-date, clean `main`, run `npm run release`. It builds and tests first, then applies the pending changesets (bumping the versions and writing the `CHANGELOG.md`s), regenerates the oclif command reference in the READMEs of the packages that moved, publishes to npm, and only then tags each of them `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 is [`@datocms/release-toolchain`](https://github.com/datocms/release-toolchain), +shared with every other DatoCMS repository and pinned here by tag; +`toolchain/release.mjs` is the twenty lines that are only true here, where the +oclif command reference has to be regenerated between the version bump and the +release commit. + Releases up to v4.0.29 carried a single `vX.Y.Z` tag covering the whole repo. Those tags stay where they are; new ones are per package, which is also what the source links in the generated command reference now point at. diff --git a/package-lock.json b/package-lock.json index e8166ed..951350e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@biomejs/biome": "1.6.4", "@changesets/cli": "^3.0.1", "@datocms/dashboard-client": "^6.1.0", - "@manypkg/get-packages": "^3.1.0", + "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.2.0", "@oclif/test": "^4", "@types/chai": "^4", "@types/mocha": "^10", @@ -1551,6 +1551,24 @@ "async-scheduler": "^1.4.4" } }, + "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/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -14854,6 +14872,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" + } + }, "@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", diff --git a/package.json b/package.json index 6069922..2d24212 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,14 @@ "build": "turbo run build", "test": "turbo run test", "changeset": "changeset", - "publish": "node toolchain/publish.mjs", - "publish-next": "node toolchain/publish.mjs --tag next" + "release": "node toolchain/release.mjs", + "release:next": "node toolchain/release.mjs --tag next" }, "devDependencies": { "@biomejs/biome": "1.6.4", "@changesets/cli": "^3.0.1", "@datocms/dashboard-client": "^6.1.0", - "@manypkg/get-packages": "^3.1.0", + "@datocms/release-toolchain": "github:datocms/release-toolchain#v1.2.0", "@oclif/test": "^4", "@types/chai": "^4", "@types/mocha": "^10", diff --git a/toolchain/publish.mjs b/toolchain/publish.mjs deleted file mode 100755 index 07f7788..0000000 --- a/toolchain/publish.mjs +++ /dev/null @@ -1,298 +0,0 @@ -#!/usr/bin/env node -// -// Releases the datocms CLI, its legacy alias and the two import plugins. -// -// 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 () => { - const { packages } = await getPackages(ROOT); - const dirOf = new Map( - packages.map((pkg) => [pkg.packageJson.name, pkg.relativeDir]), - ); - - // 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]); - // Being *ahead* of origin is not a problem here, it's the normal resume - // state: the release commit is made locally and pushed only once npm has - // accepted the packages, so a run that died in between leaves exactly that. - // What we can't work with is the other direction — commits on origin we - // don't have, which we'd release without. - if ( - !succeeds('git', [ - 'merge-base', - '--is-ancestor', - `origin/${branch}`, - 'HEAD', - ]) - ) { - fail( - `origin/${branch} has commits ${branch} doesn't. Pull (and rerun) 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) { - // `oclif readme` rewrites each command's docs and the source links under - // them, which are templated with the package's own version. Lerna used to - // trigger this through npm's `version` lifecycle hook; `changeset version` - // runs no lifecycle hooks at all, which is why it is spelled out here. - // - // Only for the packages this release tags: the links point at - // `name@version`, so regenerating a package that isn't moving would write - // links to a tag that will never exist. Its README keeps the links from its - // own last release, which do resolve, until it moves again. - step('Regenerating the oclif READMEs'); - for (const { name } of plan) { - const pkg = packages.find((entry) => entry.packageJson.name === name); - if (!pkg?.packageJson.oclif?.commands) continue; - console.log(` ${name}`); - run('npx', ['oclif', 'readme'], { cwd: pkg.dir }); - } - - step('Committing the release'); - // `release: v4.0.30` when the release lands everything on one version, - // which is the usual outcome in a linked group, and the explicit list when - // only part of the group moves and the versions stay apart. - const tags = plan.map((entry) => `${entry.name}@${entry.version}`); - const versions = new Set(plan.map((entry) => entry.version)); - const subject = - 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'); - 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); -} diff --git a/toolchain/release.mjs b/toolchain/release.mjs new file mode 100644 index 0000000..66380e6 --- /dev/null +++ b/toolchain/release.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +// +// The shared release script, plus the one thing that is only true here. + +import { release, reportFailure, run, step } from '@datocms/release-toolchain'; + +try { + await release({ + // `oclif readme` rewrites each command's docs and the source links under + // them, which are templated with the package's own version. Lerna used to + // trigger this through npm's `version` lifecycle hook; `changeset version` + // runs no lifecycle hooks at all, which is why it is spelled out here. + // + // Only for the packages this release tags: the links point at + // `name@version`, so regenerating a package that isn't moving would write + // links to a tag that will never exist. Its README keeps the links from its + // own last release, which do resolve, until it moves again. + beforeCommit: ({ packages, plan }) => { + step('Regenerating the oclif READMEs'); + for (const { name } of plan) { + const pkg = packages.find((entry) => entry.packageJson.name === name); + if (!pkg?.packageJson.oclif?.commands) continue; + console.log(` ${name}`); + run('npx', ['oclif', 'readme'], { cwd: pkg.dir }); + } + }, + }); +} catch (error) { + reportFailure(error); + process.exit(1); +}