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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1536 additions and 40 deletions

330
.github/workflows/hourly-mac-build.yml vendored Normal file
View File

@ -0,0 +1,330 @@
name: Hourly macOS Dev Build
# Why: gives developers a signed macOS build of main every hour that the in-app
# updater can install directly, without waiting for an RC cut.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
# and release-cut remain the gates that matter.
# - Signed but NOT notarized. Squirrel.Mac validates the replacement bundle's
# signature, not its notarization, so in-place updates still work while the
# ~10min notary round trip is skipped 24x a day.
#
# Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so 24 hourly
# tags a day would evict every stable/RC entry and break updates for real users.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use a
# GitHub App installed on orca-hourly with Contents: Read and write. Its private
# key does not expire, unlike a PAT — nothing here needs yearly rotation, and the
# credential belongs to the org rather than to whoever created it.
#
# Provision the two secrets with `bash config/scripts/setup-hourly-release-token.sh`:
# HOURLY_RELEASE_APP_ID the App's numeric id
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, which is ample here: this job skips tests,
# notarization, and Windows signing entirely, so a run is pack + upload and lands
# well inside that. If one ever did overrun, the release is still an unpublished
# draft at that point, so nothing user-visible breaks.
on:
schedule:
# Top of every hour. Skipped automatically when main has not moved.
- cron: '0 * * * *'
workflow_dispatch:
inputs:
force:
description: Build even if main has not moved since the last hourly
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: hourly-mac-build
cancel-in-progress: false
env:
HOURLY_REPO: stablyai/orca-hourly
# Keep ~3 days of history so a regression can be bisected across a weekend.
HOURLY_RETAIN_COUNT: 72
jobs:
build-hourly-mac:
if: github.repository == 'stablyai/orca'
runs-on: blacksmith-6vcpu-macos-15
# Why 120: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x25 = 80, plus ~30 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all. A typical run
# is far shorter — there is no notarization, Windows signing, or test phase.
timeout-minutes: 120
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the hourly repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
# build step is compromised (zizmor: artipacked).
persist-credentials: false
- name: Mint hourly repo token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-hourly
# Why: main is often idle overnight. Rebuilding an unchanged commit burns a
# runner hour and adds a redundant tag to the retention window.
- name: Check whether main moved since the last hourly
id: freshness
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
run: |
set -euo pipefail
head_sha="$(git rev-parse HEAD)"
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
if [[ "$FORCED" == "true" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "Forced dispatch; building $head_sha."
exit 0
fi
# The previous hourly records its source commit in the release body.
# Drafts are excluded: an unpublished leftover never shipped, so treating
# it as "the last build" would skip a build that never actually happened.
last_body="$(gh release list --repo "$HOURLY_REPO" --limit 20 --json tagName,isDraft \
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
if [[ -z "$last_body" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "No prior hourly release found; building $head_sha."
exit 0
fi
last_sha="$(gh release view "$last_body" --repo "$HOURLY_REPO" --json body \
--jq '.body | capture("commit `(?<sha>[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)"
if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then
echo "should_build=false" >>"$GITHUB_OUTPUT"
echo "main is unchanged since $last_body ($last_sha); skipping."
else
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "main moved to $head_sha (last hourly built $last_sha); building."
fi
- name: Setup pnpm
if: steps.freshness.outputs.should_build == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
if: steps.freshness.outputs.should_build == 'true'
uses: actions/cache@v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-mac-
- name: Install dependencies
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: signing is what makes an hourly installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/verify-macos-release-env.mjs
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Compute hourly version
id: hourly
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/hourly-build-version.mjs >>"$GITHUB_OUTPUT"
- name: Build app
if: steps.freshness.outputs.should_build == 'true'
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: hourly builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted dev artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
- name: Create hourly release
id: release
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: v${{ steps.hourly.outputs.version }}
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
short_sha="${SHA:0:12}"
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on. It is flipped live only
# after the manifest is verified.
gh release create "$TAG" \
--repo "$HOURLY_REPO" \
--title "$TAG" \
--draft \
--notes "Automated hourly macOS dev build from commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
**Unvetted.** No tests ran. Signed but not notarized — installable
through Orca's in-app updater, but a manual download will be
Gatekeeper-quarantined."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish hourly macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
# Why lower than the release pipeline's 3x45: that budget is sized for
# notarization and Windows signing, neither of which runs here. An attempt
# is pack + upload only.
timeout_minutes: 25
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
env:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-hourly.
GH_TOKEN: ${{ steps.app_token.outputs.token }}
ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
- name: Verify update manifest published
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$HOURLY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
# Deleting inline under `set -e` would also let the delete's exit code
# preempt this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Hourly draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Hourly draft $TAG has no ZIP artifact for the updater to download."
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
- name: Publish the verified release
id: publish_live
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease
echo "Published $TAG"
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
#
# Why it is gated on publish_live not having succeeded: a later failure (the
# prune step) must not delete a release that already went live and that users
# may already be installing. A job killed by the outer timeout runs no steps
# at all — which is exactly why the release stays a draft until verified.
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
# so without it a manual cancel mid-publish would strand the draft.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -uo pipefail
# No --cleanup-tag: an unpublished draft never created a git tag.
echo "Run failed before publish; discarding draft $TAG"
gh release delete "$TAG" --repo "$HOURLY_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old hourly releases
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
# keep showing up in tag lists with no release or assets attached.
# Drafts are excluded so retention counts shipped builds only; a stale
# draft is handled by the failure path, not by the retention window.
stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,createdAt,isDraft \
--jq "map(select(.isDraft | not)) | sort_by(.createdAt) | reverse | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune; at or under $HOURLY_RETAIN_COUNT retained builds."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
echo "Pruning $tag"
gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"

View File

@ -16,9 +16,14 @@ const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibilit
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
// Why: hourly dev builds must carry the *release* identity — same bundle id and
// Developer ID signature — or Squirrel.Mac refuses to swap them over an installed
// Orca. Only notarization is skipped, which in-place updates never check.
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const hourlyBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION : undefined
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
@ -65,7 +70,11 @@ const winSpeechNativeResource = {
module.exports = {
appId,
productName: 'Orca',
...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
...(hourlyBuildVersion
? { extraMetadata: { version: hourlyBuildVersion } }
: localBuildVersion
? { extraMetadata: { version: localBuildVersion } }
: {}),
directories: {
buildResources: 'resources/build'
},
@ -321,7 +330,11 @@ module.exports = {
// explicit release path so production artifacts remain strict while dev
// artifacts do not fail with broken ad-hoc launch behavior.
hardenedRuntime: isMacRelease,
notarize: isMacRelease,
// Why: Squirrel.Mac validates the replacement bundle's signature, not its
// notarization, so hourly builds stay installable while skipping the ~10min
// notary round trip 24x a day. A manually-downloaded hourly zip will be
// Gatekeeper-quarantined — that is the accepted tradeoff for a dev channel.
notarize: isMacRelease && !isMacHourly,
extraResources: [
...commonExtraResources,
...createPackagedRuntimeNodeModuleResources('darwin'),
@ -461,8 +474,11 @@ module.exports = {
publish: {
provider: 'github',
owner: 'stablyai',
repo: 'orca',
releaseType: 'release'
// Why: hourly tags must never enter the main repo's releases atom feed — it
// exposes only the 10 newest entries, so 24 hourly tags a day would evict
// every stable/RC entry and strand users on a feed with nothing to install.
repo: isMacHourly ? 'orca-hourly' : 'orca',
releaseType: isMacHourly ? 'prerelease' : 'release'
}
}

View File

@ -19,6 +19,39 @@ const {
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
expect(electronBuilderConfig.appId).toBe(
@ -267,6 +300,52 @@ describe('electron-builder config', () => {
}
})
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why: notarization is the one release step hourly skips; in-place updates never
// check it, and 24 notary round trips a day is the cost being avoided.
it('skips notarization only for hourly builds', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(false)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)

View File

@ -0,0 +1,43 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
/** `1.4.160-hourly.202607281400` UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
export function createHourlyBuildVersion(baseVersion, date) {
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
if (!match) {
throw new Error(`Package version is not valid semver: ${baseVersion}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Hourly build timestamp is invalid.')
}
const pad = (value, width = 2) => String(value).padStart(width, '0')
const stamp = [
pad(date.getUTCFullYear(), 4),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes())
].join('')
// Why: drop any -rc.N tail. Keeping it makes every hourly semver-NEWER than the
// RC it was cut from (1.4.160-rc.3-hourly.X > 1.4.160-rc.3), which would let an
// ordinary RC-channel check offer untested hourly builds to RC users. Stripping
// to the base parks hourlies below both rc.N and stable ('hourly' < 'rc'
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-hourly.${stamp}`
}
export function getHourlyBuildIdentity(now = new Date()) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
return { commit, version: createHourlyBuildVersion(packageJson.version, now) }
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getHourlyBuildIdentity()
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(`version=${identity.version}\ncommit=${identity.commit}\n`)
}

View File

@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { createHourlyBuildVersion } from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createHourlyBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp', () => {
expect(createHourlyBuildVersion('1.4.160', new Date('2026-07-28T04:05:00Z'))).toBe(
'1.4.160-hourly.202607280405'
)
})
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
// every hourly semver-NEWER than the RC it was cut from (1.4.160-rc.3-hourly.X >
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested hourly
// builds to RC users. Dropping it parks hourlies below both rc.N and stable,
// reachable only by an explicit pinned jump.
it('drops an in-flight rc tail so hourlies never outrank the rc series', () => {
const version = createHourlyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z'))
expect(version).toBe('1.4.160-hourly.202607281400')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions('1.4.160-rc.3-hourly.202607281400', '1.4.160-rc.3')).toBeGreaterThan(
0
)
})
it('rejects invalid input', () => {
expect(() => createHourlyBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createHourlyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})

View File

@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Provisions the credentials hourly-mac-build.yml uses to publish into
# stablyai/orca-hourly. GITHUB_TOKEN cannot be used: it is scoped to the repo
# running the workflow, and hourly artifacts are published to a different one.
#
# A GitHub App is used rather than a PAT because its private key does not expire
# — no yearly rotation — and it belongs to the org rather than to the person who
# created it, so it survives that person leaving.
#
# The key is read from a file and piped straight into `gh secret set`. It is never
# echoed, never passed as a command-line argument (argv is world-readable via
# `ps`), and never copied anywhere on disk.
#
# Usage: bash config/scripts/setup-hourly-release-token.sh [path/to/key.pem]
#
set -euo pipefail
# Guard: xtrace would echo the key to stderr on every expansion. Test before
# disabling, or the check reads the state this line just cleared and never fires.
if [[ -o xtrace ]]; then
echo "Refusing to run with xtrace enabled; it would echo the private key." >&2
exit 1
fi
set +x
MAIN_REPO="stablyai/orca"
HOURLY_REPO="stablyai/orca-hourly"
APP_ID_SECRET="HOURLY_RELEASE_APP_ID"
APP_KEY_SECRET="HOURLY_RELEASE_APP_PRIVATE_KEY"
fail() {
echo "error: $*" >&2
exit 1
}
command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com"
gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login"
# Setting repo secrets requires admin; check before asking for anything.
if [[ "$(gh api "repos/$MAIN_REPO" --jq '.permissions.admin' 2>/dev/null)" != "true" ]]; then
fail "You need admin on $MAIN_REPO to set repository secrets."
fi
gh api "repos/$HOURLY_REPO" --jq '.full_name' >/dev/null 2>&1 ||
fail "$HOURLY_REPO does not exist or you cannot see it."
cat <<EOF
Create a GitHub App (one time — the key never expires)
──────────────────────────────────────────────────────
1. Open: https://github.com/organizations/stablyai/settings/apps/new
2. Name .................. orca-hourly-release
Homepage URL .......... https://github.com/$HOURLY_REPO
Webhook ............... UNCHECK "Active"
3. Repository permissions -> Contents: Read and write
(leave everything else alone)
4. "Where can this app be installed?" -> Only on this account
5. Create, then note the App ID shown at the top of the page.
6. Generate a private key (bottom of the page) — a .pem downloads.
7. Install App -> Only select repositories -> $HOURLY_REPO
EOF
read -rp "App ID (numeric): " APP_ID
[[ "$APP_ID" =~ ^[0-9]+$ ]] || fail "App ID must be numeric, got: ${APP_ID:-<empty>}"
KEY_PATH="${1:-}"
if [[ -z "$KEY_PATH" ]]; then
read -rp "Path to the downloaded .pem: " KEY_PATH
fi
# Expand a leading ~ so a pasted path works without quoting rules.
KEY_PATH="${KEY_PATH/#\~/$HOME}"
[[ -r "$KEY_PATH" ]] || fail "Cannot read key file: $KEY_PATH"
grep -q "BEGIN.*PRIVATE KEY" "$KEY_PATH" ||
fail "$KEY_PATH does not look like a PEM private key."
echo "Storing $APP_ID_SECRET in $MAIN_REPO..."
printf '%s' "$APP_ID" | gh secret set "$APP_ID_SECRET" --repo "$MAIN_REPO" ||
fail "Could not set $APP_ID_SECRET."
# Piped on stdin so the key never appears in argv or in shell history.
echo "Storing $APP_KEY_SECRET in $MAIN_REPO..."
gh secret set "$APP_KEY_SECRET" --repo "$MAIN_REPO" <"$KEY_PATH" ||
fail "Could not set $APP_KEY_SECRET."
echo
echo "Done. Both secrets are set on $MAIN_REPO."
echo
echo "Delete your local copy of the key — the workflow reads it from the secret,"
echo "and a .pem sitting in ~/Downloads is a standing credential:"
echo " rm '$KEY_PATH'"
echo
echo "Smoke-test the pipeline without waiting for the hour (after this merges):"
echo " gh workflow run hourly-mac-build.yml --repo $MAIN_REPO -f force=true"
echo " gh run watch --repo $MAIN_REPO"

View File

@ -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(),

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()
}
})

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/)
})
})

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) }
}

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.

View File

@ -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) }
}
}
)
}

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
}

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,

View File

@ -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>
)

View File

@ -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>
)
}

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": {

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.

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
}

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)
})
})

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))
}

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. */