From ed0e19e0c6b4be1283f8a78e5040e724236a26e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 12:04:23 +0000 Subject: [PATCH] fix(changeset): stop lockstep group forcing a whole-stack major release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-0090 changeset marked `@objectstack/spec: major`. Because every publishable package sits in one Changesets `fixed` (lockstep) group, that single `major` promotes the ENTIRE monorepo (~70 packages) to a new major version — 14.2.0 → 15.0.0 — even though every other changeset was minor/patch. That is why versioning "always wants a major" despite no major being intended. - Downgrade the spec bump to `minor` per the launch-window convention (breaking changes ship as minor while the stack versions in lockstep; the changeset body still documents the break). Next release computes 14.3.0. - Add `scripts/check-changeset-no-major.mjs` (zero-dep) and run it in the `changeset-check` CI job so an accidental `major` can never again silently turn a PR into a whole-stack major release. Escape hatch: the `allow-major` PR label skips the guard when a major is genuinely intended. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018yGgQz1UdeGQN4PX29LNVz --- .../adr-0090-docs-audience-enforcement.md | 6 +- .github/workflows/pr-automation.yml | 8 ++ scripts/check-changeset-no-major.mjs | 98 +++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 scripts/check-changeset-no-major.mjs diff --git a/.changeset/adr-0090-docs-audience-enforcement.md b/.changeset/adr-0090-docs-audience-enforcement.md index 595444ad1e..ba9ddc6424 100644 --- a/.changeset/adr-0090-docs-audience-enforcement.md +++ b/.changeset/adr-0090-docs-audience-enforcement.md @@ -1,5 +1,5 @@ --- -'@objectstack/spec': major +'@objectstack/spec': minor '@objectstack/rest': minor '@objectstack/plugin-security': minor '@objectstack/lint': minor @@ -19,7 +19,9 @@ ADR-0090 follow-through wave: enforce book audience at the read layer; finish th single-item reads bypass the shared meta cache (per-caller gate vs shared ETag). - **spec**: new pure helpers powering that gate — `audienceAllows`, `resolveDocAudiences`, `docAudienceAllows`, `resolveBookClaimedDocs` - (+ `AudienceCaller`/`AudienceBook` types). BREAKING (launch window): + (+ `AudienceCaller`/`AudienceBook` types). BREAKING but ships as a `minor` + per the launch-window convention (pre-1.0 semantics — breaking changes do + not burn a major version number while the whole stack is in lockstep): `METADATA_FORM_REGISTRY` keys `role`/`profile` are gone — `position` is the registered form (the `position` type had LOST its form layout in the P1 rename); `EnvironmentArtifactMetadataSchema` declares `positions` instead of diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index a496f6fc8d..e475e0e76f 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -84,3 +84,11 @@ jobs: else echo "Found $CHANGESET_COUNT changeset(s)" fi + + - name: Guard against accidental major bumps (launch window) + # Every publishable package is in one Changesets "fixed" (lockstep) group, + # so a single `major` bump promotes the ENTIRE monorepo to a new major + # version. During the launch window we ship breaking changes as `minor`. + # Add the `allow-major` PR label when a whole-stack major is intended. + if: "!contains(github.event.pull_request.labels.*.name, 'allow-major')" + run: node scripts/check-changeset-no-major.mjs diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs new file mode 100644 index 0000000000..fad55f2e60 --- /dev/null +++ b/scripts/check-changeset-no-major.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Launch-window guard: rejects any changeset that declares a `major` bump. + * + * Run: node scripts/check-changeset-no-major.mjs + * + * WHY THIS EXISTS + * --------------- + * Every publishable package is enumerated in the Changesets `fixed` group + * (see `.changeset/config.json` + `check-changeset-fixed.mjs`), so the whole + * monorepo versions in LOCKSTEP. Changesets applies the HIGHEST bump found + * across the group to EVERY package in it. That means a single `major` on any + * one package — even a tiny spec helper — silently promotes the entire release + * (all ~70 packages) from e.g. `14.2.0` to `15.0.0`. + * + * During the launch window we ship breaking changes as `minor` (pre-1.0 + * semantics: a breaking change does not burn a major version number while the + * stack is in lockstep). This guard makes that convention enforceable instead + * of tribal, so an over-strict `major` marker can never again turn an ordinary + * PR into a whole-stack major release by accident. + * + * Exits with code 1 (and a clear list of offenders) if any changeset frontmatter + * bumps a package `major`. + * + * ESCAPE HATCH: when a major release is genuinely intended, gate this check off + * in CI with the `allow-major` PR label (see `.github/workflows/pr-automation.yml`). + * + * The script intentionally has zero third-party dependencies so it can run in + * minimal CI environments before `pnpm install`. + */ + +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..'); +const changesetDir = resolve(repoRoot, '.changeset'); + +/** + * Extract the YAML frontmatter block (between the first two `---` fences) and + * return the list of `major`-bumped package names declared in it. + * + * A frontmatter line looks like: "@objectstack/spec": major + * (single or double quotes, any surrounding whitespace). + * + * @param {string} text + * @returns {string[]} + */ +function majorPackagesIn(text) { + const lines = text.split(/\r?\n/); + if (lines[0]?.trim() !== '---') return []; + const majors = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === '---') break; // end of frontmatter + // "": | '': | : + const m = /^\s*["']?([^"':]+)["']?\s*:\s*([A-Za-z]+)\s*$/.exec(line); + if (m && m[2].toLowerCase() === 'major') majors.push(m[1].trim()); + } + return majors; +} + +let entries; +try { + entries = readdirSync(changesetDir); +} catch { + console.log('No .changeset directory found — nothing to check.'); + process.exit(0); +} + +const offenders = []; +for (const name of entries) { + if (!name.endsWith('.md') || name === 'README.md') continue; + const file = join(changesetDir, name); + const majors = majorPackagesIn(readFileSync(file, 'utf8')); + if (majors.length) offenders.push({ file: `.changeset/${name}`, majors }); +} + +if (offenders.length === 0) { + console.log('✓ No `major` bumps in pending changesets.'); + process.exit(0); +} + +console.error('⛔ Changeset(s) declare a `major` bump.\n'); +for (const { file, majors } of offenders) { + console.error(` ${file}`); + for (const pkg of majors) console.error(` - ${pkg}: major`); +} +console.error( + '\nEvery publishable package is in the Changesets `fixed` (lockstep) group, so a single\n' + + '`major` promotes the ENTIRE monorepo to a new major version. During the launch window\n' + + 'ship breaking changes as `minor` instead (they do not burn a major version number).\n' + + '\n' + + 'If a whole-stack major release is genuinely intended, add the `allow-major` label to\n' + + 'the PR to skip this check.', +); +process.exit(1);