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
148 changes: 148 additions & 0 deletions docs/issues/updater-metadata-consumer-validation/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Updater Metadata Consumer Validation

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
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.
- 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
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

- [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.
- [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

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.

### 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.
81 changes: 60 additions & 21 deletions scripts/ci/assemble-release.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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+)?$/

Expand Down Expand Up @@ -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 {
Expand All @@ -454,9 +457,34 @@ 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')
// 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,
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) {
Expand Down Expand Up @@ -585,20 +613,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}`)
Expand All @@ -611,7 +648,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) {
Expand Down
41 changes: 41 additions & 0 deletions scripts/ci/updater-metadata-consumer.mjs
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 1 addition & 6 deletions src/shared/contracts/events/upgrade.events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
26 changes: 26 additions & 0 deletions test/main/routes/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading