From e361b4339e5a52cdca1040ea13a3765cce9b616d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 13 May 2026 21:45:33 -0700 Subject: [PATCH] fix(release): harden prerelease update fallback checks (#1793) Co-authored-by: Orca --- .github/workflows/release-cut.yml | 167 +++++ .../verify-release-required-assets.mjs | 153 ++++ src/main/updater-events.ts | 81 ++- src/main/updater-fallback.ts | 11 + src/main/updater-prerelease-feed.test.ts | 6 + src/main/updater-prerelease-feed.ts | 88 ++- src/main/updater.fallback.test.ts | 18 +- src/main/updater.test.ts | 661 +++++++++++++++++- src/main/updater.ts | 255 ++++++- 9 files changed, 1371 insertions(+), 69 deletions(-) create mode 100644 config/scripts/verify-release-required-assets.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index e062db993..0f2dc8ba6 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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, diff --git a/config/scripts/verify-release-required-assets.mjs b/config/scripts/verify-release-required-assets.mjs new file mode 100644 index 000000000..9d8fcf15e --- /dev/null +++ b/config/scripts/verify-release-required-assets.mjs @@ -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 ') + } + 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) + }) +} diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 12dec9962..e30ad095e 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -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 + sendCheckFailureStatus: ( + message: string, + userInitiated?: boolean, + source?: 'event' | 'promise' | 'fallback-promise', + sourceError?: unknown + ) => Promise 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) diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index b9265e232..70e302304 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -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. */ diff --git a/src/main/updater-prerelease-feed.test.ts b/src/main/updater-prerelease-feed.test.ts index d0e95c948..d4209da6d 100644 --- a/src/main/updater-prerelease-feed.test.ts +++ b/src/main/updater-prerelease-feed.test.ts @@ -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']) + }) }) diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index ab905f78f..39f3d5810 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -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 { + 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 { - 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 { + 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) } diff --git a/src/main/updater.fallback.test.ts b/src/main/updater.fallback.test.ts index 42d550007..882389a9a 100644 --- a/src/main/updater.fallback.test.ts +++ b/src/main/updater.fallback.test.ts @@ -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) + }) +}) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 3f488a729..bd262797c 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -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', diff --git a/src/main/updater.ts b/src/main/updater.ts index c0186a8da..22c68c9a1 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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 { - const failureKey = `${userInitiated ? 'user' : 'auto'}:${message}` +async function sendCheckFailureStatus( + message: string, + userInitiated?: boolean, + source: CheckFailureSource = 'promise', + sourceError?: unknown +): Promise { + 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 { // 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 { 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): Promise { + 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 => 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 => 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,