Fix updater feed resolution for cancelled RC releases (#1809)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
5cf52199e0
commit
eb5470408b
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue