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..73956ea92 100644 --- a/apps/desktop/src/updater.ts +++ b/apps/desktop/src/updater.ts @@ -109,10 +109,144 @@ 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 + }) +} + +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 +} + +/** + * Decoding runs after the scan, so `<script>` — which is how GitHub + * renders a literal tag an author typed — turns back into `Kept { + 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: '

Note

<script>payload</script>', + }) + + expect(getLocalUpdateState().releaseNotes).toBe('Note\n<script>payload</script>') + }) + 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); +});