Skip to content

Commit b8ac021

Browse files
authored
Merge branch 'main' into changeset-release/main
2 parents 79db152 + ee880d9 commit b8ac021

3 files changed

Lines changed: 144 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"release:status": "node scripts/release/release-status.mjs",
3434
"test:release": "node --test scripts/release/*.test.mjs",
3535
"version": "changeset version",
36-
"version:release": "changeset version",
36+
"version:release": "node scripts/release/link-desktop-changesets.mjs && changeset version",
3737
"publish": "pnpm run typecheck && pnpm run lint && pnpm run sherif && pnpm run test && pnpm run build && pnpm run lint:pkg && changeset publish",
3838
"prepare": "simple-git-hooks",
3939
"dev:agent-gateway": "pnpm -C apps/pythinker-code run dev:agent-gateway",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* The desktop app is a shell around the CLI and the web UI, so every CLI
3+
* release changes what desktop users see. Changesets only bumps a package a
4+
* changeset names, and most changesets name the CLI alone, so the CLI shipped
5+
* to npm while desktop stayed on its old version.
6+
*
7+
* This runs before `changeset version` on the release branch: every changeset
8+
* that names the CLI but not the desktop app gets the desktop app added at the
9+
* same bump level. The desktop changelog then carries the real entries, and
10+
* `release.yml` cuts the `desktop-v*` tag from the bump it detects. Desktop
11+
* keeps its own version line; this is deliberately not a `fixed` group.
12+
*
13+
* Idempotent: a changeset that already names the desktop app is left alone.
14+
*/
15+
16+
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
17+
import { join } from 'node:path';
18+
import { pathToFileURL } from 'node:url';
19+
20+
export const CLI_PACKAGE = '@pymodel/pythinker-code';
21+
export const DESKTOP_PACKAGE = '@pymodel/pythinker-desktop';
22+
23+
const FRONTMATTER_LINE = /^(\s*)(?:"([^"]+)"|'([^']+)'|([^:'"]+?))\s*:\s*(?:"([A-Za-z]+)"|'([A-Za-z]+)'|([A-Za-z]+))\s*$/u;
24+
25+
/**
26+
* Add the desktop package to one changeset when it names the CLI alone.
27+
*
28+
* @param source - Raw changeset file contents.
29+
* @returns The rewritten source, or `null` when nothing changes.
30+
*/
31+
export function linkDesktop(source) {
32+
const normalized = source.replaceAll('\r\n', '\n');
33+
if (!normalized.startsWith('---\n')) return null;
34+
const end = normalized.indexOf('\n---', 3);
35+
if (end === -1) return null;
36+
const lines = normalized.slice(4, end + 1).split('\n');
37+
38+
let cliLine = -1;
39+
let cliLevel = null;
40+
let cliIndent = '';
41+
for (const [index, line] of lines.entries()) {
42+
const match = FRONTMATTER_LINE.exec(line);
43+
if (match === null) continue;
44+
const name = match[2] ?? match[3] ?? match[4];
45+
if (name === DESKTOP_PACKAGE) return null;
46+
if (name === CLI_PACKAGE) {
47+
cliLine = index;
48+
cliLevel = (match[5] ?? match[6] ?? match[7]).toLowerCase();
49+
cliIndent = match[1];
50+
}
51+
}
52+
if (cliLine === -1) return null;
53+
54+
lines.splice(cliLine + 1, 0, `${cliIndent}"${DESKTOP_PACKAGE}": ${cliLevel}`);
55+
return `---\n${lines.join('\n')}${normalized.slice(end + 1)}`;
56+
}
57+
58+
/**
59+
* Rewrite every changeset in a directory.
60+
*
61+
* @param dir - The `.changeset` directory.
62+
* @returns The file names that changed.
63+
*/
64+
export function linkDesktopChangesets(dir) {
65+
const changed = [];
66+
for (const name of readdirSync(dir).toSorted()) {
67+
if (!name.endsWith('.md') || name === 'README.md') continue;
68+
const path = join(dir, name);
69+
const next = linkDesktop(readFileSync(path, 'utf8'));
70+
if (next === null) continue;
71+
writeFileSync(path, next);
72+
changed.push(name);
73+
}
74+
return changed;
75+
}
76+
77+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
78+
const changed = linkDesktopChangesets(process.argv[2] ?? '.changeset');
79+
for (const name of changed) console.log(`linked desktop bump: ${name}`);
80+
console.log(`${changed.length} changeset(s) now bump ${DESKTOP_PACKAGE} alongside ${CLI_PACKAGE}.`);
81+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import assert from 'node:assert/strict';
2+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { test } from 'node:test';
6+
7+
import { linkDesktop, linkDesktopChangesets } from './link-desktop-changesets.mjs';
8+
9+
const cliOnly = '---\n"@pymodel/pythinker-code": minor\n---\n\nAdd panel tabs.\n';
10+
11+
void test('adds the desktop package at the CLI bump level', () => {
12+
assert.equal(
13+
linkDesktop(cliOnly),
14+
'---\n"@pymodel/pythinker-code": minor\n"@pymodel/pythinker-desktop": minor\n---\n\nAdd panel tabs.\n',
15+
);
16+
});
17+
18+
void test('leaves a changeset that already names desktop alone', () => {
19+
const both = '---\n"@pymodel/pythinker-desktop": patch\n"@pymodel/pythinker-code": patch\n---\n\nx\n';
20+
assert.equal(linkDesktop(both), null);
21+
assert.equal(linkDesktop(linkDesktop(cliOnly)), null);
22+
});
23+
24+
void test('accepts quoted bump values and does not duplicate the desktop entry', () => {
25+
const quoted = '---\n"@pymodel/pythinker-code": "minor"\n---\n\nx\n';
26+
assert.equal(linkDesktop(quoted), '---\n"@pymodel/pythinker-code": "minor"\n"@pymodel/pythinker-desktop": minor\n---\n\nx\n');
27+
assert.equal(linkDesktop(linkDesktop(quoted)), null);
28+
assert.equal(linkDesktop("---\n'@pymodel/pythinker-desktop': 'patch'\n\"@pymodel/pythinker-code\": 'patch'\n---\n\nx\n"), null);
29+
});
30+
31+
void test('reuses the CLI entry indentation', () => {
32+
assert.equal(
33+
linkDesktop('---\n "@pymodel/pythinker-code": patch\n---\n\nx\n'),
34+
'---\n "@pymodel/pythinker-code": patch\n "@pymodel/pythinker-desktop": patch\n---\n\nx\n',
35+
);
36+
});
37+
38+
void test('ignores changesets that do not name the CLI', () => {
39+
assert.equal(linkDesktop('---\n"@pymodel/klient": patch\n---\n\nx\n'), null);
40+
assert.equal(linkDesktop('no frontmatter\n'), null);
41+
});
42+
43+
void test('keeps other packages and the prose intact', () => {
44+
const source = '---\n"@pymodel/klient": patch\n"@pymodel/pythinker-code": patch\n---\n\nBody mentions "@pymodel/pythinker-desktop": major but that is prose.\n';
45+
const next = linkDesktop(source);
46+
assert.equal(
47+
next,
48+
'---\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',
49+
);
50+
});
51+
52+
void test('rewrites only the changesets that need it and is idempotent', () => {
53+
const dir = mkdtempSync(join(tmpdir(), 'changesets-'));
54+
writeFileSync(join(dir, 'README.md'), '---\n"@pymodel/pythinker-code": patch\n---\n');
55+
writeFileSync(join(dir, 'config.json'), '{}');
56+
writeFileSync(join(dir, 'a.md'), cliOnly);
57+
writeFileSync(join(dir, 'b.md'), '---\n"@pymodel/klient": patch\n---\n\nx\n');
58+
assert.deepEqual(linkDesktopChangesets(dir), ['a.md']);
59+
assert.match(readFileSync(join(dir, 'a.md'), 'utf8'), /pythinker-desktop": minor/u);
60+
assert.deepEqual(linkDesktopChangesets(dir), []);
61+
assert.equal(readFileSync(join(dir, 'README.md'), 'utf8'), '---\n"@pymodel/pythinker-code": patch\n---\n');
62+
});

0 commit comments

Comments
 (0)