From 6824c4854fcf7f60a12a4ec64314129944f77f5f Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 27 Aug 2026 16:36:37 -0400 Subject: [PATCH 1/3] fix(desktop): show the changelog in the update dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release body was a fixed string — "Pythinker Desktop ( channel), built from ." — so the in-app updater had nothing to tell users about a version. The body now carries the entries for that version from apps/desktop/CHANGELOG.md, with the changesets PR/commit/thanks prefix stripped, and keeps the source commit URL as a footer because resuming a draft checks for it. A stable release whose version has no changelog entry fails in the prepare job instead of shipping a build nobody can describe. The notes also arrived as markup. electron-updater's GitHub provider reads the releases Atom feed, whose content is the body GitHub has already rendered to HTML, and the renderer treats that field as Markdown — so tags printed literally, down to `PyModel/pythinker-code@`. The main process now reduces that HTML to text at the boundary that already normalizes this field. --- .../desktop-release-notes-from-changelog.md | 5 ++ .github/workflows/desktop-release.yml | 8 +- AGENTS.md | 1 + apps/desktop/scripts/desktop-release.mjs | 51 +++++++++++- apps/desktop/src/updater.ts | 55 ++++++++++++- .../tests/desktop-release-workflow.spec.ts | 6 ++ apps/desktop/tests/updater.spec.ts | 52 +++++++++++++ scripts/release/desktop-release.test.mjs | 78 +++++++++++++++++++ 8 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 .changeset/desktop-release-notes-from-changelog.md diff --git a/.changeset/desktop-release-notes-from-changelog.md b/.changeset/desktop-release-notes-from-changelog.md new file mode 100644 index 000000000..b847803a4 --- /dev/null +++ b/.changeset/desktop-release-notes-from-changelog.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-desktop": patch +--- + +Show the changelog for the new version in the update dialog instead of a build stamp with raw HTML tags. diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 46fd4dddd..55b532d84 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -138,12 +138,18 @@ jobs: echo "Draft release ${RELEASE_TAG} already exists; resuming it." exit 0 fi + # The body is what the in-app updater shows users, so it carries the + # changelog entries for this version. A stable release with no entry + # fails here rather than shipping a build nobody can describe. + notes_file="$(mktemp)" + node apps/desktop/scripts/desktop-release.mjs notes \ + apps/desktop/CHANGELOG.md "${RELEASE_TAG#v}" "$RELEASE_CHANNEL" "$SOURCE_URL" > "$notes_file" args=( "$RELEASE_TAG" --repo "$RELEASE_REPO" --draft --title "$RELEASE_TAG" - --notes "Pythinker Desktop ${RELEASE_TAG#v} (${RELEASE_CHANNEL} channel), built from ${SOURCE_URL}." + --notes-file "$notes_file" ) if [ "$PRERELEASE" = 'true' ]; then args+=(--prerelease); fi gh release create "${args[@]}" diff --git a/AGENTS.md b/AGENTS.md index 18d53d48a..6d9ecb675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ Gate behind flags. Env: `PYTHINKER_CODE_EXPERIMENTAL_` toggles one; `PYTHI - PR titles: Conventional Commit style (e.g. `chore: remove legacy format commands`). - Fill in `.github/pull_request_template.md` — link the issue, describe changes. No placeholder text or vague AI-generated PR summaries; the human author must understand the change well enough to explain the code, edge cases, and why the approach fits. - Run `gen-changesets` skill before submitting PRs. Changesets must strictly follow its rules: one short user-facing sentence stating only what changed; skip any change users cannot perceive. Never decide `major` on your own — stop, explain, and get explicit user confirmation first; default to `minor`, fall back to `patch`. +- Changeset text is shipped text: the desktop release body is generated from `apps/desktop/CHANGELOG.md`, and the in-app updater shows it to users verbatim. A release body must state what changed for users — never a build stamp, a commit hash, or placeholder text. `desktop-release.yml` fails a stable release whose version has no changelog entry. - Prefer `import ... from '#/...'` (equivalent to `@/...`). - Do not commit throwaway scratch or exploratory files. Never stage agent working notes or handoff documents (e.g. `HANDOVER-*.md`, `HANDOFF-*.md`, `handoff.md`), or throwaway UI/UX prototypes or design mockups (e.g. `*-designs.html`, `*-mockup.html`, `*-demo(s).html`). The only tracked `.html` files should be Vite `index.html` entrypoints. Put scratch work under `.tmp/` (gitignored). diff --git a/apps/desktop/scripts/desktop-release.mjs b/apps/desktop/scripts/desktop-release.mjs index f8a1659fe..3c8c21a1b 100644 --- a/apps/desktop/scripts/desktop-release.mjs +++ b/apps/desktop/scripts/desktop-release.mjs @@ -165,6 +165,45 @@ export function configureDesktopPackage(value, version, channel) { }; } +const attributionPattern = /^\s*(?:\[[^\]]*\]\([^)]*\)\s*)+(?:Thanks\s+\[[^\]]*\]\([^)]*\)!\s*)?-\s*/u; + +function changelogSection(changelog, version) { + if (typeof changelog !== 'string') throw new Error('Desktop changelog must be a string.'); + const lines = changelog.split('\n'); + const start = lines.findIndex(line => line.trim() === `## ${version}`); + if (start === -1) return []; + const rest = lines.slice(start + 1); + const end = rest.findIndex(line => line.startsWith('## ')); + return end === -1 ? rest : rest.slice(0, end); +} + +/** + * Users read the release body in the updater, so it carries the changelog + * entries and nothing else. Changesets prefixes every entry with its PR link, + * commit link, and a thanks line; those are noise in an update dialog. + */ +export function desktopReleaseNotes(options) { + const version = desktopVersion(options?.version); + const channel = desktopChannel(options?.channel); + const sourceUrl = options?.sourceUrl; + if (typeof sourceUrl !== 'string' || sourceUrl.length === 0) { + throw new Error('Desktop release notes require the source commit URL.'); + } + const entries = []; + for (const line of changelogSection(options?.changelog ?? '', version)) { + if (!line.startsWith('- ')) continue; + const text = line.slice(2).replace(attributionPattern, '').trim(); + if (text.length > 0) entries.push(`- ${text}`); + } + if (entries.length === 0) { + if (channel === 'stable') { + throw new Error(`apps/desktop/CHANGELOG.md has no entries for ${version}; a stable release must tell users what changed.`); + } + entries.push(`- Preview build of the ${channel} channel.`); + } + return `${entries.join('\n')}\n\n---\n\nBuilt from ${sourceUrl}.\n`; +} + function writeOutputs(result) { const lines = [ `version=${result.version}`, @@ -201,6 +240,16 @@ function main() { })); return; } + if (command === 'notes' && args.length === 4) { + const [changelogPath, version, channel, sourceUrl] = args; + process.stdout.write(desktopReleaseNotes({ + changelog: readFileSync(resolve(changelogPath), 'utf8'), + version, + channel, + sourceUrl, + })); + return; + } if (command === 'configure' && args.length === 3) { const [path, version, channel] = args; const packagePath = resolve(path); @@ -208,7 +257,7 @@ function main() { writeFileSync(packagePath, `${JSON.stringify(configured, null, 2)}\n`, 'utf8'); return; } - throw new Error('Usage: desktop-release.mjs resolve | configure '); + throw new Error('Usage: desktop-release.mjs resolve | notes | configure '); } if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/apps/desktop/src/updater.ts b/apps/desktop/src/updater.ts index 477a5b98f..96b0f0136 100644 --- a/apps/desktop/src/updater.ts +++ b/apps/desktop/src/updater.ts @@ -109,10 +109,61 @@ export function writeUpdateSettings(dir: string, value: UpdateSettings): void { writeFileSync(join(dir, UPDATE_SETTINGS_FILE), `${JSON.stringify(value, null, 2)}\n`, 'utf8') } +const HTML_MARKUP_PATTERN = /<\/?[a-z][^>]*>/iu +const NAMED_ENTITIES: Readonly> = { + amp: '&', + apos: "'", + gt: '>', + lt: '<', + nbsp: ' ', + quot: '"', +} + +function decodeEntities(value: string): string { + return value.replaceAll(/&(#x[0-9a-f]+|#\d+|[a-z]+);/giu, (match, entity: string) => { + if (!entity.startsWith('#')) return NAMED_ENTITIES[entity.toLowerCase()] ?? match + const code = entity.startsWith('#x') || entity.startsWith('#X') + ? Number.parseInt(entity.slice(2), 16) + : Number.parseInt(entity.slice(1), 10) + return Number.isSafeInteger(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match + }) +} + +/** + * The GitHub provider reads the releases Atom feed, whose `` is the + * body GitHub has already rendered to HTML. The renderer shows these notes as + * Markdown, so the tags would print literally — `PyModel/pythinker-code@` + * and the rest. Reduce the markup to text here, at the boundary that already + * owns this field. + */ +function plainReleaseNotes(value: string): string { + const text = value + .replaceAll(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/giu, '') + .replaceAll(/]*>/giu, '\n- ') + .replaceAll(//giu, '\n') + .replaceAll(/<\/(p|div|li|ul|ol|tr|h[1-6])>/giu, '\n') + .replaceAll(/<[^>]*>/gu, '') + return decodeEntities(text) + .replaceAll(/[^\S\n]+\n/gu, '\n') + .replaceAll(/\n{3,}/gu, '\n\n') + .trim() +} + +function normalizedNote(value: string): string { + return HTML_MARKUP_PATTERN.test(value) ? plainReleaseNotes(value) : value.trim() +} + function releaseNotesText(value: UpdateInfo['releaseNotes']): string | undefined { - if (typeof value === 'string') return value + if (typeof value === 'string') { + const note = normalizedNote(value) + return note.length > 0 ? note : undefined + } if (!Array.isArray(value)) return undefined - const notes = value.flatMap(item => typeof item.note === 'string' ? [item.note] : []) + const notes = value.flatMap(item => { + if (typeof item.note !== 'string') return [] + const note = normalizedNote(item.note) + return note.length > 0 ? [note] : [] + }) return notes.length > 0 ? notes.join('\n\n') : undefined } diff --git a/apps/desktop/tests/desktop-release-workflow.spec.ts b/apps/desktop/tests/desktop-release-workflow.spec.ts index 022d1515d..bbda49c95 100644 --- a/apps/desktop/tests/desktop-release-workflow.spec.ts +++ b/apps/desktop/tests/desktop-release-workflow.spec.ts @@ -36,6 +36,12 @@ describe('desktop release workflow', () => { expect(workflow).toContain('verify-update-manifest.ts win') }) + it('gives users the changelog as the release body instead of a build stamp', () => { + expect(workflow).toContain('scripts/desktop-release.mjs notes') + expect(workflow).toContain('--notes-file "$notes_file"') + expect(workflow).not.toContain('--notes "Pythinker Desktop') + }) + it('never publishes a manual unsigned build to the release feed', () => { expect(workflow).toContain("publish: ${{ steps.resolve.outputs.publish }}") expect(workflow).toContain("if: needs.prepare.outputs.publish == 'true'") diff --git a/apps/desktop/tests/updater.spec.ts b/apps/desktop/tests/updater.spec.ts index ebf594a2e..bad783155 100644 --- a/apps/desktop/tests/updater.spec.ts +++ b/apps/desktop/tests/updater.spec.ts @@ -888,6 +888,58 @@ describe('update prompt receipts', () => { expect(readLocalUpdateSettings(directory).pendingInstallVersion).toBeUndefined() }) + it('renders GitHub HTML release notes as text', async () => { + vi.resetModules() + const directory = temporaryDirectory() + writeFileSync(join(directory, 'app-update.yml'), '', 'utf8') + const { app: localApp } = await import('electron') + const localElectronUpdater = (await import('electron-updater')).default + const { + getUpdateState: getLocalUpdateState, + initUpdater: initLocalUpdater, + } = await import('../src/updater') + const localAutoUpdater = localElectronUpdater.autoUpdater + vi.mocked(localApp.getPath).mockReturnValue(directory) + Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true }) + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory }) + + initLocalUpdater(() => undefined) + const available = vi.mocked(localAutoUpdater.on).mock.calls + .find(([event]) => event === 'update-available')?.[1] as ((info: { version: string, releaseNotes?: string }) => void) | undefined + available?.({ + version: '1.2.3', + releaseNotes: '
    \n
  • Install Windows updates in the background.
  • \n
\n
\n' + + '

Built from PyModel/pythinker-code@f27686a.

', + }) + + expect(getLocalUpdateState().releaseNotes).toBe( + '- Install Windows updates in the background.\n\nBuilt from PyModel/pythinker-code@f27686a.', + ) + }) + + it('keeps plain release notes untouched', async () => { + vi.resetModules() + const directory = temporaryDirectory() + writeFileSync(join(directory, 'app-update.yml'), '', 'utf8') + const { app: localApp } = await import('electron') + const localElectronUpdater = (await import('electron-updater')).default + const { + getUpdateState: getLocalUpdateState, + initUpdater: initLocalUpdater, + } = await import('../src/updater') + const localAutoUpdater = localElectronUpdater.autoUpdater + vi.mocked(localApp.getPath).mockReturnValue(directory) + Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true }) + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory }) + + initLocalUpdater(() => undefined) + const available = vi.mocked(localAutoUpdater.on).mock.calls + .find(([event]) => event === 'update-available')?.[1] as ((info: { version: string, releaseNotes?: string }) => void) | undefined + available?.({ version: '1.2.3', releaseNotes: '- One fix\n- Another fix' }) + + expect(getLocalUpdateState().releaseNotes).toBe('- One fix\n- Another fix') + }) + it('reports a pending install that did not take effect as an error', async () => { vi.resetModules() const directory = temporaryDirectory() diff --git a/scripts/release/desktop-release.test.mjs b/scripts/release/desktop-release.test.mjs index 9be05c994..90d061c0e 100644 --- a/scripts/release/desktop-release.test.mjs +++ b/scripts/release/desktop-release.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import { configureDesktopPackage, desktopManifestName, + desktopReleaseNotes, nightlyDesktopVersion, resolveDesktopRelease, } from '../../apps/desktop/scripts/desktop-release.mjs'; @@ -118,3 +119,80 @@ void test('rejects mismatched tags and unsupported prerelease channels', () => { publishNightly: false, }), /explicit Nightly publishing permission/u); }); + +const changelog = [ + '# @pymodel/pythinker-desktop', + '', + '## 0.3.8', + '', + '### Patch Changes', + '', + '- [#225](https://example.com/pull/225) [`f27686a`](https://example.com/commit/f27686a) Thanks [@someone](https://example.com/someone)! - Install Windows updates in the background.', + '- [#226](https://example.com/pull/226) Thanks [@someone](https://example.com/someone)! - Report an update that did not take effect.', + '', + '## 0.3.1', + '', + '- [#190](https://example.com/pull/190) Thanks [@someone](https://example.com/someone)! - Restore downloadable desktop releases.', + '', +].join('\n'); + +void test('release notes carry the changelog entries without changesets attribution', () => { + assert.equal( + desktopReleaseNotes({ + changelog, + version: '0.3.8', + channel: 'stable', + sourceUrl: 'https://example.com/commit/f27686a', + }), + [ + '- Install Windows updates in the background.', + '- Report an update that did not take effect.', + '', + '---', + '', + 'Built from https://example.com/commit/f27686a.', + '', + ].join('\n'), + ); +}); + +void test('release notes stop at the next version heading', () => { + const notes = desktopReleaseNotes({ + changelog, + version: '0.3.1', + channel: 'stable', + sourceUrl: 'https://example.com/commit/f27686a', + }); + assert.match(notes, /Restore downloadable desktop releases\./u); + assert.doesNotMatch(notes, /Install Windows updates/u); +}); + +void test('a stable release without a changelog entry fails instead of shipping', () => { + assert.throws(() => desktopReleaseNotes({ + changelog, + version: '0.4.0', + channel: 'stable', + sourceUrl: 'https://example.com/commit/f27686a', + }), /no entries for 0\.4\.0/u); +}); + +void test('a preview channel without a changelog entry still describes itself', () => { + assert.equal( + desktopReleaseNotes({ + changelog, + version: '0.4.0-nightly.4200', + channel: 'nightly', + sourceUrl: 'https://example.com/commit/f27686a', + }), + '- Preview build of the nightly channel.\n\n---\n\nBuilt from https://example.com/commit/f27686a.\n', + ); +}); + +void test('release notes require the source commit URL the resume check reads', () => { + assert.throws(() => desktopReleaseNotes({ + changelog, + version: '0.3.8', + channel: 'stable', + sourceUrl: '', + }), /source commit URL/u); +}); From 76b603565fb42909bccfef184d9a09dee709fcd7 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 27 Aug 2026 17:00:58 -0400 Subject: [PATCH 2/3] fix(desktop): scan release-notes markup instead of deleting it Deleting tags with a chained replace is single-pass: removing an inner tag lets the surrounding text close back up into markup, and a trailing unterminated `<` survives untouched. CodeQL flagged both. Walk the input once and copy out only the text the scan passes, so nothing that was not already a tag can become one. --- apps/desktop/src/updater.ts | 83 +++++++++++++++++++++++++++--- apps/desktop/tests/updater.spec.ts | 30 +++++++++++ 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/updater.ts b/apps/desktop/src/updater.ts index 96b0f0136..e6e21831b 100644 --- a/apps/desktop/src/updater.ts +++ b/apps/desktop/src/updater.ts @@ -129,20 +129,91 @@ function decodeEntities(value: string): string { }) } +const BLOCK_BREAK_TAGS: ReadonlySet = new Set([ + 'blockquote', + 'div', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'li', + 'ol', + 'p', + 'pre', + 'table', + 'tr', + 'ul', +]) + +const RAW_TEXT_TAGS: ReadonlySet = new Set(['script', 'style']) + +const TAG_NAME_CHARACTER = /[a-z0-9]/iu + +interface HtmlTag { + name: string + closing: boolean + end: number +} + +function readTag(value: string, start: number): HtmlTag | undefined { + const end = value.indexOf('>', start) + if (end < 0) return undefined + const closing = value[start + 1] === '/' + const nameStart = start + (closing ? 2 : 1) + let cursor = nameStart + while (cursor < end && TAG_NAME_CHARACTER.test(value[cursor] ?? '')) cursor += 1 + return { name: value.slice(nameStart, cursor).toLowerCase(), closing, end } +} + +function skipRawTextElement(value: string, name: string, from: number): number { + let index = from + while (index < value.length) { + const next = value.indexOf('<', index) + if (next < 0) return value.length + const tag = readTag(value, next) + if (tag === undefined) return value.length + if (tag.closing && tag.name === name) return tag.end + 1 + index = tag.end + 1 + } + return value.length +} + /** * The GitHub provider reads the releases Atom feed, whose `` is the * body GitHub has already rendered to HTML. The renderer shows these notes as * Markdown, so the tags would print literally — `PyModel/pythinker-code@` * and the rest. Reduce the markup to text here, at the boundary that already * owns this field. + * + * This walks the input once and copies out only the text it passes, rather + * than deleting tags from the string. Deletion is what lets `ipt>` + * close back up into markup; a scan that never re-reads what it emitted cannot + * produce a tag that was not already there. Markup the scan cannot terminate + * ends the walk, so an unclosed `<` is dropped with the rest of the tail. */ function plainReleaseNotes(value: string): string { - const text = value - .replaceAll(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/giu, '') - .replaceAll(/]*>/giu, '\n- ') - .replaceAll(//giu, '\n') - .replaceAll(/<\/(p|div|li|ul|ol|tr|h[1-6])>/giu, '\n') - .replaceAll(/<[^>]*>/gu, '') + let text = '' + let index = 0 + while (index < value.length) { + const next = value.indexOf('<', index) + if (next < 0) { + text += value.slice(index) + break + } + text += value.slice(index, next) + const tag = readTag(value, next) + if (tag === undefined) break + if (!tag.closing && RAW_TEXT_TAGS.has(tag.name)) { + index = skipRawTextElement(value, tag.name, tag.end + 1) + continue + } + if (tag.name === 'li' && !tag.closing) text += '\n- ' + else if (tag.name === 'br') text += '\n' + else if (tag.closing && BLOCK_BREAK_TAGS.has(tag.name)) text += '\n' + index = tag.end + 1 + } return decodeEntities(text) .replaceAll(/[^\S\n]+\n/gu, '\n') .replaceAll(/\n{3,}/gu, '\n\n') diff --git a/apps/desktop/tests/updater.spec.ts b/apps/desktop/tests/updater.spec.ts index bad783155..b5b71169c 100644 --- a/apps/desktop/tests/updater.spec.ts +++ b/apps/desktop/tests/updater.spec.ts @@ -940,6 +940,36 @@ describe('update prompt receipts', () => { expect(getLocalUpdateState().releaseNotes).toBe('- One fix\n- Another fix') }) + it('cannot be made to emit markup by nesting or truncating tags', async () => { + vi.resetModules() + const directory = temporaryDirectory() + writeFileSync(join(directory, 'app-update.yml'), '', 'utf8') + const { app: localApp } = await import('electron') + const localElectronUpdater = (await import('electron-updater')).default + const { + getUpdateState: getLocalUpdateState, + initUpdater: initLocalUpdater, + } = await import('../src/updater') + const localAutoUpdater = localElectronUpdater.autoUpdater + vi.mocked(localApp.getPath).mockReturnValue(directory) + Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true }) + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory }) + + initLocalUpdater(() => undefined) + const available = vi.mocked(localAutoUpdater.on).mock.calls + .find(([event]) => event === 'update-available')?.[1] as ((info: { version: string, releaseNotes?: string }) => void) | undefined + available?.({ + version: '1.2.3', + releaseNotes: '

Real note.

ipt>xKept { vi.resetModules() const directory = temporaryDirectory() From e4e84f73d315625c3191a79db4119a042802dbb1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 27 Aug 2026 17:13:22 -0400 Subject: [PATCH 3/3] fix(desktop): keep decoded release notes inert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entity decoding runs after the tag scan, so `<script>` — how GitHub renders a tag an author typed literally — became `