From fdd00ed7c4c6d54c298320d06151833623a4bd35 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 28 Aug 2026 17:47:59 -0400 Subject: [PATCH 1/2] ci: bump the desktop app with every CLI release Changesets only bumps a package a changeset names, and nearly every changeset names the CLI alone, so CLI and web changes shipped to npm while the desktop app stayed on its old version. Before the release bot runs changeset version, add the desktop app to every changeset that names the CLI, at the same bump level. Desktop keeps its own version line and its changelog carries the real entries. --- package.json | 2 +- scripts/release/link-desktop-changesets.mjs | 78 +++++++++++++++++++ .../release/link-desktop-changesets.test.mjs | 48 ++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 scripts/release/link-desktop-changesets.mjs create mode 100644 scripts/release/link-desktop-changesets.test.mjs 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..1fa2390f5 --- /dev/null +++ b/scripts/release/link-desktop-changesets.mjs @@ -0,0 +1,78 @@ +/** + * 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'; + +export const CLI_PACKAGE = '@pymodel/pythinker-code'; +export const DESKTOP_PACKAGE = '@pymodel/pythinker-desktop'; + +const FRONTMATTER_LINE = /^(\s*)(?:"([^"]+)"|'([^']+)'|([^:'"]+?))\s*:\s*([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; + 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].toLowerCase(); + } + } + if (cliLine === -1) return null; + + lines.splice(cliLine + 1, 0, `"${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 === new URL(`file://${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..2ca48547b --- /dev/null +++ b/scripts/release/link-desktop-changesets.test.mjs @@ -0,0 +1,48 @@ +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('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'); +}); From 23803780635dbd8facbdd7acdbd8442d7740d430 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 28 Aug 2026 18:02:45 -0400 Subject: [PATCH 2/2] ci: harden the desktop changeset linker Accept quoted bump values, keep the CLI entry's indentation on the inserted desktop line, and resolve the main-module guard through pathToFileURL so a path with URL characters still runs the linker. --- scripts/release/link-desktop-changesets.mjs | 11 +++++++---- scripts/release/link-desktop-changesets.test.mjs | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/release/link-desktop-changesets.mjs b/scripts/release/link-desktop-changesets.mjs index 1fa2390f5..fe35f0b45 100644 --- a/scripts/release/link-desktop-changesets.mjs +++ b/scripts/release/link-desktop-changesets.mjs @@ -15,11 +15,12 @@ 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]+)\s*$/u; +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. @@ -36,6 +37,7 @@ export function linkDesktop(source) { 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; @@ -43,12 +45,13 @@ export function linkDesktop(source) { if (name === DESKTOP_PACKAGE) return null; if (name === CLI_PACKAGE) { cliLine = index; - cliLevel = match[5].toLowerCase(); + cliLevel = (match[5] ?? match[6] ?? match[7]).toLowerCase(); + cliIndent = match[1]; } } if (cliLine === -1) return null; - lines.splice(cliLine + 1, 0, `"${DESKTOP_PACKAGE}": ${cliLevel}`); + lines.splice(cliLine + 1, 0, `${cliIndent}"${DESKTOP_PACKAGE}": ${cliLevel}`); return `---\n${lines.join('\n')}${normalized.slice(end + 1)}`; } @@ -71,7 +74,7 @@ export function linkDesktopChangesets(dir) { return changed; } -if (process.argv[1] !== undefined && import.meta.url === new URL(`file://${process.argv[1]}`).href) { +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 index 2ca48547b..f37676110 100644 --- a/scripts/release/link-desktop-changesets.test.mjs +++ b/scripts/release/link-desktop-changesets.test.mjs @@ -21,6 +21,20 @@ void test('leaves a changeset that already names desktop alone', () => { 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);