From 83849ffbd3a3115b10427decb24ccb7edfc84cbe Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Fri, 31 Jul 2026 13:13:14 -0700 Subject: [PATCH] fix(release): stop every release escalating to a major @pdsjs/spaces in the fixed group escalated every bump to a major, whatever the changesets asked for. That is how 2.0.0 shipped carrying one minor and one patch, with no "### Major Changes" in any changelog. From a 2.0.0 baseline with a single core:minor changeset: all eleven in the group 3.0.0 spaces excluded 2.1.0 no fixed group 2.1.0 Removing spaces fixes it. An earlier commit put it back, reasoning that cloudflare's hard dependency drags the group on a spaces change anyway so the split bought nothing. That was true and beside the point: it is one major per upstream 0016 change either way, against a major on every release with spaces in the group. Config alone would not have caught this, since the only symptom was the version in the publish output. version-packages now compares the bump against the highest any changeset requested and stops before release. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/README.md | 51 ++++++++++++++---------- .changeset/config.json | 11 +++++- package.json | 2 +- scripts/version-packages.js | 78 +++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 scripts/version-packages.js diff --git a/.changeset/README.md b/.changeset/README.md index e7c4c48..1326948 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -12,25 +12,36 @@ npm run release # build, then publish `release` builds first because the `.d.ts` files are generated output — they have to exist before packing, or the published packages ship without types. -## One version for everything - -All packages are a **fixed** group: a single version across the repo, always -released together. A consumer cannot end up with `@pdsjs/node` built against a -different `@pdsjs/core` than it expects, which is the failure mode a repo full -of independently-versioned packages invites. Internal `workspace:*` ranges -resolve to that exact version at pack time. - -`@pdsjs/spaces` is in the group too, even though it implements -[proposal 0016](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data) -and takes breaking changes whenever that moves. Versioning it separately does -not decouple it: `@pdsjs/cloudflare` imports it statically — the Durable Object -constructor is synchronous and cannot defer the import the way `@pdsjs/node` -does — so a spaces bump propagates through cloudflare to the whole group either -way. Splitting the version scheme would buy nothing and cost the single-version -guarantee. - -That means an upstream change to 0016 lands as a major across every package, -including for people who never enabled spaces. The lever on that is the -cloudflare import, not the release config. +## Two release groups + +The ten runtime and adapter packages are a **fixed** group: one version across +all of them, always released together. A consumer cannot end up with +`@pdsjs/node` built against a different `@pdsjs/core` than it expects, which is +the failure mode a repo full of independently-versioned packages invites. +Internal `workspace:*` ranges resolve to that exact version at pack time. + +`@pdsjs/spaces` versions on its own, and must stay out of the fixed group. +Inside it, **every** release escalated to a major regardless of what the +changesets asked for — a minor and a patch shipped as 2.0.0 with no +`### Major Changes` in any changelog. Measured from a 2.0.0 baseline with a +single `core: minor` changeset: + +| fixed group | result | +|---|---| +| all eleven packages | 3.0.0 | +| spaces excluded | 2.1.0 | +| no fixed group | 2.1.0 | + +`@pdsjs/cloudflare` depends on spaces and cannot defer the import — the Durable +Object constructor is synchronous — so a spaces bump still propagates to the +whole group. That is one major per upstream change to +[proposal 0016](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data), +which is the price of the coupling; it is not a reason to put spaces back in the +group and pay a major on every release instead. The lever on the remaining +propagation is that cloudflare import, not the release config. + +`npm run version-packages` refuses to continue if the version moves further +than any changeset asked for, because this failure was otherwise invisible +until after publishing. Private packages (the examples) are neither versioned nor published. diff --git a/.changeset/config.json b/.changeset/config.json index be4d6f1..a3c49cb 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,7 +4,16 @@ "commit": false, "fixed": [ [ - "@pdsjs/*" + "@pdsjs/core", + "@pdsjs/node", + "@pdsjs/deno", + "@pdsjs/cloudflare", + "@pdsjs/readonly", + "@pdsjs/storage-sqlite", + "@pdsjs/lexicon-resolver", + "@pdsjs/blobs-fs", + "@pdsjs/blobs-s3", + "@pdsjs/blobs-deno" ] ], "linked": [], diff --git a/package.json b/package.json index 2434d03..4ffaf5c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:reference": "./scripts/space-reference/run.sh", "dev:runclub": "npm run dev --workspace=pds-runclub-example", "changeset": "changeset", - "version-packages": "changeset version && node scripts/sync-version.js && pnpm install --lockfile-only", + "version-packages": "node scripts/version-packages.js", "release": "npm run build && changeset publish" }, "dependencies": { diff --git a/scripts/version-packages.js b/scripts/version-packages.js new file mode 100644 index 0000000..403d04c --- /dev/null +++ b/scripts/version-packages.js @@ -0,0 +1,78 @@ +// Applies pending changesets, then checks that the version actually moved the +// way the changesets asked. +// +// This exists because it did not. A release carrying one minor and one patch +// changeset — no `major` anywhere, and no "### Major Changes" in any changelog — +// published as 2.0.0. The cause was `@pdsjs/spaces` sitting in the fixed group: +// with it there, every bump escalated to major, and the only visible sign was +// the version number in the success output, after publishing. +// +// Config alone would not have caught that, because the failure was silent. This +// makes it loud and puts it before `npm run release` rather than after. +import { execFileSync } from 'node:child_process'; +import { readdirSync, readFileSync } from 'node:fs'; + +const RANK = { patch: 1, minor: 2, major: 3 }; +const CORE = new URL('../packages/core/package.json', import.meta.url); + +/** @param {URL} url */ +const versionOf = (url) => JSON.parse(readFileSync(url, 'utf8')).version; + +/** + * Highest bump any pending changeset asks for. + * @returns {'patch'|'minor'|'major'|null} Null when nothing is pending. + */ +function requestedBump() { + const dir = new URL('../.changeset/', import.meta.url); + let highest = null; + for (const file of readdirSync(dir)) { + if (!file.endsWith('.md') || file === 'README.md') continue; + const text = readFileSync(new URL(file, dir), 'utf8'); + const frontmatter = text.split('---')[1] ?? ''; + for (const match of frontmatter.matchAll(/:\s*(patch|minor|major)\s*$/gm)) { + const bump = /** @type {'patch'|'minor'|'major'} */ (match[1]); + if (!highest || RANK[bump] > RANK[highest]) highest = bump; + } + } + return highest; +} + +/** + * How a version actually moved. + * @param {string} before + * @param {string} after + */ +function actualBump(before, after) { + const [bMaj, bMin] = before.split('.').map(Number); + const [aMaj, aMin] = after.split('.').map(Number); + if (aMaj > bMaj) return 'major'; + if (aMin > bMin) return 'minor'; + return 'patch'; +} + +const wanted = requestedBump(); +if (!wanted) { + console.error('No pending changesets — nothing to version.'); + process.exit(1); +} + +const before = versionOf(CORE); +execFileSync('pnpm', ['exec', 'changeset', 'version'], { stdio: 'inherit' }); +const after = versionOf(CORE); +const got = actualBump(before, after); + +if (RANK[got] > RANK[wanted]) { + console.error( + `\nRefusing to continue: @pdsjs/core went ${before} -> ${after}, a ${got} ` + + `bump, but the highest any changeset asked for was ${wanted}.\n\n` + + 'Something in .changeset/config.json is escalating the bump — a package ' + + 'in the fixed group that drags the rest is the usual cause. The working ' + + 'tree now holds the wrong versions: run `git checkout -- . && git clean ' + + '-fd packages` before trying again.\n', + ); + process.exit(1); +} + +console.log(`\n@pdsjs/core ${before} -> ${after} (${got}, as requested)`); +execFileSync('node', ['scripts/sync-version.js'], { stdio: 'inherit' }); +execFileSync('pnpm', ['install', '--lockfile-only'], { stdio: 'inherit' }); -- 2.51.2