mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 16:17:51 +00:00
feat(updater): add hourly dev channel and build switching (#11250)
* feat(updater): add hourly dev channel and build switching Adds an hourly macOS build channel plus a dev-only surface for switching update channels and jumping to any published build, including older ones. Hourly builds publish to a separate stablyai/orca-hourly repo. The routine update path resolves tags from the main repo's releases atom feed, which exposes only its 10 newest entries — 24 hourly tags a day would evict every stable/RC entry there and leave real users with nothing to update to. Hourly artifacts carry the release bundle id and Developer ID signature so Squirrel.Mac can swap them in place; only notarization is skipped, which in-place updates never check. Version tails are stripped to the base (1.4.160-hourly.<stamp>, not 1.4.160-rc.3-hourly.<stamp>) so hourlies sort below both rc.N and stable and are reachable only by an explicit pinned jump, never by an ordinary check. The picker is revealed by Option-clicking the Updates header, matching the Help menu's existing hidden admin affordance. Pinned jumps set allowDowngrade and release the feed on every settle path so a jump can never leave background checks permanently deferred. * chore(hourly): create orca-hourly and add token provisioning script Adds setup-hourly-release-token.sh, which provisions HOURLY_RELEASE_TOKEN without the value ever reaching stdout, argv, or shell history: it is read with `read -rs`, passed to gh through GH_TOKEN in the environment rather than as an argument (argv is world-readable via ps), piped into `gh secret set` on stdin, and scrubbed by an EXIT trap. Verification creates and deletes a draft release in orca-hourly to prove Contents:write for real rather than trusting the permission checkbox. Drafts are absent from the releases atom feed, so the probe cannot disturb users. Refuses to run without a controlling terminal instead of falling through having set nothing, and refuses to run under xtrace, which would echo the token on every expansion. * fix(updater): address review feedback on the hourly channel Renderer: - Guard listBuilds against out-of-order responses. activeChannel flips once getVersion resolves, and rapid channel clicks stack requests, so a slower earlier load could land last and fill the list with builds from a channel the picker was no longer showing. - Selecting the running build's own channel now clears the override instead of pinning it. There was previously no way back to "follow this build's channel", so merely opening the panel left background checks pinned. - Validate releaseChannelOverride on hydration, matching every other enum-like field in that function. Main: - Exclude pinned jumps from recordCompletedUpdateCheck() in update-available. A dev browsing the picker was persisting lastUpdateCheckAt and suppressing the next real background check for a full day. - parseHourlyVersionStamp now anchors on the whole version and round-trips the parsed fields. It accepted garbage prefixes, and Date.UTC rolled impossible dates forward, so ...hourly.202602300000 rendered as March 2. Workflow: - Publish into a draft and flip it live only after the manifest check. The window between creating the release and verifying its assets previously exposed a tag the picker would offer and the download would 404 on; a draft is invisible to listReleaseBuilds, so a job that dies in that window — including a hard kill by the job timeout, which runs no cleanup step — leaves nothing user-visible behind. - Add a failure handler that discards the draft, gated on the publish step not having succeeded so a later prune failure cannot delete a live release. - Align retry budgets with the job timeout (was 60min against a worst case of ~185min, so a mid-retry kill skipped the cleanup that step exists for). - Exclude drafts from the freshness and retention queries. - persist-credentials: false; the job only reads this repo and never pushes. * refactor(hourly): authenticate with a GitHub App instead of a PAT A fine-grained PAT expires, and the hourly build would then fail silently on a schedule nobody watches. A GitHub App's private key has no expiry, so this is set up once. It is also owned by the org rather than by the person who created it, so the credential survives that person leaving. The workflow mints a short-lived installation token via actions/create-github-app-token and passes it as GH_TOKEN. Installation tokens live one hour, which is ample: this job runs no tests, no notarization, and no Windows signing, so it is pack + upload. The retry budgets and job timeout are re-sized to that reality rather than copied from the release pipeline, whose 3x45 publish budget exists for notarization and SignPath. setup-hourly-release-token.sh now provisions HOURLY_RELEASE_APP_ID and HOURLY_RELEASE_APP_PRIVATE_KEY. The key is redirected from a file straight into `gh secret set` on stdin, so its contents never enter a shell variable, argv, or the terminal. * fix(hourly): make the xtrace guard fire and cover cancelled runs The xtrace guard disabled tracing before testing for it, so `[[ -o xtrace ]]` read the state the previous line had just cleared and never fired. `bash -x` ran straight through, tracing exactly the key handling the guard exists to prevent. Test first, then disable. The draft cleanup only ran on failure(), but a run stopped from the Actions UI is cancelled(), not failed — a manual cancel mid-publish stranded the draft. Cover both.
This commit is contained in:
@@ -250,6 +250,7 @@ const UiUpdateFields = z
|
||||
lastUpdateCheckAt: z.number().finite().nullable().optional(),
|
||||
pendingUpdateNudgeId: NullableString.optional(),
|
||||
dismissedUpdateNudgeId: NullableString.optional(),
|
||||
releaseChannelOverride: z.enum(['stable', 'rc', 'hourly']).nullable().optional(),
|
||||
notificationPermissionRequested: z.boolean().optional(),
|
||||
updateReassuranceSeen: z.boolean().optional(),
|
||||
osc52ClipboardDefaultOnNoticePending: z.boolean().optional(),
|
||||
|
||||
+28
-11
@@ -32,6 +32,7 @@ type UpdaterHandlerContext = {
|
||||
isQuitAndInstallHandoffActive: () => boolean
|
||||
hasInstallableDownloadedVersion: () => boolean
|
||||
isLocalBuildCheck: () => boolean
|
||||
isPinnedBuildCheck: () => boolean
|
||||
shouldHandleUpdaterErrorEvent: () => boolean
|
||||
clearUpdateAvailableEventPending: (attemptId: number | null) => void
|
||||
isActiveUpdateCheckAttempt: (attemptId: number) => boolean
|
||||
@@ -74,6 +75,7 @@ export function registerAutoUpdaterHandlers({
|
||||
isQuitAndInstallHandoffActive,
|
||||
hasInstallableDownloadedVersion,
|
||||
isLocalBuildCheck,
|
||||
isPinnedBuildCheck,
|
||||
shouldHandleUpdaterErrorEvent,
|
||||
clearUpdateAvailableEventPending,
|
||||
isActiveUpdateCheckAttempt,
|
||||
@@ -162,8 +164,12 @@ export function registerAutoUpdaterHandlers({
|
||||
const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck()
|
||||
setUserInitiatedCheck(false)
|
||||
|
||||
// Release checks remain newer-only; validated local builds may intentionally downgrade.
|
||||
if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) {
|
||||
// Release checks remain newer-only; validated local builds and pinned dev jumps may intentionally downgrade.
|
||||
if (
|
||||
!isLocalBuildCheck() &&
|
||||
!isPinnedBuildCheck() &&
|
||||
compareVersions(info.version, app.getVersion()) <= 0
|
||||
) {
|
||||
clearAvailableUpdateContext()
|
||||
if (missingManifestFallback || publishingWindowLastGoodCheck) {
|
||||
// Why: a current-version fallback manifest means the primary is transiently missing; keep the short retry cadence.
|
||||
@@ -182,9 +188,10 @@ export function registerAutoUpdaterHandlers({
|
||||
markUpdateAvailableEventPending(attemptId)
|
||||
void (async () => {
|
||||
try {
|
||||
const changelog = isLocalBuildCheck()
|
||||
? null
|
||||
: await fetchChangelog(info.version, app.getVersion()).catch(() => null)
|
||||
const changelog =
|
||||
isLocalBuildCheck() || isPinnedBuildCheck()
|
||||
? null
|
||||
: await fetchChangelog(info.version, app.getVersion()).catch(() => null)
|
||||
|
||||
// Why: async fetch may take seconds; bail if a newer event superseded this attempt to avoid a stale 'available' broadcast.
|
||||
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
||||
@@ -197,7 +204,10 @@ export function registerAutoUpdaterHandlers({
|
||||
// Why: side effects must run after the guard so a concurrent 'error' during the fetch can't leave orphaned state.
|
||||
setAvailableVersion(info.version)
|
||||
setAvailableReleaseUrl(null)
|
||||
if (!isLocalBuildCheck()) {
|
||||
// Why: a pinned dev jump is not a release check. Letting it call
|
||||
// recordCompletedUpdateCheck() would persist lastUpdateCheckAt and
|
||||
// suppress the next real background check for a full day.
|
||||
if (!isLocalBuildCheck() && !isPinnedBuildCheck()) {
|
||||
if (missingManifestFallback || publishingWindowLastGoodCheck) {
|
||||
// Why: last-good release is a temporary fallback; keep probing so users can move to the newest tag once it publishes.
|
||||
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
|
||||
@@ -226,9 +236,12 @@ export function registerAutoUpdaterHandlers({
|
||||
const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck()
|
||||
const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck()
|
||||
const localBuildCheck = isLocalBuildCheck()
|
||||
// Why: an unpinned outcome must hand the feed back, else the pin blocks every
|
||||
// later background check for the process lifetime.
|
||||
const pinnedBuildCheck = isPinnedBuildCheck()
|
||||
setUserInitiatedCheck(false)
|
||||
clearAvailableUpdateContext()
|
||||
if (!localBuildCheck) {
|
||||
if (!localBuildCheck && !pinnedBuildCheck) {
|
||||
if (missingManifestFallback || publishingWindowLastGoodCheck) {
|
||||
// Why: last-good not-available is a transient release-transition outcome; keep the short retry, don't suppress for 24h.
|
||||
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
|
||||
@@ -240,7 +253,7 @@ export function registerAutoUpdaterHandlers({
|
||||
}
|
||||
}
|
||||
sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined })
|
||||
if (localBuildCheck) {
|
||||
if (localBuildCheck || pinnedBuildCheck) {
|
||||
restoreReleaseUpdateSource()
|
||||
}
|
||||
})
|
||||
@@ -256,8 +269,12 @@ export function registerAutoUpdaterHandlers({
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
clearBackgroundCheckLaunchPending()
|
||||
// Release downloads remain newer-only; the local source was validated before checking.
|
||||
if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) {
|
||||
// Release downloads remain newer-only; the local source was validated before checking, and a pinned jump is explicit.
|
||||
if (
|
||||
!isLocalBuildCheck() &&
|
||||
!isPinnedBuildCheck() &&
|
||||
compareVersions(info.version, app.getVersion()) <= 0
|
||||
) {
|
||||
clearAvailableUpdateContext()
|
||||
sendStatus({ state: 'not-available' })
|
||||
return
|
||||
@@ -302,7 +319,7 @@ export function registerAutoUpdaterHandlers({
|
||||
return
|
||||
}
|
||||
sendErrorStatus(message, wasUserInitiated || undefined)
|
||||
if (isLocalBuildCheck()) {
|
||||
if (isLocalBuildCheck() || isPinnedBuildCheck()) {
|
||||
restoreReleaseUpdateSource()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
vi.mock('electron', () => ({ net: { fetch: (...args: unknown[]) => fetchMock(...args) } }))
|
||||
|
||||
const { listReleaseBuilds, resolveTargetBuild } = await import('./updater-release-builds')
|
||||
|
||||
function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}) {
|
||||
return {
|
||||
ok: init.ok ?? true,
|
||||
status: init.status ?? 200,
|
||||
json: () => Promise.resolve(body)
|
||||
}
|
||||
}
|
||||
|
||||
const release = (tag: string, extra: Record<string, unknown> = {}) => ({
|
||||
tag_name: tag,
|
||||
draft: false,
|
||||
published_at: '2026-07-28T14:00:00Z',
|
||||
html_url: `https://github.com/stablyai/orca/releases/tag/${tag}`,
|
||||
...extra
|
||||
})
|
||||
|
||||
describe('listReleaseBuilds', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
})
|
||||
|
||||
it('lists hourly builds from the dedicated repo, newest first', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse([
|
||||
release('v1.4.160-hourly.202607280900'),
|
||||
release('v1.4.160-hourly.202607281400'),
|
||||
release('v1.4.160-hourly.202607281000')
|
||||
])
|
||||
)
|
||||
|
||||
const builds = await listReleaseBuilds('hourly')
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('stablyai/orca-hourly')
|
||||
expect(builds.map((build) => build.version)).toEqual([
|
||||
'1.4.160-hourly.202607281400',
|
||||
'1.4.160-hourly.202607281000',
|
||||
'1.4.160-hourly.202607280900'
|
||||
])
|
||||
})
|
||||
|
||||
// Why: the main repo serves stable and rc from one endpoint, so an unfiltered
|
||||
// list would offer RC tags under the Stable channel.
|
||||
it('separates stable from rc in the shared main repo', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse([release('v1.4.160-rc.2'), release('v1.4.159'), release('v1.4.158')])
|
||||
)
|
||||
|
||||
await expect(listReleaseBuilds('stable').then((b) => b.map((x) => x.version))).resolves.toEqual(
|
||||
['1.4.159', '1.4.158']
|
||||
)
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse([release('v1.4.160-rc.2'), release('v1.4.159'), release('v1.4.158')])
|
||||
)
|
||||
await expect(listReleaseBuilds('rc').then((b) => b.map((x) => x.version))).resolves.toEqual([
|
||||
'1.4.160-rc.2'
|
||||
])
|
||||
})
|
||||
|
||||
// Why: a draft release has no downloadable assets; offering it makes the
|
||||
// switch action fail with a 404 after the user commits to it.
|
||||
it('skips drafts and unparseable tags', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse([
|
||||
release('v1.4.159'),
|
||||
release('v1.4.158', { draft: true }),
|
||||
release('not-a-version'),
|
||||
{ tag_name: 42 }
|
||||
])
|
||||
)
|
||||
|
||||
const builds = await listReleaseBuilds('stable')
|
||||
expect(builds.map((build) => build.version)).toEqual(['1.4.159'])
|
||||
})
|
||||
|
||||
it('surfaces a rate limit as an actionable message', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 403 }))
|
||||
await expect(listReleaseBuilds('hourly')).rejects.toThrow(/rate limit/i)
|
||||
})
|
||||
|
||||
it('reports a missing hourly repo distinctly', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 404 }))
|
||||
await expect(listReleaseBuilds('hourly')).rejects.toThrow(/No releases repository/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTargetBuild', () => {
|
||||
it('pins an hourly tag at the hourly repo download path', () => {
|
||||
expect(resolveTargetBuild('hourly', 'v1.4.160-hourly.202607281400')).toEqual({
|
||||
tag: 'v1.4.160-hourly.202607281400',
|
||||
version: '1.4.160-hourly.202607281400',
|
||||
feedUrl:
|
||||
'https://github.com/stablyai/orca-hourly/releases/download/v1.4.160-hourly.202607281400'
|
||||
})
|
||||
})
|
||||
|
||||
it('pins a stable tag at the main repo download path', () => {
|
||||
expect(resolveTargetBuild('stable', 'v1.4.159').feedUrl).toBe(
|
||||
'https://github.com/stablyai/orca/releases/download/v1.4.159'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a tag that is not a version', () => {
|
||||
expect(() => resolveTargetBuild('stable', 'main')).toThrow(/not a valid release tag/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { net } from 'electron'
|
||||
import {
|
||||
getReleaseRepoForChannel,
|
||||
getVersionChannel,
|
||||
normalizeTagToVersion,
|
||||
sortReleaseBuildsNewestFirst,
|
||||
type ReleaseBuild,
|
||||
type ReleaseChannel
|
||||
} from '../shared/release-channel'
|
||||
import { isValidVersion } from './updater-fallback'
|
||||
|
||||
const FETCH_TIMEOUT_MS = 8000
|
||||
const MAX_LISTED_BUILDS = 100
|
||||
|
||||
function getReleasesApiUrl(repo: string): string {
|
||||
return `https://api.github.com/repos/${repo}/releases?per_page=${MAX_LISTED_BUILDS}`
|
||||
}
|
||||
|
||||
export function getReleaseDownloadUrlForRepo(repo: string, tag: string): string {
|
||||
return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}`
|
||||
}
|
||||
|
||||
type GitHubReleaseEntry = {
|
||||
tag_name?: unknown
|
||||
draft?: unknown
|
||||
published_at?: unknown
|
||||
html_url?: unknown
|
||||
}
|
||||
|
||||
function parseReleaseEntry(entry: GitHubReleaseEntry, repo: string): ReleaseBuild | null {
|
||||
if (typeof entry.tag_name !== 'string' || entry.draft === true) {
|
||||
return null
|
||||
}
|
||||
const tag = entry.tag_name
|
||||
const version = normalizeTagToVersion(tag)
|
||||
const channel = getVersionChannel(version)
|
||||
if (!isValidVersion(version) || !channel) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
tag,
|
||||
version,
|
||||
channel,
|
||||
publishedAt: typeof entry.published_at === 'string' ? entry.published_at : null,
|
||||
releaseUrl:
|
||||
typeof entry.html_url === 'string'
|
||||
? entry.html_url
|
||||
: `https://github.com/${repo}/releases/tag/${encodeURIComponent(tag)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists published releases for a channel so the dev picker can offer an exact
|
||||
* build — including older ones — to jump to.
|
||||
*
|
||||
* Why the REST API rather than the atom feed the routine update path uses: the
|
||||
* feed caps at the 10 newest entries, which cannot express "jump back to
|
||||
* yesterday's hourly". This runs only on explicit dev interaction, so its
|
||||
* unauthenticated rate limit never touches background checks.
|
||||
*/
|
||||
export async function listReleaseBuilds(channel: ReleaseChannel): Promise<ReleaseBuild[]> {
|
||||
const repo = getReleaseRepoForChannel(channel)
|
||||
const res = await net.fetch(getReleasesApiUrl(repo), {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
throw new Error(`No releases repository found at ${repo}.`)
|
||||
}
|
||||
if (res.status === 403 || res.status === 429) {
|
||||
throw new Error('GitHub rate limit reached. Try again in a few minutes.')
|
||||
}
|
||||
throw new Error(`Could not list ${channel} builds (HTTP ${res.status}).`)
|
||||
}
|
||||
const payload: unknown = await res.json()
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error(`Could not read the ${channel} release list.`)
|
||||
}
|
||||
const builds = payload
|
||||
.map((entry) => parseReleaseEntry(entry as GitHubReleaseEntry, repo))
|
||||
.filter((build): build is ReleaseBuild => build !== null)
|
||||
// Why: the main repo serves both stable and rc, so filter to the asked-for channel.
|
||||
.filter((build) => build.channel === channel)
|
||||
return sortReleaseBuildsNewestFirst(builds)
|
||||
}
|
||||
|
||||
export type ResolvedTargetBuild = {
|
||||
tag: string
|
||||
version: string
|
||||
feedUrl: string
|
||||
}
|
||||
|
||||
/** Resolves a tag the user picked into a pinned generic feed URL. */
|
||||
export function resolveTargetBuild(channel: ReleaseChannel, tag: string): ResolvedTargetBuild {
|
||||
const version = normalizeTagToVersion(tag)
|
||||
if (!isValidVersion(version)) {
|
||||
throw new Error(`"${tag}" is not a valid release tag.`)
|
||||
}
|
||||
const repo = getReleaseRepoForChannel(channel)
|
||||
return { tag, version, feedUrl: getReleaseDownloadUrlForRepo(repo, tag) }
|
||||
}
|
||||
+133
-12
@@ -44,6 +44,8 @@ import {
|
||||
requestServeUpdateHandoff
|
||||
} from './serve-update-handoff'
|
||||
import type { LocalBuildFeed } from './local-builds/local-build-feed-server'
|
||||
import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds'
|
||||
import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel'
|
||||
|
||||
type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
|
||||
type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
|
||||
@@ -140,9 +142,16 @@ let downloadInFlight = false
|
||||
/** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */
|
||||
let quittingForUpdate = false
|
||||
let autoUpdater: ElectronAutoUpdater | null = null
|
||||
let activeUpdateSource: 'release' | 'local' = 'release'
|
||||
let activeUpdateSource: 'release' | 'local' | 'hourly' = 'release'
|
||||
let activeLocalBuildFeed: LocalBuildFeed | null = null
|
||||
let localBuildSelectionInProgress = false
|
||||
// Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade
|
||||
// like local builds — but off a real release feed, not a loopback server.
|
||||
let pinnedBuildSelectionInProgress = false
|
||||
// Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a
|
||||
// deliberate downgrade, so newer-only gates must yield to it too.
|
||||
let isPinnedBuildActive = false
|
||||
let getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null
|
||||
|
||||
function getAutoUpdater(): ElectronAutoUpdater {
|
||||
if (!autoUpdater) {
|
||||
@@ -167,9 +176,13 @@ function closeLocalBuildFeed(): void {
|
||||
function restoreReleaseUpdateSource(): void {
|
||||
closeLocalBuildFeed()
|
||||
activeUpdateSource = 'release'
|
||||
isPinnedBuildActive = false
|
||||
if (autoUpdater) {
|
||||
autoUpdater.allowDowngrade = false
|
||||
autoUpdater.disableDifferentialDownload = false
|
||||
// Why: a pinned jump forces allowPrerelease on; leaving it set would opt
|
||||
// every later background check into the RC channel behind the user's back.
|
||||
autoUpdater.allowPrerelease = includePrereleaseActive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +278,7 @@ function sendStatus(status: UpdateStatus): void {
|
||||
}
|
||||
|
||||
const sourcedStatus: UpdateStatus =
|
||||
activeUpdateSource === 'local' ? { ...status, source: 'local' } : status
|
||||
activeUpdateSource === 'release' ? status : { ...status, source: activeUpdateSource }
|
||||
const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus)
|
||||
|
||||
if (isUpdateCheckResultState(status.state)) {
|
||||
@@ -318,6 +331,12 @@ function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant
|
||||
if (options?.includePrerelease) {
|
||||
return 'prerelease'
|
||||
}
|
||||
// Why: a persisted 'rc' override makes every routine check follow the RC series
|
||||
// without the user re-holding shift; 'hourly' needs an explicit tag, so it is
|
||||
// not a routine-check variant.
|
||||
if (getReleaseChannelOverride?.() === 'rc') {
|
||||
return 'prerelease'
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
@@ -573,7 +592,10 @@ function getKnownReleaseUrl(): string | undefined {
|
||||
function hasInstallableDownloadedVersion(): boolean {
|
||||
return (
|
||||
availableVersion !== null &&
|
||||
(activeUpdateSource === 'local' || compareVersions(availableVersion, app.getVersion()) > 0)
|
||||
// Why: local builds and pinned dev jumps may intentionally move backwards.
|
||||
(activeUpdateSource !== 'release' ||
|
||||
isPinnedBuildActive ||
|
||||
compareVersions(availableVersion, app.getVersion()) > 0)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -823,6 +845,14 @@ async function sendCheckFailureStatus(
|
||||
sendLocalBuildErrorAndRestore(message, userInitiated)
|
||||
return
|
||||
}
|
||||
if (isPinnedBuildActive) {
|
||||
// Why: a failed pinned jump must hand the feed back before surfacing the
|
||||
// error, or the pin blocks background checks for the process lifetime.
|
||||
clearAvailableUpdateContext()
|
||||
restoreReleaseUpdateSource()
|
||||
sendStatus({ state: 'error', message, userInitiated })
|
||||
return
|
||||
}
|
||||
const failureKey = getCheckFailureKey(message, userInitiated)
|
||||
if (
|
||||
source === 'promise' &&
|
||||
@@ -1266,7 +1296,14 @@ function retryPrereleaseFallbackAfterMissingManifest(
|
||||
function runBackgroundUpdateCheck(
|
||||
nudgeId: string | null = getPersistedPendingUpdateNudgeId()
|
||||
): boolean {
|
||||
if (activeUpdateSource === 'local' || localBuildSelectionInProgress) {
|
||||
// Why: a pinned dev jump owns the feed until it settles; a background check
|
||||
// would repoint it mid-flight and download the wrong build.
|
||||
if (
|
||||
activeUpdateSource !== 'release' ||
|
||||
isPinnedBuildActive ||
|
||||
localBuildSelectionInProgress ||
|
||||
pinnedBuildSelectionInProgress
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (backgroundCheckLaunchPending || currentStatus.state === 'checking') {
|
||||
@@ -1340,11 +1377,15 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
|
||||
void checkForLocalBuildFromMenu()
|
||||
return
|
||||
}
|
||||
if (localBuildSelectionInProgress) {
|
||||
if (options?.targetTag && options.channel) {
|
||||
void checkForPinnedBuild(options.channel, options.targetTag)
|
||||
return
|
||||
}
|
||||
if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
activeUpdateSource === 'local' &&
|
||||
activeUpdateSource !== 'release' &&
|
||||
(currentStatus.state === 'checking' || currentStatus.state === 'downloading')
|
||||
) {
|
||||
return
|
||||
@@ -1466,12 +1507,83 @@ async function checkForLocalBuildFromMenu(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise<ReleaseBuild[]> {
|
||||
return listReleaseBuilds(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the updater at one exact release tag and checks it, so a dev can move to
|
||||
* any published build on any channel — including an older one.
|
||||
*
|
||||
* Unlike a routine check this sets `allowDowngrade`, because "jump to yesterday's
|
||||
* hourly" is a downgrade by semver. The pin is torn down as soon as the attempt
|
||||
* settles so ordinary background checks never inherit it.
|
||||
*/
|
||||
async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise<void> {
|
||||
if (!app.isPackaged || is.dev) {
|
||||
sendStatus({ state: 'not-available', userInitiated: true })
|
||||
return
|
||||
}
|
||||
if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') {
|
||||
return
|
||||
}
|
||||
if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) {
|
||||
return
|
||||
}
|
||||
pinnedBuildSelectionInProgress = true
|
||||
try {
|
||||
const target = resolveTargetBuild(channel, tag)
|
||||
if (compareVersions(target.version, app.getVersion()) === 0) {
|
||||
sendStatus({ state: 'not-available', userInitiated: true })
|
||||
return
|
||||
}
|
||||
closeLocalBuildFeed()
|
||||
activeUpdateSource = channel === 'hourly' ? 'hourly' : 'release'
|
||||
isPinnedBuildActive = true
|
||||
clearPrereleaseFallbackContext()
|
||||
clearPublishingWindowLastGoodCheck()
|
||||
clearAvailableUpdateContext()
|
||||
activeUpdateNudgeId = null
|
||||
userInitiatedCheck = true
|
||||
sendStatus({ state: 'checking', userInitiated: true })
|
||||
|
||||
const updater = getAutoUpdater()
|
||||
// Why: an intentional jump to an older tag must not be filtered out as "not newer".
|
||||
updater.allowDowngrade = true
|
||||
updater.disableDifferentialDownload = true
|
||||
updater.allowPrerelease = true
|
||||
console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`)
|
||||
updater.setFeedURL({ provider: 'generic', url: target.feedUrl })
|
||||
availableReleaseUrl = target.feedUrl
|
||||
const attemptId = beginUpdateCheckAttempt()
|
||||
markUpdateCheckLaunched(attemptId)
|
||||
await updater.checkForUpdates()
|
||||
handleSettledUpdateCheckPromise(attemptId)
|
||||
} catch (error) {
|
||||
userInitiatedCheck = false
|
||||
clearAvailableUpdateContext()
|
||||
restoreReleaseUpdateSource()
|
||||
sendStatus({
|
||||
state: 'error',
|
||||
message: String((error as Error)?.message ?? error),
|
||||
userInitiated: true
|
||||
})
|
||||
} finally {
|
||||
pinnedBuildSelectionInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
export function isQuittingForUpdate(): boolean {
|
||||
return quittingForUpdate
|
||||
}
|
||||
|
||||
export function quitAndInstall(): void {
|
||||
if (localBuildSelectionInProgress || pendingQuitAndInstallTimer || quitAndInstallInProgress) {
|
||||
if (
|
||||
localBuildSelectionInProgress ||
|
||||
pinnedBuildSelectionInProgress ||
|
||||
pendingQuitAndInstallTimer ||
|
||||
quitAndInstallInProgress
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1562,14 +1674,18 @@ export function dismissNudge(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* The user closed an offered update without taking it. For a local build that ends the session:
|
||||
* nothing will consume the local feed now, so release checks must stop being deferred.
|
||||
* The user closed an offered update without taking it. For a local build or a
|
||||
* pinned dev jump that ends the session: nothing will consume that feed now, so
|
||||
* release checks must stop being deferred.
|
||||
*/
|
||||
export function dismissAvailableUpdate(): void {
|
||||
if (activeUpdateSource !== 'local' || localBuildSelectionInProgress) {
|
||||
if (activeUpdateSource === 'release' && !isPinnedBuildActive) {
|
||||
return
|
||||
}
|
||||
// Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the local feed and allowDowngrade.
|
||||
if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) {
|
||||
return
|
||||
}
|
||||
// Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade.
|
||||
if (currentStatus.state !== 'available') {
|
||||
return
|
||||
}
|
||||
@@ -1589,6 +1705,7 @@ export function setupAutoUpdater(
|
||||
getDismissedUpdateNudgeId?: () => string | null
|
||||
setPendingUpdateNudgeId?: (id: string | null) => void
|
||||
setDismissedUpdateNudgeId?: (id: string | null) => void
|
||||
getReleaseChannelOverride?: () => ReleaseChannel | null
|
||||
installMode?: UpdateInstallMode
|
||||
}
|
||||
): void {
|
||||
@@ -1600,6 +1717,7 @@ export function setupAutoUpdater(
|
||||
_getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null
|
||||
_setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null
|
||||
_setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null
|
||||
getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null
|
||||
updateInstallMode = opts?.installMode ?? 'interactive'
|
||||
lastInstallDeferralVersion = { download: null, install: null }
|
||||
|
||||
@@ -1669,6 +1787,9 @@ export function setupAutoUpdater(
|
||||
isQuitAndInstallHandoffActive,
|
||||
hasInstallableDownloadedVersion,
|
||||
isLocalBuildCheck: () => activeUpdateSource === 'local',
|
||||
// Why: pinned jumps are deliberate, so update-available/-downloaded must not
|
||||
// reject them for being older than the running version.
|
||||
isPinnedBuildCheck: () => isPinnedBuildActive,
|
||||
shouldHandleUpdaterErrorEvent,
|
||||
performQuitAndInstall,
|
||||
clearUpdateAvailableEventPending,
|
||||
@@ -1733,7 +1854,7 @@ export function setupAutoUpdater(
|
||||
}
|
||||
|
||||
export function downloadUpdate(): void {
|
||||
if (localBuildSelectionInProgress || downloadInFlight) {
|
||||
if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress || downloadInFlight) {
|
||||
return
|
||||
}
|
||||
// Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works.
|
||||
|
||||
@@ -6,9 +6,11 @@ import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type {
|
||||
CreateWorktreeResult,
|
||||
ReleaseBuildListResult,
|
||||
UpdateCheckOptions,
|
||||
WorktreeStartupLaunch
|
||||
} from '../../shared/types'
|
||||
import { RELEASE_CHANNELS, type ReleaseChannel } from '../../shared/release-channel'
|
||||
import {
|
||||
acknowledgePendingTccPromptNotice,
|
||||
consumePendingTccPromptNotice,
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
setupAutoUpdater,
|
||||
dismissAvailableUpdate,
|
||||
dismissNudge,
|
||||
listAvailableReleaseBuilds,
|
||||
type UpdateInstallMode
|
||||
} from '../updater'
|
||||
import { scheduleHistoryGc } from '../terminal-history-gc'
|
||||
@@ -175,6 +178,7 @@ export function attachMainWindowServices(
|
||||
setDismissedUpdateNudgeId: (id) => {
|
||||
store.updateUI({ dismissedUpdateNudgeId: id })
|
||||
},
|
||||
getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null,
|
||||
installMode: options?.updateInstallMode
|
||||
})
|
||||
logStartupMilestone('updater-setup-done')
|
||||
@@ -526,6 +530,7 @@ export function registerUpdaterHandlers(_store: Store): void {
|
||||
ipcMain.removeHandler('updater:quitAndInstall')
|
||||
ipcMain.removeHandler('updater:dismissNudge')
|
||||
ipcMain.removeHandler('updater:dismissAvailableUpdate')
|
||||
ipcMain.removeHandler('updater:listBuilds')
|
||||
|
||||
ipcMain.handle('updater:getStatus', () => getUpdateStatus())
|
||||
ipcMain.handle('updater:getVersion', () => app.getVersion())
|
||||
@@ -537,4 +542,19 @@ export function registerUpdaterHandlers(_store: Store): void {
|
||||
ipcMain.handle('updater:quitAndInstall', () => quitAndInstall())
|
||||
ipcMain.handle('updater:dismissNudge', () => dismissNudge())
|
||||
ipcMain.handle('updater:dismissAvailableUpdate', () => dismissAvailableUpdate())
|
||||
ipcMain.handle(
|
||||
'updater:listBuilds',
|
||||
async (_event, channel: ReleaseChannel): Promise<ReleaseBuildListResult> => {
|
||||
if (!RELEASE_CHANNELS.includes(channel)) {
|
||||
return { ok: false, channel, message: `Unknown release channel "${channel}".` }
|
||||
}
|
||||
try {
|
||||
return { ok: true, channel, builds: await listAvailableReleaseBuilds(channel) }
|
||||
} catch (error) {
|
||||
// Why: a network/rate-limit failure is expected here; return it as data so
|
||||
// the picker can render the reason instead of rejecting the invoke.
|
||||
return { ok: false, channel, message: String((error as Error)?.message ?? error) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
} from '../shared/local-log-tail-types'
|
||||
import type { ReadClipboardTextOptions } from '../shared/clipboard-text'
|
||||
import type { AppIdentity } from '../shared/app-identity'
|
||||
import type { ReleaseChannel } from '../shared/release-channel'
|
||||
import type {
|
||||
HostQualifiedDetectedWorktreeResult,
|
||||
LegacyDetectedWorktreeRequest,
|
||||
@@ -255,6 +256,7 @@ import type {
|
||||
StatsSummary,
|
||||
MemorySnapshot,
|
||||
TuiAgent,
|
||||
ReleaseBuildListResult,
|
||||
UpdateCheckOptions,
|
||||
UpdateStatus,
|
||||
Worktree,
|
||||
@@ -2672,6 +2674,7 @@ export type PreloadApi = {
|
||||
quitAndInstall: () => Promise<void>
|
||||
dismissNudge: () => Promise<void>
|
||||
dismissAvailableUpdate: () => Promise<void>
|
||||
listBuilds: (channel: ReleaseChannel) => Promise<ReleaseBuildListResult>
|
||||
onStatus: (callback: (status: UpdateStatus) => void) => () => void
|
||||
onClearDismissal: (callback: () => void) => () => void
|
||||
}
|
||||
|
||||
@@ -2952,6 +2952,7 @@ const api = {
|
||||
download: () => ipcRenderer.invoke('updater:download'),
|
||||
dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'),
|
||||
dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'),
|
||||
listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel),
|
||||
quitAndInstall: async (): Promise<void> => {
|
||||
await prepareRendererForAppRestart(window, {
|
||||
startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options'
|
||||
import { GeneralRemoteServerUpdates } from './GeneralRemoteServerUpdates'
|
||||
import { ReleaseChannelSection } from './ReleaseChannelSection'
|
||||
|
||||
export function GeneralUpdateSettingsSection(): React.JSX.Element {
|
||||
const updateStatus = useAppStore((s) => s.updateStatus)
|
||||
@@ -39,6 +40,10 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
|
||||
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const updateCheckHint = getUpdateCheckHint()
|
||||
// Why: channel switching is a power-user escape hatch that can downgrade the app
|
||||
// onto an unvetted build. Option/Alt-clicking the header reveals it, matching the
|
||||
// Help menu's hidden admin options rather than shipping it on the default surface.
|
||||
const [channelSwitcherRevealed, setChannelSwitcherRevealed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -62,17 +67,25 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<section key="updates" className="space-y-4">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate(
|
||||
'auto.components.settings.GeneralUpdateSettingsSection.f2b1ccc12a',
|
||||
'Updates'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.GeneralUpdateSettingsSection.d91ebfb87e',
|
||||
'Current version: {{value0}}',
|
||||
{ value0: appVersion ?? '...' }
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
onClick={(event) => {
|
||||
if (event.altKey) {
|
||||
setChannelSwitcherRevealed((revealed) => !revealed)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SettingsSubsectionHeader
|
||||
title={translate(
|
||||
'auto.components.settings.GeneralUpdateSettingsSection.f2b1ccc12a',
|
||||
'Updates'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.GeneralUpdateSettingsSection.d91ebfb87e',
|
||||
'Current version: {{value0}}',
|
||||
{ value0: appVersion ?? '...' }
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
@@ -245,6 +258,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
|
||||
))}
|
||||
</p>
|
||||
</SearchableSetting>
|
||||
{channelSwitcherRevealed ? <ReleaseChannelSection /> : null}
|
||||
<GeneralRemoteServerUpdates />
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '../../store'
|
||||
import { Button } from '../ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { SettingsSegmentedControl, SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
RELEASE_CHANNELS,
|
||||
getVersionChannel,
|
||||
parseHourlyVersionStamp,
|
||||
type ReleaseBuild,
|
||||
type ReleaseChannel
|
||||
} from '../../../../shared/release-channel'
|
||||
|
||||
const CHANNEL_LABELS: Record<ReleaseChannel, string> = {
|
||||
stable: 'Stable',
|
||||
rc: 'RC',
|
||||
hourly: 'Hourly'
|
||||
}
|
||||
|
||||
const CHANNEL_DESCRIPTIONS: Record<ReleaseChannel, string> = {
|
||||
stable: 'Shipped releases. What everyone else is running.',
|
||||
rc: 'Release candidates cut ahead of each stable.',
|
||||
hourly: 'Unvetted macOS builds from main, built every hour. No tests, no notarization.'
|
||||
}
|
||||
|
||||
function formatBuildLabel(build: ReleaseBuild): string {
|
||||
const stamp = parseHourlyVersionStamp(build.version)
|
||||
if (!stamp) {
|
||||
return build.version
|
||||
}
|
||||
// Why: an hourly's semver tail is an opaque timestamp; show it as local time so
|
||||
// "which build was that" is answerable at a glance.
|
||||
return `${build.version.split('-')[0]} · ${stamp.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`
|
||||
}
|
||||
|
||||
export function ReleaseChannelSection(): React.JSX.Element {
|
||||
const updateStatus = useAppStore((s) => s.updateStatus)
|
||||
const releaseChannelOverride = useAppStore((s) => s.releaseChannelOverride)
|
||||
const setReleaseChannelOverride = useAppStore((s) => s.setReleaseChannelOverride)
|
||||
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [builds, setBuilds] = useState<ReleaseBuild[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null)
|
||||
|
||||
const runningChannel = appVersion ? getVersionChannel(appVersion) : null
|
||||
const activeChannel = releaseChannelOverride ?? runningChannel ?? 'stable'
|
||||
const busy = updateStatus.state === 'checking' || updateStatus.state === 'downloading'
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void window.api.updater.getVersion().then((version) => {
|
||||
if (!cancelled) {
|
||||
setAppVersion(version)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: two loads can be in flight at once — activeChannel flips on mount when
|
||||
// getVersion resolves, and rapid channel clicks stack requests. Without this
|
||||
// guard a slower earlier request can land last and fill the list with builds
|
||||
// from a channel the picker is no longer showing.
|
||||
const latestRequestRef = useRef(0)
|
||||
|
||||
const loadBuilds = useCallback(async (channel: ReleaseChannel): Promise<void> => {
|
||||
const requestId = latestRequestRef.current + 1
|
||||
latestRequestRef.current = requestId
|
||||
const isStale = (): boolean => latestRequestRef.current !== requestId
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const result = await window.api.updater.listBuilds(channel)
|
||||
if (isStale()) {
|
||||
return
|
||||
}
|
||||
if (result.ok) {
|
||||
setBuilds(result.builds)
|
||||
setSelectedTag(result.builds[0]?.tag ?? null)
|
||||
} else {
|
||||
setBuilds(null)
|
||||
setLoadError(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isStale()) {
|
||||
return
|
||||
}
|
||||
setBuilds(null)
|
||||
setLoadError(String((error as Error)?.message ?? error))
|
||||
} finally {
|
||||
// Why: only the newest request owns the spinner; a superseded one clearing
|
||||
// it would show "no builds" while the current load is still running.
|
||||
if (!isStale()) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: reload whenever the channel changes so the picker never offers tags
|
||||
// from the channel the user just switched away from.
|
||||
useEffect(() => {
|
||||
setBuilds(null)
|
||||
setSelectedTag(null)
|
||||
void loadBuilds(activeChannel)
|
||||
}, [activeChannel, loadBuilds])
|
||||
|
||||
const selectedBuild = useMemo(
|
||||
() => builds?.find((build) => build.tag === selectedTag) ?? null,
|
||||
[builds, selectedTag]
|
||||
)
|
||||
|
||||
const handleSwitchTo = (build: ReleaseBuild): void => {
|
||||
void window.api.updater
|
||||
.check({ channel: build.channel, targetTag: build.tag })
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.settings.ReleaseChannelSection.switchFailed',
|
||||
'Could not switch to that build.'
|
||||
),
|
||||
{ description: String((error as Error)?.message ?? error) }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const isRunningBuild = selectedBuild?.version === appVersion
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate(
|
||||
'auto.components.settings.ReleaseChannelSection.title',
|
||||
'Release channel'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.ReleaseChannelSection.description',
|
||||
'Switch update channels or jump to any published build, including older ones. Downgrades are allowed and unvetted builds can be broken.'
|
||||
)}
|
||||
/>
|
||||
<Badge variant="outline" className="mt-0.5 shrink-0">
|
||||
{translate('auto.components.settings.ReleaseChannelSection.devOnly', 'Dev only')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<SettingsSegmentedControl<ReleaseChannel>
|
||||
value={activeChannel}
|
||||
// Why: selecting the running build's own channel clears the override
|
||||
// rather than pinning it. Without this there is no way back to "follow
|
||||
// this build's channel", so a dev who merely looked at the panel would
|
||||
// leave background checks pinned to whatever they last clicked.
|
||||
onChange={(channel) =>
|
||||
setReleaseChannelOverride(channel === runningChannel ? null : channel)
|
||||
}
|
||||
ariaLabel={translate(
|
||||
'auto.components.settings.ReleaseChannelSection.channelAriaLabel',
|
||||
'Update channel'
|
||||
)}
|
||||
options={RELEASE_CHANNELS.map((channel) => ({
|
||||
value: channel,
|
||||
label: CHANNEL_LABELS[channel]
|
||||
}))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{CHANNEL_DESCRIPTIONS[activeChannel]}</p>
|
||||
</div>
|
||||
|
||||
{activeChannel === 'hourly' ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.ReleaseChannelSection.hourlyWarning',
|
||||
'Hourly builds are macOS-only, ship straight from main with no test gate, and are signed but not notarized. Keep a stable build handy.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedTag ?? undefined}
|
||||
onValueChange={setSelectedTag}
|
||||
disabled={loading || !builds || builds.length === 0}
|
||||
>
|
||||
<SelectTrigger size="sm" className="min-w-64 flex-1">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loading
|
||||
? translate(
|
||||
'auto.components.settings.ReleaseChannelSection.loadingBuilds',
|
||||
'Loading builds…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.ReleaseChannelSection.noBuilds',
|
||||
'No builds found'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(builds ?? []).map((build) => (
|
||||
<SelectItem key={build.tag} value={build.tag}>
|
||||
{formatBuildLabel(build)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
aria-label={translate(
|
||||
'auto.components.settings.ReleaseChannelSection.refresh',
|
||||
'Refresh build list'
|
||||
)}
|
||||
disabled={loading}
|
||||
onClick={() => void loadBuilds(activeChannel)}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!selectedBuild || busy || isRunningBuild}
|
||||
onClick={() => selectedBuild && handleSwitchTo(selectedBuild)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
translate(
|
||||
'auto.components.settings.ReleaseChannelSection.switchTo',
|
||||
'Switch to build'
|
||||
)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadError ? (
|
||||
<p className="text-xs text-destructive">{loadError}</p>
|
||||
) : isRunningBuild ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.ReleaseChannelSection.alreadyRunning',
|
||||
'This is the build you are running.'
|
||||
)}
|
||||
</p>
|
||||
) : selectedBuild ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.ReleaseChannelSection.willSwitch',
|
||||
'{{value0}} → {{value1}}',
|
||||
{ value0: appVersion ?? '…', value1: selectedBuild.version }
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -10075,6 +10075,21 @@
|
||||
"setupRecipe": "Set up an environment recipe for your cloud provider.",
|
||||
"createWorkspace": "Create a workspace and select that recipe under Run on.",
|
||||
"openSetup": "Set up environment recipes"
|
||||
},
|
||||
"ReleaseChannelSection": {
|
||||
"switchFailed": "Could not switch to that build.",
|
||||
"title": "Release channel",
|
||||
"description": "Switch update channels or jump to any published build, including older ones. Downgrades are allowed and unvetted builds can be broken.",
|
||||
"devOnly": "Dev only",
|
||||
"channelAriaLabel": "Update channel",
|
||||
"hourlyWarning": "Hourly builds are macOS-only, ship straight from main with no test gate, and are signed but not notarized. Keep a stable build handy.",
|
||||
"loadingBuilds": "Loading builds…",
|
||||
"noBuilds": "No builds found",
|
||||
"refresh": "Refresh build list",
|
||||
"switchTo": "Switch to build",
|
||||
"alreadyRunning": "This is the build you are running.",
|
||||
"willSwitch": "{{value0}} → {{value1}}",
|
||||
"webUnavailable": "Switching builds is only available in the desktop app."
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
normalizeManualRepoOrder
|
||||
} from '../../../../shared/manual-repo-order'
|
||||
import { isTopLevelView } from '../../../../shared/top-level-view'
|
||||
import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel'
|
||||
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
|
||||
import {
|
||||
DEFAULT_USAGE_PERCENTAGE_DISPLAY,
|
||||
@@ -969,6 +970,9 @@ export type UISlice = {
|
||||
dismissedUpdateVersion: string | null
|
||||
dismissUpdate: (versionOverride?: string) => void
|
||||
clearDismissedUpdateVersion: () => void
|
||||
/** Dev-only channel override; null follows the running build's own channel. */
|
||||
releaseChannelOverride: ReleaseChannel | null
|
||||
setReleaseChannelOverride: (channel: ReleaseChannel | null) => void
|
||||
// Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus).
|
||||
updateCardCollapsed: boolean
|
||||
setUpdateCardCollapsed: (collapsed: boolean) => void
|
||||
@@ -2494,6 +2498,12 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
return DEFAULT_PET_ID
|
||||
})(),
|
||||
dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null,
|
||||
// Why: a persisted value from a build that knew a different channel set
|
||||
// would otherwise survive as-is; activeChannel only falls back on null,
|
||||
// so an unknown string reaches listBuilds and the segmented control.
|
||||
releaseChannelOverride: isReleaseChannel(ui.releaseChannelOverride)
|
||||
? ui.releaseChannelOverride
|
||||
: null,
|
||||
updateReassuranceSeen: ui.updateReassuranceSeen ?? false,
|
||||
osc52ClipboardDefaultOnNoticePending: ui.osc52ClipboardDefaultOnNoticePending === true,
|
||||
browserDefaultUrl: ui.browserDefaultUrl ?? null,
|
||||
@@ -2588,6 +2598,11 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
clearDismissedUpdateVersion: () => {
|
||||
set({ dismissedUpdateVersion: null })
|
||||
},
|
||||
releaseChannelOverride: null,
|
||||
setReleaseChannelOverride: (channel) => {
|
||||
void window.api.ui.set({ releaseChannelOverride: channel }).catch(console.error)
|
||||
set({ releaseChannelOverride: channel })
|
||||
},
|
||||
dismissUpdate: (versionOverride?: string) =>
|
||||
set((s) => {
|
||||
// Why: the 'error' variant has no version field, so the card passes it via versionOverride.
|
||||
|
||||
@@ -3070,6 +3070,17 @@ function createUpdaterApi(): NonNullable<Partial<PreloadApi>['updater']> {
|
||||
quitAndInstall: () => Promise.resolve(),
|
||||
dismissNudge: () => Promise.resolve(),
|
||||
dismissAvailableUpdate: () => Promise.resolve(),
|
||||
// Why: the web client cannot install a desktop build, so channel switching
|
||||
// reports unavailable rather than an empty list that looks like a fetch miss.
|
||||
listBuilds: (channel) =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
channel,
|
||||
message: translate(
|
||||
'auto.components.settings.ReleaseChannelSection.webUnavailable',
|
||||
'Switching builds is only available in the desktop app.'
|
||||
)
|
||||
}),
|
||||
onStatus: () => noopUnsubscribe,
|
||||
onClearDismissal: () => noopUnsubscribe
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatHourlyVersion,
|
||||
getReleaseRepoForChannel,
|
||||
getVersionChannel,
|
||||
isHourlyVersion,
|
||||
isReleaseChannel,
|
||||
parseHourlyVersionStamp,
|
||||
sortReleaseBuildsNewestFirst,
|
||||
type ReleaseBuild
|
||||
} from './release-channel'
|
||||
import { compareAppVersions } from './app-version'
|
||||
|
||||
describe('release channel', () => {
|
||||
it('classifies versions by channel', () => {
|
||||
expect(getVersionChannel('1.4.160')).toBe('stable')
|
||||
expect(getVersionChannel('v1.4.160')).toBe('stable')
|
||||
expect(getVersionChannel('1.4.160-rc.3')).toBe('rc')
|
||||
expect(getVersionChannel('1.4.160-hourly.202607281400')).toBe('hourly')
|
||||
expect(getVersionChannel('not-a-version')).toBeNull()
|
||||
})
|
||||
|
||||
// Why: hourly tags must never resolve to the main repo — the releases atom feed
|
||||
// exposes only 10 entries, so 24 hourly tags a day would evict every stable/RC
|
||||
// entry and leave real users with nothing to update to.
|
||||
it('keeps hourly builds out of the main release repo', () => {
|
||||
expect(getReleaseRepoForChannel('hourly')).toBe('stablyai/orca-hourly')
|
||||
expect(getReleaseRepoForChannel('stable')).toBe('stablyai/orca')
|
||||
expect(getReleaseRepoForChannel('rc')).toBe('stablyai/orca')
|
||||
})
|
||||
|
||||
it('round-trips an hourly version stamp as UTC', () => {
|
||||
const version = formatHourlyVersion('1.4.160', '202607281405')
|
||||
expect(isHourlyVersion(version)).toBe(true)
|
||||
expect(parseHourlyVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:00.000Z')
|
||||
})
|
||||
|
||||
it('rejects malformed hourly identifiers', () => {
|
||||
expect(isHourlyVersion('1.4.160-hourly')).toBe(false)
|
||||
expect(isHourlyVersion('1.4.160-hourly.2026')).toBe(false)
|
||||
expect(isHourlyVersion('1.4.160-rc.3')).toBe(false)
|
||||
expect(parseHourlyVersionStamp('1.4.160-rc.3')).toBeNull()
|
||||
})
|
||||
|
||||
// Why: an unanchored tail match also accepted garbage prefixes, and Date.UTC
|
||||
// rolls impossible dates forward, so `...hourly.202602300000` rendered as
|
||||
// March 2 rather than being rejected.
|
||||
it('rejects a bad base version and impossible calendar stamps', () => {
|
||||
expect(parseHourlyVersionStamp('not-a-version-hourly.202601010000')).toBeNull()
|
||||
expect(parseHourlyVersionStamp('1.4-hourly.202601010000')).toBeNull()
|
||||
expect(parseHourlyVersionStamp('1.4.160-hourly.202602300000')).toBeNull()
|
||||
expect(parseHourlyVersionStamp('1.4.160-hourly.202613010000')).toBeNull()
|
||||
expect(parseHourlyVersionStamp('1.4.160-hourly.202601012500')).toBeNull()
|
||||
// Leap day 2028 is real and must still parse.
|
||||
expect(parseHourlyVersionStamp('1.4.160-hourly.202802290000')?.toISOString()).toBe(
|
||||
'2028-02-29T00:00:00.000Z'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts only known channels', () => {
|
||||
expect(isReleaseChannel('hourly')).toBe(true)
|
||||
expect(isReleaseChannel('stable')).toBe(true)
|
||||
expect(isReleaseChannel('nightly')).toBe(false)
|
||||
expect(isReleaseChannel(null)).toBe(false)
|
||||
expect(isReleaseChannel(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: consecutive hourlies differ only in the timestamp tail, so semver
|
||||
// ordering must follow the clock or the picker offers them out of order.
|
||||
it('sorts consecutive hourly builds newest first', () => {
|
||||
const build = (version: string): ReleaseBuild => ({
|
||||
tag: `v${version}`,
|
||||
version,
|
||||
channel: 'hourly',
|
||||
publishedAt: null,
|
||||
releaseUrl: `https://github.com/stablyai/orca-hourly/releases/tag/v${version}`
|
||||
})
|
||||
const sorted = sortReleaseBuildsNewestFirst([
|
||||
build('1.4.160-hourly.202607280900'),
|
||||
build('1.4.160-hourly.202607281400'),
|
||||
build('1.4.160-hourly.202607281000')
|
||||
])
|
||||
expect(sorted.map((entry) => entry.version)).toEqual([
|
||||
'1.4.160-hourly.202607281400',
|
||||
'1.4.160-hourly.202607281000',
|
||||
'1.4.160-hourly.202607280900'
|
||||
])
|
||||
})
|
||||
|
||||
// Why: an hourly is cut from main and must not read as newer than the stable it
|
||||
// is based on, or stable users would be offered it by an ordinary check.
|
||||
it('orders an hourly below its own stable release', () => {
|
||||
expect(compareAppVersions('1.4.160-hourly.202607281400', '1.4.160')).toBeLessThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { compareAppVersions, isValidAppVersion } from './app-version'
|
||||
|
||||
export type ReleaseChannel = 'stable' | 'rc' | 'hourly'
|
||||
|
||||
export const RELEASE_CHANNELS: readonly ReleaseChannel[] = ['stable', 'rc', 'hourly']
|
||||
|
||||
/** Hourly builds live in their own repo so their tags never enter the main
|
||||
* releases atom feed, which only exposes the 10 newest entries — 24 hourly
|
||||
* tags a day would evict every stable/RC entry and strand real users. */
|
||||
export const HOURLY_RELEASE_REPO = 'stablyai/orca-hourly'
|
||||
export const MAIN_RELEASE_REPO = 'stablyai/orca'
|
||||
|
||||
export const HOURLY_PRERELEASE_IDENTIFIER = 'hourly'
|
||||
|
||||
export function isReleaseChannel(value: unknown): value is ReleaseChannel {
|
||||
return typeof value === 'string' && RELEASE_CHANNELS.includes(value as ReleaseChannel)
|
||||
}
|
||||
|
||||
export function getReleaseRepoForChannel(channel: ReleaseChannel): string {
|
||||
return channel === 'hourly' ? HOURLY_RELEASE_REPO : MAIN_RELEASE_REPO
|
||||
}
|
||||
|
||||
export function normalizeTagToVersion(tag: string): string {
|
||||
return tag.replace(/^v/i, '')
|
||||
}
|
||||
|
||||
/** `1.4.160-hourly.202607281400` — a timestamp identifier keeps every build
|
||||
* uniquely versioned so electron-updater never reads one as "same version". */
|
||||
export function isHourlyVersion(version: string): boolean {
|
||||
return /^\d+\.\d+\.\d+-hourly\.\d{12}$/.test(normalizeTagToVersion(version))
|
||||
}
|
||||
|
||||
export function formatHourlyVersion(baseVersion: string, stamp: string): string {
|
||||
return `${baseVersion}-${HOURLY_PRERELEASE_IDENTIFIER}.${stamp}`
|
||||
}
|
||||
|
||||
/** Returns the build's UTC timestamp, or null when the version isn't hourly. */
|
||||
export function parseHourlyVersionStamp(version: string): Date | null {
|
||||
const normalized = normalizeTagToVersion(version)
|
||||
// Why anchored on the whole version: an unanchored tail match also accepts
|
||||
// garbage prefixes, so `not-a-version-hourly.202601010000` would parse.
|
||||
const match = normalized.match(/^\d+\.\d+\.\d+-hourly\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [year, month, day, hour, minute] = match.slice(1).map(Number)
|
||||
const parsed = new Date(Date.UTC(year, month - 1, day, hour, minute))
|
||||
// Why the round-trip: Date.UTC silently rolls impossible dates forward, so a
|
||||
// corrupt `...hourly.202602300000` would render as March 2 rather than fail.
|
||||
if (
|
||||
parsed.getUTCFullYear() !== year ||
|
||||
parsed.getUTCMonth() !== month - 1 ||
|
||||
parsed.getUTCDate() !== day ||
|
||||
parsed.getUTCHours() !== hour ||
|
||||
parsed.getUTCMinutes() !== minute
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function getVersionChannel(version: string): ReleaseChannel | null {
|
||||
const normalized = normalizeTagToVersion(version)
|
||||
if (!isValidAppVersion(normalized)) {
|
||||
return null
|
||||
}
|
||||
if (isHourlyVersion(normalized)) {
|
||||
return 'hourly'
|
||||
}
|
||||
return normalized.includes('-') ? 'rc' : 'stable'
|
||||
}
|
||||
|
||||
export type ReleaseBuild = {
|
||||
tag: string
|
||||
version: string
|
||||
channel: ReleaseChannel
|
||||
publishedAt: string | null
|
||||
releaseUrl: string
|
||||
}
|
||||
|
||||
/** Newest first, so the picker's first row is always the channel's current tip. */
|
||||
export function sortReleaseBuildsNewestFirst(builds: ReleaseBuild[]): ReleaseBuild[] {
|
||||
return [...builds].sort((left, right) => compareAppVersions(right.version, left.version))
|
||||
}
|
||||
+11
-1
@@ -3,6 +3,7 @@ import type { ExecutionHostId } from './execution-host'
|
||||
import type { RemovedSshTargetTombstone, SshRemotePtyLease, SshTarget } from './ssh-types'
|
||||
import type { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types'
|
||||
import type { WorkspaceSource } from './workspace-source'
|
||||
import type { ReleaseBuild, ReleaseChannel } from './release-channel'
|
||||
import type { GitHubProjectSettings } from './github-project-types'
|
||||
import type {
|
||||
AgentStatusState,
|
||||
@@ -2373,9 +2374,12 @@ export type UpdateCheckOptions = {
|
||||
includePrerelease?: boolean
|
||||
includePerfPrerelease?: boolean
|
||||
localBuild?: boolean
|
||||
/** Dev channel switching; `targetTag` pins an exact build, including older ones. */
|
||||
channel?: ReleaseChannel
|
||||
targetTag?: string
|
||||
}
|
||||
|
||||
export type UpdateSource = 'local'
|
||||
export type UpdateSource = 'local' | 'hourly'
|
||||
|
||||
export type UpdateStatus = (
|
||||
| { state: 'idle' }
|
||||
@@ -2402,6 +2406,10 @@ export type UpdateStatus = (
|
||||
| { state: 'error'; message: string; userInitiated?: boolean; activeNudgeId?: string }
|
||||
) & { source?: UpdateSource }
|
||||
|
||||
export type ReleaseBuildListResult =
|
||||
| { ok: true; channel: ReleaseChannel; builds: ReleaseBuild[] }
|
||||
| { ok: false; channel: ReleaseChannel; message: string }
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────────────
|
||||
export type NotificationSettings = {
|
||||
enabled: boolean
|
||||
@@ -3377,6 +3385,8 @@ export type PersistedUIState = {
|
||||
statusBarUsageMode?: StatusBarUsageMode
|
||||
dismissedUpdateVersion: string | null
|
||||
lastUpdateCheckAt: number | null
|
||||
/** Dev-only update channel override; absent means the build's own channel. */
|
||||
releaseChannelOverride?: ReleaseChannel | null
|
||||
pendingUpdateNudgeId?: string | null
|
||||
dismissedUpdateNudgeId?: string | null
|
||||
/** Whether Orca already tried triggering the macOS notification permission dialog; prevents re-firing every launch. */
|
||||
|
||||
Reference in New Issue
Block a user