fix(release): harden prerelease update fallback checks (#1793)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-13 21:45:33 -07:00 committed by GitHub
parent 70ec7c0603
commit e361b4339e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1371 additions and 69 deletions

View File

@ -412,6 +412,112 @@ jobs:
--generate-notes \
--prerelease="$is_rc"
# Why: the rc.7 incident did not match the original publisher hypothesis.
# Poll both authenticated release state and anonymous atom visibility during
# real cuts so the next diagnosis has proof instead of timing guesses.
monitor-release:
needs:
- cut
- create-release
if: needs.cut.outputs.should_release == 'true'
runs-on: ubuntu-latest
continue-on-error: true
permissions:
contents: read
steps:
- name: Poll release visibility during cut
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: |
set -euo pipefail
log_path="$RUNNER_TEMP/release-cut-instrumentation-${TAG}.jsonl"
release_error="$RUNNER_TEMP/release-api-error.txt"
: >"$log_path"
for attempt in $(seq 1 60); do
timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
release_json=""
release_error_body=""
if releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" 2>"$release_error")"; then
# Why: releases-by-tag cannot inspect the draft we are monitoring.
if ! release_json="$(jq -e -c --arg tag "$TAG" '
map(select(.tag_name == $tag))
| if length == 1 then .[0] else empty end
' <<<"$releases_json")"; then
release_json=""
release_error_body="Release $TAG was not found in the draft-aware releases list."
fi
else
release_error_body="$(cat "$release_error")"
fi
atom_body="$(curl -fsSL --retry 2 --max-time 10 "https://github.com/$GITHUB_REPOSITORY/releases.atom" 2>/dev/null || true)"
tag_in_anon_atom=false
if printf '%s' "$atom_body" | grep -Fq "/releases/tag/$TAG"; then
tag_in_anon_atom=true
fi
if [[ -n "$release_json" ]]; then
jq -c \
--arg timestamp "$timestamp" \
--argjson tag_in_anon_atom "$tag_in_anon_atom" \
--arg run_id "$GITHUB_RUN_ID" \
--arg run_attempt "$GITHUB_RUN_ATTEMPT" \
'{
timestamp: $timestamp,
tag: .tag_name,
draft: .draft,
prerelease: .prerelease,
published_at: .published_at,
author: .author.login,
asset_count: (.assets | length),
assets: (.assets | map({
name,
state,
size,
created_at,
updated_at,
uploader: .uploader.login
})),
tag_in_anon_atom: $tag_in_anon_atom,
workflow_run: {
id: $run_id,
attempt: $run_attempt
}
}' <<<"$release_json" >>"$log_path"
draft="$(jq -r '.draft' <<<"$release_json")"
if [[ "$draft" == "false" && "$tag_in_anon_atom" == "true" ]]; then
break
fi
else
jq -nc \
--arg timestamp "$timestamp" \
--arg tag "$TAG" \
--arg error "$release_error_body" \
--argjson tag_in_anon_atom "$tag_in_anon_atom" \
'{
timestamp: $timestamp,
tag: $tag,
release_api_error: $error,
tag_in_anon_atom: $tag_in_anon_atom
}' >>"$log_path"
fi
sleep 30
done
echo "Wrote $log_path"
- name: Upload release visibility log
if: always()
uses: actions/upload-artifact@v4
with:
name: release-cut-instrumentation-${{ needs.cut.outputs.tag }}
path: ${{ runner.temp }}/release-cut-instrumentation-*.jsonl
if-no-files-found: warn
# Why: E2E runs alongside the release for visibility (failures surface as a
# red check on the tag), but is NOT in `publish-release`'s needs list.
# Releases already take a while and the suite is already a required check
@ -611,6 +717,30 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify release remains draft after artifact upload
# Why: the build matrix must never be the actor that exposes a partial
# release. If an uploader or GitHub transition flips draft early, fail
# this platform leg and leave the diagnostic monitor artifact behind.
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: |
set -euo pipefail
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
# Why: release upload must validate the draft before it is publicly visible.
draft="$(jq -e -r --arg tag "$TAG" '
map(select(.tag_name == $tag))
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
' <<<"$releases_json")" || {
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
exit 1
}
if [[ "$draft" != "true" ]]; then
echo "::error::Release $TAG was published during the ${{ matrix.platform }} artifact upload."
exit 1
fi
# Why post-publish (not pre-publish): electron-builder packs and
# uploads in a single `--publish always` invocation, so there is no
# cheap insertion point between pack and upload without splitting
@ -637,6 +767,43 @@ jobs:
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: package.json
- name: Verify release is still draft
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: |
set -euo pipefail
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
# Why: publish-release verifies the draft before making it visible.
draft="$(jq -e -r --arg tag "$TAG" '
map(select(.tag_name == $tag))
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
' <<<"$releases_json")" || {
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
exit 1
}
if [[ "$draft" != "true" ]]; then
echo "::error::Release $TAG was published before publish-release; refusing to continue."
exit 1
fi
- name: Verify release assets complete
# Why: publish-release is the only intended draft -> published
# transition. Refuse to un-draft until every updater manifest and
# referenced installer asset is present on GitHub.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: node config/scripts/verify-release-required-assets.mjs "$TAG"
- name: Publish release
# Why: derive `--prerelease` from the tag shape (not from whatever
# electron-builder left the release flagged as). On 2026-04-27,

View File

@ -0,0 +1,153 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url'
const API_VERSION = '2022-11-28'
export function getRequiredReleaseAssetNames(tag) {
const version = tag.replace(/^v/i, '')
return [
'latest-linux.yml',
'latest-mac.yml',
'latest.yml',
'orca-linux.AppImage',
`orca_${version}_amd64.deb`,
'orca-windows-setup.exe',
'orca-windows-setup.exe.blockmap',
`Orca-${version}-mac.zip`,
`Orca-${version}-mac.zip.blockmap`,
`Orca-${version}-arm64-mac.zip`,
`Orca-${version}-arm64-mac.zip.blockmap`,
'orca-macos-x64.dmg',
'orca-macos-x64.dmg.blockmap',
'orca-macos-arm64.dmg',
'orca-macos-arm64.dmg.blockmap'
]
}
export function extractManifestAssetNames(manifestText) {
const names = new Set()
for (const line of manifestText.split(/\r?\n/)) {
const match = line.match(/^\s*(?:-\s*)?(?:url|path):\s*['"]?([^'"]+)['"]?\s*$/)
if (!match) {
continue
}
const value = match[1].trim()
try {
names.add(new URL(value).pathname.split('/').filter(Boolean).at(-1) ?? value)
} catch {
names.add(value.split('/').filter(Boolean).at(-1) ?? value)
}
}
return [...names]
}
async function githubFetch(url, token, accept = 'application/vnd.github+json') {
const res = await fetch(url, {
headers: {
Accept: accept,
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': API_VERSION
}
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`)
}
return res
}
async function fetchRelease(repo, tag, token) {
// The publish gate runs while the release is still draft.
const res = await githubFetch(`https://api.github.com/repos/${repo}/releases?per_page=100`, token)
const releases = await res.json()
if (!Array.isArray(releases)) {
throw new Error(`GitHub releases response for ${repo} was not an array`)
}
const release = releases.find((candidate) => candidate.tag_name === tag)
if (!release) {
throw new Error(`Release ${repo}@${tag} was not found in the draft-aware releases list`)
}
return release
}
async function fetchAssetText(repo, asset, token) {
const res = await githubFetch(
`https://api.github.com/repos/${repo}/releases/assets/${asset.id}`,
token,
'application/octet-stream'
)
return res.text()
}
export async function verifyRequiredReleaseAssets({ repo, tag, token }) {
const release = await fetchRelease(repo, tag, token)
const assetsByName = new Map(release.assets.map((asset) => [asset.name, asset]))
const requiredNames = new Set(getRequiredReleaseAssetNames(tag))
const manifestNames = ['latest-linux.yml', 'latest-mac.yml', 'latest.yml']
for (const manifestName of manifestNames) {
const manifestAsset = assetsByName.get(manifestName)
if (!manifestAsset) {
continue
}
const manifestText = await fetchAssetText(repo, manifestAsset, token)
for (const referencedName of extractManifestAssetNames(manifestText)) {
requiredNames.add(referencedName)
}
}
const missing = [...requiredNames].filter((name) => !assetsByName.has(name)).sort()
const notUploaded = [...requiredNames]
.map((name) => assetsByName.get(name))
.filter((asset) => asset && asset.state && asset.state !== 'uploaded')
.map((asset) => `${asset.name}:${asset.state}`)
.sort()
const empty = [...requiredNames]
.map((name) => assetsByName.get(name))
.filter((asset) => asset && asset.size === 0)
.map((asset) => asset.name)
.sort()
if (missing.length > 0 || notUploaded.length > 0 || empty.length > 0) {
throw new Error(
[
`Release ${tag} is missing required assets.`,
missing.length > 0 ? `Missing: ${missing.join(', ')}` : null,
notUploaded.length > 0 ? `Not uploaded: ${notUploaded.join(', ')}` : null,
empty.length > 0 ? `Empty: ${empty.join(', ')}` : null
]
.filter(Boolean)
.join('\n')
)
}
return {
tag,
checked: [...requiredNames].sort(),
draft: release.draft,
prerelease: release.prerelease
}
}
async function main() {
const tag = process.argv[2]
if (!tag) {
throw new Error('Usage: node config/scripts/verify-release-required-assets.mjs <tag>')
}
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
if (!token) {
throw new Error('GH_TOKEN or GITHUB_TOKEN must be set')
}
const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
const result = await verifyRequiredReleaseAssets({ repo, tag, token })
console.log(`Verified ${result.checked.length} required release assets for ${repo}@${tag}`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error.message)
process.exit(1)
})
}

View File

@ -12,20 +12,33 @@ import {
import { compareVersions } from './updater-fallback'
import { fetchChangelog } from './updater-changelog'
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
type UpdaterHandlerContext = {
clearBackgroundCheckLaunchPending: () => void
clearAvailableUpdateContext: () => void
consumeMissingManifestPrereleaseFallbackResult: () => { userInitiated: boolean } | null
getMissingManifestPrereleaseFallbackUserInitiated: () => boolean | null
getCurrentStatus: () => UpdateStatus
getKnownReleaseUrl: () => string | undefined
getPendingInstallVersion: () => string
getUserInitiatedCheck: () => boolean
hasNewerDownloadedVersion: () => boolean
markMissingManifestPrereleaseFallbackChecking: () => void
performQuitAndInstall: () => void
recordCompletedUpdateCheck: () => void
sendCheckFailureStatus: (message: string, userInitiated?: boolean) => Promise<void>
sendCheckFailureStatus: (
message: string,
userInitiated?: boolean,
source?: 'event' | 'promise' | 'fallback-promise',
sourceError?: unknown
) => Promise<void>
sendErrorStatus: (message: string, userInitiated?: boolean) => void
sendStatus: (status: UpdateStatus) => void
scheduleAutomaticUpdateCheck: (delayMs: number) => void
shouldSuppressMissingManifestPrereleaseFallbackEvent: (message: string, error: unknown) => boolean
suppressMissingManifestPrereleaseFallbackPromiseFailure: (message: string) => void
setAvailableReleaseUrl: (releaseUrl: string | null) => void
setAvailableVersion: (version: string | null) => void
setUserInitiatedCheck: (value: boolean) => void
@ -34,17 +47,22 @@ type UpdaterHandlerContext = {
export function registerAutoUpdaterHandlers({
clearBackgroundCheckLaunchPending,
clearAvailableUpdateContext,
consumeMissingManifestPrereleaseFallbackResult,
getMissingManifestPrereleaseFallbackUserInitiated,
getCurrentStatus,
getKnownReleaseUrl,
getPendingInstallVersion,
getUserInitiatedCheck,
hasNewerDownloadedVersion,
markMissingManifestPrereleaseFallbackChecking,
performQuitAndInstall,
recordCompletedUpdateCheck,
sendCheckFailureStatus,
sendErrorStatus,
sendStatus,
scheduleAutomaticUpdateCheck,
shouldSuppressMissingManifestPrereleaseFallbackEvent,
suppressMissingManifestPrereleaseFallbackPromiseFailure,
setAvailableReleaseUrl,
setAvailableVersion,
setUserInitiatedCheck
@ -93,21 +111,31 @@ export function registerAutoUpdaterHandlers({
clearBackgroundCheckLaunchPending()
resetMacInstallState()
clearAvailableUpdateContext()
sendStatus({ state: 'checking', userInitiated: getUserInitiatedCheck() || undefined })
markMissingManifestPrereleaseFallbackChecking()
const fallbackUserInitiated = getMissingManifestPrereleaseFallbackUserInitiated()
const wasUserInitiated = fallbackUserInitiated ?? getUserInitiatedCheck()
sendStatus({ state: 'checking', userInitiated: wasUserInitiated || undefined })
})
autoUpdater.on('update-available', (info) => {
clearBackgroundCheckLaunchPending()
// --- synchronous preamble (runs before any await) ---
const wasUserInitiated = getUserInitiatedCheck()
const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult()
const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck()
setUserInitiatedCheck(false)
// Guard: don't show an update that isn't actually newer than what's running.
if (compareVersions(info.version, app.getVersion()) <= 0) {
clearAvailableUpdateContext()
recordCompletedUpdateCheck()
if (!wasUserInitiated) {
scheduleAutomaticUpdateCheck(24 * 60 * 60 * 1000)
if (missingManifestFallback) {
// Why: a fallback manifest at the current version is still the result of
// a transient missing primary manifest, so keep the short retry cadence.
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
} else {
recordCompletedUpdateCheck()
if (!wasUserInitiated) {
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
}
}
sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined })
return
@ -136,9 +164,16 @@ export function registerAutoUpdaterHandlers({
// timestamp persisted for a check that never showed a result.
setAvailableVersion(info.version)
setAvailableReleaseUrl(null)
recordCompletedUpdateCheck()
if (!wasUserInitiated) {
scheduleAutomaticUpdateCheck(24 * 60 * 60 * 1000)
if (missingManifestFallback) {
// Why: offering the previous good release is only a temporary fallback;
// keep probing soon so users can move to the newest tag once its
// platform manifest finishes publishing.
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
} else {
recordCompletedUpdateCheck()
if (!wasUserInitiated) {
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
}
}
sendStatus({ state: 'available', version: info.version, changelog })
@ -148,12 +183,19 @@ export function registerAutoUpdaterHandlers({
autoUpdater.on('update-not-available', () => {
clearBackgroundCheckLaunchPending()
resetMacInstallState()
const wasUserInitiated = getUserInitiatedCheck()
const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult()
const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck()
setUserInitiatedCheck(false)
clearAvailableUpdateContext()
recordCompletedUpdateCheck()
if (!wasUserInitiated) {
scheduleAutomaticUpdateCheck(24 * 60 * 60 * 1000)
if (missingManifestFallback) {
// Why: the primary/newest release manifest was missing, so fallback
// not-available is still a transient release-transition outcome.
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
} else {
recordCompletedUpdateCheck()
if (!wasUserInitiated) {
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
}
}
sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined })
})
@ -190,13 +232,20 @@ export function registerAutoUpdaterHandlers({
})
autoUpdater.on('error', (err) => {
const message = err?.message ?? 'Unknown error'
// Why: primary/fallback promise handlers may already own this failure; do
// not let their delayed paired error event consume fallback context.
if (shouldSuppressMissingManifestPrereleaseFallbackEvent(message, err)) {
return
}
clearBackgroundCheckLaunchPending()
resetMacInstallState()
const wasUserInitiated = getUserInitiatedCheck()
suppressMissingManifestPrereleaseFallbackPromiseFailure(message)
const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult()
const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck()
setUserInitiatedCheck(false)
const message = err?.message ?? 'Unknown error'
if (getCurrentStatus().state === 'checking') {
void sendCheckFailureStatus(message, wasUserInitiated || undefined)
void sendCheckFailureStatus(message, wasUserInitiated || undefined, 'event', err)
return
}
sendErrorStatus(message, wasUserInitiated || undefined)

View File

@ -53,6 +53,17 @@ export function isGitHubReleaseTransitionFailure(normalizedMessage: string): boo
)
}
export function isMissingUpdateManifestFailure(message: string): boolean {
const normalizedMessage = message.toLowerCase()
return (
normalizedMessage.includes('404') &&
(normalizedMessage.includes('cannot find channel') ||
normalizedMessage.includes('latest.yml') ||
normalizedMessage.includes('latest-mac.yml') ||
normalizedMessage.includes('latest-linux.yml'))
)
}
/** Identifies update-check failures that are transient or infrastructure-related
* (e.g. network blips, GitHub release transitions) and should NOT be surfaced
* to the user as errors. */

View File

@ -73,4 +73,10 @@ describe('fetchNewerReleaseTag', () => {
const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
expect(await fetchNewerReleaseTag('1.3.19-rc.6')).toBe('v1.3.20-rc.1')
})
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'])
})
})

View File

@ -18,6 +18,40 @@ export function normalizeTagToVersion(tag: string): string {
return tag.replace(/^v/i, '')
}
type ReleaseFeedTag = {
tag: string
version: string
}
async function fetchReleaseFeedTags(): Promise<ReleaseFeedTag[] | null> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const res = await net.fetch(ATOM_FEED_URL, { signal: controller.signal })
if (!res.ok) {
return null
}
const body = await res.text()
const tags: ReleaseFeedTag[] = []
for (const match of body.matchAll(TAG_HREF_RE)) {
const tag = match[1]
const version = normalizeTagToVersion(tag)
if (isValidVersion(version)) {
tags.push({ tag, version })
}
}
tags.sort((left, right) => compareVersions(right.version, left.version))
return tags
} catch {
return null
} finally {
clearTimeout(timeout)
}
}
/**
* Walks the GitHub releases atom feed and returns the tag of the newest
* release strictly greater than `currentVersion`, regardless of channel.
@ -33,38 +67,24 @@ export function normalizeTagToVersion(tag: string): string {
* nothing in the feed is newer than `currentVersion`.
*/
export async function fetchNewerReleaseTag(currentVersion: string): Promise<string | null> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const res = await net.fetch(ATOM_FEED_URL, { signal: controller.signal })
if (!res.ok) {
return null
}
const body = await res.text()
let bestTag: string | null = null
let bestVersion: string | null = null
for (const match of body.matchAll(TAG_HREF_RE)) {
const tag = match[1]
const version = normalizeTagToVersion(tag)
if (!isValidVersion(version)) {
continue
}
if (compareVersions(version, currentVersion) <= 0) {
continue
}
if (bestVersion === null || compareVersions(version, bestVersion) > 0) {
bestTag = tag
bestVersion = version
}
}
return bestTag
} catch {
return null
} finally {
clearTimeout(timeout)
}
return (await fetchNewerReleaseTags(currentVersion, 1))[0] ?? null
}
export async function fetchNewerReleaseTags(
currentVersion: string,
maxTags: number
): Promise<string[]> {
const tags = await fetchReleaseFeedTags()
if (!tags || maxTags <= 0) {
return []
}
const newestNewerIndex = tags.findIndex(
({ version }) => compareVersions(version, currentVersion) > 0
)
if (newestNewerIndex === -1) {
return []
}
return tags.slice(newestNewerIndex, newestNewerIndex + maxTags).map(({ tag }) => tag)
}

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { compareVersions, isPrereleaseVersion } from './updater-fallback'
import {
compareVersions,
isMissingUpdateManifestFailure,
isPrereleaseVersion
} from './updater-fallback'
describe('compareVersions', () => {
it('compares prerelease and build semver strings correctly', () => {
@ -25,3 +29,15 @@ describe('isPrereleaseVersion', () => {
expect(isPrereleaseVersion('not-a-version')).toBe(false)
})
})
describe('isMissingUpdateManifestFailure', () => {
it('matches platform manifest 404s but not generic network failures', () => {
expect(
isMissingUpdateManifestFailure(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
).toBe(true)
expect(isMissingUpdateManifestFailure('net::ERR_FAILED')).toBe(false)
expect(isMissingUpdateManifestFailure('Unable to find latest version on GitHub')).toBe(false)
})
})

View File

@ -120,12 +120,12 @@ vi.mock('./updater-nudge', () => ({
shouldApplyNudge: shouldApplyNudgeMock
}))
const { fetchNewerReleaseTagMock } = vi.hoisted(() => ({
fetchNewerReleaseTagMock: vi.fn()
const { fetchNewerReleaseTagsMock } = vi.hoisted(() => ({
fetchNewerReleaseTagsMock: vi.fn()
}))
vi.mock('./updater-prerelease-feed', () => ({
fetchNewerReleaseTag: fetchNewerReleaseTagMock,
fetchNewerReleaseTags: fetchNewerReleaseTagsMock,
getReleaseDownloadUrl: (tag: string) =>
`https://github.com/stablyai/orca/releases/download/${tag}`
}))
@ -146,7 +146,7 @@ describe('updater', () => {
powerMonitorOnMock.mockReset()
fetchNudgeMock.mockReset().mockResolvedValue(null)
shouldApplyNudgeMock.mockReset().mockReturnValue(false)
fetchNewerReleaseTagMock.mockReset().mockResolvedValue(null)
fetchNewerReleaseTagsMock.mockReset().mockResolvedValue([])
vi.unstubAllGlobals()
vi.useRealTimers()
})
@ -777,7 +777,7 @@ describe('updater', () => {
// releases for RC users, trapping them on the RC channel.
it('repins the generic feed to the newest RC tag for a prerelease user', async () => {
appMock.getVersion.mockReturnValue('1.3.17-rc.1')
fetchNewerReleaseTagMock.mockResolvedValue('v1.3.17-rc.2')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.17-rc.2'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
@ -795,7 +795,7 @@ describe('updater', () => {
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(fetchNewerReleaseTagMock).toHaveBeenCalledWith('1.3.17-rc.1')
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17-rc.1', 2)
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.17-rc.2'
@ -809,7 +809,7 @@ describe('updater', () => {
// prerelease user so the 'update-available' event fires against it.
it('repins the generic feed to a newer stable tag for a prerelease user', async () => {
appMock.getVersion.mockReturnValue('1.3.19-rc.6')
fetchNewerReleaseTagMock.mockResolvedValue('v1.3.19')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.19'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
@ -833,7 +833,7 @@ describe('updater', () => {
// can still complete and report "not-available" (rather than error out).
it('falls back to /releases/latest/download when the atom resolver returns null', async () => {
appMock.getVersion.mockReturnValue('1.3.19-rc.6')
fetchNewerReleaseTagMock.mockResolvedValue(null)
fetchNewerReleaseTagsMock.mockResolvedValue([])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
@ -852,6 +852,647 @@ describe('updater', () => {
})
})
it('retries a prerelease check once against the previous feed tag when the manifest is missing', async () => {
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return Promise.reject(missingManifest)
}
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.51-rc.7'
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.51-rc.6'
})
})
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available', userInitiated: true })
expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' }))
})
it('surfaces a promise-only prerelease fallback failure after the primary error event', async () => {
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
return Promise.reject(missingManifest)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
})
it('allows the short background retry to launch after a promise-only prerelease fallback failure', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
if (callCount === 1) {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (callCount === 2) {
return Promise.reject(missingManifest)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'idle' })
})
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('does not let user-initiated promise-only fallback failures taint the next background check', async () => {
let lastUpdateCheckAt = Date.now()
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
if (callCount === 1) {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (callCount === 2) {
return Promise.reject(missingManifest)
}
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => lastUpdateCheckAt })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
sendMock.mockClear()
lastUpdateCheckAt = Date.now() - 25 * 60 * 60 * 1000
appMock.emit('browser-window-focus')
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available' })
expect(statuses).not.toContainEqual({ state: 'checking', userInitiated: true })
expect(statuses).not.toContainEqual({ state: 'not-available', userInitiated: true })
})
})
it('preserves user-initiated state for delayed prerelease fallback not-available', async () => {
vi.useFakeTimers()
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
if (callCount === 1) {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return Promise.reject(missingManifest)
}
setTimeout(() => {
autoUpdaterMock.emit('update-not-available')
}, 10)
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
await vi.advanceTimersByTimeAsync(10)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available', userInitiated: true })
})
it('ignores a delayed primary error after a promise-launched prerelease fallback', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
setTimeout(() => {
autoUpdaterMock.emit('error', missingManifest)
}, 10)
return Promise.reject(missingManifest)
}
if (callCount === 2) {
setTimeout(() => {
autoUpdaterMock.emit('update-not-available')
}, 20)
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
await vi.advanceTimersByTimeAsync(30)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available' })
expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' }))
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('handles an event-only fallback error after a promise-only primary failure', async () => {
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifestMessage =
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
const primaryMissingManifest = new Error(missingManifestMessage)
const fallbackMissingManifest = new Error(missingManifestMessage)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
return Promise.reject(primaryMissingManifest)
}
queueMicrotask(() => {
autoUpdaterMock.emit('error', fallbackMissingManifest)
})
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses.at(-1)).toEqual({
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
})
it('suppresses a delayed background fallback error after the fallback promise handled it', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (callCount === 2) {
setTimeout(() => {
autoUpdaterMock.emit('error', missingManifest)
}, 10)
return Promise.reject(missingManifest)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'idle' })
})
sendMock.mockClear()
await vi.advanceTimersByTimeAsync(10)
const statusesAfterLateError = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statusesAfterLateError).not.toContainEqual(
expect.objectContaining({ state: 'error', message: missingManifest.message })
)
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('suppresses a delayed user fallback error after the fallback promise handled it', async () => {
vi.useFakeTimers()
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
setTimeout(() => {
autoUpdaterMock.emit('error', missingManifest)
}, 10)
return Promise.reject(missingManifest)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
sendMock.mockClear()
await vi.advanceTimersByTimeAsync(10)
const statusesAfterLateError = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statusesAfterLateError).not.toContainEqual(
expect.objectContaining({ state: 'error', message: missingManifest.message })
)
})
it('keeps background prerelease fallback not-available on the short retry cadence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 2) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available' })
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('keeps user prerelease fallback not-available on the short retry cadence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (callCount === 2) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setLastUpdateCheckAt
})
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({ state: 'not-available', userInitiated: true })
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('keeps user prerelease fallback available on the short retry cadence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
appMock.getVersion.mockReturnValue('1.3.51-rc.5')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return new Promise(() => {})
}
if (callCount === 2) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.3.51-rc.6' })
})
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setLastUpdateCheckAt
})
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({
state: 'available',
version: '1.3.51-rc.6',
changelog: null
})
})
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('surfaces the failure when the bounded prerelease fallback also misses its manifest', async () => {
appMock.getVersion.mockReturnValue('1.3.51-rc.6')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.51-rc.7', 'v1.3.51-rc.6'])
const missingManifest = new Error(
'Cannot find channel "latest-mac.yml" update info: HttpError: 404'
)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('error', missingManifest)
})
return Promise.reject(missingManifest)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
await vi.waitFor(() => {
const statuses = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statuses).toContainEqual({
state: 'error',
message: "Couldn't reach the update server. Try again in a few minutes.",
userInitiated: true
})
})
})
it('does not invoke the atom-feed resolver for a stable user', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
@ -866,7 +1507,7 @@ describe('updater', () => {
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(fetchNewerReleaseTagMock).not.toHaveBeenCalled()
expect(fetchNewerReleaseTagsMock).not.toHaveBeenCalled()
expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/latest/download'
@ -890,7 +1531,7 @@ describe('updater', () => {
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(fetchNewerReleaseTagMock).not.toHaveBeenCalled()
expect(fetchNewerReleaseTagsMock).not.toHaveBeenCalled()
expect(autoUpdaterMock.allowPrerelease).toBe(true)
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'github',

View File

@ -15,12 +15,17 @@ import { registerAutoUpdaterHandlers } from './updater-events'
import {
compareVersions,
isBenignCheckFailure,
isMissingUpdateManifestFailure,
isPrereleaseVersion,
statusesEqual
} from './updater-fallback'
import { fetchNewerReleaseTag, getReleaseDownloadUrl } from './updater-prerelease-feed'
import { fetchNewerReleaseTags, getReleaseDownloadUrl } from './updater-prerelease-feed'
import { fetchNudge, shouldApplyNudge } from './updater-nudge'
type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
type PrimaryEventSuppression = { failureKey: string; error: unknown }
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000
@ -54,6 +59,20 @@ let activeUpdateNudgeId: string | null = null
let awaitingNudgeCheckOutcome = false
let nudgeCheckInFlight = false
let lastNudgeCheckAt = 0
let pendingPrereleaseFallback: {
primaryTag: string
fallbackTag: string
// Why: the primary promise cleanup can run after fallback starts; fallback
// events need the attempt-scoped initiation state, not the mutable global.
userInitiated: boolean
suppressedPrimaryPromiseFailureKey: string | null
suppressedPrimaryEventFailure: PrimaryEventSuppression | null
suppressedFallbackPromiseFailureKey: string | null
suppressedFallbackEventFailureKey: string | null
fallbackResultHandled: boolean
fallbackCheckingForUpdateSeen: boolean
retryLaunched: boolean
} | null = null
let _getPendingUpdateNudgeId: (() => string | null) | null = null
let _getDismissedUpdateNudgeId: (() => string | null) | null = null
@ -72,6 +91,10 @@ function clearAvailableUpdateContext(): void {
availableReleaseUrl = null
}
function clearPrereleaseFallbackContext(): void {
pendingPrereleaseFallback = null
}
function clearPendingUpdateNudge(): void {
activeUpdateNudgeId = null
awaitingNudgeCheckOutcome = false
@ -168,6 +191,22 @@ function getPendingInstallVersion(): string {
return ''
}
function getCheckFailureKey(message: string, userInitiated?: boolean): string {
return `${userInitiated ? 'user' : 'auto'}:${message}`
}
function clearPrereleaseFallbackContextIfSettled(): void {
if (
pendingPrereleaseFallback?.fallbackResultHandled &&
!pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey &&
!pendingPrereleaseFallback.suppressedPrimaryEventFailure &&
!pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey &&
!pendingPrereleaseFallback.suppressedFallbackEventFailureKey
) {
clearPrereleaseFallbackContext()
}
}
function performQuitAndInstall(): void {
if (pendingQuitAndInstallTimer) {
clearTimeout(pendingQuitAndInstallTimer)
@ -194,8 +233,42 @@ function performQuitAndInstall(): void {
autoUpdater.quitAndInstall(false, true)
}
async function sendCheckFailureStatus(message: string, userInitiated?: boolean): Promise<void> {
const failureKey = `${userInitiated ? 'user' : 'auto'}:${message}`
async function sendCheckFailureStatus(
message: string,
userInitiated?: boolean,
source: CheckFailureSource = 'promise',
sourceError?: unknown
): Promise<void> {
const failureKey = getCheckFailureKey(message, userInitiated)
if (
source === 'promise' &&
pendingPrereleaseFallback?.suppressedPrimaryPromiseFailureKey === failureKey
) {
pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = null
clearPrereleaseFallbackContextIfSettled()
return
}
if (
source === 'fallback-promise' &&
pendingPrereleaseFallback?.suppressedFallbackPromiseFailureKey === failureKey
) {
pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = null
clearPrereleaseFallbackContextIfSettled()
return
}
if (
retryPrereleaseFallbackAfterMissingManifest(
message,
userInitiated,
source,
failureKey,
sourceError
)
) {
return
}
if (pendingCheckFailureKey === failureKey && pendingCheckFailurePromise) {
return pendingCheckFailurePromise
}
@ -263,6 +336,92 @@ function recordCompletedUpdateCheck(): void {
persistLastUpdateCheckAt?.(Date.now())
}
function getMissingManifestPrereleaseFallbackUserInitiated(): boolean | null {
if (
!pendingPrereleaseFallback?.retryLaunched ||
pendingPrereleaseFallback.fallbackResultHandled
) {
return null
}
return pendingPrereleaseFallback.userInitiated
}
function markMissingManifestPrereleaseFallbackChecking(): void {
if (
!pendingPrereleaseFallback?.retryLaunched ||
pendingPrereleaseFallback.fallbackResultHandled
) {
return
}
pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = true
}
function consumeMissingManifestPrereleaseFallbackResult(): MissingManifestPrereleaseFallbackResult | null {
if (
!pendingPrereleaseFallback?.retryLaunched ||
pendingPrereleaseFallback.fallbackResultHandled
) {
return null
}
const result = { userInitiated: pendingPrereleaseFallback.userInitiated }
pendingPrereleaseFallback.fallbackResultHandled = true
clearPrereleaseFallbackContextIfSettled()
return result
}
function suppressMissingManifestPrereleaseFallbackPromiseFailure(message: string): void {
if (
!pendingPrereleaseFallback?.retryLaunched ||
pendingPrereleaseFallback.fallbackResultHandled
) {
return
}
pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = getCheckFailureKey(
message,
pendingPrereleaseFallback.userInitiated
)
}
function shouldSuppressMissingManifestPrereleaseFallbackEvent(
message: string,
error: unknown
): boolean {
if (!pendingPrereleaseFallback?.retryLaunched) {
return false
}
const failureKey = getCheckFailureKey(message, pendingPrereleaseFallback.userInitiated)
const primaryEventSuppression = pendingPrereleaseFallback.suppressedPrimaryEventFailure
if (primaryEventSuppression?.failureKey === failureKey) {
const isPrimaryPromisePair = primaryEventSuppression.error === error
// Why: after fallback checking starts, same-message errors may belong to
// the fallback attempt, so message matching alone is not safe.
if (isPrimaryPromisePair || !pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) {
pendingPrereleaseFallback.suppressedPrimaryEventFailure = null
clearPrereleaseFallbackContextIfSettled()
return true
}
}
if (pendingPrereleaseFallback.suppressedFallbackEventFailureKey === failureKey) {
pendingPrereleaseFallback.suppressedFallbackEventFailureKey = null
clearPrereleaseFallbackContextIfSettled()
return true
}
return false
}
function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): void {
if (
!pendingPrereleaseFallback?.retryLaunched ||
pendingPrereleaseFallback.fallbackResultHandled
) {
return
}
pendingPrereleaseFallback.suppressedFallbackEventFailureKey = getCheckFailureKey(
message,
pendingPrereleaseFallback.userInitiated
)
}
function shouldResolvePrereleaseFeed(): 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. The
@ -285,7 +444,24 @@ async function pinPrereleaseFeed(): Promise<void> {
// case that feed will report the latest stable and compareVersions in the
// 'update-available' handler will correctly mark it as not-available.
const currentVersion = app.getVersion()
const newerTag = await fetchNewerReleaseTag(currentVersion)
const releaseTags = await fetchNewerReleaseTags(currentVersion, 2)
const newerTag = releaseTags[0] ?? null
const fallbackTag = releaseTags[1] ?? null
pendingPrereleaseFallback =
newerTag && fallbackTag
? {
primaryTag: newerTag,
fallbackTag,
userInitiated: false,
suppressedPrimaryPromiseFailureKey: null,
suppressedPrimaryEventFailure: null,
suppressedFallbackPromiseFailureKey: null,
suppressedFallbackEventFailureKey: null,
fallbackResultHandled: false,
fallbackCheckingForUpdateSeen: false,
retryLaunched: false
}
: null
// Why: console.info goes to stdout and is captured by Console.app on macOS
// and by --enable-logging elsewhere. This is the only window we have into
// the updater on a user's machine when something goes wrong (issue: RC user
@ -295,12 +471,65 @@ async function pinPrereleaseFeed(): Promise<void> {
console.info(`[updater] prerelease feed pinned: current=${currentVersion}${url}`)
autoUpdater.setFeedURL({ provider: 'generic', url })
} else {
clearPrereleaseFallbackContext()
const url = 'https://github.com/stablyai/orca/releases/latest/download'
console.info(`[updater] prerelease feed fallback: current=${currentVersion}${url}`)
autoUpdater.setFeedURL({ provider: 'generic', url })
}
}
function retryPrereleaseFallbackAfterMissingManifest(
message: string,
userInitiated: boolean | undefined,
source: CheckFailureSource,
failureKey: string,
sourceError?: unknown
): boolean {
if (
!pendingPrereleaseFallback ||
pendingPrereleaseFallback.retryLaunched ||
!isMissingUpdateManifestFailure(message)
) {
return false
}
// Why: a published tag can briefly point at a missing platform manifest
// during GitHub release transitions. Walk back once to the previous feed
// entry so users on the last good build see a normal not-available result.
pendingPrereleaseFallback.retryLaunched = true
pendingPrereleaseFallback.userInitiated = Boolean(userInitiated)
pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey =
source === 'event' ? failureKey : null
pendingPrereleaseFallback.suppressedPrimaryEventFailure =
source === 'promise' ? { failureKey, error: sourceError } : null
pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = false
const { primaryTag, fallbackTag } = pendingPrereleaseFallback
const url = getReleaseDownloadUrl(fallbackTag)
console.info(
`[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}`
)
autoUpdater.setFeedURL({ provider: 'generic', url })
userInitiatedCheck = Boolean(userInitiated)
backgroundCheckLaunchPending = !userInitiated
void autoUpdater.checkForUpdates().catch((err) => {
const message = String(err?.message ?? err)
if (userInitiated) {
userInitiatedCheck = false
} else {
backgroundCheckLaunchPending = false
}
markMissingManifestPrereleaseFallbackPromiseHandled(message)
consumeMissingManifestPrereleaseFallbackResult()
void sendCheckFailureStatus(message, userInitiated, 'fallback-promise', err)
})
return true
}
function launchWithoutPrereleaseFallback(launch: () => Promise<unknown>): Promise<unknown> {
clearPrereleaseFallbackContext()
return launch()
}
function runBackgroundUpdateCheck(
nudgeId: string | null = getPersistedPendingUpdateNudgeId()
): void {
@ -325,10 +554,12 @@ 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 = shouldResolvePrereleaseFeed() ? pinPrereleaseFeed().then(launch) : launch()
const run = shouldResolvePrereleaseFeed()
? pinPrereleaseFeed().then(launch)
: launchWithoutPrereleaseFallback(launch)
void Promise.resolve(run).catch((err) => {
backgroundCheckLaunchPending = false
void sendCheckFailureStatus(String(err?.message ?? err))
void sendCheckFailureStatus(String(err?.message ?? err), undefined, 'promise', err)
})
}
@ -363,6 +594,7 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
}
if (options?.includePrerelease) {
clearPrereleaseFallbackContext()
enableIncludePrerelease()
}
@ -375,10 +607,12 @@ 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 = shouldResolvePrereleaseFeed() ? pinPrereleaseFeed().then(launch) : launch()
const run = shouldResolvePrereleaseFeed()
? pinPrereleaseFeed().then(launch)
: launchWithoutPrereleaseFallback(launch)
void Promise.resolve(run).catch((err) => {
userInitiatedCheck = false
void sendCheckFailureStatus(String(err?.message ?? err), true)
void sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err)
})
}
@ -567,6 +801,8 @@ export function setupAutoUpdater(
registerAutoUpdaterHandlers({
clearAvailableUpdateContext,
consumeMissingManifestPrereleaseFallbackResult,
getMissingManifestPrereleaseFallbackUserInitiated,
getCurrentStatus: () => currentStatus,
getKnownReleaseUrl,
getPendingInstallVersion,
@ -575,6 +811,9 @@ export function setupAutoUpdater(
performQuitAndInstall,
sendCheckFailureStatus,
sendErrorStatus,
markMissingManifestPrereleaseFallbackChecking,
shouldSuppressMissingManifestPrereleaseFallbackEvent,
suppressMissingManifestPrereleaseFallbackPromiseFailure,
recordCompletedUpdateCheck,
sendStatus,
scheduleAutomaticUpdateCheck,