mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user