diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml new file mode 100644 index 000000000..d256faecb --- /dev/null +++ b/.github/workflows/hourly-mac-build.yml @@ -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 `(?[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" diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index da0f690ad..39a6df73b 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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' } } diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 1dd0cf78f..b9c4a4991 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -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) diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs new file mode 100644 index 000000000..6ed30a059 --- /dev/null +++ b/config/scripts/hourly-build-version.mjs @@ -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`) +} diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs new file mode 100644 index 000000000..e75951c48 --- /dev/null +++ b/config/scripts/hourly-build-version.test.mjs @@ -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/) + }) +}) diff --git a/config/scripts/setup-hourly-release-token.sh b/config/scripts/setup-hourly-release-token.sh new file mode 100755 index 000000000..f21ce7b5b --- /dev/null +++ b/config/scripts/setup-hourly-release-token.sh @@ -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 < 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:-}" + +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" diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 21a6fa8f4..2e38b4848 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -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(), diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 411bb1a71..1e3f7de33 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -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() } }) diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts new file mode 100644 index 000000000..2ab08ae62 --- /dev/null +++ b/src/main/updater-release-builds.test.ts @@ -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 = {}) => ({ + 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/) + }) +}) diff --git a/src/main/updater-release-builds.ts b/src/main/updater-release-builds.ts new file mode 100644 index 000000000..a7ff6089b --- /dev/null +++ b/src/main/updater-release-builds.ts @@ -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 { + 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) } +} diff --git a/src/main/updater.ts b/src/main/updater.ts index 3f9cb2a53..838c304a2 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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 { } } +export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + 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 { + 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. diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index ba51231c0..5984fe715 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -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 => { + 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) } + } + } + ) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 81796d17e..cdb5000d6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 dismissNudge: () => Promise dismissAvailableUpdate: () => Promise + listBuilds: (channel: ReleaseChannel) => Promise onStatus: (callback: (status: UpdateStatus) => void) => () => void onClearDismissal: (callback: () => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c63fd975..9da862f48 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => { await prepareRendererForAppRestart(window, { startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index b9e6635f3..7095ab971 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -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(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 (
- +
{ + if (event.altKey) { + setChannelSwitcherRevealed((revealed) => !revealed) + } + }} + > + +
+ {channelSwitcherRevealed ? : null}
) diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx new file mode 100644 index 000000000..368ded2a0 --- /dev/null +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -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 = { + stable: 'Stable', + rc: 'RC', + hourly: 'Hourly' +} + +const CHANNEL_DESCRIPTIONS: Record = { + 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(null) + const [builds, setBuilds] = useState(null) + const [loadError, setLoadError] = useState(null) + const [loading, setLoading] = useState(false) + const [selectedTag, setSelectedTag] = useState(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 => { + 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 ( +
+
+ + + {translate('auto.components.settings.ReleaseChannelSection.devOnly', 'Dev only')} + +
+ +
+ + 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] + }))} + /> +

{CHANNEL_DESCRIPTIONS[activeChannel]}

+
+ + {activeChannel === 'hourly' ? ( +
+ +

+ {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.' + )} +

+
+ ) : null} + +
+
+ + + + + +
+ + {loadError ? ( +

{loadError}

+ ) : isRunningBuild ? ( +

+ {translate( + 'auto.components.settings.ReleaseChannelSection.alreadyRunning', + 'This is the build you are running.' + )} +

+ ) : selectedBuild ? ( +

+ {translate( + 'auto.components.settings.ReleaseChannelSection.willSwitch', + '{{value0}} → {{value1}}', + { value0: appVersion ?? '…', value1: selectedBuild.version } + )} +

+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 76557b355..e3c586a4d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 024027403..625a8e9ab 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 = (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 = (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. diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2ae7a4d50..6e6367a5a 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3070,6 +3070,17 @@ function createUpdaterApi(): NonNullable['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 } diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts new file mode 100644 index 000000000..c02ffe888 --- /dev/null +++ b/src/shared/release-channel.test.ts @@ -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) + }) +}) diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts new file mode 100644 index 000000000..5295f4447 --- /dev/null +++ b/src/shared/release-channel.ts @@ -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)) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index b8acdc76d..fa6ca7b9f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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. */