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
5 changes: 5 additions & 0 deletions .changeset/desktop-release-notes-from-changelog.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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[@]}"
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ Gate behind flags. Env: `PYTHINKER_CODE_EXPERIMENTAL_<NAME>` 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).

Expand Down
51 changes: 50 additions & 1 deletion apps/desktop/scripts/desktop-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -201,14 +240,24 @@ 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);
const configured = configureDesktopPackage(JSON.parse(readFileSync(packagePath, 'utf8')), version, channel);
writeFileSync(packagePath, `${JSON.stringify(configured, null, 2)}\n`, 'utf8');
return;
}
throw new Error('Usage: desktop-release.mjs resolve <event> <package-version> <channel> <tag> <commit-count> <publish-nightly> | configure <package-json> <version> <channel>');
throw new Error('Usage: desktop-release.mjs resolve <event> <package-version> <channel> <tag> <commit-count> <publish-nightly> | notes <changelog> <version> <channel> <source-url> | configure <package-json> <version> <channel>');
}

if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
Expand Down
138 changes: 136 additions & 2 deletions apps/desktop/src/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
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<string> = new Set([
'blockquote',
'div',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'li',
'ol',
'p',
'pre',
'table',
'tr',
'ul',
])

const RAW_TEXT_TAGS: ReadonlySet<string> = 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 `&lt;script&gt;` — which is how GitHub
* renders a literal tag an author typed — turns back into `<script>` once the
* scan can no longer see it. The update dialog renders this value with a
* Markdown component that does render raw HTML, so the decoded text has to
* leave here inert. Escaping the angle brackets keeps it readable: a Markdown
* renderer prints `&lt;` as `<` text rather than opening an element.
*/
function escapeMarkupStarts(value: string): string {
return value.replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}

/**
* The GitHub provider reads the releases Atom feed, whose `<content>` 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@<tt>`
* 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 `<scr<x>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 {
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 escapeMarkupStarts(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
}

Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/tests/desktop-release-workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'")
Expand Down
108 changes: 108 additions & 0 deletions apps/desktop/tests/updater.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,114 @@ 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: '<ul>\n<li>Install Windows updates in the background.</li>\n</ul>\n<hr>\n'
+ '<p>Built from <a class="commit-link" href="https://example.com/commit/f27686a">PyModel/pythinker-code@<tt>f27686a</tt></a>.</p>',
})

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('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: '<p>Real note.</p><scr<span>ipt>x</script><script>hidden()</script><b>Kept</b><spa',
})

const notes = getLocalUpdateState().releaseNotes ?? ''
expect(notes).not.toContain('<')
expect(notes).toContain('Real note.')
expect(notes).toContain('Kept')
expect(notes).not.toContain('hidden()')
})

it('does not let encoded markup decode back into 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: '<p>Note</p>&lt;script&gt;payload&lt;/script&gt;',
})

expect(getLocalUpdateState().releaseNotes).toBe('Note\n&lt;script&gt;payload&lt;/script&gt;')
})

it('reports a pending install that did not take effect as an error', async () => {
vi.resetModules()
const directory = temporaryDirectory()
Expand Down
Loading
Loading