From 6ac7e940ee82cc42277b96384a6c9240d04766b6 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 26 Jul 2026 15:44:09 +0800 Subject: [PATCH 1/4] docs(issue): specify updater metadata oracle --- .../spec.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/issues/updater-metadata-consumer-validation/spec.md diff --git a/docs/issues/updater-metadata-consumer-validation/spec.md b/docs/issues/updater-metadata-consumer-validation/spec.md new file mode 100644 index 000000000..88bbb43b6 --- /dev/null +++ b/docs/issues/updater-metadata-consumer-validation/spec.md @@ -0,0 +1,136 @@ +# Updater Metadata Consumer Validation + +Status: implementation in progress. + +GitHub issue: not created; this is a local SDD record. PR #2025 fixed the immediate +`releaseDate` producer/consumer type mismatch and provides the historical context for this +follow-up. + +## Issue + +The release assembler writes updater metadata with the `yaml` package and validates the generated +`latest*.yml` files with the same parser. DeepChat clients consume those files through +`electron-updater`, whose `parseUpdateInfo()` implementation uses `js-yaml`. + +Those parsers do not assign identical types to every legal YAML scalar. In the incident fixed by PR +#2025, an unquoted ISO timestamp remained a string under the release-side parser but became a +`Date` under the client-side parser. Producer-side validation therefore passed an artifact that +violated the typed upgrade event contract at runtime. + +PR #2025 force-quotes `releaseDate` and normalizes legacy `Date` values in the upgrade service. +However, CI still validates final metadata only from the producer's point of view, so a future +serializer edit or another timestamp-shaped string field could recreate the same failure. + +## Impact + +- A syntactically valid release can make update checks fail on installed clients. +- Producer-only tests can report success while the real updater observes different value types. +- Runtime tolerance prevents crashes for known legacy metadata but cannot prove newly published + artifacts satisfy the release contract. + +## Required Invariants + +### Final artifact parsing + +- Keep the existing strict `parseYamlObject()` pass for release-side YAML policy, including warning + handling, unique keys and alias restrictions. +- Parse the exact generated bytes a second time with the installed `electron-updater` + `parseUpdateInfo(rawData, channelFile, channelFileUrl)` implementation. +- Perform all final updater field and release-asset checks against the consumer-parsed object. +- Require the consumer-parsed value to preserve the complete semantic value and type of the + in-memory metadata model supplied to the serializer. This covers every supported string field, + including nested release notes, without maintaining a timestamp-field blacklist. +- Validate all four published channel files: `latest.yml`, `latest-mac.yml`, `latest-linux.yml` and + `latest-linux-arm64.yml`. +- Continue validating version, canonical ISO `releaseDate`, updater file count, URL, size, SHA-512, + legacy `path`/`sha512` selection and the macOS ZIP-only updater rule. +- A missing consumer parser, parse failure, semantic type change or invalid field must fail release + assembly; validation must never be skipped. + +### Consumer adapter + +- Isolate the deliberate dependency on + `electron-updater/out/providers/Provider.js#parseUpdateInfo` behind one release-validation + adapter. +- Resolve the provider relative to the installed `electron-updater` package rather than relying on + package-manager hoisting of its transitive `js-yaml` dependency. +- If `electron-updater` is absent or changes its internal provider API, fail with a diagnostic that + states release validation requires that installed consumer. +- Do not add a direct `js-yaml` dependency: using a separately resolved parser could drift from the + version actually bundled into DeepChat. + +### Serialization + +- `writeMetadata()` accepts only a string `releaseDate` and fails at that source boundary instead + of silently skipping force-quoting. +- Retain the current `yaml` serializer and explicit quoted scalar. Serializer replacement is not + required once consumer-semantic validation protects the final bytes. + +### Runtime compatibility and event boundary + +- Continue accepting a runtime `Date` from `electron-updater` only at `toVersionInfo()`, where it is + normalized to an ISO string for previously published malformed metadata. +- Require the typed `upgrade.status.changed` event contract to contain a string `releaseDate`. +- Cover the update-marker restore path separately because it constructs event information directly + from persisted JSON rather than entering through `toVersionInfo()`. +- A valid marker written by DeepChat must continue to restore the failed-update state after the + event contract is tightened. + +## Compatibility and Risk + +- The release gate executes the parser bundled with the current DeepChat dependency + (`electron-updater` 6.8.9 at the time of this change). It does not prove compatibility with every + consumer version embedded in historical clients. The retained runtime `Date` normalization + remains the compatibility layer for the known historical mismatch. +- `parseUpdateInfo()` is an internal `electron-updater` API. Its isolated adapter intentionally + fails closed when an updater upgrade moves or removes the API, requiring maintainers to reverify + the release oracle instead of silently falling back to another parser. +- Update markers live in `userData` across application versions. Current writers persist a string + `releaseDate`, but older or externally corrupted markers may omit required fields. The existing + broad marker-read error handling can suppress the previous-update-failed state in that case. + Changing marker migration and user-visible error behavior is deferred; this change records the + risk and protects the valid persisted shape. +- Parsing four small metadata files twice has negligible release-only cost and no application + runtime performance impact. + +## Non-goals + +- Do not replace `yaml` with `js-yaml.dump()`. +- Do not remove the legacy runtime `Date` normalization. +- Do not change the current fallback of an unsupported runtime `releaseDate` value to an empty + string; a fail-closed runtime error requires a separate updater error-state design. +- Do not add migration or recovery behavior for malformed historical update-marker files. +- Do not claim compatibility coverage for every historical `electron-updater` version. +- Do not create or synchronize a new GitHub issue without explicit approval. + +## Task Checklist + +- [ ] Add an isolated adapter for the installed `electron-updater` consumer parser. +- [ ] Parse final updater metadata with both the strict release parser and the real consumer. +- [ ] Validate semantic round-trip and final release facts from the consumer-parsed object. +- [ ] Fail immediately when `writeMetadata()` receives a non-string `releaseDate`. +- [ ] Add regression coverage for all four final channel files and timestamp-shaped strings. +- [ ] Keep runtime `Date` normalization while making the typed event contract string-only. +- [ ] Cover the valid persisted update-marker restore path. +- [ ] Run formatting, i18n, lint, type checking and relevant main-process tests. + +## Validation + +Automated validation must demonstrate: + +- unquoted timestamp-shaped strings from package metadata cannot leave final release assembly with + a changed consumer-side type; +- the four assembled channel files parse through the installed `electron-updater` consumer with + semantic values and types equal to the serializer input; +- invalid or unavailable consumer parsing stops assembly with an actionable error; +- a legacy `Date` emitted by `electron-updater` becomes an ISO string before event publication; +- the event contract rejects a `Date` that bypasses normalization; +- a valid JSON update marker still emits a string `releaseDate` and restores the failed-update + status. + +The release acceptance criteria are: + +- Published updater metadata with a consumer-visible type mismatch cannot leave CI. +- Legacy timestamp metadata remains compatible at the runtime adapter. +- Internal upgrade events always carry a string `releaseDate`. +- A missing or incompatible consumer oracle fails CI explicitly instead of disabling validation. From da6d91023e07d75abfedb8cdde986c40506d8329 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 26 Jul 2026 15:48:10 +0800 Subject: [PATCH 2/4] ci(release): validate updater metadata --- .../spec.md | 10 +- scripts/ci/assemble-release.mjs | 79 +++++++++++----- scripts/ci/updater-metadata-consumer.mjs | 41 ++++++++ test/main/scripts/releaseAssembly.test.ts | 94 +++++++++++++++---- 4 files changed, 181 insertions(+), 43 deletions(-) create mode 100644 scripts/ci/updater-metadata-consumer.mjs diff --git a/docs/issues/updater-metadata-consumer-validation/spec.md b/docs/issues/updater-metadata-consumer-validation/spec.md index 88bbb43b6..8f65dea5f 100644 --- a/docs/issues/updater-metadata-consumer-validation/spec.md +++ b/docs/issues/updater-metadata-consumer-validation/spec.md @@ -105,11 +105,11 @@ serializer edit or another timestamp-shaped string field could recreate the same ## Task Checklist -- [ ] Add an isolated adapter for the installed `electron-updater` consumer parser. -- [ ] Parse final updater metadata with both the strict release parser and the real consumer. -- [ ] Validate semantic round-trip and final release facts from the consumer-parsed object. -- [ ] Fail immediately when `writeMetadata()` receives a non-string `releaseDate`. -- [ ] Add regression coverage for all four final channel files and timestamp-shaped strings. +- [x] Add an isolated adapter for the installed `electron-updater` consumer parser. +- [x] Parse final updater metadata with both the strict release parser and the real consumer. +- [x] Validate semantic round-trip and final release facts from the consumer-parsed object. +- [x] Fail immediately when `writeMetadata()` receives a non-string `releaseDate`. +- [x] Add regression coverage for all four final channel files and timestamp-shaped strings. - [ ] Keep runtime `Date` normalization while making the typed event contract string-only. - [ ] Cover the valid persisted update-marker restore path. - [ ] Run formatting, i18n, lint, type checking and relevant main-process tests. diff --git a/scripts/ci/assemble-release.mjs b/scripts/ci/assemble-release.mjs index cd781a8dc..65ee5c620 100644 --- a/scripts/ci/assemble-release.mjs +++ b/scripts/ci/assemble-release.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { deepStrictEqual } from 'node:assert/strict' import { copyFile, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { pathToFileURL } from 'node:url' @@ -29,6 +30,7 @@ import { validateRawUpdateMetadata, validateSmokeReports } from './package-manifest.mjs' +import { parseElectronUpdaterMetadata } from './updater-metadata-consumer.mjs' const RELEASE_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-(?:alpha|beta)\.\d+)?$/ @@ -439,12 +441,13 @@ async function writeMetadata(outputDirectory, name, metadata) { // 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 + if (typeof metadata.releaseDate !== 'string') { + throw new Error(`${name} releaseDate must be a string before serialization`) } + const serializable = { ...metadata } + const quotedReleaseDate = new Scalar(metadata.releaseDate) + quotedReleaseDate.type = 'QUOTE_SINGLE' + serializable.releaseDate = quotedReleaseDate await writeFile(outputPath, stringify(serializable), 'utf8') const inspected = await inspectRegularFile(outputPath, outputDirectory) return { @@ -454,9 +457,32 @@ async function writeMetadata(outputDirectory, name, metadata) { } } -async function validateFinalMetadata(outputDirectory, name, publicAssets) { +async function validateFinalMetadata( + outputDirectory, + name, + expectedMetadata, + publicAssets +) { const metadataPath = path.join(outputDirectory, name) - const metadata = parseYamlObject(await readFile(metadataPath, 'utf8'), name) + const rawMetadata = await readFile(metadataPath, 'utf8') + parseYamlObject(rawMetadata, name) + const metadata = parseElectronUpdaterMetadata( + rawMetadata, + name, + pathToFileURL(metadataPath) + ) + deepStrictEqual( + metadata, + expectedMetadata, + `${name} must preserve its semantic values and types when parsed by electron-updater` + ) + if ( + typeof metadata.releaseDate !== 'string' || + !Number.isFinite(Date.parse(metadata.releaseDate)) || + new Date(metadata.releaseDate).toISOString() !== metadata.releaseDate + ) { + throw new Error(`${name} releaseDate must be a canonical ISO string`) + } const expectedEntries = name === 'latest-linux.yml' || name === 'latest-linux-arm64.yml' ? 1 : 2 if (!Array.isArray(metadata.files) || metadata.files.length !== expectedEntries) { @@ -585,20 +611,29 @@ export async function assembleRelease({ const macOS = packages.filter(({ definition }) => definition.platform === 'darwin') const linuxX64 = packages.find(({ definition }) => definition.id === 'linux-x64') const linuxArm64 = packages.find(({ definition }) => definition.id === 'linux-arm64') - const metadataAssets = await Promise.all([ - writeMetadata(outputPath, 'latest.yml', mergeArchitectureMetadata(windows, version)), - writeMetadata(outputPath, 'latest-mac.yml', mergeArchitectureMetadata(macOS, version)), - writeMetadata( - outputPath, - 'latest-linux.yml', - normalizeSingleArchitectureMetadata(linuxX64, version) - ), - writeMetadata( - outputPath, - 'latest-linux-arm64.yml', - normalizeSingleArchitectureMetadata(linuxArm64, version) + const metadataDefinitions = [ + { + name: 'latest.yml', + metadata: mergeArchitectureMetadata(windows, version) + }, + { + name: 'latest-mac.yml', + metadata: mergeArchitectureMetadata(macOS, version) + }, + { + name: 'latest-linux.yml', + metadata: normalizeSingleArchitectureMetadata(linuxX64, version) + }, + { + name: 'latest-linux-arm64.yml', + metadata: normalizeSingleArchitectureMetadata(linuxArm64, version) + } + ] + const metadataAssets = await Promise.all( + metadataDefinitions.map(({ name, metadata }) => + writeMetadata(outputPath, name, metadata) ) - ]) + ) for (const metadataAsset of metadataAssets) { if (publicNames.has(metadataAsset.name)) { throw new Error(`Release contains duplicate metadata name ${metadataAsset.name}`) @@ -611,7 +646,9 @@ export async function assembleRelease({ }) } await Promise.all( - metadataAssets.map(({ name }) => validateFinalMetadata(outputPath, name, publicAssets)) + metadataDefinitions.map(({ name, metadata }) => + validateFinalMetadata(outputPath, name, metadata, publicAssets) + ) ) if (publicAssets.length !== expectedReleaseAssetCount() - 1) { diff --git a/scripts/ci/updater-metadata-consumer.mjs b/scripts/ci/updater-metadata-consumer.mjs new file mode 100644 index 000000000..2e6bb3ec5 --- /dev/null +++ b/scripts/ci/updater-metadata-consumer.mjs @@ -0,0 +1,41 @@ +import { createRequire } from 'node:module' +import path from 'node:path' + +const require = createRequire(import.meta.url) +const PROVIDER_RELATIVE_PATH = path.join('out', 'providers', 'Provider.js') +const CONSUMER_REQUIREMENT = + 'Release metadata validation requires the installed electron-updater package to expose ' + + 'out/providers/Provider.js#parseUpdateInfo' + +export function loadElectronUpdaterMetadataParser({ + resolvePackage = (specifier) => require.resolve(specifier), + loadProvider = (specifier) => require(specifier) +} = {}) { + try { + const updaterRoot = path.dirname(resolvePackage('electron-updater/package.json')) + const provider = loadProvider(path.join(updaterRoot, PROVIDER_RELATIVE_PATH)) + if (typeof provider?.parseUpdateInfo !== 'function') { + throw new TypeError('parseUpdateInfo is not a function') + } + return provider.parseUpdateInfo + } catch (cause) { + throw new Error(CONSUMER_REQUIREMENT, { cause }) + } +} + +const parseUpdateInfo = loadElectronUpdaterMetadataParser() + +export function parseElectronUpdaterMetadata(rawData, channelFile, channelFileUrl) { + const metadata = parseUpdateInfo(rawData, channelFile, channelFileUrl) + const prototype = + metadata && typeof metadata === 'object' ? Object.getPrototypeOf(metadata) : undefined + if ( + !metadata || + typeof metadata !== 'object' || + Array.isArray(metadata) || + (prototype !== Object.prototype && prototype !== null) + ) { + throw new Error(`${channelFile} must contain an updater metadata object`) + } + return metadata +} diff --git a/test/main/scripts/releaseAssembly.test.ts b/test/main/scripts/releaseAssembly.test.ts index 849044fc3..ab147b855 100644 --- a/test/main/scripts/releaseAssembly.test.ts +++ b/test/main/scripts/releaseAssembly.test.ts @@ -9,6 +9,7 @@ import { } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { pathToFileURL } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { parse, stringify } from 'yaml' @@ -25,6 +26,10 @@ import { verifyGitHubDraftRelease, verifyReleaseAssets } from '../../../scripts/ci/verify-release-assets.mjs' +import { + loadElectronUpdaterMetadataParser, + parseElectronUpdaterMetadata +} from '../../../scripts/ci/updater-metadata-consumer.mjs' vi.unmock('fs') vi.unmock('node:fs') @@ -55,6 +60,12 @@ interface PackageManifest { reports: Array<{ name: string; bytes: number; sha256: string }> } +interface FinalUpdaterMetadata { + releaseDate: unknown + files: Array<{ url: string; blockMapSize?: number }> + path: string +} + describe('fail-closed release assembly', () => { let tempDirectory: string let artifactsDirectory: string @@ -128,6 +139,15 @@ describe('fail-closed release assembly', () => { }) } + async function readFinalMetadata(name: string): Promise { + const metadataPath = path.join(outputDirectory, name) + return parseElectronUpdaterMetadata( + await readFile(metadataPath, 'utf8'), + name, + pathToFileURL(metadataPath) + ) as FinalUpdaterMetadata + } + it('assembles six manifests into exactly 19 public release assets', async () => { const releaseIndex = await assemble() const entries = (await readdir(outputDirectory)).sort() @@ -142,24 +162,23 @@ describe('fail-closed release assembly', () => { }) expect(releaseIndex.assets.every((asset) => !('sha512' in asset))).toBe(true) - const windows = parse( - await readFile(path.join(outputDirectory, 'latest.yml'), 'utf8') - ) as { - files: Array<{ url: string }> - path: string + const [windows, macOS, linuxX64, linuxArm64] = await Promise.all([ + readFinalMetadata('latest.yml'), + readFinalMetadata('latest-mac.yml'), + readFinalMetadata('latest-linux.yml'), + readFinalMetadata('latest-linux-arm64.yml') + ]) + for (const metadata of [windows, macOS, linuxX64, linuxArm64]) { + expect(typeof metadata.releaseDate).toBe('string') + expect(metadata.releaseDate).toBe(generatedAt) } + expect(windows.files.map(({ url }) => url)).toEqual([ `DeepChat-${version}-windows-x64.exe`, `DeepChat-${version}-windows-arm64.exe` ]) expect(windows.path).toBe(windows.files[0].url) - const macOS = parse( - await readFile(path.join(outputDirectory, 'latest-mac.yml'), 'utf8') - ) as { - files: Array<{ url: string }> - path: string - } expect(macOS.files.map(({ url }) => url)).toEqual([ `DeepChat-${version}-mac-x64.zip`, `DeepChat-${version}-mac-arm64.zip` @@ -167,12 +186,6 @@ describe('fail-closed release assembly', () => { expect(macOS.files.every(({ url }) => !url.endsWith('.dmg'))).toBe(true) expect(macOS.path).toBe(macOS.files[0].url) - const linuxX64 = parse( - await readFile(path.join(outputDirectory, 'latest-linux.yml'), 'utf8') - ) as { files: Array<{ url: string }> } - const linuxArm64 = parse( - await readFile(path.join(outputDirectory, 'latest-linux-arm64.yml'), 'utf8') - ) as { files: Array<{ url: string }> } expect(linuxX64.files).toHaveLength(1) expect(linuxX64.files[0].url).toMatch(/-linux-x64\.AppImage$/) expect(linuxX64.files[0]).toHaveProperty('blockMapSize') @@ -193,6 +206,53 @@ describe('fail-closed release assembly', () => { }) }) + it('rejects string fields whose type changes under the updater consumer', async () => { + await Promise.all( + TARGET_DEFINITIONS.map(({ id }) => + updateRawMetadata(id, (metadata) => { + metadata.releaseName = '2026-07-25' + }) + ) + ) + + await expect(assemble()).rejects.toThrow( + /must preserve its semantic values and types when parsed by electron-updater/ + ) + }) + + it( + 'fails clearly when the installed updater consumer parser is unavailable or incompatible', + () => { + expect(() => + loadElectronUpdaterMetadataParser({ + resolvePackage: () => { + throw new Error('electron-updater is unavailable') + } + }) + ).toThrow( + /Release metadata validation requires the installed electron-updater package/ + ) + expect(() => + loadElectronUpdaterMetadataParser({ + resolvePackage: () => '/tmp/node_modules/electron-updater/package.json', + loadProvider: () => ({}) + }) + ).toThrow( + /Release metadata validation requires the installed electron-updater package/ + ) + } + ) + + it('rejects a consumer result that is not an updater metadata mapping', () => { + expect(() => + parseElectronUpdaterMetadata( + generatedAt, + 'latest.yml', + new URL('https://example.com/latest.yml') + ) + ).toThrow(/latest.yml must contain an updater metadata object/) + }) + it('revalidates the complete release directory before publication', async () => { await assemble() const verified = await verifyReleaseAssets({ From 5e57f62ed12a2d6477f2e6d09419cfa296be50b5 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 26 Jul 2026 15:53:08 +0800 Subject: [PATCH 3/4] fix(upgrade): tighten update event contract --- .../spec.md | 18 +++-- src/shared/contracts/events/upgrade.events.ts | 7 +- test/main/routes/contracts.test.ts | 26 ++++++++ test/main/upgrade/upgradeService.test.ts | 66 ++++++++++++++++++- 4 files changed, 105 insertions(+), 12 deletions(-) diff --git a/docs/issues/updater-metadata-consumer-validation/spec.md b/docs/issues/updater-metadata-consumer-validation/spec.md index 8f65dea5f..70332d618 100644 --- a/docs/issues/updater-metadata-consumer-validation/spec.md +++ b/docs/issues/updater-metadata-consumer-validation/spec.md @@ -1,6 +1,6 @@ # Updater Metadata Consumer Validation -Status: implementation in progress. +Status: implemented and validated locally. GitHub issue: not created; this is a local SDD record. PR #2025 fixed the immediate `releaseDate` producer/consumer type mismatch and provides the historical context for this @@ -110,9 +110,9 @@ serializer edit or another timestamp-shaped string field could recreate the same - [x] Validate semantic round-trip and final release facts from the consumer-parsed object. - [x] Fail immediately when `writeMetadata()` receives a non-string `releaseDate`. - [x] Add regression coverage for all four final channel files and timestamp-shaped strings. -- [ ] Keep runtime `Date` normalization while making the typed event contract string-only. -- [ ] Cover the valid persisted update-marker restore path. -- [ ] Run formatting, i18n, lint, type checking and relevant main-process tests. +- [x] Keep runtime `Date` normalization while making the typed event contract string-only. +- [x] Cover the valid persisted update-marker restore path. +- [x] Run formatting, i18n, lint, type checking and relevant main-process tests. ## Validation @@ -134,3 +134,13 @@ The release acceptance criteria are: - Legacy timestamp metadata remains compatible at the runtime adapter. - Internal upgrade events always carry a string `releaseDate`. - A missing or incompatible consumer oracle fails CI explicitly instead of disabling validation. + +### Local results + +Validated on 2026-07-26: + +- `pnpm run format`, `pnpm run i18n`, `pnpm run lint` and `pnpm run typecheck` passed. +- `pnpm exec vitest run test/main/scripts --reporter=dot` passed 25 files and 182 tests. +- The focused upgrade service and contract run passed 2 files and 47 tests. +- The complete main-process suite passed 436 files and 5,046 tests, with 19 files and 239 tests + skipped by the existing suite configuration. diff --git a/src/shared/contracts/events/upgrade.events.ts b/src/shared/contracts/events/upgrade.events.ts index 3e26e9e8b..d1416ff9d 100644 --- a/src/shared/contracts/events/upgrade.events.ts +++ b/src/shared/contracts/events/upgrade.events.ts @@ -4,12 +4,7 @@ import { defineEventContract } from '../common' const UpgradeInfoSchema = z .object({ version: 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)), + releaseDate: z.string(), releaseNotes: z.string(), githubUrl: z.string().optional(), downloadUrl: z.string().optional(), diff --git a/test/main/routes/contracts.test.ts b/test/main/routes/contracts.test.ts index 45fe6cd4a..5bd7515ba 100644 --- a/test/main/routes/contracts.test.ts +++ b/test/main/routes/contracts.test.ts @@ -1838,6 +1838,32 @@ describe('main kernel contracts', () => { expect(new Set(eventKeys).size).toBe(eventKeys.length) }) + it('requires upgrade release dates to be normalized before event publication', () => { + const payload = { + status: 'available', + info: { + version: '1.1.0-beta.6', + releaseDate: '2026-07-25T11:28:19.451Z', + releaseNotes: '' + }, + version: 1 + } + const contract = DEEPCHAT_EVENT_CATALOG['upgrade.status.changed'].payload + + expect(contract.safeParse(payload).success).toBe(true) + + // This intentionally bypasses the publisher's output type to protect the runtime boundary + // when an unnormalized external value reaches contract parsing. + const bypassedPayload = { + ...payload, + info: { + ...payload.info, + releaseDate: new Date(payload.info.releaseDate) + } + } as unknown + expect(contract.safeParse(bypassedPayload).success).toBe(false) + }) + it('accepts only byte arrays for browser preview frames', () => { const payload = { sessionId: 'session-1', diff --git a/test/main/upgrade/upgradeService.test.ts b/test/main/upgrade/upgradeService.test.ts index 877e2ac06..f84b8b72b 100644 --- a/test/main/upgrade/upgradeService.test.ts +++ b/test/main/upgrade/upgradeService.test.ts @@ -1,3 +1,7 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -7,6 +11,7 @@ const { appQuitMock, appRelaunchMock, appExitMock, + appGetPathMock, appGetVersionMock } = vi.hoisted(() => { const autoUpdaterState = { @@ -15,7 +20,6 @@ const { this.listeners.clear() } } - return { autoUpdaterState, publishEventMock: vi.fn(), @@ -23,13 +27,14 @@ const { appQuitMock: vi.fn(), appRelaunchMock: vi.fn(), appExitMock: vi.fn(), + appGetPathMock: vi.fn(() => ''), appGetVersionMock: vi.fn(() => '1.0.0') } }) vi.mock('electron', () => ({ app: { - getPath: vi.fn(() => '/tmp/deepchat-test'), + getPath: appGetPathMock, getVersion: appGetVersionMock, quit: appQuitMock, relaunch: appRelaunchMock, @@ -58,6 +63,13 @@ vi.mock('electron-updater', () => ({ } })) +vi.unmock('fs') +vi.unmock('node:fs') +vi.unmock('fs/promises') +vi.unmock('node:fs/promises') +vi.unmock('path') +vi.unmock('node:path') + import electronUpdater from 'electron-updater' import { UpgradeService } from '../../../src/main/upgrade' @@ -73,6 +85,10 @@ describe('UpgradeService', () => { appQuitMock.mockReset() appRelaunchMock.mockReset() appExitMock.mockReset() + appGetPathMock.mockReset() + appGetPathMock.mockReturnValue( + path.join(os.tmpdir(), `deepchat-upgrade-service-test-${process.pid}`) + ) appGetVersionMock.mockReset() appGetVersionMock.mockReturnValue('1.0.0') vi.mocked(electronUpdater.autoUpdater.checkForUpdates).mockReset() @@ -267,4 +283,50 @@ describe('UpgradeService', () => { expect(typeof info.releaseDate).toBe('string') expect(info.releaseDate).toBe('2026-07-25T11:28:19.451Z') }) + + it('restores a valid persisted update marker with a string releaseDate', async () => { + const userDataDirectory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-upgrade-marker-')) + const markerPath = path.join(userDataDirectory, 'auto_update_marker.json') + appGetPathMock.mockReturnValue(userDataDirectory) + await writeFile( + markerPath, + JSON.stringify({ + version: '1.1.0', + releaseDate: '2026-07-25T11:28:19.451Z', + releaseNotes: 'Release notes', + githubUrl: 'https://github.com/ThinkInAIXYZ/deepchat/releases/tag/v1.1.0', + downloadUrl: 'https://deepchatai.cn/#/download', + timestamp: Date.now() + }) + ) + + try { + const settings = { + getChannel: vi.fn(() => 'stable') + } as any + + const service = new UpgradeService( + settings, + () => false, + requestUpdateInstallMock, + publishEventMock + ) + + expect((service as any)._status).toBe('error') + expect((service as any)._previousUpdateFailed).toBe(true) + expect(publishEventMock).toHaveBeenCalledWith( + 'upgrade.status.changed', + expect.objectContaining({ + status: 'error', + info: expect.objectContaining({ + version: '1.1.0', + releaseDate: '2026-07-25T11:28:19.451Z' + }) + }) + ) + expect(existsSync(markerPath)).toBe(false) + } finally { + await rm(userDataDirectory, { recursive: true, force: true }) + } + }) }) From 18fdf5918d533d86aa00fa01d767b1f2e0058b17 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 26 Jul 2026 16:06:51 +0800 Subject: [PATCH 4/4] test(upgrade): isolate marker filesystem Create and clean a real userData directory for every upgrade-service test. Future marker writes can no longer fail behind the production error boundary. Document the release parser and package-export assumptions. --- .../spec.md | 2 + scripts/ci/assemble-release.mjs | 2 + test/main/upgrade/upgradeService.test.ts | 67 ++++++++----------- 3 files changed, 33 insertions(+), 38 deletions(-) diff --git a/docs/issues/updater-metadata-consumer-validation/spec.md b/docs/issues/updater-metadata-consumer-validation/spec.md index 70332d618..6732960eb 100644 --- a/docs/issues/updater-metadata-consumer-validation/spec.md +++ b/docs/issues/updater-metadata-consumer-validation/spec.md @@ -54,6 +54,8 @@ serializer edit or another timestamp-shaped string field could recreate the same adapter. - Resolve the provider relative to the installed `electron-updater` package rather than relying on package-manager hoisting of its transitive `js-yaml` dependency. +- The current resolver depends on `electron-updater` exposing its package metadata path; a future + restrictive package `exports` map is an intentional fail-closed compatibility event. - If `electron-updater` is absent or changes its internal provider API, fail with a diagnostic that states release validation requires that installed consumer. - Do not add a direct `js-yaml` dependency: using a separately resolved parser could drift from the diff --git a/scripts/ci/assemble-release.mjs b/scripts/ci/assemble-release.mjs index 65ee5c620..92be29330 100644 --- a/scripts/ci/assemble-release.mjs +++ b/scripts/ci/assemble-release.mjs @@ -465,6 +465,8 @@ async function validateFinalMetadata( ) { const metadataPath = path.join(outputDirectory, name) const rawMetadata = await readFile(metadataPath, 'utf8') + // Retain the release-side warning and alias policy as a separate syntax gate. + // Semantic validation below intentionally uses electron-updater's parsed result. parseYamlObject(rawMetadata, name) const metadata = parseElectronUpdaterMetadata( rawMetadata, diff --git a/test/main/upgrade/upgradeService.test.ts b/test/main/upgrade/upgradeService.test.ts index f84b8b72b..03cbc6585 100644 --- a/test/main/upgrade/upgradeService.test.ts +++ b/test/main/upgrade/upgradeService.test.ts @@ -64,17 +64,14 @@ vi.mock('electron-updater', () => ({ })) vi.unmock('fs') -vi.unmock('node:fs') -vi.unmock('fs/promises') -vi.unmock('node:fs/promises') -vi.unmock('path') -vi.unmock('node:path') import electronUpdater from 'electron-updater' import { UpgradeService } from '../../../src/main/upgrade' describe('UpgradeService', () => { - beforeEach(() => { + let userDataDirectory: string + + beforeEach(async () => { vi.useFakeTimers() autoUpdaterState.reset() publishEventMock.mockReset() @@ -86,9 +83,8 @@ describe('UpgradeService', () => { appRelaunchMock.mockReset() appExitMock.mockReset() appGetPathMock.mockReset() - appGetPathMock.mockReturnValue( - path.join(os.tmpdir(), `deepchat-upgrade-service-test-${process.pid}`) - ) + userDataDirectory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-upgrade-service-')) + appGetPathMock.mockReturnValue(userDataDirectory) appGetVersionMock.mockReset() appGetVersionMock.mockReturnValue('1.0.0') vi.mocked(electronUpdater.autoUpdater.checkForUpdates).mockReset() @@ -97,6 +93,7 @@ describe('UpgradeService', () => { afterEach(async () => { vi.clearAllTimers() vi.useRealTimers() + await rm(userDataDirectory, { recursive: true, force: true }) }) it('asks App to stop before quitAndInstall during update restart', async () => { @@ -285,9 +282,7 @@ describe('UpgradeService', () => { }) it('restores a valid persisted update marker with a string releaseDate', async () => { - const userDataDirectory = await mkdtemp(path.join(os.tmpdir(), 'deepchat-upgrade-marker-')) const markerPath = path.join(userDataDirectory, 'auto_update_marker.json') - appGetPathMock.mockReturnValue(userDataDirectory) await writeFile( markerPath, JSON.stringify({ @@ -300,33 +295,29 @@ describe('UpgradeService', () => { }) ) - try { - const settings = { - getChannel: vi.fn(() => 'stable') - } as any - - const service = new UpgradeService( - settings, - () => false, - requestUpdateInstallMock, - publishEventMock - ) - - expect((service as any)._status).toBe('error') - expect((service as any)._previousUpdateFailed).toBe(true) - expect(publishEventMock).toHaveBeenCalledWith( - 'upgrade.status.changed', - expect.objectContaining({ - status: 'error', - info: expect.objectContaining({ - version: '1.1.0', - releaseDate: '2026-07-25T11:28:19.451Z' - }) + const settings = { + getChannel: vi.fn(() => 'stable') + } as any + + const service = new UpgradeService( + settings, + () => false, + requestUpdateInstallMock, + publishEventMock + ) + + expect((service as any)._status).toBe('error') + expect((service as any)._previousUpdateFailed).toBe(true) + expect(publishEventMock).toHaveBeenCalledWith( + 'upgrade.status.changed', + expect.objectContaining({ + status: 'error', + info: expect.objectContaining({ + version: '1.1.0', + releaseDate: '2026-07-25T11:28:19.451Z' }) - ) - expect(existsSync(markerPath)).toBe(false) - } finally { - await rm(userDataDirectory, { recursive: true, force: true }) - } + }) + ) + expect(existsSync(markerPath)).toBe(false) }) })