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:
Neil
2026-07-30 22:53:02 -07:00
committed by GitHub
parent 0ffdb79d34
commit f998f7ec62
22 changed files with 1536 additions and 40 deletions
@@ -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
View File
@@ -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()
}
})
+113
View File
@@ -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/)
})
})
+102
View File
@@ -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
View File
@@ -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) }
}
}
)
}
+3
View File
@@ -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
}
+1
View File
@@ -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>
)
}
+15
View File
@@ -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": {
+15
View File
@@ -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.
+11
View File
@@ -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
}
+95
View File
@@ -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)
})
})
+84
View File
@@ -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
View File
@@ -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. */