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) {