diff --git a/.changeset/auth-components-react-spec-symbol-burn-down.md b/.changeset/auth-components-react-spec-symbol-burn-down.md index fb3eca011..c4b785b8d 100644 --- a/.changeset/auth-components-react-spec-symbol-burn-down.md +++ b/.changeset/auth-components-react-spec-symbol-burn-down.md @@ -1,7 +1,7 @@ --- -"@object-ui/auth": major -"@object-ui/components": major -"@object-ui/react": major +"@object-ui/auth": minor +"@object-ui/components": minor +"@object-ui/react": minor --- Stop declaring 18 `@object-ui/auth` / `@object-ui/components` / `@object-ui/react` @@ -55,3 +55,8 @@ output type — every `.default()`ed key required — would reject outright. `@objectstack/spec` moves from `devDependencies` to `dependencies` in `@object-ui/components`: its public type surface now references the spec. + +Scored `minor`, not `major`, per this repo's fixed-group rule — objectui's major +tracks `@objectstack`, so breaking changes of our own ship as minor with the +semantics spelled out above (see AGENTS.md §版本号策略). A `major` here would carry +all 39 packages of the fixed group to `18.0.0` and off objectstack's 17.x line. diff --git a/.changeset/data-objectstack-chatbot-list-spec-symbol-burn-down.md b/.changeset/data-objectstack-chatbot-list-spec-symbol-burn-down.md index b2c6a05e8..d3470e5bc 100644 --- a/.changeset/data-objectstack-chatbot-list-spec-symbol-burn-down.md +++ b/.changeset/data-objectstack-chatbot-list-spec-symbol-burn-down.md @@ -1,7 +1,7 @@ --- -"@object-ui/data-objectstack": major -"@object-ui/plugin-chatbot": major -"@object-ui/plugin-list": major +"@object-ui/data-objectstack": minor +"@object-ui/plugin-chatbot": minor +"@object-ui/plugin-list": minor --- Stop declaring 12 `@object-ui/data-objectstack` / `@object-ui/plugin-chatbot` / @@ -58,3 +58,8 @@ vendored Vercel AI Elements / Shadcn primitives — upstream's component API, no objectui's authored surface — so the guard now skips that directory the same way it already skips `components/src/ui/`, with a test that fails if any file there stops carrying its vendor banner. + +Scored `minor`, not `major`, per this repo's fixed-group rule — objectui's major +tracks `@objectstack`, so breaking changes of our own ship as minor with the +semantics spelled out above (see AGENTS.md §版本号策略). A `major` here would carry +all 39 packages of the fixed group to `18.0.0` and off objectstack's 17.x line. diff --git a/.changeset/long-tail-spec-symbol-burn-down.md b/.changeset/long-tail-spec-symbol-burn-down.md index d836b3b21..c8a482e4e 100644 --- a/.changeset/long-tail-spec-symbol-burn-down.md +++ b/.changeset/long-tail-spec-symbol-burn-down.md @@ -1,14 +1,14 @@ --- -"@object-ui/collaboration": major -"@object-ui/fields": major -"@object-ui/layout": major -"@object-ui/plugin-charts": major -"@object-ui/plugin-detail": major -"@object-ui/plugin-form": major -"@object-ui/plugin-grid": major -"@object-ui/providers": major -"@object-ui/runner": major -"@object-ui/sdui-parser": major +"@object-ui/collaboration": minor +"@object-ui/fields": minor +"@object-ui/layout": minor +"@object-ui/plugin-charts": minor +"@object-ui/plugin-detail": minor +"@object-ui/plugin-form": minor +"@object-ui/plugin-grid": minor +"@object-ui/providers": minor +"@object-ui/runner": minor +"@object-ui/sdui-parser": minor --- Stop declaring 14 symbols across ten packages under names `@objectstack/spec` @@ -67,3 +67,8 @@ batch 3, so one concept does not acquire two dialect names one package apart. `@objectstack/spec` moves from `devDependencies` to `dependencies` in `@object-ui/fields` (it re-exports a runtime function) and `@object-ui/providers` (its public `.d.ts` now references the spec). + +Scored `minor`, not `major`, per this repo's fixed-group rule — objectui's major +tracks `@objectstack`, so breaking changes of our own ship as minor with the +semantics spelled out above (see AGENTS.md §版本号策略). A `major` here would carry +all 39 packages of the fixed group to `18.0.0` and off objectstack's 17.x line. diff --git a/.changeset/remove-dead-layout-page-node-renderer.md b/.changeset/remove-dead-layout-page-node-renderer.md index a75a78806..3976828cc 100644 --- a/.changeset/remove-dead-layout-page-node-renderer.md +++ b/.changeset/remove-dead-layout-page-node-renderer.md @@ -1,5 +1,5 @@ --- -"@object-ui/layout": major +"@object-ui/layout": minor --- Remove `PageNodeRenderer`, the dead page-node renderer (objectui#3223, ADR-0049 @@ -33,3 +33,8 @@ Also note: this supersedes the `Page` → `PageNodeRenderer` rename shipped for this package in the batch 7 symbol burn-down — the renamed symbol is gone rather than renamed again. `PageHeaderProps` → `PageHeaderComponentProps` from that same batch is unaffected. + +Scored `minor`, not `major`, per this repo's fixed-group rule — objectui's major +tracks `@objectstack`, so breaking changes of our own ship as minor with the +semantics spelled out above (see AGENTS.md §版本号策略). A `major` here would carry +all 39 packages of the fixed group to `18.0.0` and off objectstack's 17.x line. diff --git a/.github/workflows/changeset-guard.yml b/.github/workflows/changeset-guard.yml new file mode 100644 index 000000000..d7a0b7055 --- /dev/null +++ b/.github/workflows/changeset-guard.yml @@ -0,0 +1,44 @@ +name: Changeset Guard + +# Why this is its own workflow instead of a job in `ci.yml`: a changeset is a +# markdown file under `.changeset/`, and both `ci.yml` and `lint.yml` list +# `'**/*.md'` AND `.changeset/**` under `paths-ignore`. A PR that adds only a +# changeset therefore starts no workflow at all — which is exactly the PR this +# check needs to see. GitHub has no per-job path filter, so the gate lives here, +# with the inverse trigger: it runs *only* when `.changeset/**` changes. +# +# It needs no install and no build — a checkout plus one `node` call, a few +# seconds — so keep it that way if you add checks to it. + +on: + pull_request: + branches: [main, develop] + paths: + - '.changeset/**' + push: + branches: [main] + paths: + - '.changeset/**' + +concurrency: + group: changeset-guard-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + no-major: + name: Changeset Bump Policy + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + # One `major` in the 39-package `fixed` group publishes all 39 as the next + # major, taking objectui off the `@objectstack` major it is pinned to. + # See AGENTS.md §版本号策略, and the script's header for the full rationale. + - name: Verify no changeset declares a major bump + run: node scripts/check-changeset-no-major.mjs diff --git a/AGENTS.md b/AGENTS.md index 9b93dd2a1..dafb77100 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,7 @@ export const SchemaRenderer = ({ schema }: { schema: UIComponent }) => { - minor/patch **独立演进**——objectstack 没动时不必跟发;objectui 自己的改动照常用 changeset 推进(从当前 major 起步,如 `11.0.0 → 11.1.0`)。 - objectstack 跨 major(→12)时,下一次 objectui 发版一并把 major 提到 `12`。 - 推论:**changeset 里不要声明 `major`** —— fixed 组任一 `major` 都会把全组推上去、脱离 objectstack 的节奏(如 17.x 期间被推到 18)。objectui 自身的破坏性变更也标 `minor`(在正文里写清 breaking 语义即可);唯一例外是跟随 objectstack 跨 major 的那一次同步升级。 +- **这一条现在由 CI 机械强制** —— `scripts/check-changeset-no-major.mjs` 在任一 changeset 声明 `major` 时退出非零,由 `.github/workflows/changeset-guard.yml` 跑(它是唯一以 `.changeset/**` 为**触发**路径的 workflow:`ci.yml`/`lint.yml` 都把 `**/*.md` 和 `.changeset/**` 列进 `paths-ignore`,只加 changeset 的 PR 不会启动任何 workflow),`pnpm test` 里另有一条仓库状态断言兜底。跟随 objectstack 跨 major 的那一次发版设 `OBJECTUI_ALLOW_MAJOR=1` 放行。前情:objectui#3161/#3159/#3160/#3225 四个 changeset 在 17.x 期间标了 `major`(17 个包条目),足以把 39 个包发成 `18.0.0`。 - 这是约定优先于 semver 纯粹性的取舍(为可维护/好记),因此 objectui 的 major 不代表「它自身 API 的破坏性变更次数」。`@object-ui/site` 与 `@object-ui/example-*` 在 `ignore` 列表,不随组联动。 ### 多 agent 协作纪律(并行修改本仓库,务必遵守) diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 4138cb11c..0d0d28653 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -152,6 +152,22 @@ Uses [Changesets](https://github.com/changesets/changesets) for automated versio 3. Publishes to npm. 4. Configures a pnpm-lock.yaml merge driver to prevent lock file conflicts. +### Changeset Guard (`changeset-guard.yml`) + +**Trigger:** PR to `main`/`develop`, and push to `main`, **when `.changeset/**` changes** — the +inverse of every other workflow's filter. `ci.yml` and `lint.yml` both list `'**/*.md'` and +`.changeset/**` under `paths-ignore`, so a PR that adds only a changeset starts nothing else. + +Runs `scripts/check-changeset-no-major.mjs`, which fails if any pending changeset declares a +`major` bump. Every publishable package is in one `fixed` group (39 packages), so a single +`major` publishes all of them as the next major — and objectui's major is pinned to the +`@objectstack` major it is compatible with, not to its own count of breaking changes. Score +breaking changes of our own as `minor` and describe the break in the changeset body. + +The one release that legitimately bumps the major is the one following `@objectstack` across +its major; it sets `OBJECTUI_ALLOW_MAJOR=1`. `pnpm test` asserts the same repository state, so +the rule survives this workflow being skipped. + ### Changelog Generation (`changelog.yml`) **Trigger:** `release` event (when a GitHub Release is published), or manual dispatch. diff --git a/package.json b/package.json index a50a337c3..6ebdb3521 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "build:console": "pnpm --filter @object-ui/console build", "shadcn:diff": "node scripts/shadcn-sync.js --diff", "shadcn:list": "node scripts/shadcn-sync.js --list", - "changeset:check": "node scripts/check-changeset-fixed.mjs", + "changeset:check": "node scripts/check-changeset-fixed.mjs && node scripts/check-changeset-no-major.mjs", "changeset": "changeset", "changeset:version": "changeset version", "changeset:publish": "changeset publish", diff --git a/scripts/__tests__/check-changeset-no-major.test.ts b/scripts/__tests__/check-changeset-no-major.test.ts new file mode 100644 index 000000000..c53e0a72e --- /dev/null +++ b/scripts/__tests__/check-changeset-no-major.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// @ts-expect-error — plain-JS CI helper, intentionally untyped +import { findMajorBumps, parseFrontmatterBumps } from '../check-changeset-no-major.mjs'; + +/** + * objectui's major is not a count of its own breaking changes — it is pinned to + * the `@objectstack` major so that "same major ⇒ compatible" holds across the + * two repos (AGENTS.md §版本号策略). Since every publishable package sits in one + * `fixed` group, ONE `major` in ONE changeset publishes all 39 of them as the + * next major and breaks that pin. + * + * Four pending changesets had scored `major` (17 package entries) during the + * 17.x line, which would have shipped 39 packages as 18.0.0 against an + * `@objectstack` still on 17. The rule was written down and nothing ran it: + * `ci.yml` and `lint.yml` both `paths-ignore` `.changeset/**`, so a + * changeset-only PR started no workflow at all. + * + * These tests are the second lock. `.github/workflows/changeset-guard.yml` + * catches it on the PR that adds the changeset; the repo-state test below + * catches it in `pnpm test` even if that workflow is ever removed or its + * trigger stops matching. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const changesetDir = path.join(repoRoot, '.changeset'); +const workflowDir = path.join(repoRoot, '.github/workflows'); + +describe('parseFrontmatterBumps', () => { + it('reads the package/bump pairs out of the frontmatter block', () => { + const source = [ + '---', + '"@object-ui/layout": minor', + "'@object-ui/fields': patch", + '@object-ui/core: minor', + '---', + '', + 'Body text.', + ].join('\n'); + + expect(parseFrontmatterBumps(source)).toEqual([ + { pkg: '@object-ui/layout', bump: 'minor', line: 2 }, + { pkg: '@object-ui/fields', bump: 'patch', line: 3 }, + { pkg: '@object-ui/core', bump: 'minor', line: 4 }, + ]); + }); + + it('stops at the closing `---`, so prose about a bump is not a bump', () => { + // Changeset bodies in this repo discuss the major/minor call explicitly — + // and a body may hold a fenced YAML example. Neither may register as a + // declaration, or the guard would fail on a changeset that is obeying it. + const source = [ + '---', + '"@object-ui/layout": minor', + '---', + '', + 'Scored `minor`, not `major`, per the fixed-group rule.', + '', + '```yaml', + '"@object-ui/layout": major', + '```', + ].join('\n'); + + expect(parseFrontmatterBumps(source)).toEqual([ + { pkg: '@object-ui/layout', bump: 'minor', line: 2 }, + ]); + expect(findMajorBumps([{ file: 'x.md', source }])).toEqual([]); + }); + + it('returns nothing for a file with no frontmatter', () => { + expect(parseFrontmatterBumps('# Just a readme\n')).toEqual([]); + }); +}); + +describe('findMajorBumps', () => { + it('reports every major entry with a file:line to jump to', () => { + const source = ['---', '"@object-ui/auth": major', '"@object-ui/react": minor', '---'].join( + '\n' + ); + + expect(findMajorBumps([{ file: '.changeset/a.md', source }])).toEqual([ + { file: '.changeset/a.md', pkg: '@object-ui/auth', line: 2 }, + ]); + }); +}); + +describe('the repository itself', () => { + it('has no pending changeset declaring a `major` bump', () => { + const changesets = fs + .readdirSync(changesetDir) + .filter((name) => name.endsWith('.md') && name !== 'README.md') + .map((name) => ({ + file: path.join('.changeset', name), + source: fs.readFileSync(path.join(changesetDir, name), 'utf8'), + })); + + const offenders = findMajorBumps(changesets) as { file: string; pkg: string; line: number }[]; + + expect( + offenders.map(({ file, pkg, line }) => `${pkg} at ${file}:${line}`), + 'A `major` publishes all 39 fixed-group packages as the next major, off the @objectstack ' + + 'major objectui is pinned to. Score it `minor` and describe the break in the body — ' + + 'see AGENTS.md §版本号策略.' + ).toEqual([]); + }); +}); + +describe('changeset-guard.yml', () => { + const guard = fs.readFileSync(path.join(workflowDir, 'changeset-guard.yml'), 'utf8'); + + it('runs the guard script', () => { + expect(guard).toContain('node scripts/check-changeset-no-major.mjs'); + }); + + it('triggers on `.changeset/**` — the paths every other workflow ignores', () => { + // The inverse pin. `ci.yml` cannot host this check: a changeset-only PR + // matches its `paths-ignore` twice over (`**/*.md` and `.changeset/**`), so + // no job in it would ever run. If that ever stops being true, this test + // fails and the separate workflow can be folded back in. + expect(guard).toMatch(/paths:\s*\n\s*- '\.changeset\/\*\*'/); + + const ci = fs.readFileSync(path.join(workflowDir, 'ci.yml'), 'utf8'); + expect(ci).toContain("paths-ignore:"); + expect(ci).toContain("- '**/*.md'"); + }); +}); diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs new file mode 100644 index 000000000..53142e82b --- /dev/null +++ b/scripts/check-changeset-no-major.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * Fails when a pending changeset declares a `major` bump. + * + * Why this is a gate and not a review note: every publishable package in this + * repo sits in ONE `fixed` group (`.changeset/config.json`, 39 packages), and + * changesets applies the highest bump in the group to all of it. So a single + * `"@object-ui/layout": major` does not release one package as a major — it + * carries the whole family to the next major and off `@objectstack`'s line. + * + * That matters because objectui's major is not its own count of breaking + * changes: it is pinned to the `@objectstack` (spec/client/formula) major, so + * that "same major ⇒ compatible" holds across the two repos (AGENTS.md + * §版本号策略). objectui's own breaking changes ship as `minor` with the break + * spelled out in the changeset body. The major moves exactly once per + * objectstack major, deliberately. + * + * This ran for the first time against four pending changesets scoring `major` + * (17 package entries between them) during the 17.x line — enough to publish + * 39 packages as 18.0.0 against an `@objectstack` still on 17. The rule was + * already written down; nothing executed it, and no workflow even looked at + * `.changeset/**` (both `ci.yml` and `lint.yml` list it under `paths-ignore`). + * + * The one legitimate major is the synchronized bump that follows objectstack + * across ITS major. Set `OBJECTUI_ALLOW_MAJOR=1` for that release, and only + * then. + * + * Run: node scripts/check-changeset-no-major.mjs + * Exit: 0 = no `major` declared, 1 = at least one `major` declared + */ + +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const changesetDir = resolve(root, '.changeset'); + +/** `.changeset/README.md` is documentation, not a changeset. */ +const NOT_A_CHANGESET = new Set(['README.md']); + +/** + * Package/bump pairs from a changeset's YAML frontmatter — the block between + * the leading `---` and the next `---`. + * + * Hand-parsed on purpose: this check runs in CI on a bare checkout with no + * `pnpm install` (the job is a checkout plus a `node` invocation), so it may + * not import a YAML parser or `@changesets/*`. The frontmatter dialect is + * tiny and fully covered here — one `name: bump` per line, name optionally + * quoted, `#` comments and blank lines ignored. + * + * @param {string} source raw file contents + * @returns {{ pkg: string, bump: string, line: number }[]} + */ +export function parseFrontmatterBumps(source) { + const lines = source.split(/\r?\n/); + const open = lines.findIndex((line) => line.trim() === '---'); + if (open === -1) return []; + + const bumps = []; + for (let i = open + 1; i < lines.length; i++) { + const raw = lines[i]; + if (raw.trim() === '---') break; // end of frontmatter + const text = raw.trim(); + if (text === '' || text.startsWith('#')) continue; + + const match = text.match( + /^(?:"([^"]+)"|'([^']+)'|([^:]+?))\s*:\s*(major|minor|patch)\s*$/ + ); + if (!match) continue; + + bumps.push({ + pkg: match[1] ?? match[2] ?? match[3], + bump: match[4], + line: i + 1, // 1-based, for a clickable `file:line` + }); + } + return bumps; +} + +/** + * @param {{ file: string, source: string }[]} changesets + * @returns {{ file: string, pkg: string, line: number }[]} every `major` entry + */ +export function findMajorBumps(changesets) { + return changesets.flatMap(({ file, source }) => + parseFrontmatterBumps(source) + .filter(({ bump }) => bump === 'major') + .map(({ pkg, line }) => ({ file, pkg, line })) + ); +} + +/** Reads `.changeset/*.md`, minus the README. */ +function readChangesets(dir) { + let entries; + try { + entries = readdirSync(dir); + } catch { + return []; // no `.changeset/` — nothing to police + } + return entries + .filter((name) => name.endsWith('.md') && !NOT_A_CHANGESET.has(name)) + .sort() + .map((name) => ({ + file: join('.changeset', name), + source: readFileSync(join(dir, name), 'utf8'), + })); +} + +function main() { + const offenders = findMajorBumps(readChangesets(changesetDir)); + + if (offenders.length === 0) { + console.log('✅ No changeset declares a `major` bump.'); + return 0; + } + + if (process.env.OBJECTUI_ALLOW_MAJOR === '1') { + console.log( + `⚠️ OBJECTUI_ALLOW_MAJOR=1 — allowing ${offenders.length} \`major\` entr${ + offenders.length === 1 ? 'y' : 'ies' + }.` + ); + console.log( + ' Only correct when this release follows `@objectstack` across its own major.' + ); + return 0; + } + + console.error('❌ A changeset declares a `major` bump:\n'); + for (const { file, pkg, line } of offenders) { + console.error(` • ${pkg} (${file}:${line})`); + } + console.error(` +Every publishable package is in one \`fixed\` group, so ONE \`major\` publishes +all of them as the next major — objectui would leave the \`@objectstack\` major +it is pinned to, and "same major ⇒ compatible" would stop holding. + +Fix: score it \`minor\` and describe the break in the changeset body (that is +the convention, not an oversight — see AGENTS.md §版本号策略). + +Following \`@objectstack\` across its own major is the one exception; that +release sets OBJECTUI_ALLOW_MAJOR=1. +`); + return 1; +} + +// Only run when invoked as a script — the tests import the parser above. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main()); +}