diff --git a/package.json b/package.json index a859a8669..062d7e8b7 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "release:status": "node scripts/release/release-status.mjs", "test:release": "node --test scripts/release/*.test.mjs", "version": "changeset version", - "version:release": "changeset version", + "version:release": "node scripts/release/link-desktop-changesets.mjs && changeset version", "publish": "pnpm run typecheck && pnpm run lint && pnpm run sherif && pnpm run test && pnpm run build && pnpm run lint:pkg && changeset publish", "prepare": "simple-git-hooks", "dev:agent-gateway": "pnpm -C apps/pythinker-code run dev:agent-gateway", diff --git a/scripts/release/link-desktop-changesets.mjs b/scripts/release/link-desktop-changesets.mjs new file mode 100644 index 000000000..fe35f0b45 --- /dev/null +++ b/scripts/release/link-desktop-changesets.mjs @@ -0,0 +1,81 @@ +/** + * The desktop app is a shell around the CLI and the web UI, so every CLI + * release changes what desktop users see. Changesets only bumps a package a + * changeset names, and most changesets name the CLI alone, so the CLI shipped + * to npm while desktop stayed on its old version. + * + * This runs before `changeset version` on the release branch: every changeset + * that names the CLI but not the desktop app gets the desktop app added at the + * same bump level. The desktop changelog then carries the real entries, and + * `release.yml` cuts the `desktop-v*` tag from the bump it detects. Desktop + * keeps its own version line; this is deliberately not a `fixed` group. + * + * Idempotent: a changeset that already names the desktop app is left alone. + */ + +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const CLI_PACKAGE = '@pymodel/pythinker-code'; +export const DESKTOP_PACKAGE = '@pymodel/pythinker-desktop'; + +const FRONTMATTER_LINE = /^(\s*)(?:"([^"]+)"|'([^']+)'|([^:'"]+?))\s*:\s*(?:"([A-Za-z]+)"|'([A-Za-z]+)'|([A-Za-z]+))\s*$/u; + +/** + * Add the desktop package to one changeset when it names the CLI alone. + * + * @param source - Raw changeset file contents. + * @returns The rewritten source, or `null` when nothing changes. + */ +export function linkDesktop(source) { + const normalized = source.replaceAll('\r\n', '\n'); + if (!normalized.startsWith('---\n')) return null; + const end = normalized.indexOf('\n---', 3); + if (end === -1) return null; + const lines = normalized.slice(4, end + 1).split('\n'); + + let cliLine = -1; + let cliLevel = null; + let cliIndent = ''; + for (const [index, line] of lines.entries()) { + const match = FRONTMATTER_LINE.exec(line); + if (match === null) continue; + const name = match[2] ?? match[3] ?? match[4]; + if (name === DESKTOP_PACKAGE) return null; + if (name === CLI_PACKAGE) { + cliLine = index; + cliLevel = (match[5] ?? match[6] ?? match[7]).toLowerCase(); + cliIndent = match[1]; + } + } + if (cliLine === -1) return null; + + lines.splice(cliLine + 1, 0, `${cliIndent}"${DESKTOP_PACKAGE}": ${cliLevel}`); + return `---\n${lines.join('\n')}${normalized.slice(end + 1)}`; +} + +/** + * Rewrite every changeset in a directory. + * + * @param dir - The `.changeset` directory. + * @returns The file names that changed. + */ +export function linkDesktopChangesets(dir) { + const changed = []; + for (const name of readdirSync(dir).toSorted()) { + if (!name.endsWith('.md') || name === 'README.md') continue; + const path = join(dir, name); + const next = linkDesktop(readFileSync(path, 'utf8')); + if (next === null) continue; + writeFileSync(path, next); + changed.push(name); + } + return changed; +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + const changed = linkDesktopChangesets(process.argv[2] ?? '.changeset'); + for (const name of changed) console.log(`linked desktop bump: ${name}`); + console.log(`${changed.length} changeset(s) now bump ${DESKTOP_PACKAGE} alongside ${CLI_PACKAGE}.`); +} diff --git a/scripts/release/link-desktop-changesets.test.mjs b/scripts/release/link-desktop-changesets.test.mjs new file mode 100644 index 000000000..f37676110 --- /dev/null +++ b/scripts/release/link-desktop-changesets.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { linkDesktop, linkDesktopChangesets } from './link-desktop-changesets.mjs'; + +const cliOnly = '---\n"@pymodel/pythinker-code": minor\n---\n\nAdd panel tabs.\n'; + +void test('adds the desktop package at the CLI bump level', () => { + assert.equal( + linkDesktop(cliOnly), + '---\n"@pymodel/pythinker-code": minor\n"@pymodel/pythinker-desktop": minor\n---\n\nAdd panel tabs.\n', + ); +}); + +void test('leaves a changeset that already names desktop alone', () => { + const both = '---\n"@pymodel/pythinker-desktop": patch\n"@pymodel/pythinker-code": patch\n---\n\nx\n'; + assert.equal(linkDesktop(both), null); + assert.equal(linkDesktop(linkDesktop(cliOnly)), null); +}); + +void test('accepts quoted bump values and does not duplicate the desktop entry', () => { + const quoted = '---\n"@pymodel/pythinker-code": "minor"\n---\n\nx\n'; + assert.equal(linkDesktop(quoted), '---\n"@pymodel/pythinker-code": "minor"\n"@pymodel/pythinker-desktop": minor\n---\n\nx\n'); + assert.equal(linkDesktop(linkDesktop(quoted)), null); + assert.equal(linkDesktop("---\n'@pymodel/pythinker-desktop': 'patch'\n\"@pymodel/pythinker-code\": 'patch'\n---\n\nx\n"), null); +}); + +void test('reuses the CLI entry indentation', () => { + assert.equal( + linkDesktop('---\n "@pymodel/pythinker-code": patch\n---\n\nx\n'), + '---\n "@pymodel/pythinker-code": patch\n "@pymodel/pythinker-desktop": patch\n---\n\nx\n', + ); +}); + +void test('ignores changesets that do not name the CLI', () => { + assert.equal(linkDesktop('---\n"@pymodel/klient": patch\n---\n\nx\n'), null); + assert.equal(linkDesktop('no frontmatter\n'), null); +}); + +void test('keeps other packages and the prose intact', () => { + const source = '---\n"@pymodel/klient": patch\n"@pymodel/pythinker-code": patch\n---\n\nBody mentions "@pymodel/pythinker-desktop": major but that is prose.\n'; + const next = linkDesktop(source); + assert.equal( + next, + '---\n"@pymodel/klient": patch\n"@pymodel/pythinker-code": patch\n"@pymodel/pythinker-desktop": patch\n---\n\nBody mentions "@pymodel/pythinker-desktop": major but that is prose.\n', + ); +}); + +void test('rewrites only the changesets that need it and is idempotent', () => { + const dir = mkdtempSync(join(tmpdir(), 'changesets-')); + writeFileSync(join(dir, 'README.md'), '---\n"@pymodel/pythinker-code": patch\n---\n'); + writeFileSync(join(dir, 'config.json'), '{}'); + writeFileSync(join(dir, 'a.md'), cliOnly); + writeFileSync(join(dir, 'b.md'), '---\n"@pymodel/klient": patch\n---\n\nx\n'); + assert.deepEqual(linkDesktopChangesets(dir), ['a.md']); + assert.match(readFileSync(join(dir, 'a.md'), 'utf8'), /pythinker-desktop": minor/u); + assert.deepEqual(linkDesktopChangesets(dir), []); + assert.equal(readFileSync(join(dir, 'README.md'), 'utf8'), '---\n"@pymodel/pythinker-code": patch\n---\n'); +});