diff --git a/.github/workflows/changeset-presence.yml b/.github/workflows/changeset-presence.yml new file mode 100644 index 0000000000..5f1fdcc72f --- /dev/null +++ b/.github/workflows/changeset-presence.yml @@ -0,0 +1,107 @@ +name: Changeset Presence + +# Demands the DECLARATION that objectstack#4731 / #4843 made the criterion for +# "which frontend changes shipped": a change to the source of a package the +# release covers must add a `.changeset/*.md`. An empty frontmatter counts — what +# is required is one sentence written while the author still knows what the change +# does, not a release. Full rationale, the measured history, and the exemption's +# exact spelling: `scripts/check-changeset-presence.mjs`. +# +# Why this is a SECOND changeset workflow rather than a wider trigger on the +# first. `changeset-guard.yml` runs only when `.changeset/**` changes, and that +# inversion is deliberate and documented in its own header: `ci.yml` and +# `lint.yml` both list `.changeset/**` under `paths-ignore`, so a PR that adds +# ONLY a changeset starts nothing else, and that guard exists to see exactly that +# PR. A PR which FORGOT its changeset does not touch `.changeset/**` at all, so +# the one check that could notice is the one guaranteed not to run. Widening those +# paths would break the case it was built for. Hence two workflows, opposite +# directions: that one polices the level of a declaration that exists, this one +# polices the existence of a declaration at all. +# +# Hence also: no `paths` and no `paths-ignore` here, deliberately — the same +# choice `control-bytes.yml` and `docs-links.yml` made and for a stronger reason. +# A path filter on the trigger skips the WHOLE workflow (GitHub has no per-job +# path filter), so the context is never CREATED on a pull request that does not +# match, and a required context that is never created leaves the PR pending rather +# than failing it — in the queue, until the ruleset's 60-minute timeout. That is +# objectui#3523's second half, and it is why the four gates moved their filters +# into the jobs. This gate reports on every pull request instead: it decides from +# the diff, inside the script, and says so when nothing is owed. A `paths` filter +# would additionally be a second copy of the script's guarded surface, free to +# drift from it — and the surface is derived from `.changeset/config.json` +# precisely so there is only one. `scripts/__tests__/check-changeset-presence.test.ts` +# fails if a filter is ever added. +# +# It needs no install and no build — a checkout, `setup-node`, and one `node` call +# over `git diff` — so keep it that way if you add checks to it. + +on: + pull_request: + branches: [main, develop] + # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note + # and the measurements behind it). A required context that does not report on a + # queue build stalls the queue until the ruleset's 60-minute status-check + # timeout fails it, so a gate that carries no path filter — and therefore CAN + # be required — has to subscribe. `types:` is named although `checks_requested` + # is currently the only one GitHub defines. + # + # The script needs no per-event branch to work here: it resolves the base as + # the merge base with the target branch, and on a queue build (no + # `GITHUB_BASE_REF`, no `github.event.pull_request`) that falls through to the + # merge base with `origin/main`, which is the commit the queue built the group + # on. `ci.yml`'s `pnpm check:i18n-drift` step already resolves its base exactly + # this way on this event. + merge_group: + types: [checks_requested] + +# Deliberately NOT subscribed: +# +# - `push` to `main`. There is nothing left to demand: the change has landed, +# and failing the push would only paint `main` red at the author of the next +# commit. The pull request and the queue build are where a declaration can +# still be written. +# - `workflow_dispatch`. A manual run has no revision range to judge, and this +# gate fails loudly rather than inventing one. Locally it is +# `node scripts/check-changeset-presence.mjs`, which defaults to this branch +# against its merge base and reads the working tree, so an author gets the +# answer before committing. + +concurrency: + group: changeset-presence-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changeset-presence: + name: Changeset Declaration + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + # The gate compares this change against its MERGE BASE with the target + # branch, so it needs history — checkout's default is a depth-1 clone + # where `git merge-base` has nothing to find. An unresolvable base is a + # hard failure in the script, never a skip, so getting this wrong is a + # red build rather than a silent pass; it is spelled out here so it + # stays that way. Same requirement, same reason, as the `fetch-depth: 0` + # on `ci.yml`'s `type-check` job for `pnpm check:i18n-drift`. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + + # Reads `.changeset/config.json` and `git diff`, and nothing else — no + # install, no network. The guarded surface is every workspace package named + # in the `fixed` group, so it follows the release configuration instead of + # being a hand-written glob: `@object-ui/console` lives at `apps/console`, + # outside `packages/`, and is both the most-edited published package here + # and the one the platform's `bump-objectui.sh` writes a changeset for. + - name: Verify a changeset declares this change + run: node scripts/check-changeset-presence.mjs diff --git a/AGENTS.md b/AGENTS.md index b18b880344..d684c25a83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,9 @@ export const SchemaRenderer = ({ schema }: { schema: UIComponent }) => { - `.gitignore` 已锚定 `/*.png` 等防兜底,并额外忽略根级 `/--*` —— 名字以 `--` 开头的根文件必然是把 CLI 参数当成了输出文件名(#3193:一张叫 `--full-page` 的 68KB 截图被提交进来,因为没有 `.png` 后缀,`/*.png` 兜不住)。兜底只是最后一道,仍要主动清。 - 删这类文件要用 `--` 断开参数解析:`rm -- ./--full-page`、`git rm -- './--full-page'`。 - 任务结束:停**自己起的**后台服务(见下方"服务纪律";别按端口杀别人的)、清 `.playwright-mcp/`。 -- 改完代码提交时:功能改进(feature)需写 changeset(`pnpm changeset`);纯 bug 修复不需要。 +- 改完代码提交时:**只要改了发版包的 `src/`(`.changeset/config.json` 的 `fixed` 组,含 `apps/console`),就必须新增一个 `.changeset/*.md`** —— 这一条由 `.github/workflows/changeset-presence.yml` 机械强制(objectui#3387),`pnpm changeset` 写正常 bump,**纯内部改动/只动测试就写空 frontmatter(`---` 紧跟 `---`)显式声明"不发版"**,那是合法的一等通过写法。要的是"声明一次",不是强制发版。 + - 别再按"feature 要写、bug 修复不用"来判断 —— 正是这个旧判据让三条用户可见的修复(`19716b5bf` fix(charts)、`5e7ef1141` fix(i18n)、`0e50440` #3518)搭顺风车发了出去,任何 CHANGELOG/版本号/发布记录里都查不到:平台侧的发布判据(objectstack#4731/#4843)读的就是本仓声明的 changeset。 + - 本地先自查:`node scripts/check-changeset-presence.mjs`(未提交的 changeset 也算)。 ### 怎么跑测试(有两种写法会静默假绿 —— 现已机械拦截) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c9620f8f6..134f8bd303 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -496,20 +496,43 @@ Changesets is a tool that helps us: ### When to Create a Changeset -Create a changeset when your PR makes changes to any package in `packages/`: +**If your PR changes the `src/` of a package the release covers, it must add a `.changeset/*.md` — +this is enforced by CI** (`changeset-presence.yml`, objectui#3387). "The release covers it" means +the package is named in the `fixed` group of `.changeset/config.json`, which includes +`@object-ui/console` at `apps/console` as well as everything under `packages/`; `@object-ui/site` +and the examples are in `ignore` and are not gated. -- ✅ **DO create a changeset for**: +What the gate asks for is a **declaration**, not a release: + +- ✅ **Score a bump** (`patch` / `minor` — never `major`, see below) for: - New features - Bug fixes - - Breaking changes + - Breaking changes (scored `minor`, with the break described in the body) - Performance improvements - API changes -- ❌ **DON'T create a changeset for**: - - Documentation updates only - - Changes to examples or apps - - Internal refactoring with no user-facing changes - - Test updates without code changes +- ✅ **Declare that it releases nothing** — a changeset with an **empty frontmatter** — for: + - Internal refactoring with no user-facing change + - Test-only changes under a package's `src/` + - Dead-code removal + + ```md + --- + --- + + Removed the orphaned SystemObjectViewPage; no published behaviour changes. + ``` + + This is a first-class pass, not a workaround. It costs one line and it puts the reason in the + repository, where the next reader finds it — which is the whole point: three user-visible fixes + shipped with no changeset and therefore appear in no CHANGELOG, version number or release note + anywhere (objectui#3387). + +- ❌ **No changeset is needed at all for** changes that touch no released package's `src/`: + documentation, CI configuration, repo-level scripts, the examples, `apps/site`. + +Run `node scripts/check-changeset-presence.mjs` locally to get the same answer CI will give, +including for a changeset you have written but not yet committed. ### How to Create a Changeset diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 79f0a7a057..85b3948cc9 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -26,6 +26,7 @@ one has its own section below. | `ci.yml` | CI | Push / PR to `main`, `develop`; merge-queue builds | **Yes** — every job but `test-coverage` (push only) runs on PRs and on queue builds | | `lint.yml` | Lint | Push / PR to `main`, `develop`; merge-queue builds; manual | **Yes** — ESLint **errors** only | | `changeset-guard.yml` | Changeset Bump Policy | PR / push touching `.changeset/**` | **Yes** | +| `changeset-presence.yml` | Changeset Declaration | PR to `main`, `develop` — **no path filter**; merge-queue builds | **Yes** — when a released package's `src/` changed and no changeset was added | | `control-bytes.yml` | Control Byte Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** | | `docs-links.yml` | Internal Docs Link Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** | | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | @@ -54,6 +55,10 @@ The path filters explain most "why did nothing run on my PR?" questions: required at all. - `changeset-guard.yml` carries the inverse filter — it runs *only* when `.changeset/**` changes, which is precisely why it is a separate workflow instead of a job inside `ci.yml`. +- `changeset-presence.yml` is that guard's mirror image and the reason there are two: a PR which + *forgot* its changeset does not touch `.changeset/**`, so the inverse filter guarantees the one + check that could notice never runs. It therefore carries **no** filter and decides from the diff + inside its script. - `control-bytes.yml` and `docs-links.yml` carry **no** filter of any kind, which is equally deliberate: both guard markdown, and a gate that a markdown-only PR cannot start is no gate on the change most likely to trip it. Both cost a checkout plus one `node` call. @@ -67,8 +72,12 @@ checks it requires are green **on that rebuilt commit**. Those runs are a distin `merge_group`, on a throwaway `gh-readonly-queue/**` branch — a workflow that does not subscribe to that event simply does not run there. -Four workflows subscribe: `ci.yml`, `lint.yml`, `control-bytes.yml` and `docs-links.yml`. None of -them did until [#3523](https://github.com/objectstack-ai/objectui/issues/3523), and the consequence was not subtle. A queue whose required set +Five workflows subscribe: `ci.yml`, `lint.yml`, `control-bytes.yml`, `docs-links.yml` and +`changeset-presence.yml` (the last added with the gate itself, in +[#3387](https://github.com/objectstack-ai/objectui/issues/3387) — a gate that carries no path +filter reports on every pull request and is therefore requirable, which is exactly the property +this list tracks). None of the first four did until +[#3523](https://github.com/objectstack-ai/objectui/issues/3523), and the consequence was not subtle. A queue whose required set is empty validates nothing: it rebuilds the PR, sees no failing required check because there are no required checks, and merges. On 2026-08-07 three pull requests ([#3503](https://github.com/objectstack-ai/objectui/issues/3503), [#3510](https://github.com/objectstack-ai/objectui/issues/3510), [#3516](https://github.com/objectstack-ai/objectui/issues/3516)) merged with **Type Check** at @@ -520,24 +529,82 @@ The one release that legitimately bumps the major is the one following `@objects its major; it sets `OBJECTUI_ALLOW_MAJOR=1`. `pnpm test` asserts the same repository state, so the rule survives this workflow being skipped. -> **Nothing in CI requires a pull request to add a changeset, and there is no -> `skip-changeset` label.** Both were documented for months, by a second workflow inventory -> that lived at `.github/WORKFLOWS.md` — unpinned, therefore free to drift — until -> [#3724](https://github.com/objectstack-ai/objectui/issues/3724) deleted it. That page gave -> a "Changeset Check" workflow its own numbered section: it supposedly failed any PR touching -> `packages/` without a `.changeset/*.md`, and was skippable with a `skip-changeset` or -> `dependencies` label. None of it existed. No workflow file of that name has ever been in -> `.github/workflows/`, and the repository has no `skip-changeset` label (checked against the -> labels API, 2026-08-08 — the only one of the two names that exists is `dependencies`, which -> the auto-labeler applies and no gate reads). +> **A changeset IS now required, by `changeset-presence.yml` — but there is still no +> `skip-changeset` label.** Until [#3387](https://github.com/objectstack-ai/objectui/issues/3387) +> nothing in CI asked whether a PR had added one, and this note said so at length, because the +> opposite had been documented for months: a second workflow inventory at `.github/WORKFLOWS.md` +> — unpinned, therefore free to drift — gave a "Changeset Check" workflow its own numbered +> section, failing any PR touching `packages/` without a `.changeset/*.md` and skippable with a +> `skip-changeset` or `dependencies` label. None of it existed; +> [#3724](https://github.com/objectstack-ai/objectui/issues/3724) deleted the page. The label +> still does not exist (checked against the labels API, 2026-08-08 — of the two names only +> `dependencies` exists, applied by the auto-labeler and read by no gate), and the real gate has +> no label escape hatch by design: its exemption is a changeset with an **empty frontmatter**, +> which lives in the repository where the next reader finds it, rather than a label that vanishes +> from history. > -> The two real things with adjacent names do something else. **This** workflow reads pending -> changesets and rejects a `major` bump. `ci.yml`'s `changeset-check` job (**Changeset Fixed -> Group Check**) checks `fixed`-group *membership*. Neither asks whether the PR added a -> changeset, and no third thing does. Whether a change needs one is a judgement call — see -> "When to Create a Changeset" in `CONTRIBUTING.md` — and it is enforced by review, not by a -> check. This is the size-check lesson in a second place: a page that advertises a guardrail -> CI does not have is worse than no page, because contributors trust it and stop checking. +> The three real things with adjacent names each do something different, and none of them +> subsumes another. `changeset-guard.yml` reads pending changesets and rejects a `major` bump. +> `ci.yml`'s `changeset-check` job (**Changeset Fixed Group Check**) checks `fixed`-group +> *membership*. `changeset-presence.yml` asks whether this change declared anything at all. + +### Changeset Presence (`changeset-presence.yml`) + +**Trigger:** PR to `main`/`develop`, and merge-queue builds. **No path filter** — see below. +**Blocks a PR:** yes, when a released package's `src/` changed and the PR added no changeset. + +Runs `scripts/check-changeset-presence.mjs`, which compares the change against its merge base with +the target branch and asks one question: did anything under the `src/` of a package the release +covers change, and if so, does this change **add** a `.changeset/*.md`? + +- **The exemption is an empty frontmatter.** What is demanded is a declaration, once, by the person + who still knows what the change does — not a release. A changeset whose frontmatter names no + package is a first-class pass: + + ```md + --- + --- + + Test-only change to the grid column resolver; no published behaviour changes. + ``` + +- **The changeset must be ADDED by this change.** `.changeset/` accumulates until a release, so "a + changeset exists in the tree" would be satisfied by somebody else's pending declaration and make + the gate vacuous for every change that followed one. +- **The guarded surface is derived, not written down.** Every workspace package named in the + `fixed` group of `.changeset/config.json` contributes its `src/`; everything in `ignore` is + skipped. That matters more than it sounds: `@object-ui/console` lives at `apps/console`, outside + `packages/`, and is both the most-edited published package here and the one the platform's + `bump-objectui.sh` writes a changeset for — a hand-written `packages/*/src/**` glob would have + missed it. A changed source file whose package is in *neither* list fails the check rather than + being assumed unreleased; `check-changeset-fixed.mjs` is the gate that owns that classification. +- **Every missing input fails loudly.** An unresolvable base commit, a `git diff` that errors, a + missing `.changeset/` directory: all red, none a silent pass. Note the direction is the *opposite* + of the filter gates in `ci.yml` — those decide whether to run work, so "cannot tell" means run; + here the work *is* the decision, so "cannot tell" means fail. Both refuse to report green having + looked at nothing ([objectstack#4928](https://github.com/objectstack-ai/objectstack/issues/4928)). +- **No path filter, deliberately**, and it is the point of the whole workflow. A `paths:` filter + skips the entire workflow, so the context is never created on a PR that does not match — and a + required context that is never created leaves the PR pending rather than failing it + ([#3523](https://github.com/objectstack-ai/objectui/issues/3523)). It would also be a second copy + of the guarded surface, free to drift from the config the script reads. + +**Why this is separate from `changeset-guard.yml`, which also polices changesets:** that workflow's +trigger is `paths: ['.changeset/**']`, and the inversion is deliberate — a PR adding *only* a +changeset starts no other workflow, and that guard exists to see it. A PR that **forgot** its +changeset does not touch `.changeset/**` at all, so the one check able to notice was the one check +guaranteed not to run. Widening those paths would have broken the case that guard was built for. +Two workflows, opposite directions: one polices the *level* of a declaration that exists, the other +the *existence* of a declaration at all. + +Why it exists: [objectstack#4731](https://github.com/objectstack-ai/objectstack/issues/4731) / +[#4843](https://github.com/objectstack-ai/objectstack/issues/4843) made the declared changesets the +single criterion for which frontend changes shipped, and the premise underneath — published source +changed, so a changeset was written — was enforced by nothing. Replaying this gate over the 80 +commits before it landed reports 10 that would have failed, two of them user-visible fixes +(`918888a30` `fix(fields)`, `dcff16e06` `fix(cli,create-plugin)`) that reached a release with no +CHANGELOG line anywhere. `scripts/__tests__/check-changeset-presence.test.ts` pins the verdicts, the derived +surface, and every loud-failure path. ### Changelog Generation (`changelog.yml`) diff --git a/scripts/__tests__/check-changeset-presence.test.ts b/scripts/__tests__/check-changeset-presence.test.ts new file mode 100644 index 0000000000..35e527a193 --- /dev/null +++ b/scripts/__tests__/check-changeset-presence.test.ts @@ -0,0 +1,575 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { changedFiles, describeDeclaration, discoverPackages, readReleaseConfig, resolveBaseRef } from '../check-changeset-presence.mjs'; + +/** + * objectui#3387 — a change to published source could ship with no changeset, and + * nothing said so. + * + * objectstack#4731 / #4843 made the DECLARED changesets the single criterion for + * which frontend changes shipped: the platform reads this repository's + * `.changeset/*.md` into its own release notes. The premise underneath — + * *published source changed ⇒ a changeset was written* — was enforced by nothing, + * and three measured instances rode releases out anonymously (`19716b5bf` + * fix(charts), `5e7ef1141` fix(i18n), `0e50440` #3518). + * + * What this file pins, in the order the gate can fail: + * + * 1. **The workflow is reachable at all.** A gate behind a path filter it cannot + * start is the shape objectui#3523 spent a P0 on, and this gate's own subject + * matter makes it acute: `changeset-guard.yml` is invisible to a PR that + * forgot its changeset precisely because it filters on `.changeset/**`. + * 2. **The guarded surface is DERIVED**, not a hand-written glob. The issue + * proposed one glob under `packages/`; `@object-ui/console` lives at + * `apps/console` and would have been missed — the most-edited published + * package in the repository, and the one the platform bump script writes a + * changeset for. + * 3. **The verdicts**, against throwaway repositories rather than this one's + * history, so they stay decidable when the history moves. + * 4. **Every missing input fails LOUD.** A diff gate that cannot compute its + * diff and exits 0 reports "declared" while having looked at nothing + * (objectstack#4928, objectui#4690). + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const GATE = 'scripts/check-changeset-presence.mjs'; +const WORKFLOW = '.github/workflows/changeset-presence.yml'; +const workflowDir = path.join(repoRoot, '.github/workflows'); + +/** + * A workflow's YAML with whole-line comments removed. + * + * Required, not cosmetic: this workflow's header discusses `paths`, + * `paths-ignore` and `push` at length — a scan that counted the prose would + * report filters and triggers the file does not have. Same helper, same reason, + * as in `docs-links-workflow.test.ts` and `merge-queue-reporting.test.ts`. + */ +function withoutComments(yaml: string): string { + return yaml + .split('\n') + .filter((line) => !/^\s*#/.test(line)) + .join('\n'); +} + +const workflowYaml = withoutComments(fs.readFileSync(path.join(repoRoot, WORKFLOW), 'utf8')); + +// ── fixture repositories ───────────────────────────────────────────────────── + +const fixtures: string[] = []; +afterAll(() => { + for (const dir of fixtures) fs.rmSync(dir, { recursive: true, force: true }); +}); + +interface Fixture { + root: string; + git: (...args: string[]) => string; + write: (rel: string, body: string) => void; + commit: (message: string) => string; +} + +/** + * A throwaway repository shaped like this one: a `fixed` group, an `ignore`d + * package, packages under BOTH `packages/` and `apps/`. + * + * Named `@fixture/*` on purpose — if the gate ever stopped reading + * `.changeset/config.json` and fell back to a hard-coded `@object-ui/*` surface, + * every verdict below would flip, which is the drift these fixtures exist to + * catch. + */ +function fixtureRepo(label: string, { classifyAll = true } = {}): Fixture { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `changeset-presence-${label}-`)); + fixtures.push(root); + + const git = (...args: string[]): string => + execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + + const write = (rel: string, body: string): void => { + fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), body); + }; + + const commit = (message: string): string => { + execFileSync('git', ['add', '-A', '-f'], { cwd: root }); + execFileSync('git', ['commit', '-q', '-m', message], { cwd: root }); + return git('rev-parse', 'HEAD'); + }; + + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 'fixture@example.com'); + git('config', 'user.name', 'Fixture'); + + write( + '.changeset/config.json', + JSON.stringify({ fixed: [['@fixture/alpha', '@fixture/console']], ignore: ['@fixture/ignored-*'] }, null, 2), + ); + write('.changeset/README.md', '# Changesets\n\nDocumentation, not a declaration.\n'); + write('packages/alpha/package.json', JSON.stringify({ name: '@fixture/alpha', version: '1.0.0' })); + write('packages/alpha/src/index.ts', 'export const alpha = 1;\n'); + write('packages/alpha/README.md', 'alpha\n'); + write('packages/ignored-demo/package.json', JSON.stringify({ name: '@fixture/ignored-demo', version: '1.0.0' })); + write('packages/ignored-demo/src/index.ts', 'export const demo = 1;\n'); + write('apps/console/package.json', JSON.stringify({ name: '@fixture/console', version: '1.0.0' })); + write('apps/console/src/main.ts', 'export const main = 1;\n'); + write('docs/guide.md', 'guide\n'); + if (!classifyAll) { + // A package in NEITHER the fixed group nor `ignore` — the case the gate + // refuses to guess about. + write('packages/newcomer/package.json', JSON.stringify({ name: '@fixture/newcomer', version: '1.0.0' })); + write('packages/newcomer/src/index.ts', 'export const newcomer = 1;\n'); + } + commit('base'); + + return { root, git, write, commit }; +} + +interface Run { + status: number; + output: string; +} + +/** Runs the real gate against a fixture, capturing status and both streams. */ +function runGate(root: string, args: string[] = []): Run { + try { + const stdout = execFileSync('node', [path.join(repoRoot, GATE), '--root', root, ...args], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { status: 0, output: stdout }; + } catch (error) { + const failure = error as { status?: number; stdout?: string; stderr?: string }; + return { status: failure.status ?? -1, output: `${failure.stdout ?? ''}${failure.stderr ?? ''}` }; + } +} + +/** base..head for a fixture's last commit. */ +function lastCommitRange(fixture: Fixture): string[] { + return ['--base', fixture.git('rev-parse', 'HEAD~1'), '--head', fixture.git('rev-parse', 'HEAD')]; +} + +// ── 1. the workflow is reachable ───────────────────────────────────────────── + +describe('changeset-presence.yml — the gate can start on the PR that needs it', () => { + it('exists, and runs the gate script', () => { + expect(fs.existsSync(path.join(repoRoot, WORKFLOW)), 'a check nothing runs is not a gate').toBe(true); + expect(fs.existsSync(path.join(repoRoot, GATE)), `${GATE} must exist for the workflow to run it`).toBe(true); + expect(workflowYaml).toMatch(new RegExp(`run:\\s*node\\s+${GATE.replace(/[.]/g, '\\.')}`)); + }); + + it('carries NO path filter of any kind', () => { + // The reason it is its own workflow AND unfiltered. A filter on the trigger + // skips the whole workflow (GitHub has no per-job path filter), so the + // context is never CREATED on a PR that does not match — and a required + // context that is never created leaves the PR pending rather than failing + // it, which in the queue means the ruleset's 60-minute timeout + // (objectui#3523, #3509). It would also be a second copy of the script's + // guarded surface, free to drift from the `.changeset/config.json` the + // script derives it from. + expect(workflowYaml).not.toMatch(/paths-ignore:/); + expect(workflowYaml).not.toMatch(/^\s+paths:/m); + }); + + it('reports on pull requests and on merge-queue builds', () => { + expect(workflowYaml).toMatch(/^\s*pull_request:/m); + expect( + workflowYaml, + 'A gate with no path filter reports on every PR and can therefore be REQUIRED. A required ' + + 'context that does not report on a queue build does not fail the queue, it stalls it until ' + + "the ruleset's 60-minute status-check timeout (objectui#3523).", + ).toMatch(/^\s*merge_group:/m); + }); + + it('does not run on push to main, where nothing can be declared any more', () => { + // Not tidiness: the change has already landed, so a red push paints `main` + // red at whoever committed next, and no declaration can still be written + // into that change. The PR and the queue build are the two moments it can. + expect(workflowYaml).not.toMatch(/^\s*push:/m); + }); + + it('checks out enough history for a merge base to exist', () => { + // `fetch-depth: 0`. The default depth-1 clone gives `git merge-base` nothing + // to find, and the script treats an unresolvable base as a hard failure — so + // omitting this is a red build, not a silent pass. Pinned so it stays that + // way rather than being "fixed" by making the script skip. + expect(workflowYaml).toMatch(/fetch-depth:\s*0/); + }); + + it('is the only workflow that runs this gate, and needs no install to do it', () => { + const runners = fs + .readdirSync(workflowDir) + .filter((file) => file.endsWith('.yml')) + .filter((file) => withoutComments(fs.readFileSync(path.join(workflowDir, file), 'utf8')).includes(GATE)); + expect(runners).toEqual(['changeset-presence.yml']); + expect( + workflowYaml, + 'This gate reads `.changeset/config.json` and `git diff` only. A `pnpm install` here would ' + + 'turn a few-second check into a full install on every pull request.', + ).not.toMatch(/pnpm install/); + }); + + it('leaves changeset-guard.yml alone — the two gates face opposite directions', () => { + // `changeset-guard.yml`'s `paths: ['.changeset/**']` is deliberate: a PR + // adding ONLY a changeset starts no other workflow, and that guard exists to + // see it. A PR that FORGOT its changeset does not touch `.changeset/**`, so + // widening those paths would have broken the case it was built for while + // still not catching this one. Two workflows, one direction each. + const guard = withoutComments(fs.readFileSync(path.join(workflowDir, 'changeset-guard.yml'), 'utf8')); + expect(guard).toMatch(/^\s+paths:/m); + expect(guard).toMatch(/\.changeset\//); + expect(guard).toMatch(/check-changeset-no-major\.mjs/); + }); +}); + +// ── 2. the guarded surface is derived from the release configuration ───────── + +describe('the guarded surface follows .changeset/config.json, not a hand-written glob', () => { + it('guards apps/console — the package a `packages/*` glob would have missed', () => { + // objectui#3387 proposed the surface as one glob under `packages/`. + // `@object-ui/console` is published, is the most-edited package here, and is + // the one the platform's `bump-objectui.sh` writes a changeset FOR — and it + // lives outside that glob. This assertion is the reason the surface is read + // from the release configuration instead. + const config = readReleaseConfig(repoRoot); + const packages = discoverPackages(repoRoot, config); + + const console_ = packages.get('apps/console'); + expect(console_?.name).toBe('@object-ui/console'); + expect(console_?.versioned, '@object-ui/console is in the fixed group, so the release covers it').toBe(true); + + expect(packages.get('packages/plugin-charts')?.versioned).toBe(true); + expect(packages.get('packages/i18n')?.versioned).toBe(true); + }); + + it('does not guard what changesets ignores', () => { + const config = readReleaseConfig(repoRoot); + const packages = discoverPackages(repoRoot, config); + const site = packages.get('apps/site'); + expect(site?.name).toBe('@object-ui/site'); + expect(site?.versioned, '@object-ui/site is in `ignore` — no release covers it').toBe(false); + expect(site?.ignored).toBe(true); + }); + + it('would guard nothing only if the release configuration said so — and refuses to', () => { + // The empty-surface trap: a gate whose surface collapses to zero passes + // everything while looking healthy. `readReleaseConfig` rejects an empty + // `fixed` group rather than guarding nothing. + const empty = fixtureRepo('empty-fixed'); + empty.write('.changeset/config.json', JSON.stringify({ fixed: [], ignore: [] })); + empty.commit('empty fixed group'); + const run = runGate(empty.root, lastCommitRange(empty)); + expect(run.status).toBe(1); + expect(run.output).toMatch(/empty `fixed` group/); + }); +}); + +// ── 3. the verdicts ────────────────────────────────────────────────────────── + +describe('a change to guarded source must declare a changeset', () => { + it('FAILS when guarded source changes with no changeset', () => { + const repo = fixtureRepo('missing'); + repo.write('packages/alpha/src/index.ts', 'export const alpha = 2;\n'); + repo.commit('fix(alpha): a user-visible fix nobody declared'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(1); + expect(run.output).toMatch(/adds no changeset/); + expect(run.output).toMatch(/@fixture\/alpha/); + expect(run.output).toMatch(/packages\/alpha\/src\/index\.ts/); + // The remedy has to include the exemption, or the gate reads as "you must + // release something", which is not what it asks for. + expect(run.output).toMatch(/pnpm changeset/); + expect(run.output).toMatch(/EMPTY frontmatter/); + }); + + it('FAILS the same way for apps/console source', () => { + const repo = fixtureRepo('console'); + repo.write('apps/console/src/main.ts', 'export const main = 2;\n'); + repo.commit('fix(console): undeclared'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(1); + expect(run.output).toMatch(/@fixture\/console/); + }); + + it('PASSES when the change declares a changeset', () => { + const repo = fixtureRepo('declared'); + repo.write('packages/alpha/src/index.ts', 'export const alpha = 2;\n'); + repo.write('.changeset/brave-pandas-sing.md', '---\n"@fixture/alpha": patch\n---\n\nFix the alpha.\n'); + repo.commit('fix(alpha): declared'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(0); + expect(run.output).toMatch(/declares 1 changeset/); + }); + + it('PASSES on an EMPTY frontmatter — the explicit release-nothing exemption', () => { + // The whole point of objectui#3387's wording: what is demanded is a + // declaration, once, not a release. A gate that refused this would push + // authors to invent a patch bump for a test-only change, which is worse than + // the silence it replaced. + const repo = fixtureRepo('exempt'); + repo.write('packages/alpha/src/__tests__/alpha.test.ts', 'it("works", () => {});\n'); + repo.write('.changeset/no-release.md', '---\n---\n\nTest-only change; no published behaviour changes.\n'); + repo.commit('test(alpha): exempt'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(0); + expect(run.output).toMatch(/EMPTY frontmatter/); + expect(run.output).toMatch(/explicit exemption/); + }); + + it('PASSES when nothing guarded changed', () => { + const repo = fixtureRepo('unguarded'); + repo.write('docs/guide.md', 'guide, revised\n'); + repo.write('packages/alpha/README.md', 'alpha, revised\n'); + repo.write('packages/ignored-demo/src/index.ts', 'export const demo = 2;\n'); + repo.commit('docs: no released source touched'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(0); + expect(run.output).toMatch(/no changeset is owed/); + // The ignored package's source changed and was deliberately not counted. + expect(run.output).toMatch(/1 file\(s\) under a package changesets ignores/); + }); + + it('does not accept a changeset that was already pending — it must be ADDED here', () => { + // `.changeset/` accumulates until a release, so "a changeset exists in the + // tree" is satisfied by somebody else's declaration and would make this gate + // vacuous for every change that follows one. + // + // What this pins is the RANGE (a two-commit diff, not a scan of the tree). It + // is deliberately NOT the assertion that covers `--diff-filter=A`: measured by + // deleting that filter, this test stays green, because a changeset added in an + // earlier commit is already outside the diff. The two tests below are the ones + // that reach the filter, and they were written after that measurement — the + // first draft claimed this one covered it, which would have left a fixture + // passing for a weaker reason than its comment said. + const repo = fixtureRepo('pending'); + repo.write('.changeset/someone-elses.md', '---\n"@fixture/alpha": patch\n---\n\nAn earlier change.\n'); + repo.commit('feat(alpha): an earlier, declared change'); + repo.write('packages/alpha/src/index.ts', 'export const alpha = 3;\n'); + repo.commit('fix(alpha): undeclared, riding on the pending changeset'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(1); + expect(run.output).toMatch(/adds no changeset/); + }); + + it('does not accept EDITING a pre-existing changeset as declaring this change', () => { + // Reaches `--diff-filter=A`: an edit to somebody else's pending declaration is + // a `M` in the diff, so without the filter it would satisfy this gate — + // measured, by removing the filter and watching this go green. Touching a + // changeset is not writing one. + const repo = fixtureRepo('edits-pending'); + repo.write('.changeset/someone-elses.md', '---\n"@fixture/alpha": patch\n---\n\nAn earlier change.\n'); + repo.commit('feat(alpha): an earlier, declared change'); + + repo.write('packages/alpha/src/index.ts', 'export const alpha = 10;\n'); + repo.write('.changeset/someone-elses.md', '---\n"@fixture/alpha": patch\n---\n\nAn earlier change, reworded.\n'); + repo.commit('fix(alpha): undeclared, but it did touch a changeset'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(1); + expect(run.output).toMatch(/adds no changeset/); + }); + + it('does not accept DELETING a pre-existing changeset as declaring this change', () => { + // The same filter from the other side: a `D` is a touched `.changeset/*.md` + // too, so removing a pending declaration while editing source must not pass. + // + // Measured, and NOT the same story as the edit case above: with + // `--diff-filter=A` deleted this test stays GREEN, because a deleted file + // cannot be read at `head`, so it parses as declaring nothing and is rejected + // by the second mechanism instead. Two independent defences, and this test + // pins the OUTCOME rather than either one of them — worth keeping precisely + // because whichever defence is removed first, the other still holds the line. + const repo = fixtureRepo('deletes-pending'); + repo.write('.changeset/someone-elses.md', '---\n"@fixture/alpha": patch\n---\n\nAn earlier change.\n'); + repo.commit('feat(alpha): an earlier, declared change'); + + repo.write('packages/alpha/src/index.ts', 'export const alpha = 11;\n'); + fs.rmSync(path.join(repo.root, '.changeset/someone-elses.md')); + repo.commit('fix(alpha): undeclared, and it dropped the pending changeset'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(1); + expect(run.output).toMatch(/adds no changeset/); + }); + + it('does not accept .changeset/README.md as a declaration', () => { + const repo = fixtureRepo('readme'); + fs.rmSync(path.join(repo.root, '.changeset/README.md')); + repo.commit('chore: drop the changeset README'); + repo.write('packages/alpha/src/index.ts', 'export const alpha = 4;\n'); + repo.write('.changeset/README.md', '# Changesets\n\nRe-added documentation.\n'); + repo.commit('fix(alpha): with only the README back'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(1); + }); + + it('does not accept an added .changeset file with no frontmatter block', () => { + // It declares nothing, and changesets reads nothing out of it. Reported as + // ignored rather than accepted, so a gate satisfied by an empty gesture + // cannot be mistaken for one satisfied by a declaration. + const repo = fixtureRepo('no-frontmatter'); + repo.write('packages/alpha/src/index.ts', 'export const alpha = 5;\n'); + repo.write('.changeset/notes.md', 'Some notes with no frontmatter at all.\n'); + repo.commit('fix(alpha): with a note instead of a changeset'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(1); + expect(run.output).toMatch(/no `---` frontmatter block/); + }); + + it('counts an UNTRACKED changeset when judging the working tree', () => { + // `pnpm changeset` leaves a brand-new, unstaged file, and `git diff` cannot + // see one. Without this, running the gate locally right after writing a + // changeset reports it missing — a false red that teaches the author the + // gate is broken. + const repo = fixtureRepo('untracked'); + repo.write('packages/alpha/src/index.ts', 'export const alpha = 6;\n'); + repo.commit('fix(alpha): source only, so far'); + const base = repo.git('rev-parse', 'HEAD~1'); + + const before = runGate(repo.root, ['--base', base]); + expect(before.status, 'working tree, no changeset yet').toBe(1); + + repo.write('.changeset/fresh-from-pnpm-changeset.md', '---\n"@fixture/alpha": patch\n---\n\nFix it.\n'); + const after = runGate(repo.root, ['--base', base]); + expect(after.status, 'the untracked changeset counts against the working tree').toBe(0); + expect(after.output).toMatch(/fresh-from-pnpm-changeset\.md/); + }); +}); + +// ── 4. every missing input fails loud ─────────────────────────────────────── + +describe('a missing input is a failure, never a silent pass', () => { + it('FAILS on an explicitly named base that does not exist — it does NOT fall back', () => { + // The direction is the whole point, and it is the OPPOSITE of a filter gate's + // (objectstack#4928): `ci.yml`'s gates decide whether to RUN work, so "cannot + // tell" means run. Here the work IS the decision, so "cannot tell" means + // fail. Both refuse to report green having looked at nothing. + // + // This assertion found a real defect in the first draft. `resolveBaseRef` + // treated `--base` as merely the FIRST candidate in a chain, so an explicit + // sha that was missing from the clone fell through to `merge-base with main` + // — the gate then compared the change against a completely different commit + // and printed a confident green (measured: exit 0). "The base you named is + // missing" and "you named no base" are different facts; only the second may + // be answered by guessing. + const repo = fixtureRepo('no-base'); + repo.write('packages/alpha/src/index.ts', 'export const alpha = 9;\n'); + repo.commit('fix(alpha): undeclared, and judged against a base that does not exist'); + + const run = runGate(repo.root, ['--base', '0123456789abcdef0123456789abcdef01234567']); + expect(run.status).toBe(1); + expect(run.output).toMatch(/Cannot resolve the commit to compare against/); + expect(run.output).toMatch(/named EXPLICITLY/); + expect(run.output).toMatch(/failure, not a skip/); + // And specifically NOT the pass it used to produce by comparing against main. + expect(run.output).not.toMatch(/no changeset is owed/); + }); + + it('names the shallow-clone case, which is how CI hits it', () => { + const repo = fixtureRepo('shallow-source'); + repo.write('packages/alpha/src/index.ts', 'export const alpha = 7;\n'); + repo.commit('fix(alpha): second commit'); + + const shallow = fs.mkdtempSync(path.join(os.tmpdir(), 'changeset-presence-shallow-')); + fixtures.push(shallow); + execFileSync('git', ['clone', '-q', '--depth', '1', `file://${repo.root}`, shallow], { stdio: ['ignore', 'pipe', 'pipe'] }); + // What a depth-1 CI checkout of a pull-request merge ref actually looks like: + // a detached HEAD, no local `main`, and no `origin/main` — so there is no ref + // any merge base can be computed against. (HEAD is detached FIRST; git + // refuses to delete the branch a worktree has checked out.) + execFileSync('git', ['checkout', '-q', '--detach'], { cwd: shallow }); + execFileSync('git', ['branch', '-q', '-D', 'main'], { cwd: shallow, stdio: ['ignore', 'pipe', 'pipe'] }); + execFileSync('git', ['update-ref', '-d', 'refs/remotes/origin/main'], { cwd: shallow }); + + const run = runGate(shallow); + expect(run.status).toBe(1); + expect(run.output).toMatch(/SHALLOW/); + expect(run.output).toMatch(/fetch-depth: 0/); + }); + + it('FAILS when .changeset/ does not exist', () => { + // Named explicitly by objectui#3387: losing the ability to tell what a + // declaration would even be about must be a red build. + const repo = fixtureRepo('no-changeset-dir'); + repo.write('packages/alpha/src/index.ts', 'export const alpha = 8;\n'); + repo.commit('fix(alpha): change'); + fs.rmSync(path.join(repo.root, '.changeset'), { recursive: true, force: true }); + + const run = runGate(repo.root, ['--base', repo.git('rev-parse', 'HEAD~1')]); + expect(run.status).toBe(1); + expect(run.output).toMatch(/does not exist/); + expect(run.output).toMatch(/cannot tell whether one is owed/); + }); + + it('FAILS when changed source belongs to a package the release configuration does not classify', () => { + // The gate will not guess "not released" for a package nobody classified — + // that would leave the NEWEST package in the repository the one nothing + // guards. `check-changeset-fixed.mjs` owns the classification itself. + const repo = fixtureRepo('unclassified', { classifyAll: false }); + repo.write('packages/newcomer/src/index.ts', 'export const newcomer = 2;\n'); + repo.commit('feat(newcomer): source change in an unclassified package'); + + const run = runGate(repo.root, lastCommitRange(repo)); + expect(run.status).toBe(1); + expect(run.output).toMatch(/@fixture\/newcomer/); + expect(run.output).toMatch(/neither the `fixed` group nor `ignore`/); + expect(run.output).toMatch(/check-changeset-fixed\.mjs/); + }); + + it('surfaces git\'s own explanation when a diff cannot be computed', () => { + // `changedFiles` throws rather than returning an empty list. The two are + // indistinguishable to a caller that swallows the error, and "no files + // changed" is the reading that passes. + const repo = fixtureRepo('diff-failure'); + expect(() => changedFiles(repo.root, { base: 'refs/heads/definitely-not-a-branch' })).toThrowError(/git diff/); + }); + + it('reports which candidates it tried when no base resolves', () => { + const repo = fixtureRepo('tried'); + execFileSync('git', ['checkout', '-q', '-b', 'detached-from-main'], { cwd: repo.root }); + execFileSync('git', ['branch', '-q', '-D', 'main'], { cwd: repo.root }); + const outcome = resolveBaseRef(repo.root, { env: {} }); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.tried.join(', ')).toMatch(/merge-base with origin\/main \(unresolved\)/); + expect(outcome.tried.join(', ')).toMatch(/merge-base with main \(unresolved\)/); + } + }); +}); + +// ── 5. the frontmatter reader ──────────────────────────────────────────────── + +describe('describeDeclaration — what counts as a declaration', () => { + it('reads an empty frontmatter as a declaration of nothing', () => { + expect(describeDeclaration('---\n---\n\nWhy this releases nothing.\n')).toEqual({ kind: 'frontmatter', entries: 0 }); + }); + + it('counts package entries, quoted or bare', () => { + expect(describeDeclaration('---\n"@fixture/alpha": patch\n@fixture/beta: minor\n---\n\nBody.\n')).toEqual({ + kind: 'frontmatter', + entries: 2, + }); + }); + + it('ignores comments and blank lines inside the frontmatter', () => { + expect(describeDeclaration('---\n# a comment\n\n"@fixture/alpha": patch\n---\n')).toEqual({ + kind: 'frontmatter', + entries: 1, + }); + }); + + it('rejects a file with no frontmatter, and one whose frontmatter never closes', () => { + expect(describeDeclaration('Just prose.\n').kind).toBe('none'); + expect(describeDeclaration('---\n"@fixture/alpha": patch\n\nBody with no closing delimiter.\n').kind).toBe('none'); + }); +}); diff --git a/scripts/__tests__/merge-queue-reporting.test.ts b/scripts/__tests__/merge-queue-reporting.test.ts index 31c72af932..de12292a59 100644 --- a/scripts/__tests__/merge-queue-reporting.test.ts +++ b/scripts/__tests__/merge-queue-reporting.test.ts @@ -60,6 +60,12 @@ const MUST_SUBSCRIBE_MERGE_GROUP = new Map([ ['lint.yml', 'produces Lint — the ESLint error ratchets'], ['control-bytes.yml', 'produces Control Byte Scan, one of the two contexts #3523 found safe to require today'], ['docs-links.yml', 'produces Internal Docs Link Check, the other one'], + [ + 'changeset-presence.yml', + 'produces Changeset Declaration — added by objectui#3387 with no path filter at all, for the ' + + 'reason this list exists: it reports on every pull request, so it is requirable, and a ' + + 'requirable context that skips the queue build stalls it', + ], ]); /** Workflows whose path filtering had to move from the trigger into the jobs. */ diff --git a/scripts/check-changeset-presence.mjs b/scripts/check-changeset-presence.mjs new file mode 100644 index 0000000000..f151537021 --- /dev/null +++ b/scripts/check-changeset-presence.mjs @@ -0,0 +1,663 @@ +#!/usr/bin/env node +/** + * When a change touches the SOURCE of a package changesets versions, the same + * change must declare a `.changeset/*.md`. An EMPTY frontmatter counts. + * + * Run: node scripts/check-changeset-presence.mjs + * Exit: 0 = nothing guarded changed, or a changeset was added + * 1 = guarded source changed with no declaration, or the inputs could not + * be read (both are failures — see "Never silent" below) + * + * ## The gap this closes (objectui#3387) + * + * objectstack#4731 / #4843 made the DECLARED changesets the single criterion for + * "which frontend changes shipped": the platform reads this repository's + * `.changeset/*.md` and writes them into its own release notes. That criterion is + * only as true as the premise underneath it — *a change to published source + * carries a changeset* — and nothing enforced the premise. + * + * Measured over `7d9734d5e321..785b8a5d432c` (53 non-merge commits): 7 carried no + * changeset, and two of those changed published source in a user-visible way — + * `19716b5bf` `fix(charts): name the slices` (`plugin-charts`, `plugin-dashboard`) + * and `5e7ef1141` `fix(i18n): resolve qualified view ids` (`i18n`). Neither + * appears in a CHANGELOG, a version number, or any release note: both rode the + * next release out as anonymous cargo. A third landed after the issue was + * written — `0e50440` (#3518), 26 files across five packages and all ten locale + * packs — which is the point worth keeping in view: objectstack#4843 made the + * omission *audible* on the platform side, and it did not make it stop. Only a + * gate does. + * + * ## Why this is a second workflow and not a wider trigger on the first + * + * `.github/workflows/changeset-guard.yml` already exists and looks like the + * natural home. It is not: its trigger is `paths: ['.changeset/**']`, and that + * INVERSION is deliberate and documented in its own header — `ci.yml` and + * `lint.yml` both list `.changeset/**` under `paths-ignore`, so a PR that adds + * ONLY a changeset starts no other workflow at all, and that guard exists to see + * exactly that PR. A PR which forgot its changeset, by definition, does not touch + * `.changeset/**`: the one check that could notice is the one check guaranteed + * not to run. Widening its paths would break the case it was built for, so this + * gate is forward-triggered and lives alongside it. + * + * ## What is guarded, and why it is derived rather than listed + * + * `/src/**` for every workspace package NAMED IN the `fixed` group of + * `.changeset/config.json`. That set is where the release actually is: changesets + * versions those packages (and writes their CHANGELOGs) as one family, so a + * source edit to any of them ships. Everything in the `ignore` list is skipped — + * changesets never versions it, so demanding a declaration would be demanding a + * declaration about nothing. + * + * Derived, not spelled out, and that is the load-bearing part. objectui#3387 + * proposed the surface as one literal glob — `packages`, any one package, `src`, + * anything (not spelled out here: a glob whose star is followed by a slash closes + * this comment block early, the trap `tsconfig.scripts.json`'s header records) — + * which reads as + * exhaustive and is not: `@object-ui/console` — the single most-edited published + * package in this repository, and the one the platform's `bump-objectui.sh` + * writes a changeset FOR — lives at `apps/console`, outside that glob entirely. + * A hand-written surface would have shipped a gate that misses the package the + * whole criterion is mostly about. Reading `.changeset/config.json` instead means + * the guarded set follows the release configuration by construction, and + * `scripts/check-changeset-fixed.mjs` already fails when a workspace package is + * in neither `fixed` nor `ignore` — so the two gates compose into "every package + * is classified, and every classified-as-released package's source is declared". + * + * Two deliberate boundaries, stated so they are not mistaken for oversights: + * + * - **`src/**` only.** A dependency bump in a package's `package.json` can be + * just as user-visible, and this gate does not see it. Widening to whole + * package directories would demand a changeset for `README` edits and test + * fixtures outside `src`, which is friction with no release meaning. + * - **No carve-out for test files under `src/`.** A change confined to + * `src/__tests__/**` is answered by the empty-frontmatter exemption, in one + * line. The alternative — teaching the gate which files "don't count" — is + * where the holes live, and this gate asks for a sentence, not a release. + * + * ## The empty-frontmatter exemption + * + * A changeset whose frontmatter declares no package (`---` immediately followed + * by `---`) is a first-class pass. What is being demanded is a DECLARATION, once, + * by the person who still knows what the change does — not a release. So: + * + * --- + * --- + * + * Internal refactor of the grid's column resolver; no behaviour change. + * + * is a complete, correct answer to this gate, and it leaves the reason in the + * repository where the next reader finds it. + * + * An added `.changeset/*.md` with NO frontmatter block at all does not count: it + * declares nothing, and changesets reads nothing out of it. That file is reported + * as ignored rather than accepted, so a gate satisfied by an empty gesture cannot + * be mistaken for a gate satisfied by a declaration. + * + * ## Never silent (objectui#4690, objectstack#4928) + * + * Every input this gate needs can be missing, and every one of them fails LOUD: + * an unresolvable base commit, a `git diff` that errors, a missing `.changeset/` + * directory, a changed source file belonging to a package that is in neither + * `fixed` nor `ignore`. A diff gate that cannot compute its diff and exits 0 + * reports "declaration present" while having looked at nothing — which is the + * same shape of green-while-blind that objectstack#4928 named the filter + * contract, and the same one this gate exists to close one level up. Note the + * direction: for a filter deciding whether to RUN work, "cannot tell" means run; + * here the work IS the decision, so "cannot tell" means fail. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +/** + * The separator `git -z` writes between paths, as a CODE POINT rather than a + * character literal. + * + * `scripts/check-control-bytes.mjs` spells it exactly this way, and the reason is + * the gate it implements: a raw control byte in a tracked file makes grep and + * ripgrep classify the whole file as binary and return no matches at all, so the + * file silently drops out of code search and out of every grep-based lint. Writing + * the byte into this source — which happens most easily in the file that talks + * about it — would take this gate out of every future grep, and a `.split()` + * argument is the one place it is invisible on inspection. objectstack#4890 landed + * a NUL in the very file that was documenting the rule against it. + */ +const NUL = 0x00; + +/** Workspace directories that hold one package per subdirectory. */ +export const PACKAGE_ROOTS = ['packages', 'apps']; + +/** `.changeset/README.md` is documentation, not a declaration. */ +export const NOT_A_CHANGESET = new Set(['README.md']); + +// -- git ---------------------------------------------------------------------- + +/** + * `git` in `root`, stderr piped. + * + * Piped rather than inherited because `resolveBaseRef` probes refs that + * legitimately do not exist; git's "Not a valid object name" for a probe that was + * supposed to miss reads as a real error in a CI log. Every call that MUST + * succeed reports git's own message itself (see `changedFiles`). + */ +function git(root, args) { + return execFileSync('git', args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function gitQuiet(root, args) { + try { + return git(root, args).trim(); + } catch { + return null; + } +} + +/** + * Which commit this change should be judged against. + * + * An explicitly NAMED base (`--base`, `OS_CHANGESET_BASE`) is authoritative and + * is the only thing consulted when given. Otherwise: the merge base with the pull + * request's own base branch (`GITHUB_BASE_REF`), then `origin/main`, then a local + * `main`. The merge base — rather than the target branch's tip — is what makes a + * branch answer for the edits IT made and not for whatever landed on `main` after + * it forked. `check-i18n-en-drift.mjs` resolves its base from the same sources. + * + * This chain is also what makes the gate event-agnostic. On `pull_request`, + * `GITHUB_BASE_REF` names the target branch; on a `merge_group` build there is no + * such variable and no `github.event.pull_request` payload, and the fallback — + * merge base with `origin/main` — resolves to the commit the queue built the + * group on, so the diff is exactly the queued pull requests' changes. No + * per-event YAML, and nothing to get wrong when a third event appears. + * + * A base that cannot be resolved is a HARD FAILURE, never a skip — and an + * explicit one that cannot be resolved does NOT fall through to the discovery + * candidates. That distinction was a real defect in the first draft of this gate, + * caught by its own tests: with the fallthrough, `--base ` + * silently judged the change against `main`'s tip instead and printed a + * confident green. "The base you named is missing" and "you named no base" are + * different facts, and only the second one may be answered by guessing. (The + * sibling gate `check-i18n-en-drift.mjs` still has the fallthrough shape — filed + * separately, not fixed here.) + * + * @returns {{ ok: true, ref: string, how: string } | { ok: false, tried: string[], shallow: boolean, named: boolean }} + */ +export function resolveBaseRef(root, { explicit = null, env = process.env } = {}) { + const tried = []; + const verify = (ref) => gitQuiet(root, ['rev-parse', '--verify', `${ref}^{commit}`]); + const shallow = () => gitQuiet(root, ['rev-parse', '--is-shallow-repository']) === 'true'; + + const named = explicit + ? { how: `--base ${explicit}`, ref: explicit } + : env.OS_CHANGESET_BASE + ? { how: `OS_CHANGESET_BASE=${env.OS_CHANGESET_BASE}`, ref: env.OS_CHANGESET_BASE } + : null; + + if (named) { + const resolved = verify(named.ref); + tried.push(`${named.how}${resolved ? '' : ' (unresolved)'}`); + return resolved + ? { ok: true, ref: resolved, how: named.how } + : { ok: false, tried, shallow: shallow(), named: true }; + } + + const attempt = (how, compute) => { + const value = compute(); + tried.push(`${how}${value ? '' : ' (unresolved)'}`); + return value ? { ok: true, ref: value, how } : null; + }; + + const candidates = [ + env.GITHUB_BASE_REF + ? () => + attempt(`merge-base with origin/${env.GITHUB_BASE_REF}`, () => + gitQuiet(root, ['merge-base', 'HEAD', `origin/${env.GITHUB_BASE_REF}`]), + ) + : null, + () => attempt('merge-base with origin/main', () => gitQuiet(root, ['merge-base', 'HEAD', 'origin/main'])), + () => attempt('merge-base with main', () => gitQuiet(root, ['merge-base', 'HEAD', 'main'])), + ].filter(Boolean); + + for (const candidate of candidates) { + const hit = candidate(); + if (hit) return hit; + } + return { ok: false, tried, shallow: shallow(), named: false }; +} + +/** + * `git diff --name-only` between `base` and `head`, or between `base` and the + * WORKING TREE when `head` is null. + * + * The working tree is the default "after" side on purpose, exactly as in + * `check-i18n-en-drift.mjs`: an author running this locally gets the answer + * before committing. In CI the checkout is clean, so the two coincide. + * + * `-z` rather than newline-delimited output: it removes git's path quoting from + * the picture entirely, so a non-ASCII or space-bearing path is read verbatim + * instead of arriving as an escaped string this parser would have to unescape. + * The NUL separator is written as an escape sequence and never as a raw byte + * (objectui#3388 / objectstack#4890 — one raw control byte makes grep treat the + * whole file as binary and every later grep over it silently returns nothing). + * + * Throws with git's own stderr on failure. The caller turns that into exit 1: a + * diff that cannot be computed is not an empty diff. + * + * @param {string[]} pathspecs extra pathspecs; `[]` means the whole tree + * @returns {string[]} repo-relative POSIX paths + */ +export function changedFiles(root, { base, head = null, filter = null, pathspecs = [] }) { + const args = ['diff', '--name-only', '-z']; + if (filter) args.push(`--diff-filter=${filter}`); + args.push(base); + if (head) args.push(head); + if (pathspecs.length > 0) args.push('--', ...pathspecs); + + let out; + try { + out = git(root, args); + } catch (error) { + const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : ''; + throw new Error(`\`git ${args.join(' ')}\` failed${stderr ? `:\n ${stderr}` : ''}`); + } + return out.split(String.fromCharCode(NUL)).filter((path) => path !== ''); +} + +/** + * `.changeset/*.md` files that exist on disk but are not tracked yet. + * + * `git diff` against the working tree cannot see an untracked file, and + * `pnpm changeset` leaves precisely that: a brand-new, unstaged file. Without + * this, running the gate locally right after writing a changeset would report the + * changeset missing — a false red that teaches the author the gate is broken. + * Empty in CI, where the checkout has no untracked files. + */ +export function untrackedChangesets(root) { + const out = gitQuiet(root, [ + 'ls-files', + '--others', + '--exclude-standard', + '-z', + '--', + ':(glob).changeset/*.md', + ]); + return out === null ? [] : out.split(String.fromCharCode(NUL)).filter((path) => path !== ''); +} + +// -- the guarded surface ------------------------------------------------------ + +/** Turns a changesets `ignore` entry (`@object-ui/example-*`) into a matcher. */ +function ignoreMatcher(patterns) { + const expressions = patterns.map( + (pattern) => new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === '*' ? '.*' : `\\${c}`))}$`), + ); + return (name) => expressions.some((expression) => expression.test(name)); +} + +/** + * The release configuration, read from `.changeset/config.json`. + * + * A missing `.changeset/` directory or an unreadable config THROWS. objectui#3387 + * names that case explicitly: the gate's whole job is to demand a declaration, so + * losing the ability to tell what a declaration would even be about must be a red + * build and not a quiet pass. + */ +export function readReleaseConfig(root) { + const dir = join(root, '.changeset'); + if (!existsSync(dir)) { + throw new Error( + `${dir} does not exist. This gate reads \`.changeset/config.json\` to learn which ` + + 'packages a release covers; without it there is nothing to check against.', + ); + } + const configPath = join(dir, 'config.json'); + let config; + try { + config = JSON.parse(readFileSync(configPath, 'utf8')); + } catch (error) { + throw new Error(`cannot read .changeset/config.json: ${error.message}`); + } + const versioned = new Set((config.fixed ?? []).flat()); + if (versioned.size === 0) { + throw new Error( + '.changeset/config.json declares an empty `fixed` group, so this gate would guard nothing ' + + 'and pass everything. That is a configuration error, not an empty guarded set.', + ); + } + return { versioned, isIgnored: ignoreMatcher(config.ignore ?? []) }; +} + +/** + * `directory -> { name, versioned }` for every workspace package. + * + * Scans the same roots as `scripts/check-changeset-fixed.mjs` — one package per + * immediate subdirectory — so the two gates agree about what a package is. + * + * @returns {Map} + */ +export function discoverPackages(root, { versioned, isIgnored }) { + const packages = new Map(); + for (const parent of PACKAGE_ROOTS) { + let entries; + try { + entries = readdirSync(join(root, parent)); + } catch { + continue; // a root that does not exist in this checkout + } + for (const entry of entries.sort()) { + const manifest = join(root, parent, entry, 'package.json'); + try { + if (!statSync(manifest).isFile()) continue; + } catch { + continue; + } + const name = JSON.parse(readFileSync(manifest, 'utf8')).name; + if (!name) continue; + packages.set(`${parent}/${entry}`, { + name, + versioned: versioned.has(name), + ignored: isIgnored(name), + }); + } + } + return packages; +} + +/** + * Splits changed paths into the ones this gate demands a declaration for and the + * ones it cannot classify. + * + * `unclassified` is a changed source file under a package that appears in neither + * the `fixed` group nor `ignore`. It is a loud failure rather than a skip: the + * gate genuinely cannot tell whether that source ships, and guessing "no" would + * silently re-open the hole for exactly the newest package in the repository. + * `scripts/check-changeset-fixed.mjs` is the gate that owns the classification + * itself, and its message is the one to follow. + * + * @param {string[]} paths + * @param {Map} packages + */ +export function classifyChangedPaths(paths, packages) { + const guarded = []; + const unclassified = []; + const skipped = []; + + for (const path of paths) { + let owner = null; + for (const [directory, pkg] of packages) { + if (path.startsWith(`${directory}/src/`)) { + owner = { directory, pkg }; + break; + } + } + if (owner === null) continue; + const entry = { file: path, pkg: owner.pkg.name, directory: owner.directory }; + if (owner.pkg.versioned) guarded.push(entry); + else if (owner.pkg.ignored) skipped.push(entry); + else unclassified.push(entry); + } + return { guarded, unclassified, skipped }; +} + +// -- declarations ------------------------------------------------------------- + +/** + * What a candidate changeset file declares. + * + * `kind: 'none'` means there is no `---` … `---` frontmatter block at all, so the + * file declares nothing and cannot serve as the declaration this gate demands. + * `entries` counts `name: bump` lines; zero of them is the empty-frontmatter + * exemption and is a valid pass. + * + * The frontmatter dialect is hand-parsed for the same reason as in + * `check-changeset-no-major.mjs`: this gate runs on a bare checkout with no + * `pnpm install`, so it may not import a YAML parser or `@changesets/*`. + * + * @returns {{ kind: 'none' } | { kind: 'frontmatter', entries: number }} + */ +export function describeDeclaration(source) { + const lines = source.split(/\r?\n/); + const open = lines.findIndex((line) => line.trim() === '---'); + if (open === -1) return { kind: 'none' }; + let entries = 0; + let closed = false; + for (let i = open + 1; i < lines.length; i++) { + const text = lines[i].trim(); + if (text === '---') { + closed = true; + break; + } + if (text === '' || text.startsWith('#')) continue; + if (/^(?:"[^"]+"|'[^']+'|[^:]+?)\s*:\s*(?:major|minor|patch)\s*$/.test(text)) entries += 1; + } + return closed ? { kind: 'frontmatter', entries } : { kind: 'none' }; +} + +/** + * Every `.changeset/*.md` this change ADDS, with what each one declares. + * + * Added, not merely present: `.changeset/` accumulates until a release, so "a + * changeset exists in the tree" is satisfied by somebody else's pending + * declaration and would make the gate vacuous for every change that follows one. + * + * @returns {{ file: string, declaration: ReturnType }[]} + */ +export function addedDeclarations(root, { base, head = null }) { + const added = changedFiles(root, { base, head, filter: 'A', pathspecs: [':(glob).changeset/*.md'] }); + const candidates = [...added, ...(head === null ? untrackedChangesets(root) : [])]; + + const seen = new Set(); + const results = []; + for (const file of candidates) { + const name = file.slice(file.lastIndexOf('/') + 1); + if (NOT_A_CHANGESET.has(name) || seen.has(file)) continue; + seen.add(file); + // Added at `head`, so it is readable there — from disk when `head` is the + // working tree, out of the object store otherwise. + const source = head === null ? readFileSync(join(root, file), 'utf8') : gitQuiet(root, ['show', `${head}:${file}`]); + results.push({ file, declaration: source === null ? { kind: 'none' } : describeDeclaration(source) }); + } + return results.sort((a, b) => a.file.localeCompare(b.file)); +} + +// -- the analysis ------------------------------------------------------------- + +/** + * @typedef {object} Analysis + * @property {{ file: string, pkg: string, directory: string }[]} guarded + * @property {{ file: string, pkg: string, directory: string }[]} unclassified + * @property {{ file: string, pkg: string, directory: string }[]} skipped + * @property {{ file: string, declaration: { kind: string, entries?: number } }[]} declarations + * @property {number} changedFileCount + */ + +/** + * The whole judgement for one repo root and one revision range. Throws on any + * unreadable input; the CLI below turns that into exit 1. + * + * @returns {Analysis} + */ +export function analyze(root, { base, head = null }) { + const config = readReleaseConfig(root); + const packages = discoverPackages(root, config); + const paths = changedFiles(root, { base, head }); + const { guarded, unclassified, skipped } = classifyChangedPaths(paths, packages); + return { + guarded, + unclassified, + skipped, + declarations: addedDeclarations(root, { base, head }), + changedFileCount: paths.length, + }; +} + +/** The declarations that actually declare something (frontmatter present). */ +export const usableDeclarations = (analysis) => analysis.declarations.filter((d) => d.declaration.kind !== 'none'); + +/** + * `0` when the change is either unguarded or declared, `1` otherwise. + * + * @param {Analysis} analysis + */ +export function verdict(analysis) { + if (analysis.unclassified.length > 0) return 1; + if (analysis.guarded.length === 0) return 0; + return usableDeclarations(analysis).length > 0 ? 0 : 1; +} + +// -- CLI ---------------------------------------------------------------------- + +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); + +if (invokedDirectly) { + const argOf = (name) => { + const index = process.argv.indexOf(name); + return index > -1 ? process.argv[index + 1] : null; + }; + + // `--root` points the gate at another checkout — a worktree, or one of the + // throwaway repositories `check-changeset-presence.test.ts` builds to exercise + // these exit codes end to end. `--base` / `--head` name the two commits; + // both default to "this branch against its merge base with the target branch". + const root = resolve(argOf('--root') ?? resolve(scriptDir, '..')); + const head = argOf('--head'); + + const base = resolveBaseRef(root, { explicit: argOf('--base') }); + if (!base.ok) { + console.error( + '❌ Cannot resolve the commit to compare against, so there is nothing to diff.\n' + + ` tried: ${base.tried.join(', ')}\n` + + (base.named + ? ' That base was named EXPLICITLY, so it is not guessed around: comparing against\n' + + ' some other commit instead would answer a question nobody asked, and answer it\n' + + ' confidently. Name a commit that exists in this clone, or pass none and let the\n' + + ' merge base with the target branch be found.\n' + : base.shallow + ? ' This clone is SHALLOW. In CI, give the checkout `fetch-depth: 0`; locally,\n' + + ' run `git fetch --no-tags origin main` (or `git fetch --unshallow`).\n' + : ' Fetch the base branch (`git fetch --no-tags origin main`) and re-run.\n') + + ' This is a failure, not a skip: a diff gate with no diff would report a\n' + + ' declaration present while having looked at nothing (objectstack#4928).', + ); + process.exit(1); + } + + let analysis; + try { + analysis = analyze(root, { base: base.ref, head }); + } catch (error) { + console.error( + `❌ ${error.message}\n\n` + + ' Reported as a failure rather than a pass: this gate demands a declaration, so\n' + + ' losing an input means it cannot tell whether one is owed (objectui#4690).', + ); + process.exit(1); + } + + const usable = usableDeclarations(analysis); + const unreadable = analysis.declarations.filter((d) => d.declaration.kind === 'none'); + + console.log( + `Compared ${head ?? 'the working tree'} with ${base.ref.slice(0, 9)} (${base.how}): ` + + `${analysis.changedFileCount} file(s) changed, ${analysis.guarded.length} of them under the ` + + `src/ of a package the release covers, ${analysis.skipped.length} under a package changesets ` + + `ignores, ${analysis.declarations.length} changeset(s) added.`, + ); + + if (analysis.unclassified.length > 0) { + const packages = [...new Set(analysis.unclassified.map((entry) => `${entry.pkg} (${entry.directory})`))]; + console.error( + `\n❌ ${analysis.unclassified.length} changed source file(s) belong to a package that is in ` + + 'neither the `fixed` group nor `ignore` of .changeset/config.json:\n' + + packages.map((name) => ` • ${name}`).join('\n') + + '\n\n So this gate cannot tell whether that source ships, and it will not guess "no" —\n' + + ' that would leave the newest package in the repository the one nothing guards.\n' + + ' Classify it: `node scripts/check-changeset-fixed.mjs` is the gate that owns this,\n' + + ' and its message says where to add the name.', + ); + process.exit(1); + } + + if (analysis.guarded.length === 0) { + console.log( + '✅ No source of a released package changed in this range, so no changeset is owed.' + + (analysis.skipped.length > 0 + ? `\n (${analysis.skipped.length} file(s) under a package changesets ignores were skipped.)` + : ''), + ); + process.exit(0); + } + + if (usable.length > 0) { + const releaseNothing = usable.every((d) => d.declaration.entries === 0); + console.log( + `✅ ${analysis.guarded.length} source file(s) of ${ + new Set(analysis.guarded.map((entry) => entry.pkg)).size + } released package(s) changed, and this change declares ` + + `${usable.length} changeset(s): ${usable.map((d) => d.file).join(', ')}.` + + (releaseNothing + ? '\n Every one of them has an EMPTY frontmatter — declared as releasing nothing, which\n' + + ' is the explicit exemption and a complete answer to this gate.' + : ''), + ); + if (unreadable.length > 0) { + console.log( + ` Note: ${unreadable.length} added file(s) under .changeset/ have no frontmatter block ` + + `and declare nothing: ${unreadable.map((d) => d.file).join(', ')}.`, + ); + } + process.exit(0); + } + + const byPackage = new Map(); + for (const entry of analysis.guarded) { + if (!byPackage.has(entry.pkg)) byPackage.set(entry.pkg, []); + byPackage.get(entry.pkg).push(entry.file); + } + + console.error( + `\n❌ ${analysis.guarded.length} source file(s) of ${byPackage.size} released package(s) ` + + 'changed, and this change adds no changeset:\n', + ); + for (const [pkg, files] of [...byPackage].sort()) { + console.error(` ${pkg}`); + for (const file of files.slice(0, 5)) console.error(` ${file}`); + if (files.length > 5) console.error(` … and ${files.length - 5} more`); + } + if (unreadable.length > 0) { + console.error( + `\n ${unreadable.length} added file(s) under .changeset/ have no \`---\` frontmatter block, ` + + `so they declare nothing: ${unreadable.map((d) => d.file).join(', ')}.`, + ); + } + console.error(` + Run \`pnpm changeset\` and describe the change. It is what puts this work into a + CHANGELOG, a version number and the platform's release notes: objectstack#4731 / + #4843 made the DECLARED changesets the only criterion for what shipped, so an + undeclared fix is invisible everywhere afterwards — objectui#3387 found three of + them, including a user-visible chart fix and an i18n fix that no release record + mentions. + + If this change really should release nothing, say so — that is a pass, not a + workaround. Add .changeset/.md with an EMPTY frontmatter: + + --- + --- + + Test-only change to the grid column resolver; no published behaviour changes. + + Scored \`minor\` at most, never \`major\`: every package is in one fixed group, so + one major carries all of them off the @objectstack major this repo is pinned to + (AGENTS.md §版本号策略, enforced by scripts/check-changeset-no-major.mjs). + + See the header of scripts/check-changeset-presence.mjs.`); + process.exit(1); +}