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
15 changes: 13 additions & 2 deletions scripts/ci/assemble-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion src/main/upgrade/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/shared/contracts/events/upgrade.events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
31 changes: 31 additions & 0 deletions test/main/upgrade/upgradeService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})