Fix updater feed resolution for cancelled RC releases (#1809)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-14 00:25:03 -07:00 committed by GitHub
parent 5cf52199e0
commit eb5470408b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 219 additions and 55 deletions

View File

@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const ORIGINAL_PLATFORM = process.platform
const { netFetchMock } = vi.hoisted(() => ({
netFetchMock: vi.fn()
@ -18,19 +20,42 @@ function buildAtomFeed(tags: string[]): string {
return `<?xml version="1.0" encoding="UTF-8"?><feed>${entries}</feed>`
}
function respondWithAtom(tags: string[]): void {
netFetchMock.mockResolvedValue({
ok: true,
text: () => Promise.resolve(buildAtomFeed(tags))
function respondWithAtom(tags: string[], missingManifestTags: string[] = []): void {
const missingManifests = new Set(missingManifestTags)
netFetchMock.mockImplementation((url: string) => {
if (url === 'https://github.com/stablyai/orca/releases.atom') {
return Promise.resolve({
ok: true,
text: () => Promise.resolve(buildAtomFeed(tags))
})
}
const match = url.match(/\/releases\/download\/([^/]+)\/latest(?:-[a-z]+)?\.yml$/)
if (!match) {
return Promise.resolve({ ok: false, text: () => Promise.resolve('') })
}
return Promise.resolve({
ok: !missingManifests.has(decodeURIComponent(match[1])),
text: () => Promise.resolve('')
})
})
}
function setPlatformForTest(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform })
}
describe('fetchNewerReleaseTag', () => {
beforeEach(() => {
vi.resetModules()
netFetchMock.mockReset()
})
afterEach(() => {
setPlatformForTest(ORIGINAL_PLATFORM)
})
it('returns the newest stable tag when the user is on an RC and a newer stable exists', async () => {
respondWithAtom(['v1.3.19', 'v1.3.19-rc.6', 'v1.3.19-rc.4', 'v1.3.18'])
const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
@ -49,6 +74,40 @@ describe('fetchNewerReleaseTag', () => {
expect(await fetchNewerReleaseTag('1.3.51', { includePrerelease: false })).toBe('v1.4.0')
})
it.each([
['darwin', 'latest-mac.yml'],
['linux', 'latest-linux.yml'],
['win32', 'latest.yml']
] satisfies [NodeJS.Platform, string][])(
'probes the %s platform manifest',
async (platform, manifestName) => {
setPlatformForTest(platform)
const manifestUrls: string[] = []
netFetchMock.mockImplementation((url: string) => {
if (url === 'https://github.com/stablyai/orca/releases.atom') {
return Promise.resolve({
ok: true,
text: () => Promise.resolve(buildAtomFeed(['v1.4.1']))
})
}
manifestUrls.push(url)
return Promise.resolve({
ok: true,
text: () => Promise.resolve('')
})
})
const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
expect(await fetchNewerReleaseTag('1.4.0')).toBe('v1.4.1')
expect(manifestUrls).toEqual([
`https://github.com/stablyai/orca/releases/download/v1.4.1/${manifestName}`
])
}
)
it('returns null for stable-channel checks when only prereleases are newer', async () => {
respondWithAtom(['v1.4.1-rc.0', 'v1.3.52-rc.3', 'v1.3.51'])
const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
@ -91,4 +150,71 @@ describe('fetchNewerReleaseTag', () => {
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'])
})
it('skips feed tags whose platform updater manifest is missing', async () => {
respondWithAtom(
['v1.4.1-rc.4', 'v1.4.1-rc.3', 'v1.4.1-rc.2', 'v1.4.1-rc.1'],
['v1.4.1-rc.4', 'v1.4.1-rc.3']
)
const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed')
expect(await fetchNewerReleaseTags('1.4.1-rc.1', 2)).toEqual(['v1.4.1-rc.2', 'v1.4.1-rc.1'])
})
it('does not return the current tag as the primary update when newer manifests are missing', async () => {
respondWithAtom(['v1.4.1-rc.3', 'v1.4.1-rc.2', 'v1.4.1-rc.1'], ['v1.4.1-rc.3', 'v1.4.1-rc.2'])
const { fetchNewerReleaseTag, fetchNewerReleaseTags } =
await import('./updater-prerelease-feed')
expect(await fetchNewerReleaseTag('1.4.1-rc.1')).toBeNull()
expect(await fetchNewerReleaseTags('1.4.1-rc.1', 2)).toEqual([])
})
it('probes a bounded manifest window concurrently', async () => {
const feedTags = [
'v1.4.8-rc.0',
'v1.4.7-rc.0',
'v1.4.6-rc.0',
'v1.4.5-rc.0',
'v1.4.4-rc.0',
'v1.4.3-rc.0',
'v1.4.2-rc.0',
'v1.4.1-rc.0'
]
const manifestUrls: string[] = []
const manifestResolvers: (() => void)[] = []
netFetchMock.mockImplementation((url: string) => {
if (url === 'https://github.com/stablyai/orca/releases.atom') {
return Promise.resolve({
ok: true,
text: () => Promise.resolve(buildAtomFeed(feedTags))
})
}
manifestUrls.push(url)
return new Promise((resolve) => {
manifestResolvers.push(() => {
resolve({ ok: false, text: () => Promise.resolve('') })
})
})
})
const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed')
const result = fetchNewerReleaseTags('1.4.0-rc.0', 2)
await vi.waitFor(() => {
expect(manifestUrls).toHaveLength(6)
})
expect(manifestResolvers).toHaveLength(6)
for (const resolveManifest of manifestResolvers) {
resolveManifest()
}
await expect(result).resolves.toEqual([])
expect(netFetchMock).toHaveBeenCalledTimes(7)
})
})

View File

@ -4,6 +4,7 @@ import { compareVersions, isPrereleaseVersion, isValidVersion } from './updater-
const ATOM_FEED_URL = 'https://github.com/stablyai/orca/releases.atom'
const RELEASES_DOWNLOAD_BASE = 'https://github.com/stablyai/orca/releases/download'
const FETCH_TIMEOUT_MS = 5000
const MAX_MANIFEST_PROBE_CANDIDATES = 6
// Why: GitHub's atom feed lists every release (prerelease or stable) in a
// single flat list. Each entry has a /releases/tag/<tag> URL we can mine
@ -14,6 +15,20 @@ export function getReleaseDownloadUrl(tag: string): string {
return `${RELEASES_DOWNLOAD_BASE}/${encodeURIComponent(tag)}`
}
function getPlatformManifestName(): string {
if (process.platform === 'darwin') {
return 'latest-mac.yml'
}
if (process.platform === 'linux') {
return 'latest-linux.yml'
}
return 'latest.yml'
}
function getReleaseManifestUrl(tag: string): string {
return `${getReleaseDownloadUrl(tag)}/${getPlatformManifestName()}`
}
export function normalizeTagToVersion(tag: string): string {
return tag.replace(/^v/i, '')
}
@ -52,6 +67,22 @@ async function fetchReleaseFeedTags(): Promise<ReleaseFeedTag[] | null> {
}
}
async function hasPlatformManifest(tag: string): Promise<boolean> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
// Why: cancelled/draft releases can appear in GitHub's atom feed before
// they have updater manifests. Pinning to those tags makes every check 404.
const res = await net.fetch(getReleaseManifestUrl(tag), { signal: controller.signal })
return res.ok
} catch {
return false
} finally {
clearTimeout(timeout)
}
}
/**
* Walks the GitHub releases atom feed and returns the tag of the newest
* release strictly greater than `currentVersion`.
@ -97,5 +128,30 @@ export async function fetchNewerReleaseTags(
return []
}
return candidates.slice(newestNewerIndex, newestNewerIndex + maxTags).map(({ tag }) => tag)
// 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 manifestResults = await Promise.all(
probeCandidates.map(async ({ tag, version }) => ({
tag,
version,
hasManifest: await hasPlatformManifest(tag)
}))
)
const primaryIndex = manifestResults.findIndex(
({ hasManifest, version }) => hasManifest && compareVersions(version, currentVersion) > 0
)
if (primaryIndex === -1) {
return []
}
return manifestResults
.slice(primaryIndex)
.filter(({ hasManifest }) => hasManifest)
.slice(0, maxTags)
.map(({ tag }) => tag)
}

View File

@ -224,6 +224,8 @@ describe('updater', () => {
})
it('opts into the RC channel when checkForUpdatesFromMenu is called with includePrerelease', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18-rc.1'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
@ -238,13 +240,17 @@ describe('updater', () => {
checkForUpdatesFromMenu({ includePrerelease: true })
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17', 2, {
includePrerelease: true
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.18-rc.1'
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowPrerelease).toBe(true)
const newCalls = autoUpdaterMock.setFeedURL.mock.calls.slice(setupFeedUrlCalls)
expect(newCalls).toEqual([[{ provider: 'github', owner: 'stablyai', repo: 'orca' }]])
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
// Second RC-mode invocation should not re-set the feed URL.
checkForUpdatesFromMenu({ includePrerelease: true })
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1)
})
@ -1536,11 +1542,12 @@ describe('updater', () => {
})
})
// Why: once the user Shift-clicks to opt into RC channel, we switch to the
// native github provider. The atom-feed resolver must NOT run after that,
// or it would clobber the provider switch with a generic feed URL.
it('does not run the atom resolver after a Shift-click RC opt-in', async () => {
// Why: Shift-click opts into RC updates, but the native GitHub provider can
// still select cancelled prerelease tags with missing manifests. Keep the
// manifest-probed generic feed path so those tags are skipped.
it('uses the manifest-probed generic feed after a Shift-click RC opt-in', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18-rc.1'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
@ -1551,14 +1558,15 @@ describe('updater', () => {
checkForUpdatesFromMenu({ includePrerelease: true })
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17', 2, {
includePrerelease: true
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(fetchNewerReleaseTagsMock).not.toHaveBeenCalled()
expect(autoUpdaterMock.allowPrerelease).toBe(true)
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'github',
owner: 'stablyai',
repo: 'orca'
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.18-rc.1'
})
})
})

View File

@ -38,12 +38,8 @@ let userInitiatedCheck = false
let onBeforeQuitCleanup: (() => void) | null = null
let autoUpdaterInitialized = false
// Why: Shift-clicking "Check for Updates" opts the user into the RC release
// channel for the rest of this process. We switch to the GitHub provider
// with allowPrerelease=true so both the check AND any follow-up download
// resolve against the same (possibly prerelease) release manifest.
// Resetting only after the check would leave a downloaded RC pointing at a
// feed URL that no longer advertises it. See design comment in
// enableIncludePrerelease.
// channel for the rest of this process. The generic feed still gets pinned to
// a concrete tag on every check so cancelled RCs without manifests are skipped.
let includePrereleaseActive = false
let availableVersion: string | null = null
let availableReleaseUrl: string | null = null
@ -422,12 +418,6 @@ function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): v
)
}
function shouldPinDefaultReleaseFeed(): boolean {
// Why: if the user Shift-clicked the menu to opt into RC this process, we've
// already switched to the native github provider — leave that alone.
return !includePrereleaseActive
}
async function pinDefaultReleaseFeed(): Promise<void> {
// Why: the /releases/latest/download/ redirect can move between the update
// check and the later manual download click. Pinning to the concrete tag
@ -436,7 +426,7 @@ async function pinDefaultReleaseFeed(): Promise<void> {
// Prerelease users still need any-channel resolution so they can move to a
// newer RC or the next stable. Stable users should only resolve stable tags.
const currentVersion = app.getVersion()
const includePrerelease = isPrereleaseVersion(currentVersion)
const includePrerelease = includePrereleaseActive || isPrereleaseVersion(currentVersion)
const releaseTags = await fetchNewerReleaseTags(currentVersion, includePrerelease ? 2 : 1, {
includePrerelease
})
@ -524,11 +514,6 @@ function retryPrereleaseFallbackAfterMissingManifest(
return true
}
function launchWithoutPrereleaseFallback(launch: () => Promise<unknown>): Promise<unknown> {
clearPrereleaseFallbackContext()
return launch()
}
function runBackgroundUpdateCheck(
nudgeId: string | null = getPersistedPendingUpdateNudgeId()
): void {
@ -553,9 +538,7 @@ function runBackgroundUpdateCheck(
// Don't send 'checking' here — the 'checking-for-update' event handler does it,
// and sending it from both places causes duplicate notifications (issue #35).
const launch = (): Promise<unknown> => autoUpdater.checkForUpdates()
const run = shouldPinDefaultReleaseFeed()
? pinDefaultReleaseFeed().then(launch)
: launchWithoutPrereleaseFallback(launch)
const run = pinDefaultReleaseFeed().then(launch)
void Promise.resolve(run).catch((err) => {
backgroundCheckLaunchPending = false
void sendCheckFailureStatus(String(err?.message ?? err), undefined, 'promise', err)
@ -570,18 +553,11 @@ function enableIncludePrerelease(): void {
if (includePrereleaseActive) {
return
}
// Why: the default feed points at GitHub's /releases/latest/download/
// manifest, which is scoped to the most recent non-prerelease release.
// Switch to the native github provider with allowPrerelease so latest.yml
// is sourced from the newest release on the repo regardless of the
// prerelease flag. Staying on this feed for the rest of the process
// keeps the download manifest consistent with the check result.
// Why: generic-provider checks still need this flag so electron-updater will
// accept a prerelease manifest for users who intentionally Shift-clicked.
// We keep using the manifest-probed generic feed instead of the native
// GitHub provider because cancelled RC releases can appear without assets.
autoUpdater.allowPrerelease = true
autoUpdater.setFeedURL({
provider: 'github',
owner: 'stablyai',
repo: 'orca'
})
includePrereleaseActive = true
}
@ -606,9 +582,7 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
// and sending it from both places causes duplicate notifications (issue #35).
const launch = (): Promise<unknown> => autoUpdater.checkForUpdates()
const run = shouldPinDefaultReleaseFeed()
? pinDefaultReleaseFeed().then(launch)
: launchWithoutPrereleaseFallback(launch)
const run = pinDefaultReleaseFeed().then(launch)
void Promise.resolve(run).catch((err) => {
userInitiatedCheck = false
void sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err)