diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5b375ede7f..d67382c439 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,6 +8,7 @@ on: - 'README.md' - 'AGENTS.md' - 'CHANGELOG.md' + - 'changelog.d/**' - 'CONTEXT.md' - 'CONTRIBUTING.md' - 'LICENSE' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a29b930cae..8234f16cc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: - 'README.md' - 'AGENTS.md' - 'CHANGELOG.md' + - 'changelog.d/**' - 'CONTEXT.md' - 'CONTRIBUTING.md' - 'LICENSE' diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 59c74bf5df..3c2625ab03 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -8,6 +8,7 @@ on: - 'README.md' - 'AGENTS.md' - 'CHANGELOG.md' + - 'changelog.d/**' - 'CONTEXT.md' - 'CONTRIBUTING.md' - 'LICENSE' diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index eec6b844bc..2f6c633c64 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -8,6 +8,7 @@ on: - 'README.md' - 'AGENTS.md' - 'CHANGELOG.md' + - 'changelog.d/**' - 'CONTEXT.md' - 'CONTRIBUTING.md' - 'LICENSE' diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 32320e23d9..c3f6f522f9 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -8,6 +8,7 @@ on: - 'README.md' - 'AGENTS.md' - 'CHANGELOG.md' + - 'changelog.d/**' - 'CONTEXT.md' - 'CONTRIBUTING.md' - 'LICENSE' diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index e2014c1aa6..a561f988ed 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -8,6 +8,7 @@ on: - 'README.md' - 'AGENTS.md' - 'CHANGELOG.md' + - 'changelog.d/**' - 'CONTEXT.md' - 'CONTRIBUTING.md' - 'LICENSE' diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee85fc461..6f82592c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,26 +1,9 @@ # Changelog -## Unreleased +## 0.15.1 – 0.21.13 + +These releases did not split the changelog per version. -- Fixed (android): snapshot nodes and `get attrs` carry the accessibility `heading` flag and the - `roleDescription` an app set on a node. React Native puts a header, a tab, a tab list, a link, or a - menu on a plain `android.view.View` and tells the accessibility tree what it is through these two - facts; the helper never serialized either, so every one of them was a nameless `View` to an agent. - The helper now writes `heading` when the node reports it (API 28 or later) and `role-description` - when the app set one, and the parser, the Android hierarchy node, and the published snapshot node - carry them to `get attrs` and the selector digest. The class stays the `type`. -- Fixed (ios): `perf cpu profile report --kind xctrace` on Xcode 27 no longer fails with - `Apple xctrace CPU report contained no samples` on a trace that holds thousands of samples. Xcode - 27 exports each `time-profile` sample stack as `` instead of ``, and - the parser read only the old element, so every row resolved no stack at all. Both spellings now - parse through the same `id`/`ref` resolution, so a profile recorded with an older Xcode reports - what it did before. (#2860) -- Fixed (ios): `alert get`, `accept`, or `dismiss` with no alert on screen no longer reads every - element of the app to look for a popover's dismiss region. That walk cost one XCTest round trip - per element, plus XCTest's retry cycle for each element that vanished mid-walk. On a loading - WebView it outran the 10 s alert budget and kept the runner's main thread busy for more than 30 s - after the command failed, so later commands failed with `RUNNER_BUSY`. The dismiss region is now - found with one predicate query per window set. (#2491) - Changed (apple): a read-only runner command is resent inside the same request only when the runner refused it as `RUNNER_BUSY`. Before, any `COMMAND_FAILED` carrying `details.retriable: true` was sent up to three times. That flag tells a caller's own poll, such as `wait`, to try @@ -39,25 +22,11 @@ - Fixed (mobile): a read taken right after a `scroll`, `swipe`, or `gesture swipe` no longer reports a definite miss when the surface never settled. When post-gesture stabilization ran out of budget on a surface still moving, `is visible` answered a plain `selector_not_found` and `is absent` - passed. The capture now carries `postGestureOutcome` (`{ kind, gesture: { action, positionals } }`) - with `kind: "unsettled"`, and so does a re-capture taken at once to recover or widen it. A proven - no-effect gesture rides the same field with `kind: "no-effect"`; before, its warning reached only - `snapshot`. `is`, `get`, `find`, `wait`, and every interaction that captured it (`click`, `press`, - `fill`, and the other touch and gesture commands) report the field in `data` or `error.details` - with an appended warning; `snapshot` appends the warning. `is absent` refuses an unsettled capture - with `observation: "unsettled"`, `wait absent` keeps polling, and the next read captures afresh. - A failed read also carries `targetActivation` in `error.details`, and a failed interaction now - keeps the disclosure sentences in its hint. passed. That capture now carries `unsettledGesture`: `is`, `get`, `find`, and `wait` report it (in `error.details` or `data`) with an appended warning, `snapshot` appends the warning, `is absent` refuses with `observation: "unsettled"`, `wait absent` keeps polling, and the next read captures afresh. Click, press, and fill by selector do not disclose it yet. A failed read now also carries `targetActivation` in `error.details`, the same place as `unsettledGesture`. -- Fixed (ios): a synthesized tap step inside a runner `sequence` (for example `press x y --count N`) - now follows the standalone tap's policy instead of its own. When accessibility is unavailable or no - app window resolves, the step now falls back to an XCTest coordinate tap instead of failing the - step with `UNSUPPORTED_OPERATION`. One helper now owns the synthesize-then-fallback decision at - every synthesized tap site (#2788). - Fixed (ios): `open` on a local Simulator now waits for the launched app's discovery before it decides whether the app is observable. On a loaded host `simctl spawn launchctl list` outlasts one 1.5 s discovery wait slice, and the launch observation read that slice as an unobservable app, so @@ -113,14 +82,6 @@ report no UIKit class names — the XCTest runner, whose own queries answered 76 nodes for that same state, plus `appium-source` and `limrun-ios-tree` — never trigger the cut. All 39 flows of React Navigation's Maestro suite pass on an iPhone 17 Simulator running iOS 26.2 with this change, including two that never passed on the bridge. -- Fixed (ios): runtime clang builds no longer compile with `-Werror`, so a new warning from a future - Xcode SDK cannot break the AX bridge or fold on a user's machine that this repository cannot fix - for them. The fold helper is now built through the same content- and toolchain-keyed build cache - as the AX bridge, so a fold call after the first serves a cached binary instead of recompiling - `Fold.m` on every call, and switching `DEVELOPER_DIR` busts the cache instead of serving a binary - built against a different SDK. A darwin-only CI step (`.github/workflows/ios.yml`) compiles each - build's production argv with `-Werror` appended whenever its sources change, so a new warning still - fails CI (#2796). - Fixed (ios): a local Simulator snapshot taken through the host AX bridge once again publishes the geometric `hittable` fact, so `is hittable` and a `hittable:` selector resolve the same controls on the bridge and the XCTest runner. The snapshot capability table has declared `hittable = diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 128431377e..4c6f00b6f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,11 @@ so name the version the CI lanes install and the published helper matches the CI `pnpm package:npm` is a release guard, not a routine development command. Use the specific commands above while iterating. +### Changelog fragments, not `CHANGELOG.md` edits + +A user-visible change adds a `changelog.d/.md` fragment; see `changelog.d/README.md` for the +format. Only the `npm version` release commit writes `CHANGELOG.md`. + ### The version on main never equals a published version `release:publish` runs `release:mark-dev` right after `npm publish`, moving `package.json` (and the diff --git a/changelog.d/2491-alert-popover-dismiss-query.md b/changelog.d/2491-alert-popover-dismiss-query.md new file mode 100644 index 0000000000..a5c452c034 --- /dev/null +++ b/changelog.d/2491-alert-popover-dismiss-query.md @@ -0,0 +1,6 @@ +- Fixed (ios): `alert get`, `accept`, or `dismiss` with no alert on screen no longer reads every + element of the app to look for a popover's dismiss region. That walk cost one XCTest round trip + per element, plus XCTest's retry cycle for each element that vanished mid-walk. On a loading + WebView it outran the 10 s alert budget and kept the runner's main thread busy for more than 30 s + after the command failed, so later commands failed with `RUNNER_BUSY`. The dismiss region is now + found with one predicate query per window set. (#2491) diff --git a/changelog.d/2788-sequence-tap-fallback.md b/changelog.d/2788-sequence-tap-fallback.md new file mode 100644 index 0000000000..9ad040ec60 --- /dev/null +++ b/changelog.d/2788-sequence-tap-fallback.md @@ -0,0 +1,5 @@ +- Fixed (ios): a synthesized tap step inside a runner `sequence` (for example `press x y --count N`) + now follows the standalone tap's policy instead of its own. When accessibility is unavailable or no + app window resolves, the step now falls back to an XCTest coordinate tap instead of failing the + step with `UNSUPPORTED_OPERATION`. One helper now owns the synthesize-then-fallback decision at + every synthesized tap site (#2788). diff --git a/changelog.d/2796-fold-helper-no-werror.md b/changelog.d/2796-fold-helper-no-werror.md new file mode 100644 index 0000000000..0243d05303 --- /dev/null +++ b/changelog.d/2796-fold-helper-no-werror.md @@ -0,0 +1,8 @@ +- Fixed (ios): runtime clang builds no longer compile with `-Werror`, so a new warning from a future + Xcode SDK cannot break the AX bridge or fold on a user's machine that this repository cannot fix + for them. The fold helper is now built through the same content- and toolchain-keyed build cache + as the AX bridge, so a fold call after the first serves a cached binary instead of recompiling + `Fold.m` on every call, and switching `DEVELOPER_DIR` busts the cache instead of serving a binary + built against a different SDK. A darwin-only CI step (`.github/workflows/ios.yml`) compiles each + build's production argv with `-Werror` appended whenever its sources change, so a new warning still + fails CI (#2796). diff --git a/changelog.d/2860-xctrace-tagged-backtrace.md b/changelog.d/2860-xctrace-tagged-backtrace.md new file mode 100644 index 0000000000..58fd05eeaa --- /dev/null +++ b/changelog.d/2860-xctrace-tagged-backtrace.md @@ -0,0 +1,6 @@ +- Fixed (ios): `perf cpu profile report --kind xctrace` on Xcode 27 no longer fails with + `Apple xctrace CPU report contained no samples` on a trace that holds thousands of samples. Xcode + 27 exports each `time-profile` sample stack as `` instead of ``, and + the parser read only the old element, so every row resolved no stack at all. Both spellings now + parse through the same `id`/`ref` resolution, so a profile recorded with an older Xcode reports + what it did before. (#2860) diff --git a/changelog.d/2864-post-gesture-outcome.md b/changelog.d/2864-post-gesture-outcome.md new file mode 100644 index 0000000000..661eb6f829 --- /dev/null +++ b/changelog.d/2864-post-gesture-outcome.md @@ -0,0 +1,8 @@ +- Changed (mobile): the post-gesture field that 0.21.13 added as `unsettledGesture` is now + `postGestureOutcome` (`{ kind, gesture: { action, positionals } }`), with `kind: "unsettled"` for + a surface that never settled. A re-capture taken at once to recover or widen that tree carries the + field too. A proven no-effect gesture rides the same field with `kind: "no-effect"`; before, its + warning reached only `snapshot`. Every interaction that captured the tree (`click`, `press`, + `fill`, and the other touch and gesture commands) now reports the field in `data` or + `error.details` with an appended warning, and a failed interaction keeps the disclosure sentences + in its hint. (#2864) diff --git a/changelog.d/2915-android-heading-role-description.md b/changelog.d/2915-android-heading-role-description.md new file mode 100644 index 0000000000..a3574d8325 --- /dev/null +++ b/changelog.d/2915-android-heading-role-description.md @@ -0,0 +1,7 @@ +- Fixed (android): snapshot nodes and `get attrs` carry the accessibility `heading` flag and the + `roleDescription` an app set on a node. React Native puts a header, a tab, a tab list, a link, or a + menu on a plain `android.view.View` and tells the accessibility tree what it is through these two + facts; the helper never serialized either, so every one of them was a nameless `View` to an agent. + The helper now writes `heading` when the node reports it (API 28 or later) and `role-description` + when the app set one, and the parser, the Android hierarchy node, and the published snapshot node + carry them to `get attrs` and the selector digest. The class stays the `type`. diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000000..0c72815fc8 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,42 @@ +# changelog.d/ + +A PR with a user-visible change adds one fragment file here instead of editing `CHANGELOG.md` +directly. PRs never edit `CHANGELOG.md`. Only the `npm version` release commit writes it, by +running `scripts/changelog-release.ts` to fold every fragment present into a new version section +and delete the fragments it consumed. + +This file is the only non-fragment file kept in this directory, so the directory stays in place +when no fragments are pending. The assembler ignores dotfiles such as `.DS_Store`; every other +entry must be a valid fragment. + +## Adding a fragment + +Create `changelog.d/.md`, where `` matches `^[a-z0-9][a-z0-9-]*$`. By convention, base +it on the branch name: `-`, for example +`changelog.d/2799-macos-fullscreen-surfaces.md`. The PR number is not required in the name — it is +not known before the PR opens. + +A fragment holds one or more bullets. Each bullet starts on a line matching: + +``` +^- (Breaking|Added|Changed|Deprecated|Removed|Fixed|Security)( \([^)]+\))?: \S +``` + +Continuation lines are indented by two spaces. Blank lines between bullets are allowed. Anything +else fails validation, including a leading non-bullet line or an unknown kind. + +Example: + +```md +- Fixed (macos): `screenshot --fullscreen` on the `desktop`, `menubar`, or `frontmost-app` + surface now refuses with `INVALID_ARGS` instead of being ignored. (#2849) +``` + +## Assembly + +`npm version` runs the assembler as part of its `version` lifecycle script. It sorts bullets by +kind rank (Breaking, Removed, Changed, Deprecated, Added, Fixed, Security), then by fragment file +name, then by position within the fragment — deterministic regardless of the order the fragments +were added in — and writes the result under a new `## ` heading. `release:prepare` runs +the assembler with `--check`, which fails if any fragment is still present: that means a release +skipped assembly. diff --git a/docs/agents/pull-requests.md b/docs/agents/pull-requests.md index ed220c81a2..9f9c72973d 100644 --- a/docs/agents/pull-requests.md +++ b/docs/agents/pull-requests.md @@ -32,6 +32,10 @@ Gross diff budget: 1,000 lines by `git diff --stat origin/main...HEAD`. Rename-o `refactor(move)` are exempt when `git diff -M90% --stat origin/main...HEAD` proves no material content change. +A user-visible change adds a `changelog.d/-.md` fragment (see `changelog.d/README.md`) +instead of editing `CHANGELOG.md`. PRs never edit `CHANGELOG.md`; only the `npm version` release +commit writes it, by folding pending fragments into a new version section. + ## Commits Use conventional commit prefixes; no `[codex]` tags. Implementation commits come first. Enforcement diff --git a/package.json b/package.json index f54bd19536..1ec1d5040a 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "prepare:publish-assets": "node scripts/prepare-publish-assets.mjs", "build:package": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm prepare:publish-assets", "package:npm": "pnpm build:package && pnpm check:package", - "release:prepare": "node scripts/release-mark-dev.mjs --check-release-version && rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release", + "release:prepare": "node scripts/release-mark-dev.mjs --check-release-version && node --experimental-strip-types scripts/changelog-release.ts --check && rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release", "release:publish": "pnpm release:prepare && npm publish --ignore-scripts .tmp/release/*.tgz && pnpm release:mark-dev", "release:mark-dev": "node scripts/release-mark-dev.mjs", "ad": "node bin/agent-device.mjs", @@ -166,7 +166,7 @@ "check:quick": "pnpm lint && pnpm typecheck", "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs", "check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check", - "version": "pnpm sync:mcp-metadata && git add server.json", + "version": "pnpm sync:mcp-metadata && node --experimental-strip-types scripts/changelog-release.ts && git add server.json CHANGELOG.md changelog.d", "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:packaged-runner-swift && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files && pnpm check:package", "check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", diff --git a/scripts/__tests__/changelog-release.test.ts b/scripts/__tests__/changelog-release.test.ts new file mode 100644 index 0000000000..0fb903967f --- /dev/null +++ b/scripts/__tests__/changelog-release.test.ts @@ -0,0 +1,294 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { mkdtempForTestSync } from '../../src/__tests__/test-utils/tmp-dir.ts'; +import { + assembleChangelog, + parseFragment, + readFragments, + runCli, + type ChangelogFragment, +} from '../changelog-release.ts'; + +const BASE_CHANGELOG = '# Changelog\n\n## 0.21.12\n\n- Fixed: an earlier release note. (#1)\n\n'; + +const FRAGMENTS: ChangelogFragment[] = [ + { + name: 'z-fixed-fragment.md', + text: '- Fixed: a `z`-named fragment bullet.\n A continuation line.\n', + }, + { + name: 'a-breaking-fragment.md', + text: '- Breaking: an `a`-named fragment bullet.\n', + }, + { + name: 'b-changed-fragment.md', + text: '- Changed: first bullet in this fragment.\n- Changed: second bullet in this fragment.\n', + }, +]; + +test('determinism: two input orders give byte-identical output', () => { + const forward = assembleChangelog({ + changelog: BASE_CHANGELOG, + fragments: FRAGMENTS, + version: '0.22.0', + }); + const reversed = assembleChangelog({ + changelog: BASE_CHANGELOG, + fragments: [...FRAGMENTS].reverse(), + version: '0.22.0', + }); + assert.equal(forward, reversed); + + // The expected output is a literal, not derived from the implementation: kind rank + // (Breaking, Changed, Fixed) first, fragment name second, position in the fragment third. + const expected = + '# Changelog\n\n' + + '## 0.22.0\n\n' + + '- Breaking: an `a`-named fragment bullet.\n' + + '- Changed: first bullet in this fragment.\n' + + '- Changed: second bullet in this fragment.\n' + + '- Fixed: a `z`-named fragment bullet.\n A continuation line.\n\n' + + '## 0.21.12\n\n- Fixed: an earlier release note. (#1)\n\n'; + assert.equal(forward, expected); +}); + +test('kind order: a Fixed fragment named a-… sorts after a Breaking fragment named z-…', () => { + const result = assembleChangelog({ + changelog: BASE_CHANGELOG, + fragments: [ + { name: 'a-fixed.md', text: '- Fixed: comes from the alphabetically-first fragment.\n' }, + { name: 'z-breaking.md', text: '- Breaking: comes from the alphabetically-last fragment.\n' }, + ], + version: '0.22.0', + }); + const breakingIndex = result.indexOf('- Breaking:'); + const fixedIndex = result.indexOf('- Fixed:'); + expect(breakingIndex).toBeGreaterThan(-1); + expect(fixedIndex).toBeGreaterThan(breakingIndex); +}); + +test('refusal: a "-dev" version never becomes a heading', () => { + expect(() => + assembleChangelog({ changelog: BASE_CHANGELOG, fragments: FRAGMENTS, version: '0.22.0-dev' }), + ).toThrow(/-dev/); +}); + +test('refusal: CHANGELOG.md already contains the target version heading', () => { + expect(() => + assembleChangelog({ changelog: BASE_CHANGELOG, fragments: FRAGMENTS, version: '0.21.12' }), + ).toThrow(/already has a "## 0\.21\.12" section/); +}); + +test('refusal: CHANGELOG.md still contains an Unreleased heading', () => { + const withUnreleased = '# Changelog\n\n## Unreleased\n\n- Fixed: pending.\n\n## 0.21.12\n\n'; + expect(() => + assembleChangelog({ changelog: withUnreleased, fragments: FRAGMENTS, version: '0.22.0' }), + ).toThrow(/Unreleased/); +}); + +test('refusal: an invalid fragment throws and names the fragment', () => { + const invalid: ChangelogFragment = { name: 'bad-fragment.md', text: 'not a bullet line\n' }; + expect(() => + assembleChangelog({ changelog: BASE_CHANGELOG, fragments: [invalid], version: '0.22.0' }), + ).toThrow(/bad-fragment\.md/); +}); + +test('parseFragment: fragment name must match the slug pattern', () => { + expect(() => parseFragment({ name: 'Not_A_Slug.md', text: '- Fixed: x.\n' })).toThrow( + /must match/, + ); +}); + +test('parseFragment: an unindented continuation line fails validation', () => { + expect(() => parseFragment({ name: 'ok.md', text: '- Fixed: x.\nnot indented\n' })).toThrow( + /invalid line/, + ); +}); + +test('parseFragment: a leading non-bullet line fails validation', () => { + expect(() => parseFragment({ name: 'ok.md', text: 'not a bullet\n- Fixed: x.\n' })).toThrow( + /invalid line/, + ); +}); + +test('parseFragment: an unknown kind is rejected as an invalid line', () => { + expect(() => + parseFragment({ name: 'ok.md', text: '- Info: not one of the allowed kinds.\n' }), + ).toThrow(/invalid line/); +}); + +test('parseFragment: a leading indented line has no bullet to attach to and fails validation', () => { + // The line matches the continuation pattern, but `current` is still undefined at this point, + // so the `current &&` guard must not let it through as a continuation. + expect(() => + parseFragment({ name: 'ok.md', text: ' indented before any bullet\n- Fixed: x.\n' }), + ).toThrow(/invalid line/); +}); + +test('no fragments: the input comes back unchanged, even with an unmigrated Unreleased heading', () => { + const withUnreleased = '# Changelog\n\n## Unreleased\n\n- Fixed: pending.\n\n## 0.21.12\n\n'; + assert.equal( + assembleChangelog({ changelog: withUnreleased, fragments: [], version: '0.22.0-dev' }), + withUnreleased, + ); +}); + +function scratchRepo(): string { + const root = mkdtempForTestSync('agent-device-changelog-release-'); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '0.22.0' })); + fs.writeFileSync(path.join(root, 'CHANGELOG.md'), BASE_CHANGELOG); + const fragmentsDir = path.join(root, 'changelog.d'); + fs.mkdirSync(fragmentsDir, { recursive: true }); + fs.writeFileSync(path.join(fragmentsDir, 'README.md'), '# changelog.d\n'); + fs.writeFileSync( + path.join(fragmentsDir, '2799-example.md'), + '- Fixed: a scratch-repo fragment.\n', + ); + return root; +} + +test('CLI default mode writes the assembled section and deletes consumed fragments', () => { + const root = scratchRepo(); + const exitCode = runCli({ root, check: false }); + assert.equal(exitCode, 0); + const changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); + assert.match(changelog, /## 0\.22\.0\n\n- Fixed: a scratch-repo fragment\./); + assert.deepEqual(fs.readdirSync(path.join(root, 'changelog.d')), ['README.md']); +}); + +test('CLI --check exits 1 while a fragment remains and 0 once only README.md is left', () => { + const root = scratchRepo(); + assert.equal(runCli({ root, check: true }), 1); + assert.equal(runCli({ root, check: false }), 0); + assert.equal(runCli({ root, check: true }), 0); +}); + +test('CLI double-run: assembling into an already-released version heading throws', () => { + const root = scratchRepo(); + assert.equal(runCli({ root, check: false }), 0); + + // Simulate a second commit landing before the next version bump: a new fragment arrives, but + // package.json's version is unchanged, so the "## 0.22.0" heading already exists. + fs.writeFileSync( + path.join(root, 'changelog.d', '9999-second.md'), + '- Fixed: a second, unconsumed fragment.\n', + ); + expect(() => runCli({ root, check: false })).toThrow(/already has a "## 0\.22\.0" section/); +}); + +test('CLI refusal leaves CHANGELOG.md and the pending fragment untouched on disk', () => { + const root = scratchRepo(); + assert.equal(runCli({ root, check: false }), 0); + + const fragmentPath = path.join(root, 'changelog.d', '9999-second.md'); + const fragmentText = '- Fixed: a second, unconsumed fragment.\n'; + fs.writeFileSync(fragmentPath, fragmentText); + const changelogPath = path.join(root, 'CHANGELOG.md'); + const changelogBefore = fs.readFileSync(changelogPath, 'utf8'); + + expect(() => runCli({ root, check: false })).toThrow(); + + assert.equal(fs.readFileSync(changelogPath, 'utf8'), changelogBefore); + assert.equal(fs.readFileSync(fragmentPath, 'utf8'), fragmentText); +}); + +test('CLI: a fragment without the .md extension is rejected, not silently skipped', () => { + const root = scratchRepo(); + fs.writeFileSync(path.join(root, 'changelog.d', '9999-no-extension'), '- Fixed: x.\n'); + + // The same enumeration (`readFragments`) feeds both --check and the default run, so a + // wrongly-named entry fails loudly in either mode instead of being filtered out by one of them. + expect(() => runCli({ root, check: true })).toThrow(/must end in "\.md"/); + expect(() => runCli({ root, check: false })).toThrow(/must end in "\.md"/); +}); + +test('CLI: a dotfile in changelog.d is neither a fragment nor a refusal', () => { + const root = scratchRepo(); + fs.writeFileSync(path.join(root, 'changelog.d', '.DS_Store'), '\u0000\u0001binary'); + + assert.equal(runCli({ root, check: false }), 0); + assert.deepEqual(fs.readdirSync(path.join(root, 'changelog.d')).sort(), [ + '.DS_Store', + 'README.md', + ]); + assert.equal(runCli({ root, check: true }), 0); +}); + +// Repository guard: every fragment `readFragments` would actually consume (other than README.md) +// must have a valid name and pass parseFragment, so a malformed or wrongly-named fragment is +// caught before it ships instead of being silently skipped by a stricter enumeration. +test('every real changelog.d fragment has a valid name and passes parseFragment', () => { + const repoRoot = path.resolve(import.meta.dirname, '../..'); + const fragmentsDir = path.join(repoRoot, 'changelog.d'); + for (const fragment of readFragments(fragmentsDir)) { + parseFragment(fragment); + } +}); + +// Repository guard: the real CHANGELOG.md carries no "## Unreleased" heading, so the assembler's +// refusal never fires on the first real release that ships a fragment. +test('the real CHANGELOG.md has no "## Unreleased" heading', () => { + const repoRoot = path.resolve(import.meta.dirname, '../..'); + const changelog = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8'); + assert.equal(changelog.includes('## Unreleased'), false); +}); + +const MIGRATED_HEADING = '## 0.15.1 – 0.21.13'; +const MIGRATED_TAG = 'v0.21.13'; + +/** + * The bullets under `heading`: every line from the first line starting with `- ` up to (not + * including) the next `## ` heading, with trailing blank lines trimmed. Skips any prose between + * the heading and its first bullet (for example the migrated section's one-line note), so the + * comparison is bullet content only, not the surrounding heading text. + */ +function sectionBullets(changelog: string, heading: string): string[] { + const lines = changelog.split('\n'); + const headingIndex = lines.indexOf(heading); + assert.notEqual(headingIndex, -1, `expected to find a "${heading}" heading`); + let start = headingIndex + 1; + while (start < lines.length && !lines[start].startsWith('- ')) start++; + let end = start; + while (end < lines.length && !lines[end].startsWith('## ')) end++; + while (end > start && lines[end - 1] === '') end--; + return lines.slice(start, end); +} + +// Repository guard: a rebase onto a newer base can replay the "## Unreleased" -> historical-range +// heading rename over a CHANGELOG.md that has grown new bullets since the tag, silently folding +// them into a released section they never shipped in (the "no Unreleased heading" guard above +// passes either way, since the heading is gone in both the correct and the stale case). Pin the +// migrated section's bullets to be byte-identical to the tag's own "## Unreleased" bullets, so a +// stale-base migration -- one that carries bullets the tag never had -- fails loudly instead of +// merging clean. +test(`the "${MIGRATED_HEADING}" section is byte-identical to ${MIGRATED_TAG}'s Unreleased block`, () => { + const repoRoot = path.resolve(import.meta.dirname, '../..'); + const changelog = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8'); + if (!changelog.includes(`${MIGRATED_HEADING}\n`)) { + // A later migration renamed or removed this section; nothing left here to pin against the tag. + return; + } + + let tagChangelog: string; + try { + tagChangelog = execFileSync('git', ['show', `${MIGRATED_TAG}:CHANGELOG.md`], { + cwd: repoRoot, + encoding: 'utf8', + }); + } catch (error) { + const stderr = (error as { stderr?: string }).stderr ?? ''; + throw new Error( + `This guard needs the "${MIGRATED_TAG}" tag (git show ${MIGRATED_TAG}:CHANGELOG.md failed: ` + + `${stderr}). Fetch tags from origin; the gate does not skip.`, + { cause: error }, + ); + } + + assert.deepEqual( + sectionBullets(changelog, MIGRATED_HEADING), + sectionBullets(tagChangelog, '## Unreleased'), + ); +}); diff --git a/scripts/changelog-release.ts b/scripts/changelog-release.ts new file mode 100644 index 0000000000..fe2ef8132d --- /dev/null +++ b/scripts/changelog-release.ts @@ -0,0 +1,209 @@ +// Folds `changelog.d/*.md` fragments into `CHANGELOG.md` as a new `## ` section, run by +// the `npm version` lifecycle script. See `changelog.d/README.md` for the fragment format. +// +// `parseFragment`/`assembleChangelog` are pure; `runCli` is the filesystem half, parameterized by +// `root` so it can target a scratch directory. `--check` (wired into `release:prepare`) fails a +// release that would otherwise ship with `changelog.d/` fragments still unconsumed. + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export type Kind = + | 'Breaking' + | 'Added' + | 'Changed' + | 'Deprecated' + | 'Removed' + | 'Fixed' + | 'Security'; + +export type ChangelogFragment = { name: string; text: string }; + +type Bullet = { kind: Kind; text: string }; + +// Kind rank first (Breaking, Removed, Changed, Deprecated, Added, Fixed, Security), independent of +// how often a kind appears; the array's index is the sort key. +const KIND_ORDER: readonly Kind[] = [ + 'Breaking', + 'Removed', + 'Changed', + 'Deprecated', + 'Added', + 'Fixed', + 'Security', +]; + +const SLUG = /^[a-z0-9][a-z0-9-]*$/; +const BULLET_HEADER = + /^- (Breaking|Added|Changed|Deprecated|Removed|Fixed|Security)(?: \([^)]+\))?: \S/; +const CONTINUATION = /^ {2}\S/; + +/** The fragment's basename with the required `.md` extension stripped. */ +function slug(fragmentName: string): string { + return fragmentName.slice(0, -'.md'.length); +} + +/** + * Parses one fragment's bullets, in file order. Throws — naming the fragment and the offending + * line — on a name that is missing the `.md` extension or fails the slug pattern, a leading + * non-bullet line, an unknown kind, or a continuation line that is not indented under a bullet. + * Bullet text keeps its internal newlines (a multi-line bullet's continuation lines) but drops + * trailing whitespace per line. + */ +export function parseFragment(fragment: ChangelogFragment): Bullet[] { + if (!fragment.name.endsWith('.md')) { + throw new Error(`${fragment.name}: fragment file name must end in ".md".`); + } + const name = slug(fragment.name); + if (!SLUG.test(name)) { + throw new Error( + `${fragment.name}: fragment name "${name}" must match ${SLUG.source} (lowercase, ` + + 'digits and hyphens, starting with a lowercase letter or digit).', + ); + } + + const bullets: Bullet[] = []; + let current: Bullet | undefined; + for (const rawLine of fragment.text.split('\n')) { + const line = rawLine.replace(/\s+$/, ''); + if (line.length === 0) { + current = undefined; + continue; + } + const header = BULLET_HEADER.exec(line); + if (header) { + current = { kind: header[1] as Kind, text: line }; + bullets.push(current); + continue; + } + if (current && CONTINUATION.test(line)) { + current.text += `\n${line}`; + continue; + } + throw new Error( + `${fragment.name}: invalid line ${JSON.stringify(rawLine)}. Expected a bullet matching ` + + `${BULLET_HEADER.source} or a two-space-indented continuation of one.`, + ); + } + if (bullets.length === 0) { + throw new Error(`${fragment.name}: no bullets found.`); + } + return bullets; +} + +/** + * Folds `fragments` into `changelog` as a new `## ` section, sorted by kind rank, then + * fragment name, then position within the fragment — deterministic regardless of the order + * `fragments` arrives in. With no fragments, `changelog` comes back unchanged: a release with no + * user-visible change gets no section, and the version/heading refusals below do not apply to it. + */ +export function assembleChangelog(input: { + changelog: string; + fragments: ChangelogFragment[]; + version: string; +}): string { + const { changelog, fragments, version } = input; + if (fragments.length === 0) return changelog; + + if (version.includes('-')) { + throw new Error(`Refusing to release version "${version}": a "-dev" marker is not a release.`); + } + const heading = `## ${version}`; + if (changelog.includes(`${heading}\n`) || changelog.includes(`${heading} `)) { + throw new Error( + `CHANGELOG.md already has a "${heading}" section. Fragments were already consumed for this version.`, + ); + } + if (changelog.includes('## Unreleased')) { + throw new Error( + 'CHANGELOG.md still has an "## Unreleased" heading. Migrate it before running the assembler.', + ); + } + + const sorted = fragments + .map((fragment, fragmentIndex) => ({ + fragment, + fragmentIndex, + bullets: parseFragment(fragment), + })) + .flatMap(({ fragment, fragmentIndex, bullets }) => + bullets.map((bullet, bulletIndex) => ({ fragment, fragmentIndex, bulletIndex, bullet })), + ) + .sort((a, b) => { + const kindDelta = KIND_ORDER.indexOf(a.bullet.kind) - KIND_ORDER.indexOf(b.bullet.kind); + if (kindDelta !== 0) return kindDelta; + const nameDelta = + a.fragment.name < b.fragment.name ? -1 : a.fragment.name > b.fragment.name ? 1 : 0; + if (nameDelta !== 0) return nameDelta; + return a.bulletIndex - b.bulletIndex; + }) + .map(({ bullet }) => bullet.text); + + const section = `${heading}\n\n${sorted.join('\n')}\n\n`; + const titleMatch = /^# Changelog\n+/.exec(changelog); + const insertAt = titleMatch ? titleMatch[0].length : 0; + return changelog.slice(0, insertAt) + section + changelog.slice(insertAt); +} + +const FRAGMENTS_DIR = 'changelog.d'; +const README = 'README.md'; + +/** + * Every `changelog.d` entry other than `README.md` and dotfiles (such as `.DS_Store`), regardless + * of extension. The assembler and `--check` both consume this exact list, so an entry that is not + * a valid `.md` fragment fails `parseFragment` instead of being silently skipped by an + * extension filter. + */ +export function readFragments(dir: string): ChangelogFragment[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((name) => name !== README && !name.startsWith('.')) + .sort() + .map((name) => ({ name, text: fs.readFileSync(path.join(dir, name), 'utf8') })); +} + +/** The CLI's file-system half, kept separate from `main()` so tests can point it at a scratch root. */ +export function runCli(options: { root: string; check: boolean }): number { + const { root, check } = options; + const fragmentsDir = path.join(root, FRAGMENTS_DIR); + + if (check) { + const fragments = readFragments(fragmentsDir); + for (const fragment of fragments) parseFragment(fragment); + if (fragments.length > 0) { + process.stderr.write( + `${fragments.length} fragment(s) still in ${FRAGMENTS_DIR}/: ` + + `${fragments.map((f) => f.name).join(', ')}. Run the assembler (no --check) before releasing.\n`, + ); + return 1; + } + process.stdout.write(`${FRAGMENTS_DIR}/ carries no unconsumed fragments.\n`); + return 0; + } + + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { + version: string; + }; + const fragments = readFragments(fragmentsDir); + if (fragments.length === 0) { + process.stdout.write('No changelog.d/ fragments to assemble; CHANGELOG.md is unchanged.\n'); + return 0; + } + + const changelogPath = path.join(root, 'CHANGELOG.md'); + const changelog = fs.readFileSync(changelogPath, 'utf8'); + const assembled = assembleChangelog({ changelog, fragments, version: pkg.version }); + fs.writeFileSync(changelogPath, assembled); + for (const fragment of fragments) fs.rmSync(path.join(fragmentsDir, fragment.name)); + + process.stdout.write( + `Assembled ${fragments.length} fragment(s) into CHANGELOG.md under "## ${pkg.version}" and removed them from ${FRAGMENTS_DIR}/.\n`, + ); + return 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + process.exit(runCli({ root: process.cwd(), check: process.argv.includes('--check') })); +} diff --git a/src/__tests__/npm-package-scripts.test.ts b/src/__tests__/npm-package-scripts.test.ts index 4e1aa228a9..120f455506 100644 --- a/src/__tests__/npm-package-scripts.test.ts +++ b/src/__tests__/npm-package-scripts.test.ts @@ -73,7 +73,7 @@ test('the npm package build covers every package-owned output before verificatio test('release publishing uploads the tarball that passed the package gate', () => { assert.equal( script('release:prepare'), - 'node scripts/release-mark-dev.mjs --check-release-version && rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release', + 'node scripts/release-mark-dev.mjs --check-release-version && node --experimental-strip-types scripts/changelog-release.ts --check && rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release', ); assert.equal( script('release:publish'), @@ -95,6 +95,20 @@ test('release publishing moves main off the released version', () => { assert.equal(script('release:mark-dev'), 'node scripts/release-mark-dev.mjs'); }); +// #2877: PRs never edit CHANGELOG.md, so the release version commit is the only writer. It folds +// changelog.d/ fragments into a new version section via the assembler, then stages both the +// rewritten CHANGELOG.md and the fragment deletions alongside the existing server.json sync. +test('the version lifecycle script assembles changelog fragments before staging the release commit', () => { + assert.equal( + script('version'), + 'pnpm sync:mcp-metadata && node --experimental-strip-types scripts/changelog-release.ts && git add server.json CHANGELOG.md changelog.d', + ); + assert.match( + script('release:prepare'), + / node --experimental-strip-types scripts\/changelog-release\.ts --check && /, + ); +}); + test('the package checker can retain the tarball it verifies for publishing', () => { const gate = fs.readFileSync(path.join(repoRoot, 'scripts', 'check-package.ts'), 'utf8'); assert.match(gate, /--pack-destination/); diff --git a/test/ci/root-docs-paths-ignore.test.ts b/test/ci/root-docs-paths-ignore.test.ts index d7ad80e4b4..de7b7d16ad 100644 --- a/test/ci/root-docs-paths-ignore.test.ts +++ b/test/ci/root-docs-paths-ignore.test.ts @@ -11,6 +11,9 @@ // prose-only PRs. This test reads the real workflow files and asserts the // behavior directly, via the same glob matcher the gate-manifest model uses // to decide whether a lane triggers for a given path. +// +// #2877 extends the same coverage to `changelog.d/**`: a PR that only adds a +// changelog fragment must skip these lanes the same way a root doc PR does. import fs from 'node:fs'; import path from 'node:path'; @@ -56,3 +59,11 @@ test.each(WORKFLOWS)('%s skips a pull_request triggered by only a root doc', (fi ).toBe(true); } }); + +test.each(WORKFLOWS)('%s skips a pull_request triggered by only a changelog fragment', (file) => { + const ignored = pathsIgnore(file); + expect( + ignored.some((pattern) => matchesGlob(pattern, 'changelog.d/2799-example.md')), + `${file}'s paths-ignore must match changelog.d/2799-example.md (got ${JSON.stringify(ignored)})`, + ).toBe(true); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 77b2793122..407ddaecfe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -169,6 +169,10 @@ export default defineConfig({ // #1781 A9: pins the root-doc paths-ignore entries directly against the // real workflow YAML, parse-only like its sibling above. 'test/ci/root-docs-paths-ignore.test.ts', + // The changelog-fragment assembler (#2877): pure string transforms over fixture + // changelogs plus a scratch-directory CLI check. It needs no device; its one + // subprocess is a `git show v0.21.13:CHANGELOG.md` read for the migration guard. + 'scripts/__tests__/changelog-release.test.ts', // The daemon leak oracle's lifecycle/residue rules (#1781 B1): pure // decisions over fixture state-dir listings, so they need no daemon, // device, or subprocess. diff --git a/website/docs/docs/migrating-gestures.md b/website/docs/docs/migrating-gestures.md index fe0b2217ed..184883a1a7 100644 --- a/website/docs/docs/migrating-gestures.md +++ b/website/docs/docs/migrating-gestures.md @@ -167,8 +167,8 @@ flow runs at the speed the `.ad` script ran, rather than picking up Maestro's ow This is the process a public gesture input follows on its way out, and the bar the next removal has to clear: -1. **Announce.** The input is documented as deprecated and recorded in `CHANGELOG.md` under - `Unreleased`, together with the replacement. +1. **Announce.** The input is documented as deprecated and recorded in a `changelog.d/` fragment, + together with the replacement. 2. **Warn for one minor release.** The input keeps working and normalizes to the replacement, with a `deprecations` entry in the response so an agent sees the migration while the call still succeeds.