diff --git a/scripts/ci/assemble-release.mjs b/scripts/ci/assemble-release.mjs index 8f77dc1843..f342723092 100644 --- a/scripts/ci/assemble-release.mjs +++ b/scripts/ci/assemble-release.mjs @@ -3,7 +3,7 @@ import { copyFile, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { stringify } from 'yaml' +import { Scalar, stringify } from 'yaml' import { compareFileNames, @@ -433,7 +433,18 @@ function normalizeSingleArchitectureMetadata(packageEntry, version) { async function writeMetadata(outputDirectory, name, metadata) { const outputPath = path.join(outputDirectory, name) - await writeFile(outputPath, stringify(metadata), 'utf8') + // electron-updater parses updater yml with js-yaml, which turns an unquoted ISO + // timestamp into a Date object. The app's typed-IPC zod contract requires + // releaseDate to be a string, so force-quote it to guarantee a string round-trip + // under js-yaml. The `yaml` package used here does not quote ISO timestamps by + // default, unlike js-yaml's dump — this mismatch previously broke auto-update. + const serializable = { ...metadata } + if (typeof serializable.releaseDate === 'string') { + const quotedReleaseDate = new Scalar(serializable.releaseDate) + quotedReleaseDate.type = 'QUOTE_SINGLE' + serializable.releaseDate = quotedReleaseDate + } + await writeFile(outputPath, stringify(serializable), 'utf8') const inspected = await inspectRegularFile(outputPath, outputDirectory) return { name, diff --git a/src/main/upgrade/index.ts b/src/main/upgrade/index.ts index f45de02f25..6304fec569 100644 --- a/src/main/upgrade/index.ts +++ b/src/main/upgrade/index.ts @@ -78,10 +78,21 @@ const formatReleaseNotes = (notes?: string | ReleaseNoteItem[] | null): string = } const toVersionInfo = (info: UpdateInfo): VersionInfo => { + // electron-updater parses latest*.yml via js-yaml, which may turn an unquoted + // ISO releaseDate into a Date object. The publish pipeline now force-quotes + // releaseDate, but normalize defensively so any legacy yml artifact cannot + // leak a Date into the typed-IPC payload. + const rawReleaseDate = info.releaseDate as unknown + const releaseDate = + rawReleaseDate instanceof Date + ? rawReleaseDate.toISOString() + : typeof rawReleaseDate === 'string' + ? rawReleaseDate + : '' const releaseUrl = buildReleaseUrl(info.version) return { version: info.version, - releaseDate: info.releaseDate || '', + releaseDate, releaseNotes: formatReleaseNotes(info.releaseNotes), githubUrl: releaseUrl, downloadUrl: OFFICIAL_DOWNLOAD_URL diff --git a/src/shared/contracts/events/upgrade.events.ts b/src/shared/contracts/events/upgrade.events.ts index d1416ff9db..3e26e9e8bd 100644 --- a/src/shared/contracts/events/upgrade.events.ts +++ b/src/shared/contracts/events/upgrade.events.ts @@ -4,7 +4,12 @@ import { defineEventContract } from '../common' const UpgradeInfoSchema = z .object({ version: z.string(), - releaseDate: z.string(), + // electron-updater may emit a Date when latest*.yml leaves releaseDate + // unquoted (js-yaml timestamp tag). Accept both and coerce to string so the + // publish pipeline and the client never disagree on shape. + releaseDate: z + .union([z.string(), z.date()]) + .transform((value) => (value instanceof Date ? value.toISOString() : value)), releaseNotes: z.string(), githubUrl: z.string().optional(), downloadUrl: z.string().optional(), diff --git a/test/main/upgrade/upgradeService.test.ts b/test/main/upgrade/upgradeService.test.ts index dc8097fd0a..877e2ac06a 100644 --- a/test/main/upgrade/upgradeService.test.ts +++ b/test/main/upgrade/upgradeService.test.ts @@ -236,4 +236,35 @@ describe('UpgradeService', () => { expect((service as any)._status).toBe('available') expect((service as any)._versionInfo?.version).toBe('1.0.5') }) + + it('coerces a Date releaseDate from electron-updater into an ISO string for the IPC contract', () => { + // electron-updater parses latest*.yml via js-yaml, which turns an unquoted + // ISO releaseDate into a Date object. The service must normalize it before + // emitting so the typed-IPC zod contract never receives a Date. + appGetVersionMock.mockReturnValue('1.0.0') + const settings = { + getChannel: vi.fn(() => 'beta') + } as any + + const service = new UpgradeService( + settings, + () => false, + requestUpdateInstallMock, + publishEventMock + ) + const handler = autoUpdaterState.listeners.get('update-available') + expect(handler).toBeDefined() + + const date = new Date('2026-07-25T11:28:19.451Z') + handler!({ version: '1.1.0-beta.6', releaseDate: date, releaseNotes: '' }) + + expect((service as any)._status).toBe('available') + const availableCall = publishEventMock.mock.calls.find( + (call) => call[0] === 'upgrade.status.changed' && call[1]?.status === 'available' + ) + expect(availableCall).toBeDefined() + const info = availableCall![1].info + expect(typeof info.releaseDate).toBe('string') + expect(info.releaseDate).toBe('2026-07-25T11:28:19.451Z') + }) })