fix(release): harden prerelease update fallback checks (#1793)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
70ec7c0603
commit
e361b4339e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue