fix(updater): stop Windows checks from reporting a false latest version

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.
This commit is contained in:
Jinjing
2026-08-13 11:15:44 -07:00
parent 70abf5cacc
commit 4d285226fe
3 changed files with 149 additions and 28 deletions
@@ -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<unknown>][])(
'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({
+7 -1
View File
@@ -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 () => {
+23 -25
View File
@@ -105,7 +105,14 @@ function getManifestAssetNames(manifestText: string): string[] {
type ReleaseReadiness = 'ready' | 'not-ready' | 'unavailable'
async function isReleaseAssetAvailable(tag: string, assetName: string): Promise<ReleaseReadiness> {
/**
* 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<boolean> {
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<ReleaseReadine
return 'not-ready'
}
const assetResults = await Promise.all(
assetNames.map((assetName) => 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) {