From 4d285226feaa0eff7e942f0db29040bf7214782f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:15:44 -0700 Subject: [PATCH] fix(updater): stop Windows checks from reporting a false latest version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readiness preflight HEADs every asset listed in latest.yml. The ~190MB orca-windows-setup.exe redirects to Azure blob storage, where that HEAD routinely times out or answers 302/403 — which the probe treated as "unpublished", so no newer tag ever looked ready on Windows. It then walked back through feed candidates at or below the installed version, so lastGoodTag could be the installed release itself; electron-updater compared equal versions and emitted update-not-available, surfacing as "You're on the latest version" while a newer build existed. - Only an explicit asset 404 marks a release not-ready; manifest fetch failures still yield unavailable. - Probe only candidates strictly newer than the installed version, so a last-good pin is always a real upgrade. --- .../updater-prerelease-feed-readiness.test.ts | 121 +++++++++++++++++- src/main/updater-prerelease-feed.test.ts | 8 +- src/main/updater-prerelease-feed.ts | 48 ++++--- 3 files changed, 149 insertions(+), 28 deletions(-) diff --git a/src/main/updater-prerelease-feed-readiness.test.ts b/src/main/updater-prerelease-feed-readiness.test.ts index 34340c0717e..bb8e9d3dc4d 100644 --- a/src/main/updater-prerelease-feed-readiness.test.ts +++ b/src/main/updater-prerelease-feed-readiness.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { publishingIncident } from './updater-prerelease-feed-reproduction.fixture' +const ORIGINAL_PLATFORM = process.platform + const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) @@ -33,6 +35,20 @@ function isPlatformManifestRequest(url: string): boolean { return /\/latest(?:-[a-z]+)?\.yml$/.test(url) } +function setPlatformForTest(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }) +} + +function buildWindowsManifest(version: string): string { + return [ + `version: ${version}`, + 'files:', + ' - url: orca-windows-setup.exe', + ' sha512: test', + 'path: orca-windows-setup.exe' + ].join('\n') +} + function respondWithAtom( tags: string[], missingManifestTags: string[] = [], @@ -90,14 +106,109 @@ describe('fetchNewerReleaseTagsWithReadiness', () => { afterEach(() => { vi.unstubAllGlobals() + setPlatformForTest(ORIGINAL_PLATFORM) }) - it('reports not-ready with a verified last-good tag when the newest assets are unavailable', async () => { + // Why: the ~190MB orca-windows-setup.exe redirects to Azure blob storage, so its + // HEAD probe times out or answers 302/403 far more often than it answers 200. + it.each([ + ['a timed-out', () => Promise.reject(new Error('The operation was aborted due to timeout'))], + [ + 'a redirected', + () => Promise.resolve({ ok: false, status: 302, text: () => Promise.resolve('') }) + ], + [ + 'a forbidden', + () => Promise.resolve({ ok: false, status: 403, text: () => Promise.resolve('') }) + ] + ] satisfies [string, () => Promise][])( + 'offers the newer Windows release after %s installer HEAD probe', + async (_label, respondToHead) => { + setPlatformForTest('win32') + const manifestUrls: string[] = [] + + netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(buildAtomFeed(['v1.4.182', 'v1.4.182-rc.1'])) + }) + } + if (isPlatformManifestRequest(url)) { + manifestUrls.push(url) + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(buildWindowsManifest('1.4.182')) + }) + } + if (init?.method === 'HEAD') { + return respondToHead() + } + return Promise.resolve({ ok: false, status: 503, text: () => Promise.resolve('') }) + }) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.178-rc.2', 1)).resolves.toEqual({ + tags: ['v1.4.182'], + state: 'ready' + }) + expect(manifestUrls[0]).toBe( + 'https://github.com/stablyai/orca/releases/download/v1.4.182/latest.yml' + ) + } + ) + + it('still reports a Windows release as not-ready when its installer 404s', async () => { + setPlatformForTest('win32') + netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(buildAtomFeed(['v1.4.182', 'v1.4.178-rc.2'])) + }) + } + if (isPlatformManifestRequest(url)) { + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(buildWindowsManifest('1.4.182')) + }) + } + if (init?.method === 'HEAD') { + return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('') }) + } + return Promise.resolve({ ok: false, status: 503, text: () => Promise.resolve('') }) + }) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.178-rc.2', 1)).resolves.toEqual({ + tags: [], + state: 'not-ready' + }) + }) + + it('never pins the installed version as last-good when the newest assets are missing', async () => { respondWithAtom(['v1.4.27', 'v1.4.26'], [], ['v1.4.27']) const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 1)).resolves.toEqual({ + tags: [], + state: 'not-ready' + }) + }) + + it('keeps a newer ready tag as last-good while the newest release is still publishing', async () => { + respondWithAtom(['v1.4.27', 'v1.4.26', 'v1.4.25'], [], ['v1.4.27']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.25', 1)).resolves.toEqual({ tags: [], state: 'not-ready', lastGoodTag: 'v1.4.26' @@ -399,12 +510,18 @@ describe('fetchNewerReleaseTagsWithReadiness', () => { const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') await expect( - fetchNewerReleaseTagsWithReadiness('1.4.27-rc.1', 1, { includePrerelease: true }) + fetchNewerReleaseTagsWithReadiness('1.4.26', 1, { includePrerelease: true }) ).resolves.toEqual({ tags: [], state: 'not-ready', lastGoodTag: 'v1.4.27-rc.1' }) + await expect( + fetchNewerReleaseTagsWithReadiness('1.4.27-rc.1', 1, { includePrerelease: true }) + ).resolves.toEqual({ + tags: [], + state: 'not-ready' + }) await expect( fetchNewerReleaseTagsWithReadiness('1.4.26', 1, { includePrerelease: false }) ).resolves.toEqual({ diff --git a/src/main/updater-prerelease-feed.test.ts b/src/main/updater-prerelease-feed.test.ts index bef53427e22..001e72d30ef 100644 --- a/src/main/updater-prerelease-feed.test.ts +++ b/src/main/updater-prerelease-feed.test.ts @@ -281,7 +281,13 @@ describe('fetchNewerReleaseTag', () => { it('returns a bounded fallback candidate after the newest newer tag', async () => { respondWithAtom(['v1.3.51-rc.7', 'v1.3.51-rc.6', 'v1.3.51-rc.5']) const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed') - expect(await fetchNewerReleaseTags('1.3.51-rc.6', 2)).toEqual(['v1.3.51-rc.7', 'v1.3.51-rc.6']) + expect(await fetchNewerReleaseTags('1.3.51-rc.5', 2)).toEqual(['v1.3.51-rc.7', 'v1.3.51-rc.6']) + }) + + it('never offers the installed version as a fallback candidate', async () => { + respondWithAtom(['v1.3.51-rc.7', 'v1.3.51-rc.6', 'v1.3.51-rc.5']) + const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed') + expect(await fetchNewerReleaseTags('1.3.51-rc.6', 2)).toEqual(['v1.3.51-rc.7']) }) it('reports not-ready with last-good when newer platform updater manifests are missing', async () => { diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index 6365ccb653d..ff41f345fba 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -105,7 +105,14 @@ function getManifestAssetNames(manifestText: string): string[] { type ReleaseReadiness = 'ready' | 'not-ready' | 'unavailable' -async function isReleaseAssetAvailable(tag: string, assetName: string): Promise { +/** + * Why: only an explicit 404 proves an asset is unpublished. Large installers — + * notably the ~190MB orca-windows-setup.exe — redirect to Azure blob storage, + * where a HEAD routinely times out or answers 302/403. Treating those as + * unpublished hid every Windows update behind a false "you're on the latest + * version". + */ +async function isReleaseAssetPublished(tag: string, assetName: string): Promise { try { const assetUrl = assetName.startsWith('http') ? assetName @@ -114,9 +121,9 @@ async function isReleaseAssetAvailable(tag: string, assetName: string): Promise< method: 'HEAD', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) - return res.status === 404 ? 'not-ready' : res.ok ? 'ready' : 'unavailable' + return res.status !== 404 } catch { - return 'unavailable' + return true } } @@ -144,13 +151,9 @@ async function getPlatformManifestReadiness(tag: string): Promise isReleaseAssetAvailable(tag, assetName)) + assetNames.map((assetName) => isReleaseAssetPublished(tag, assetName)) ) - return assetResults.includes('not-ready') - ? 'not-ready' - : assetResults.includes('unavailable') - ? 'unavailable' - : 'ready' + return assetResults.includes(false) ? 'not-ready' : 'ready' } catch { return 'unavailable' } @@ -217,39 +220,34 @@ export async function fetchNewerReleaseTagsWithReadiness( : includePrerelease ? tags.filter(({ tag }) => !isPerfPrereleaseTag(tag)) : tags.filter(({ version }) => !isPrereleaseVersion(version)) - const newestNewerIndex = candidates.findIndex( + // Why: probing anything at or below the installed version can hand back a + // last-good pin equal to what is already installed, which electron-updater + // then reports as "you're on the latest version" while a newer build exists. + const newerCandidates = candidates.filter( ({ version }) => compareVersions(version, currentVersion) > 0 ) - if (newestNewerIndex === -1) { + if (newerCandidates.length === 0) { return { tags: [], state: 'no-newer' } } // Why: a cancelled release can leave several feed entries without manifests, // but update checks must not stall on an unbounded run of 5s probes. - const probeCandidates = candidates.slice( - newestNewerIndex, - newestNewerIndex + MAX_MANIFEST_PROBE_CANDIDATES - ) + const probeCandidates = newerCandidates.slice(0, MAX_MANIFEST_PROBE_CANDIDATES) const manifestResults = await Promise.all( - probeCandidates.map(async ({ tag, version }) => ({ + probeCandidates.map(async ({ tag }) => ({ tag, - version, readiness: await getPlatformManifestReadiness(tag) })) ) - const primaryIndex = manifestResults.findIndex( - ({ readiness, version }) => - readiness === 'ready' && compareVersions(version, currentVersion) > 0 - ) + // Why: every probe candidate is already strictly newer than the installed + // version, so any ready one is a safe pin. + const primaryIndex = manifestResults.findIndex(({ readiness }) => readiness === 'ready') if (primaryIndex === -1) { if (manifestResults[0]?.readiness === 'unavailable') { return { tags: [], state: 'unavailable', unavailableReason: 'manifest' } } - const lastGoodTag = manifestResults.find(({ readiness }) => readiness === 'ready')?.tag - return lastGoodTag - ? { tags: [], state: 'not-ready', lastGoodTag } - : { tags: [], state: 'not-ready' } + return { tags: [], state: 'not-ready' } } if (primaryIndex > 0) {