Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions scripts/release/link-desktop-changesets.mjs
Original file line number Diff line number Diff line change
@@ -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}.`);
}
62 changes: 62 additions & 0 deletions scripts/release/link-desktop-changesets.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
Loading