From 450c82f29e685edafd999d9b5ba3402457910a21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 12:41:27 +0200 Subject: [PATCH 1/8] feat(release): assemble changelog.d fragments into CHANGELOG.md at release PRs no longer share one insertion point at the top of `## Unreleased`, which was the repo's most frequent merge-conflict file. A PR instead adds changelog.d/.md, and the npm version lifecycle script now folds every fragment present into a new `## ` section (deterministic kind, then fragment name, then position order) and deletes the consumed fragments. release:prepare runs the assembler in --check mode so a release cannot ship with unconsumed fragments. --- changelog.d/README.md | 41 +++++++ package.json | 4 +- scripts/changelog-release.ts | 202 +++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 changelog.d/README.md create mode 100644 scripts/changelog-release.ts diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000000..7030820006 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,41 @@ +# 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. + +## 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/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/changelog-release.ts b/scripts/changelog-release.ts new file mode 100644 index 0000000000..7ed7d4e218 --- /dev/null +++ b/scripts/changelog-release.ts @@ -0,0 +1,202 @@ +// The release-version commit's changelog step (#2877): PRs never edit `CHANGELOG.md`. Instead a +// PR adds `changelog.d/.md`, and `npm version` runs this to fold every fragment present into +// one new `## ` section, then deletes the fragments it consumed. `changelog.d/README.md` +// documents the fragment format for contributors; this module is its enforcement. +// +// Exports a pure core (parseFragment, assembleChangelog) so scripts/__tests__/changelog-release.test.ts +// can prove ordering, refusals and idempotence without touching the filesystem. The CLI below is a +// thin wrapper: default mode assembles and writes, `--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 `.md` stripped, or the whole name if it carries no extension. */ +function slug(fragmentName: string): string { + return fragmentName.endsWith('.md') ? fragmentName.slice(0, -'.md'.length) : fragmentName; +} + +/** + * Parses one fragment's bullets, in file order. Throws — naming the fragment and the offending + * line — on a name that 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[] { + 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'; + +function readFragments(dir: string): ChangelogFragment[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((name) => name !== README && name.endsWith('.md')) + .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') })); +} From 5e823f123751c8139063664c41881ee0ae366074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 12:41:32 +0200 Subject: [PATCH 2/8] test(release): cover the changelog fragment assembler and its CLI Pins determinism, kind ordering, all four refusal cases, the no-fragments pass-through, and a scratch-directory run of both CLI modes. Registers the suite in the unit-core project and updates the release-script assertions in npm-package-scripts.test.ts for the new version/release:prepare strings. --- scripts/__tests__/changelog-release.test.ts | 162 ++++++++++++++++++++ src/__tests__/npm-package-scripts.test.ts | 16 +- vitest.config.ts | 3 + 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/changelog-release.test.ts diff --git a/scripts/__tests__/changelog-release.test.ts b/scripts/__tests__/changelog-release.test.ts new file mode 100644 index 0000000000..a6e079a226 --- /dev/null +++ b/scripts/__tests__/changelog-release.test.ts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +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, + 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('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); +}); + +// Repository guard: every fragment actually checked in today (other than README.md) must have a +// valid name and pass parseFragment, so a malformed fragment is caught before it ships. +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'); + const names = fs.readdirSync(fragmentsDir).filter((name) => name !== 'README.md'); + for (const name of names) { + parseFragment({ name, text: fs.readFileSync(path.join(fragmentsDir, name), 'utf8') }); + } +}); 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/vitest.config.ts b/vitest.config.ts index 77b2793122..22db8654b9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -169,6 +169,9 @@ 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, so it needs no device or subprocess. + '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. From cf03fea6231735177e046cc4ff493706aa200e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 12:41:36 +0200 Subject: [PATCH 3/8] ci: skip changelog.d/** in the six paths-ignore lanes A PR that only adds a changelog fragment should skip CI/size/device lanes the same way a root-doc-only PR does. Extends the existing pin test to assert changelog.d/2799-example.md is ignored by all six workflows too. --- .github/workflows/android.yml | 1 + .github/workflows/ci.yml | 1 + .github/workflows/ios.yml | 1 + .github/workflows/linux.yml | 1 + .github/workflows/macos.yml | 1 + .github/workflows/size.yml | 1 + test/ci/root-docs-paths-ignore.test.ts | 11 +++++++++++ 7 files changed, 17 insertions(+) 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/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); +}); From 5e6a6482e8f62185ac5aaf2f1e31aa8ea81c4d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 12:41:41 +0200 Subject: [PATCH 4/8] docs: point PR and release guidance at changelog.d fragments docs/agents/pull-requests.md and CONTRIBUTING.md now say a user-visible change adds a changelog.d/.md fragment and that PRs never edit CHANGELOG.md directly; the gesture-deprecation policy page updates its one reference to recording an entry under Unreleased. --- CONTRIBUTING.md | 5 +++++ docs/agents/pull-requests.md | 4 ++++ website/docs/docs/migrating-gestures.md | 4 ++-- 3 files changed, 11 insertions(+), 2 deletions(-) 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/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/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. From f3159f5732d5bd2cda3f6153ac367221da254cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 12:48:44 +0200 Subject: [PATCH 5/8] fix(release): migrate the Unreleased changelog block and close the fragment-name enumeration gap CHANGELOG.md still carried "## Unreleased" while the version script now refuses to assemble fragments into a changelog that has one, which would block the first release after a fragment lands. No bullet was added after the v0.21.13 tag, so the whole block becomes one historical section, and a repo-guard test now pins the absence of the heading. readFragments filtered to *.md while the repo-guard test and parseFragment did not, so a fragment named without a .md extension slipped past --check and the version script and stayed in changelog.d forever. readFragments now enumerates every non-README entry and parseFragment rejects a missing .md extension, so the same list drives assembly, --check and the test. --- CHANGELOG.md | 4 +++- scripts/__tests__/changelog-release.test.ts | 19 ++++++++++++----- scripts/changelog-release.ts | 23 ++++++++++++++------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee85fc461..31daa45be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # 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 diff --git a/scripts/__tests__/changelog-release.test.ts b/scripts/__tests__/changelog-release.test.ts index a6e079a226..ac8434ac1c 100644 --- a/scripts/__tests__/changelog-release.test.ts +++ b/scripts/__tests__/changelog-release.test.ts @@ -6,6 +6,7 @@ import { mkdtempForTestSync } from '../../src/__tests__/test-utils/tmp-dir.ts'; import { assembleChangelog, parseFragment, + readFragments, runCli, type ChangelogFragment, } from '../changelog-release.ts'; @@ -150,13 +151,21 @@ test('CLI --check exits 1 while a fragment remains and 0 once only README.md is assert.equal(runCli({ root, check: true }), 0); }); -// Repository guard: every fragment actually checked in today (other than README.md) must have a -// valid name and pass parseFragment, so a malformed fragment is caught before it ships. +// 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'); - const names = fs.readdirSync(fragmentsDir).filter((name) => name !== 'README.md'); - for (const name of names) { - parseFragment({ name, text: fs.readFileSync(path.join(fragmentsDir, name), 'utf8') }); + 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); +}); diff --git a/scripts/changelog-release.ts b/scripts/changelog-release.ts index 7ed7d4e218..2af720384d 100644 --- a/scripts/changelog-release.ts +++ b/scripts/changelog-release.ts @@ -42,18 +42,22 @@ const BULLET_HEADER = /^- (Breaking|Added|Changed|Deprecated|Removed|Fixed|Security)(?: \([^)]+\))?: \S/; const CONTINUATION = /^ {2}\S/; -/** The fragment's basename with `.md` stripped, or the whole name if it carries no extension. */ +/** The fragment's basename with the required `.md` extension stripped. */ function slug(fragmentName: string): string { - return fragmentName.endsWith('.md') ? fragmentName.slice(0, -'.md'.length) : fragmentName; + 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 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. + * 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( @@ -148,11 +152,16 @@ export function assembleChangelog(input: { const FRAGMENTS_DIR = 'changelog.d'; const README = 'README.md'; -function readFragments(dir: string): ChangelogFragment[] { +/** + * Every `changelog.d` entry other than `README.md`, 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.endsWith('.md')) + .filter((name) => name !== README) .sort() .map((name) => ({ name, text: fs.readFileSync(path.join(dir, name), 'utf8') })); } From 38b516eb7bdded6518580bdf5c9a4a700386cabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 16:03:17 +0200 Subject: [PATCH 6/8] test(release): cover unknown kinds, the double-run refusal, and untouched-on-refusal CLI writes Adds pinning tests for the leading-indented-line and unknown-kind parseFragment branches, a CLI-level double-run refusal against an already-released version heading, and a check that a refusal leaves CHANGELOG.md and the pending fragment byte-identical on disk. Also covers a fragment missing the .md extension being rejected loudly by both --check and the default run, since readFragments and the CLI share one enumeration. Trims the module header comment down to the non-narrative essentials. --- scripts/__tests__/changelog-release.test.ts | 53 +++++++++++++++++++++ scripts/changelog-release.ts | 13 ++--- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/scripts/__tests__/changelog-release.test.ts b/scripts/__tests__/changelog-release.test.ts index ac8434ac1c..20bf37df94 100644 --- a/scripts/__tests__/changelog-release.test.ts +++ b/scripts/__tests__/changelog-release.test.ts @@ -113,6 +113,20 @@ test('parseFragment: a leading non-bullet line fails validation', () => { ); }); +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( @@ -151,6 +165,45 @@ test('CLI --check exits 1 while a fragment remains and 0 once only README.md is 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"/); +}); + // 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. diff --git a/scripts/changelog-release.ts b/scripts/changelog-release.ts index 2af720384d..542a8e7e8f 100644 --- a/scripts/changelog-release.ts +++ b/scripts/changelog-release.ts @@ -1,12 +1,9 @@ -// The release-version commit's changelog step (#2877): PRs never edit `CHANGELOG.md`. Instead a -// PR adds `changelog.d/.md`, and `npm version` runs this to fold every fragment present into -// one new `## ` section, then deletes the fragments it consumed. `changelog.d/README.md` -// documents the fragment format for contributors; this module is its enforcement. +// 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. // -// Exports a pure core (parseFragment, assembleChangelog) so scripts/__tests__/changelog-release.test.ts -// can prove ordering, refusals and idempotence without touching the filesystem. The CLI below is a -// thin wrapper: default mode assembles and writes, `--check` (wired into `release:prepare`) fails -// a release that would otherwise ship with `changelog.d/` fragments still unconsumed. +// `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'; From 3a4b0157201a0b013d44228e67bcb1edae444288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 17:02:56 +0200 Subject: [PATCH 7/8] fix(release): recompute the changelog migration against the rebased base The rebase onto origin/main brought three CHANGELOG.md edits that landed after the v0.21.13 tag this branch's migration used as its base: two new bullets and a rewrite of an existing one. Replaying the Unreleased-heading rename over that history folded all three under the released heading, crediting changes to versions that never shipped them. Move each post-tag change into its own changelog.d fragment instead, and restore the rewritten bullet to its v0.21.13 wording under the released heading. Add a repository-guard test that reads the tag's own Unreleased block and asserts the migrated section is byte-identical to it, so a future stale-base migration fails the suite instead of merging clean. --- CHANGELOG.md | 41 ------------- .../2491-alert-popover-dismiss-query.md | 6 ++ changelog.d/2788-sequence-tap-fallback.md | 5 ++ changelog.d/2796-fold-helper-no-werror.md | 8 +++ changelog.d/2860-xctrace-tagged-backtrace.md | 6 ++ changelog.d/2864-post-gesture-outcome.md | 12 ++++ .../2915-android-heading-role-description.md | 7 +++ scripts/__tests__/changelog-release.test.ts | 58 +++++++++++++++++++ 8 files changed, 102 insertions(+), 41 deletions(-) create mode 100644 changelog.d/2491-alert-popover-dismiss-query.md create mode 100644 changelog.d/2788-sequence-tap-fallback.md create mode 100644 changelog.d/2796-fold-helper-no-werror.md create mode 100644 changelog.d/2860-xctrace-tagged-backtrace.md create mode 100644 changelog.d/2864-post-gesture-outcome.md create mode 100644 changelog.d/2915-android-heading-role-description.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 31daa45be7..6f82592c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,25 +4,6 @@ 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 @@ -41,25 +22,11 @@ These releases did not split the changelog per version. - 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 @@ -115,14 +82,6 @@ These releases did not split the changelog per version. 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/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..ec761de6ca --- /dev/null +++ b/changelog.d/2864-post-gesture-outcome.md @@ -0,0 +1,12 @@ +- 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. 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/scripts/__tests__/changelog-release.test.ts b/scripts/__tests__/changelog-release.test.ts index 20bf37df94..f1472b3376 100644 --- a/scripts/__tests__/changelog-release.test.ts +++ b/scripts/__tests__/changelog-release.test.ts @@ -1,4 +1,5 @@ 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'; @@ -222,3 +223,60 @@ test('the real CHANGELOG.md has no "## Unreleased" heading', () => { 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'), + ); +}); From 9ec54d3b5a731747b54d3856e295fd1010151023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 17:46:18 +0200 Subject: [PATCH 8/8] fix(release): skip changelog.d dotfiles and state only #2864's post-tag change readFragments ignores dotfiles, so a stray .DS_Store no longer aborts npm version; every other entry still has to be a valid fragment. The #2864 fragment restated the unsettled-surface fix that v0.21.13 already shipped. It now names only what changed after the tag: the unsettledGesture field renamed to postGestureOutcome, the no-effect kind, and disclosure on interactions. The vitest lane comment now says the migration guard runs one git show. --- changelog.d/2864-post-gesture-outcome.md | 20 ++++++++------------ changelog.d/README.md | 3 ++- scripts/__tests__/changelog-release.test.ts | 12 ++++++++++++ scripts/changelog-release.ts | 9 +++++---- vitest.config.ts | 3 ++- 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/changelog.d/2864-post-gesture-outcome.md b/changelog.d/2864-post-gesture-outcome.md index ec761de6ca..661eb6f829 100644 --- a/changelog.d/2864-post-gesture-outcome.md +++ b/changelog.d/2864-post-gesture-outcome.md @@ -1,12 +1,8 @@ -- 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. +- 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/README.md b/changelog.d/README.md index 7030820006..0c72815fc8 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -6,7 +6,8 @@ running `scripts/changelog-release.ts` to fold every fragment present into a new 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. +when no fragments are pending. The assembler ignores dotfiles such as `.DS_Store`; every other +entry must be a valid fragment. ## Adding a fragment diff --git a/scripts/__tests__/changelog-release.test.ts b/scripts/__tests__/changelog-release.test.ts index f1472b3376..0fb903967f 100644 --- a/scripts/__tests__/changelog-release.test.ts +++ b/scripts/__tests__/changelog-release.test.ts @@ -205,6 +205,18 @@ test('CLI: a fragment without the .md extension is rejected, not silently skippe 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. diff --git a/scripts/changelog-release.ts b/scripts/changelog-release.ts index 542a8e7e8f..fe2ef8132d 100644 --- a/scripts/changelog-release.ts +++ b/scripts/changelog-release.ts @@ -150,15 +150,16 @@ const FRAGMENTS_DIR = 'changelog.d'; const README = 'README.md'; /** - * Every `changelog.d` entry other than `README.md`, 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. + * 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) + .filter((name) => name !== README && !name.startsWith('.')) .sort() .map((name) => ({ name, text: fs.readFileSync(path.join(dir, name), 'utf8') })); } diff --git a/vitest.config.ts b/vitest.config.ts index 22db8654b9..407ddaecfe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -170,7 +170,8 @@ export default defineConfig({ // 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, so it needs no device or subprocess. + // 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,