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