feat(updater): add an adhoc release channel for branch builds (#12051)

* feat(updater): add an adhoc release channel for branch builds

Hourly covers main. This covers everything that is not main yet: a
dispatchable macOS build of an unlanded branch, published to
stablyai/orca-adhoc, so the team can run an experimental feature for a
few days instead of reasoning about it from a diff.

Adhoc sits at the bottom of the version order — 'adhoc' < 'hourly' <
'rc' < stable — so no routine check can walk anyone onto somebody's
branch; only an explicit pinned jump reaches one. It gets its own repo
rather than sharing orca-hourly's, because a branch build must not
appear in the list a developer riding main is looking at.

Signed and notarized exactly like hourly, for the same reason: macOS
anchors a notarized app's TCC grants on identifier + team, so an
unnotarized build reads as a new client and silently loses file access
under Documents/Desktop/Downloads.

Tags stamp to the second rather than the minute. Hourly runs under a
concurrency group and cannot overlap itself; adhoc builds are dispatched
on demand, so two people cutting from different branches inside one
minute is ordinary — and a minute-resolution tag would collide and fail
the second build after its whole pack-and-notarize run.

Channel-specific behaviour now derives from one DEDICATED_REPO_CHANNELS
list: repo mapping, macOS-only support, and UpdateSource. The RPC schema
that validates releaseChannelOverride was a hand-copied enum missing the
new channel, which would have rejected the override on its way to the
main process; it reads the predicate now.

* fix(updater): merge the duplicated shared/types import

Co-authored-by: Orca <help@stably.ai>

* fix(ci): default the adhoc build ref to the dispatch branch

The Actions UI puts its own "Use workflow from" branch picker directly
above the ref field, and picking a branch there is what most people read
as "build this". Making the field optional means the obvious action is
also the correct one; naming a branch explicitly still wins, so main's
copy of the workflow runs rather than a stale one on an old branch.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-08-02 01:46:51 -07:00 committed by GitHub
parent 5e9186f5ff
commit 484273844a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 989 additions and 100 deletions

343
.github/workflows/adhoc-mac-build.yml vendored Normal file
View File

@ -0,0 +1,343 @@
name: Adhoc macOS Dev Build
# Why: lets anyone cut a signed macOS build of an unlanded branch so the team can
# actually run an experimental feature for a few days, instead of reasoning about
# it from a diff. Hourly covers main; this covers everything that is not main yet.
#
# Deliberately narrow scope, same trade as hourly:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. PR CI and release-cut remain the gates.
# - Signed AND notarized, exactly like a release. macOS anchors a notarized
# app's TCC grants on identifier + team rather than on its cdhash, so those
# grants survive an update; an unnotarized build reads as a new client and
# silently loses file access under Documents/Desktop/Downloads.
#
# Artifacts publish to stablyai/orca-adhoc — separate from both orca and
# orca-hourly. Separate from orca because the main repo's releases atom feed
# exposes only its 10 newest entries. Separate from orca-hourly because a build
# from someone's branch must never be picked up by a developer who only meant to
# ride main; the two are different levels of "unvetted".
#
# From the Actions tab: pick this workflow, "Run workflow", leave "Use workflow
# from" on main, and type your branch in the first field. Or from the CLI:
#
# gh workflow run adhoc-mac-build.yml --ref main -f ref=my-branch -f label=wasm-terminal
#
# Leaving the ref field empty builds whatever "Use workflow from" is set to, which
# is what someone who only touched that picker means. Naming the branch explicitly
# is still better: the workflow file is always read from the dispatch ref, so a
# branch carrying a stale copy of this file would otherwise run that copy.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
# same GitHub App as hourly, additionally installed on orca-adhoc with
# Contents: Read and write. The secret names below are historical — one App, one
# private key, both dev-channel repos — and rotating it stays a single operation.
# Provision with `bash config/scripts/setup-hourly-release-token.sh`.
on:
workflow_dispatch:
inputs:
ref:
# Why optional: the Actions UI already shows its own "Use workflow from"
# branch picker directly above this field, and picking a branch there is
# what most people will read as "build this". Defaulting to that branch
# makes the obvious action correct. Fill this in only to build a ref other
# than the one the workflow file itself is read from — normally leave the
# picker on main and name your branch here, so a stale copy of this
# workflow on an old branch is not what runs.
description: 'Branch, tag, or SHA to build (default: the branch selected above)'
required: false
default: ''
type: string
label:
description: 'Short name shown in the release title (default: the ref)'
required: false
default: ''
type: string
permissions:
contents: read
concurrency:
# Why keyed on the ref rather than global: two people cutting builds from two
# different branches at the same time is the ordinary case here, and serialising
# them would make each wait out the other's notary queue. Re-dispatching the
# *same* branch still queues, so a push mid-build cannot race itself.
group: adhoc-mac-build-${{ inputs.ref || github.ref_name }}
cancel-in-progress: false
env:
ADHOC_REPO: stablyai/orca-adhoc
# Why age and not a count like hourly: this channel is low-volume and bursty, so
# a count would either hold one week's experiments forever or evict a build
# someone is still running after a busy afternoon. A month is well past the "few
# days" these exist for, and by then the branch has landed or been abandoned.
ADHOC_RETAIN_DAYS: 30
jobs:
build-adhoc-mac:
if: github.repository == 'stablyai/orca'
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout the requested ref
uses: actions/checkout@v6
with:
# Why an input at all rather than just github.ref: the whole point is to
# build code that has not landed, and the workflow definition itself
# always comes from the dispatch ref — naming the branch here instead
# applies main's current copy of this file to an arbitrary branch.
ref: ${{ inputs.ref || github.ref_name }}
fetch-depth: 0
# This job only reads stablyai/orca and never pushes; every write goes
# to the adhoc 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: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
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
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 adhoc build installable over an existing
# Orca, so a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
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 adhoc version
id: adhoc
shell: bash
env:
REF: ${{ inputs.ref || github.ref_name }}
LABEL: ${{ inputs.label }}
run: |
set -euo pipefail
# Why this check: the version script is read from the branch being built,
# not from main, so a branch cut before the adhoc channel landed has no
# copy of it. Say that plainly instead of failing with a module-not-found.
if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then
echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \
>"$RUNNER_TEMP/adhoc-identity.txt"
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
exit 1
fi
cat "$RUNNER_TEMP/adhoc-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: adhoc 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 branch artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# Why the token is minted here and not at the top: installation tokens live
# one hour, everything before this point writes nothing, and the notary round
# trip inside the publish step can be tens of minutes. Minting after the build
# starts the clock at the first call that actually uses it.
- name: Mint adhoc 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-adhoc
- name: Create adhoc release
id: release
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: v${{ steps.adhoc.outputs.version }}
NAME: ${{ steps.adhoc.outputs.name }}
SHA: ${{ steps.adhoc.outputs.head_sha }}
REF: ${{ inputs.ref || github.ref_name }}
# Via env, not inline `${{ }}`: both land inside a shell string, and an
# expression expanded there is substituted before bash parses the line
# (zizmor: template-injection).
ACTOR: ${{ github.actor }}
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.
gh release create "$TAG" \
--repo "$ADHOC_REPO" \
--title "$NAME" \
--draft \
--notes "Adhoc macOS dev build of \`$REF\` at commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR.
**Unlanded and unvetted.** This is somebody's branch, not main. No tests
ran. Signed and notarized like a release, so it installs through Orca's
in-app updater and opens without a Gatekeeper prompt — but the branch may
never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish adhoc macOS artifacts
uses: nick-fields/retry@v4
with:
# Why 45: an attempt is pack + notarize + upload, and the notary queue is
# the unbounded part. Two attempts, because a failed adhoc build has a
# person waiting on it who can simply dispatch again.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_ADHOC=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-adhoc.
GH_TOKEN: ${{ steps.app_token.outputs.token }}
ORCA_ADHOC_BUILD_VERSION: ${{ steps.adhoc.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.adhoc.outputs.commit }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
# Apple notary service with the app-specific password, not with the
# signing cert. Omitting them fails the build rather than skipping it.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_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
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 "$ADHOC_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.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Adhoc draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Adhoc 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
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.adhoc.outputs.name }}
run: |
set -euo pipefail
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
gh release edit "$TAG" --repo "$ADHOC_REPO" --draft=false --prerelease --title "$NAME"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name. Gated on publish_live not having succeeded so a later
# failure (the prune step) cannot delete a release that already went live and
# that people may already be installing. 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 "$ADHOC_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune expired adhoc releases
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
# Why compute the cutoff in bash rather than with jq's `now`: this runs
# once per dispatch, and a fixed epoch makes the threshold visible in the
# log when someone asks where their build went.
cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 ))
echo "Pruning adhoc releases created before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
# --cleanup-tag so pruning does not leave orphan tags with no release or
# assets attached. Drafts are excluded: a stale draft is the failure
# path's business, not the retention window's.
stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,createdAt,isDraft \
--jq "map(select(.isDraft | not)) | map(select((.createdAt | fromdateiso8601) < $cutoff)) | .[].tagName")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
echo "Pruning $tag"
gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"

View File

@ -16,14 +16,25 @@ 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')
// Why: hourly dev builds must carry the *release* identity — same bundle id,
// Why: dev-channel builds must carry the *release* identity — same bundle id,
// Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to
// swap them over an installed Orca and macOS treats each build as a new app.
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc
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 devChannelBuildVersion = isMacHourly
? process.env.ORCA_HOURLY_BUILD_VERSION
: isMacAdhoc
? process.env.ORCA_ADHOC_BUILD_VERSION
: undefined
// Why each dev channel gets its own repo rather than tagging into the main one:
// the releases atom feed 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. Keeping adhoc separate from hourly too means a branch build cannot
// be picked up by someone who only meant to ride main.
const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
@ -70,8 +81,8 @@ const winSpeechNativeResource = {
module.exports = {
appId,
productName: 'Orca',
...(hourlyBuildVersion
? { extraMetadata: { version: hourlyBuildVersion } }
...(devChannelBuildVersion
? { extraMetadata: { version: devChannelBuildVersion } }
: localBuildVersion
? { extraMetadata: { version: localBuildVersion } }
: {}),
@ -330,10 +341,10 @@ module.exports = {
// explicit release path so production artifacts remain strict while dev
// artifacts do not fail with broken ad-hoc launch behavior.
hardenedRuntime: isMacRelease,
// Why hourly builds notarize too, despite the ~10min notary round trip: TCC
// Why dev builds notarize too, despite the ~10min notary round trip: TCC
// anchors a notarized Developer ID app's permission grants on identifier +
// team, which is cdhash-independent and so survives an update. Without a
// ticket there is no such stable identity, so every hourly reads as a
// ticket there is no such stable identity, so every build reads as a
// different client — the grant row stays but stops matching, and file access
// under Documents/Desktop/Downloads fails with EPERM and no re-prompt. At 24
// builds a day that revokes the user's grants faster than they can re-grant.
@ -477,11 +488,8 @@ module.exports = {
publish: {
provider: 'github',
owner: 'stablyai',
// 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'
repo: devChannelRepo ?? 'orca',
releaseType: devChannelRepo ? 'prerelease' : 'release'
}
}

View File

@ -0,0 +1,106 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
/** Long enough to name a feature, short enough that a picker row stays readable. */
export const ADHOC_LABEL_MAX_LENGTH = 32
/**
* `1.4.160-adhoc.20260728140533` UTC to the second, so tags sort
* chronologically by semver and every build is uniquely versioned.
*
* Why seconds when hourly uses minutes: hourly runs under a concurrency group and
* cannot overlap itself. Adhoc builds are dispatched on demand, so two people
* cutting from different branches in the same minute is ordinary and a
* minute-resolution tag would collide and fail the second build after its whole
* pack-and-notarize run.
*/
export function createAdhocBuildVersion(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('Adhoc 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()),
pad(date.getUTCSeconds())
].join('')
// Why: drop any -rc.N tail, same as hourly. Keeping it would make every adhoc
// build semver-NEWER than the RC it was cut from, letting an ordinary
// RC-channel check offer an unreviewed branch build to RC users. Stripping to
// the base parks adhoc below rc.N, hourly, and stable ('adhoc' sorts first
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-adhoc.${stamp}`
}
/**
* Turns a dispatch input into a label safe to put in a release title.
*
* The input is free text from whoever ran the workflow, so it cannot be trusted
* to stay inside the title's shape: a stray `` would forge a field separator,
* and a newline would break the `$GITHUB_OUTPUT` line the workflow parses. Both
* collapse to `-` here, which is why this replaces rather than rejects.
*/
export function normalizeAdhocLabel(label) {
const cleaned = String(label ?? '')
// `refs/heads/x` and `origin/x` are what a ref input tends to arrive as; the
// prefix is noise in a title where every row is already a branch build.
.replace(/^(?:refs\/heads\/|origin\/)/, '')
.replace(/[^\p{L}\p{N}._/-]+/gu, ' ')
.trim()
.replace(/\s+/g, '-')
.slice(0, ADHOC_LABEL_MAX_LENGTH)
// Truncation can land mid-separator, leaving a title ending in `-` or `/`.
.replace(/[-._/]+$/, '')
if (!cleaned) {
throw new Error(`Adhoc label has no usable characters: ${JSON.stringify(label)}`)
}
return cleaned
}
/**
* `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` the human-facing release
* title, shown verbatim in both the GitHub releases list and the build picker.
*
* Why the label sits where hourly puts its build number: several adhoc builds
* from different branches coexist in the channel, so the picker needs the branch
* to tell them apart. A counter would say nothing about which one to pick.
*/
export function formatAdhocReleaseName(version, label, commit, date) {
return [
version.split('-')[0],
normalizeAdhocLabel(label),
formatReleaseTitleTimestamp(date),
commit.slice(0, 7)
].join(' • ')
}
export function getAdhocBuildIdentity(now = new Date(), label = '') {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const version = createAdhocBuildVersion(packageJson.version, now)
return {
commit,
version,
label: normalizeAdhocLabel(label),
name: formatAdhocReleaseName(version, label, commit, now)
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '')
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`
)
}

View File

@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import {
createAdhocBuildVersion,
formatAdhocReleaseName,
normalizeAdhocLabel
} from './adhoc-build-version.mjs'
import { createHourlyBuildVersion } from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createAdhocBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp to the second', () => {
expect(createAdhocBuildVersion('1.4.160', new Date('2026-07-28T04:05:09Z'))).toBe(
'1.4.160-adhoc.20260728040509'
)
})
// Why seconds matter: adhoc builds are dispatched on demand, so two people
// cutting from different branches inside the same minute is ordinary. At
// hourly's resolution the second one would collide on the tag and die after its
// whole pack-and-notarize run.
it('distinguishes two builds cut in the same minute', () => {
const first = createAdhocBuildVersion('1.4.160', new Date('2026-07-28T14:05:02Z'))
const second = createAdhocBuildVersion('1.4.160', new Date('2026-07-28T14:05:41Z'))
expect(first).not.toBe(second)
expect(compareAppVersions(first, second)).toBeLessThan(0)
})
it('drops an in-flight rc tail so adhoc builds never outrank the rc series', () => {
const version = createAdhocBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z'))
expect(version).toBe('1.4.160-adhoc.20260728140000')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions(version, '1.4.160')).toBeLessThan(0)
})
// Why this ordering is load-bearing: an adhoc build is somebody's unlanded
// branch. It must sit below every other channel of the same base version so no
// routine check can walk a developer onto one — only an explicit pinned jump.
it('sorts below the hourly build of the same base version', () => {
expect(
compareAppVersions(
createAdhocBuildVersion('1.4.160', new Date('2026-07-28T23:59:59Z')),
createHourlyBuildVersion('1.4.160', new Date('2026-07-28T00:00:00Z'))
)
).toBeLessThan(0)
})
it('rejects invalid input', () => {
expect(() => createAdhocBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createAdhocBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
describe('normalizeAdhocLabel', () => {
it('keeps an ordinary branch name intact', () => {
expect(normalizeAdhocLabel('wasm-terminal')).toBe('wasm-terminal')
expect(normalizeAdhocLabel('nwparker/wasm-terminal')).toBe('nwparker/wasm-terminal')
})
it('strips the ref prefixes a dispatch input tends to arrive with', () => {
expect(normalizeAdhocLabel('refs/heads/wasm-terminal')).toBe('wasm-terminal')
expect(normalizeAdhocLabel('origin/wasm-terminal')).toBe('wasm-terminal')
})
// Why replaced rather than rejected: the label is free text from whoever ran
// the workflow. A `•` would forge the title's field separator and a newline
// would break the `$GITHUB_OUTPUT` line the workflow parses.
it('neutralizes characters that would corrupt the title or the output line', () => {
expect(normalizeAdhocLabel('a • b')).toBe('a-b')
expect(normalizeAdhocLabel('a\nname=evil')).toBe('a-name-evil')
expect(normalizeAdhocLabel(' spaced out ')).toBe('spaced-out')
})
it('truncates without leaving a trailing separator', () => {
expect(normalizeAdhocLabel('a'.repeat(80))).toHaveLength(32)
expect(normalizeAdhocLabel(`${'a'.repeat(31)}-tail`)).toBe('a'.repeat(31))
})
it('rejects a label with nothing usable in it', () => {
expect(() => normalizeAdhocLabel('')).toThrow(/no usable characters/)
expect(() => normalizeAdhocLabel(' ')).toThrow(/no usable characters/)
expect(() => normalizeAdhocLabel('•••')).toThrow(/no usable characters/)
expect(() => normalizeAdhocLabel(null)).toThrow(/no usable characters/)
})
})
describe('formatAdhocReleaseName', () => {
const name = (iso, label = 'wasm-terminal', commit = 'e698241abcde') =>
formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso))
it('renders version, label, Pacific timestamp, and short sha', () => {
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • 07-31 13:54 • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T02:30:00Z')).toContain(' 01-14 18:30 ')
expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ')
})
it('sanitizes the label before it reaches the title', () => {
expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe(
'1.4.163 • fix-now • 07-31 13:54 • e698241'
)
})
it('rejects an invalid timestamp', () => {
expect(() => formatAdhocReleaseName('1.4.163', 'x', 'abcdefg', new Date('nope'))).toThrow(
/invalid/
)
})
})

View File

@ -21,8 +21,10 @@ const {
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_ADHOC',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_ADHOC_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
@ -51,6 +53,7 @@ function withEnv(env, assert) {
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
@ -349,6 +352,40 @@ describe('electron-builder config', () => {
)
})
// Why adhoc carries the identical mac identity to hourly: it installs over a
// real Orca through the same updater path, so the same signing and the same TCC
// argument apply. Only the destination repo differs.
it('builds adhoc artifacts with the release identity and its own repo', () => {
withAdhocEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
})
})
it('stamps adhoc packages with the adhoc version', () => {
withEnv(
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
}
)
})
// Why: the two dev channels share every packaging decision except where they
// publish, so a future edit that collapses them must not also collapse the
// repos — a branch build landing in orca-hourly would be offered to everyone
// riding main.
it('keeps the two dev channels on separate repos', () => {
withHourlyEnv((hourly) => {
withAdhocEnv((adhoc) => {
expect(hourly.publish.repo).not.toBe(adhoc.publish.repo)
})
})
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)

View File

@ -1,6 +1,7 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
/** `1.4.160-hourly.202607281400` UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
@ -28,41 +29,18 @@ export function createHourlyBuildVersion(baseVersion, date) {
return `${match[1]}-hourly.${stamp}`
}
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
/**
* `1.4.163 • 01 • 07-31 13:54 • e698241` the human-facing release title, shown
* verbatim in both the GitHub releases list and the in-app build picker.
*
* Why Pacific while the tag's stamp stays UTC: the stamp is a sort key, and a
* local one would repeat an hour at every DST fall-back, making two distinct
* builds compare equal. The title is only ever read, so it uses the timezone the
* people reading it are in. The two therefore disagree by the current offset.
*/
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
throw new Error(`Hourly build number must be a positive integer: ${buildNumber}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Hourly build timestamp is invalid.')
}
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: RELEASE_NAME_TIME_ZONE,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
hourCycle: 'h23'
})
.formatToParts(date)
.map((part) => [part.type, part.value])
)
return [
version.split('-')[0],
String(buildNumber).padStart(2, '0'),
`${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`,
formatReleaseTitleTimestamp(date),
commit.slice(0, 7)
].join(' • ')
}

View File

@ -0,0 +1,30 @@
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
/**
* `07-31 13:54` the timestamp segment of a dev build's release title, shown
* verbatim in both the GitHub releases list and the in-app build picker.
*
* Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and
* a local one would repeat an hour at every DST fall-back, making two distinct
* builds compare equal. A title is only ever read, so it uses the timezone the
* people reading it are in. The two therefore disagree by the current offset.
*/
export function formatReleaseTitleTimestamp(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Release title timestamp is invalid.')
}
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: RELEASE_NAME_TIME_ZONE,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
hourCycle: 'h23'
})
.formatToParts(date)
.map((part) => [part.type, part.value])
)
return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
}

View File

@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# Creates stablyai/orca-adhoc and grants the existing release App write access to
# it, so adhoc-mac-build.yml can publish there.
#
# Why a separate repo rather than reusing orca-hourly: an adhoc build is somebody's
# unlanded branch. Sharing hourly's repo would put branch builds in the list a
# developer riding main sees, and the two are different levels of unvetted.
#
# Why no secrets are set here: the adhoc workflow reuses the same GitHub App as
# hourly — one App id, one private key, one thing to rotate. This script only has
# to widen that App's installation to cover the new repo.
#
# Run once, after config/scripts/setup-hourly-release-token.sh:
# bash config/scripts/setup-adhoc-release-repo.sh
#
set -euo pipefail
ORG="stablyai"
ADHOC_REPO="$ORG/orca-adhoc"
MAIN_REPO="$ORG/orca"
APP_SLUG="orca-hourly-release"
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"
if gh api "repos/$ADHOC_REPO" --jq '.full_name' >/dev/null 2>&1; then
echo "$ADHOC_REPO already exists."
else
echo "Creating $ADHOC_REPO..."
# Why public: the in-app updater fetches release assets unauthenticated, exactly
# as it does for orca-hourly. A private repo would 404 for every client.
#
# Why the features are off: this repo holds releases and nothing else. Leaving
# issues open invites bug reports against a branch build in a repo nobody
# watches, where they are simply lost.
gh repo create "$ADHOC_REPO" \
--public \
--description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \
--disable-issues \
--disable-wiki ||
fail "Could not create $ADHOC_REPO."
fi
echo
echo "Granting $APP_SLUG access to $ADHOC_REPO..."
# Why attempt the API before printing instructions: an org owner can do this in
# one call. Everyone else gets a 403 and the manual path below — GitHub does not
# let a mere admin widen an App's repository selection.
INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \
--jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)"
REPO_ID="$(gh api "repos/$ADHOC_REPO" --jq '.id' 2>/dev/null || true)"
GRANTED=false
if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then
if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then
GRANTED=true
echo "Done — $APP_SLUG can now write to $ADHOC_REPO."
fi
fi
if [[ "$GRANTED" != "true" ]]; then
# Why no automated check afterwards: the endpoints that report an App's
# repository access (repos/*/installation, user/installations/*/repositories)
# both reject an ordinary `gh auth login` token, so any "verified" this script
# printed would be guesswork. The smoke test below is the real check.
cat <<EOF
Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in the browser:
1. Open: https://github.com/organizations/$ORG/settings/installations
2. Configure -> $APP_SLUG
3. Repository access -> Only select repositories -> add $ADHOC_REPO
(keep orca-hourly selected; both dev channels use this one App)
4. Save.
EOF
fi
echo
echo "Smoke-test the pipeline (after this merges):"
echo " gh workflow run adhoc-mac-build.yml --repo $MAIN_REPO --ref main \\"
echo " -f ref=<your-branch> -f label=<short-name>"
echo " gh run watch --repo $MAIN_REPO"

View File

@ -8,6 +8,10 @@
# — no yearly rotation — and it belongs to the org rather than to the person who
# created it, so it survives that person leaving.
#
# The same App also serves adhoc-mac-build.yml, which reads these same two
# secrets: one credential, one rotation, both dev channels. Widening it to cover
# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job.
#
# 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.

View File

@ -10,6 +10,7 @@ import {
} from '../../../../shared/tui-agent-launch-defaults'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { isTaskProvider } from '../../../../shared/task-providers'
import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel'
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides'
import {
@ -250,7 +251,10 @@ const UiUpdateFields = z
lastUpdateCheckAt: z.number().finite().nullable().optional(),
pendingUpdateNudgeId: NullableString.optional(),
dismissedUpdateNudgeId: NullableString.optional(),
releaseChannelOverride: z.enum(['stable', 'rc', 'hourly']).nullable().optional(),
// Why the predicate rather than an inline z.enum: an enum here is a copy of
// RELEASE_CHANNELS, and a copy that drifts silently rejects the new
// channel's override on its way here — the picker moves, nothing installs.
releaseChannelOverride: z.custom<ReleaseChannel>(isReleaseChannel).nullable().optional(),
notificationPermissionRequested: z.boolean().optional(),
updateReassuranceSeen: z.boolean().optional(),
osc52ClipboardDefaultOnNoticePending: z.boolean().optional(),

View File

@ -1,7 +1,7 @@
/* eslint-disable max-lines */
import { app, BrowserWindow, powerMonitor } from 'electron'
import { is } from '@electron-toolkit/utils'
import type { UpdateCheckOptions, UpdateStatus } from '../shared/types'
import type { UpdateCheckOptions, UpdateSource, UpdateStatus } from '../shared/types'
import type {
RemoteServerUpdateInstallResult,
RemoteServerUpdaterSnapshot,
@ -46,6 +46,7 @@ import {
import type { LocalBuildFeed } from './local-builds/local-build-feed-server'
import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds'
import {
hasDedicatedReleaseRepo,
isChannelSupportedOnPlatform,
type ReleaseBuild,
type ReleaseChannel
@ -146,7 +147,7 @@ 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' | 'hourly' = 'release'
let activeUpdateSource: 'release' | UpdateSource = 'release'
let activeLocalBuildFeed: LocalBuildFeed | null = null
let localBuildSelectionInProgress = false
// Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade
@ -336,8 +337,8 @@ function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant
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.
// without the user re-holding shift; the dev channels need an explicit tag, so
// neither is a routine-check variant.
if (getReleaseChannelOverride?.() === 'rc') {
return 'prerelease'
}
@ -1533,7 +1534,7 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis
if (!isChannelSupportedOnPlatform(channel, process.platform)) {
sendStatus({
state: 'error',
message: 'Hourly builds are produced only for macOS.',
message: `${channel} builds are produced only for macOS.`,
userInitiated: true
})
return
@ -1552,7 +1553,7 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis
return
}
closeLocalBuildFeed()
activeUpdateSource = channel === 'hourly' ? 'hourly' : 'release'
activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release'
isPinnedBuildActive = true
clearPrereleaseFallbackContext()
clearPublishingWindowLastGoodCheck()

View File

@ -12,8 +12,9 @@ import { getShortcutPlatform } from '@/lib/shortcut-platform'
import {
RELEASE_CHANNELS,
getVersionChannel,
hasDedicatedReleaseRepo,
isChannelSupportedOnPlatform,
parseHourlyVersionStamp,
parseDevBuildStamp,
type ReleaseBuild,
type ReleaseChannel
} from '../../../../shared/release-channel'
@ -21,29 +22,34 @@ import {
const CHANNEL_LABELS: Record<ReleaseChannel, string> = {
stable: 'Stable',
rc: 'RC',
hourly: 'Hourly'
hourly: 'Hourly',
adhoc: 'Adhoc'
}
const CHANNEL_DESCRIPTIONS: Record<ReleaseChannel, string> = {
stable: 'Shipped releases. What everyone else is running.',
rc: 'Release candidates cut ahead of each stable.',
hourly: 'macOS only. Unvetted builds from main, built every hour. No tests.'
hourly: 'macOS only. Unvetted builds from main, built every hour. No tests.',
adhoc: 'macOS only. One-off builds cut from a branch to try a feature before it lands.'
}
function formatBuildLabel(build: ReleaseBuild): string {
// Why the release's own title wins: the hourly workflow composes it
// (`1.4.163 • 01 • 07-31 13:54 • e698241`), so this row is the same string the
// GitHub releases list shows — one thing to search for in either place, rather
// than two renderings of the same build that have to be matched up by eye.
// Why the release's own title wins: the build workflows compose it (hourly
// `1.4.163 • 01 • 07-31 13:54 • e698241`, adhoc `1.4.163 • wasm-terminal • …`),
// so this row is the same string the GitHub releases list shows — one thing to
// search for in either place, rather than two renderings of the same build that
// have to be matched up by eye. For adhoc it is also the only place the branch
// is named, which is what tells two concurrent adhoc builds apart.
if (build.name) {
return build.name
}
const stamp = parseHourlyVersionStamp(build.version)
const stamp = parseDevBuildStamp(build.version)
if (!stamp) {
return build.version
}
// Fallback for hourlies cut before that naming; retention ages them out in ~3
// days. An hourly's semver tail is an opaque timestamp, so show it as a date.
// Fallback for builds cut before that naming, and for any release someone
// titled by hand. A dev build's semver tail is an opaque timestamp, so show it
// as a date rather than as digits.
return `${build.version.split('-')[0]} · ${stamp.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
@ -187,7 +193,7 @@ export function ReleaseChannelSection(): React.JSX.Element {
'Update channel'
)}
// Why disabled rather than hidden: a Linux/Windows dev who has heard
// about the hourly channel should see that it exists and why it is
// about a dev channel should see that it exists and why it is
// unavailable, instead of silently not finding it.
options={RELEASE_CHANNELS.map((channel) => {
const supported = isChannelSupportedOnPlatform(channel, platform)
@ -205,14 +211,16 @@ export function ReleaseChannelSection(): React.JSX.Element {
ariaLabel: supported
? undefined
: translate(
'auto.components.settings.ReleaseChannelSection.hourlyMacOnlyAria',
'Hourly (macOS only)'
'auto.components.settings.ReleaseChannelSection.devChannelMacOnlyAria',
'{{value0}} (macOS only)',
{ value0: CHANNEL_LABELS[channel] }
),
tooltip: supported
? undefined
: translate(
'auto.components.settings.ReleaseChannelSection.hourlyMacOnly',
'Hourly builds are produced only for macOS. Linux and Windows stay on Stable or RC.'
'auto.components.settings.ReleaseChannelSection.devChannelMacOnly',
'{{value0}} builds are produced only for macOS. Linux and Windows stay on Stable or RC.',
{ value0: CHANNEL_LABELS[channel] }
)
}
})}
@ -220,14 +228,19 @@ export function ReleaseChannelSection(): React.JSX.Element {
<p className="text-xs text-muted-foreground">{CHANNEL_DESCRIPTIONS[activeChannel]}</p>
</div>
{activeChannel === 'hourly' ? (
{hasDedicatedReleaseRepo(activeChannel) ? (
<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 and ship straight from main with no test gate. Keep a stable build handy.'
)}
{activeChannel === 'hourly'
? translate(
'auto.components.settings.ReleaseChannelSection.hourlyWarning',
'Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.'
)
: translate(
'auto.components.settings.ReleaseChannelSection.adhocWarning',
'Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.'
)}
</p>
</div>
) : null}

View File

@ -10123,6 +10123,7 @@
"devOnly": "Dev only",
"channelAriaLabel": "Update channel",
"hourlyWarning": "Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.",
"adhocWarning": "Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.",
"loadingBuilds": "Loading builds…",
"noBuilds": "No builds found",
"refresh": "Refresh build list",
@ -10130,8 +10131,8 @@
"alreadyRunning": "This is the build you are running.",
"willSwitch": "{{value0}} → {{value1}}",
"webUnavailable": "Switching builds is only available in the desktop app.",
"hourlyMacOnlyAria": "Hourly (macOS only)",
"hourlyMacOnly": "Hourly builds are produced only for macOS. Linux and Windows stay on Stable or RC."
"devChannelMacOnlyAria": "{{value0}} (macOS only)",
"devChannelMacOnly": "{{value0}} builds are produced only for macOS. Linux and Windows stay on Stable or RC."
}
},
"right": {

View File

@ -1,12 +1,17 @@
import { describe, expect, it } from 'vitest'
import {
formatAdhocVersion,
formatHourlyVersion,
getReleaseNotesUrlForVersion,
getReleaseRepoForChannel,
getVersionChannel,
hasDedicatedReleaseRepo,
isAdhocVersion,
isChannelSupportedOnPlatform,
isHourlyVersion,
isReleaseChannel,
parseAdhocVersionStamp,
parseDevBuildStamp,
parseHourlyVersionStamp,
sortReleaseBuildsNewestFirst,
type ReleaseBuild
@ -19,18 +24,29 @@ describe('release channel', () => {
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('1.4.160-adhoc.20260728140533')).toBe('adhoc')
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', () => {
it('keeps dev builds out of the main release repo, and apart from each other', () => {
expect(getReleaseRepoForChannel('hourly')).toBe('stablyai/orca-hourly')
// Why adhoc gets a third repo rather than sharing hourly's: an unlanded
// branch build must never surface to someone who only meant to ride main.
expect(getReleaseRepoForChannel('adhoc')).toBe('stablyai/orca-adhoc')
expect(getReleaseRepoForChannel('stable')).toBe('stablyai/orca')
expect(getReleaseRepoForChannel('rc')).toBe('stablyai/orca')
})
it('marks exactly the dev channels as having their own repo', () => {
expect(hasDedicatedReleaseRepo('hourly')).toBe(true)
expect(hasDedicatedReleaseRepo('adhoc')).toBe(true)
expect(hasDedicatedReleaseRepo('stable')).toBe(false)
expect(hasDedicatedReleaseRepo('rc')).toBe(false)
})
// Why: an hourly tag linked against the main repo 404s — the tag only exists
// in the hourly repo.
it('builds release-notes links against the repo that published the version', () => {
@ -43,6 +59,9 @@ describe('release channel', () => {
expect(getReleaseNotesUrlForVersion('v1.4.160-rc.3')).toBe(
'https://github.com/stablyai/orca/releases/tag/v1.4.160-rc.3'
)
expect(getReleaseNotesUrlForVersion('1.4.160-adhoc.20260728140533')).toBe(
'https://github.com/stablyai/orca-adhoc/releases/tag/v1.4.160-adhoc.20260728140533'
)
expect(getReleaseNotesUrlForVersion(null)).toBe('https://github.com/stablyai/orca/releases')
})
@ -74,13 +93,54 @@ describe('release channel', () => {
)
})
// Why: the hourly workflow is macOS-only, so the channel has no artifact to
// Why seconds and not hourly's minutes: adhoc builds are dispatched on demand,
// so two people cutting from different branches inside the same minute is
// ordinary — at minute resolution the second would collide on the tag.
it('round-trips an adhoc version stamp as UTC, to the second', () => {
const version = formatAdhocVersion('1.4.160', '20260728140533')
expect(isAdhocVersion(version)).toBe(true)
expect(parseAdhocVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:33.000Z')
})
it('keeps the two dev stamp formats from matching each other', () => {
expect(isAdhocVersion('1.4.160-hourly.202607281400')).toBe(false)
expect(isHourlyVersion('1.4.160-adhoc.20260728140533')).toBe(false)
// A 12-digit adhoc tail is an hourly stamp wearing the wrong identifier, not
// a second-resolution one; rejecting it keeps the parse unambiguous.
expect(isAdhocVersion('1.4.160-adhoc.202607281405')).toBe(false)
})
it('rejects impossible adhoc calendar stamps, including the seconds field', () => {
expect(parseAdhocVersionStamp('1.4.160-adhoc.20260230000000')).toBeNull()
expect(parseAdhocVersionStamp('1.4.160-adhoc.20261301000000')).toBeNull()
expect(parseAdhocVersionStamp('1.4.160-adhoc.20260101250000')).toBeNull()
expect(parseAdhocVersionStamp('1.4.160-adhoc.20260101000060')).toBeNull()
expect(parseAdhocVersionStamp('not-a-version-adhoc.20260101000000')).toBeNull()
})
// Why one entry point for both: the picker renders a row without knowing which
// dev channel produced it, so a channel added without a case here would fall
// back to showing its raw opaque timestamp tail.
it('reads the build timestamp of either dev channel', () => {
expect(parseDevBuildStamp('1.4.160-hourly.202607281405')?.toISOString()).toBe(
'2026-07-28T14:05:00.000Z'
)
expect(parseDevBuildStamp('1.4.160-adhoc.20260728140533')?.toISOString()).toBe(
'2026-07-28T14:05:33.000Z'
)
expect(parseDevBuildStamp('1.4.160-rc.3')).toBeNull()
expect(parseDevBuildStamp('1.4.160')).toBeNull()
})
// Why: both dev workflows are macOS-only, so the channels have no artifact to
// offer elsewhere. Both the picker and the main-process check read this, so a
// regression here would silently re-expose an uninstallable channel.
it('offers hourly only on macOS', () => {
expect(isChannelSupportedOnPlatform('hourly', 'darwin')).toBe(true)
expect(isChannelSupportedOnPlatform('hourly', 'linux')).toBe(false)
expect(isChannelSupportedOnPlatform('hourly', 'win32')).toBe(false)
it('offers the dev channels only on macOS', () => {
for (const channel of ['hourly', 'adhoc'] as const) {
expect(isChannelSupportedOnPlatform(channel, 'darwin')).toBe(true)
expect(isChannelSupportedOnPlatform(channel, 'linux')).toBe(false)
expect(isChannelSupportedOnPlatform(channel, 'win32')).toBe(false)
}
})
it('offers stable and rc on every platform', () => {
@ -92,6 +152,7 @@ describe('release channel', () => {
it('accepts only known channels', () => {
expect(isReleaseChannel('hourly')).toBe(true)
expect(isReleaseChannel('adhoc')).toBe(true)
expect(isReleaseChannel('stable')).toBe(true)
expect(isReleaseChannel('nightly')).toBe(false)
expect(isReleaseChannel(null)).toBe(false)
@ -126,4 +187,35 @@ describe('release channel', () => {
it('orders an hourly below its own stable release', () => {
expect(compareAppVersions('1.4.160-hourly.202607281400', '1.4.160')).toBeLessThan(0)
})
// Why adhoc sits at the very bottom: it is an unlanded branch, the least
// trustworthy thing the updater can hand anyone. Every other channel of the
// same base version must outrank it so no routine check ever selects one.
it('orders an adhoc build below every other channel of its base version', () => {
const adhoc = '1.4.160-adhoc.20260728140533'
expect(compareAppVersions(adhoc, '1.4.160')).toBeLessThan(0)
expect(compareAppVersions(adhoc, '1.4.160-rc.1')).toBeLessThan(0)
expect(compareAppVersions(adhoc, '1.4.160-hourly.202607280000')).toBeLessThan(0)
})
it('sorts consecutive adhoc builds newest first', () => {
const build = (version: string): ReleaseBuild => ({
tag: `v${version}`,
version,
channel: 'adhoc',
name: null,
publishedAt: null,
releaseUrl: `https://github.com/stablyai/orca-adhoc/releases/tag/v${version}`
})
const sorted = sortReleaseBuildsNewestFirst([
build('1.4.160-adhoc.20260728140502'),
build('1.4.160-adhoc.20260728140541'),
build('1.4.160-adhoc.20260728090000')
])
expect(sorted.map((entry) => entry.version)).toEqual([
'1.4.160-adhoc.20260728140541',
'1.4.160-adhoc.20260728140502',
'1.4.160-adhoc.20260728090000'
])
})
})

View File

@ -1,35 +1,60 @@
import { compareAppVersions, isValidAppVersion } from './app-version'
export type ReleaseChannel = 'stable' | 'rc' | 'hourly'
export type ReleaseChannel = 'stable' | 'rc' | 'hourly' | 'adhoc'
export const RELEASE_CHANNELS: readonly ReleaseChannel[] = ['stable', 'rc', 'hourly']
export const RELEASE_CHANNELS: readonly ReleaseChannel[] = ['stable', 'rc', 'hourly', 'adhoc']
/** Hourly builds live in their own repo so their tags never enter the main
/** Dev builds live in their own repos 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 ADHOC_RELEASE_REPO = 'stablyai/orca-adhoc'
export const MAIN_RELEASE_REPO = 'stablyai/orca'
export const HOURLY_PRERELEASE_IDENTIFIER = 'hourly'
export const ADHOC_PRERELEASE_IDENTIFIER = 'adhoc'
/** The dev channels, each published to its own repo rather than the main one. */
const DEDICATED_REPO_CHANNELS = ['hourly', 'adhoc'] as const
export type DedicatedRepoChannel = (typeof DEDICATED_REPO_CHANNELS)[number]
const CHANNEL_RELEASE_REPOS: Record<ReleaseChannel, string> = {
stable: MAIN_RELEASE_REPO,
rc: MAIN_RELEASE_REPO,
hourly: HOURLY_RELEASE_REPO,
adhoc: ADHOC_RELEASE_REPO
}
export function isReleaseChannel(value: unknown): value is ReleaseChannel {
return typeof value === 'string' && RELEASE_CHANNELS.includes(value as ReleaseChannel)
}
/** True for channels published outside the main repo. The updater reports these
* as a distinct source so a pinned dev build is never mistaken for a release. */
export function hasDedicatedReleaseRepo(channel: ReleaseChannel): channel is DedicatedRepoChannel {
return (DEDICATED_REPO_CHANNELS as readonly ReleaseChannel[]).includes(channel)
}
/**
* Hourly builds are produced only by the macOS workflow, so the channel has
* nothing to offer elsewhere. Shared so the picker, the main-process check, and
* any future surface cannot drift on where it is available.
* Shared so the picker, the main-process check, and any future surface cannot
* drift on where a channel is available.
*
* Why this rides on the dev-channel list: both dev channels are produced only by
* macOS workflows, so neither has an artifact to offer elsewhere. If one ever
* gains a Windows or Linux job, split the two concepts apart they coincide
* today, but "published to its own repo" and "built for macOS only" are not the
* same claim.
*/
export function isChannelSupportedOnPlatform(
channel: ReleaseChannel,
platform: NodeJS.Platform
): boolean {
return channel !== 'hourly' || platform === 'darwin'
return !hasDedicatedReleaseRepo(channel) || platform === 'darwin'
}
export function getReleaseRepoForChannel(channel: ReleaseChannel): string {
return channel === 'hourly' ? HOURLY_RELEASE_REPO : MAIN_RELEASE_REPO
return CHANNEL_RELEASE_REPOS[channel]
}
export function normalizeTagToVersion(tag: string): string {
@ -38,25 +63,30 @@ export function normalizeTagToVersion(tag: string): string {
/** `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))
}
const HOURLY_VERSION = /^\d+\.\d+\.\d+-hourly\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/
export function formatHourlyVersion(baseVersion: string, stamp: string): string {
return `${baseVersion}-${HOURLY_PRERELEASE_IDENTIFIER}.${stamp}`
}
/**
* `1.4.160-adhoc.20260728140533` same idea, but stamped to the second.
*
* Why seconds here and not for hourly: hourly runs under a concurrency group, so
* two of them can never be cut in the same minute. Adhoc builds are dispatched
* on demand by whoever wants one, so two people cutting from different branches
* at once is ordinary and a minute-resolution stamp would collide on the tag
* and fail the second build eight minutes in.
*/
const ADHOC_VERSION = /^\d+\.\d+\.\d+-adhoc\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/
/** 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})$/)
/**
* Both patterns are anchored on the whole version: an unanchored tail match also
* accepts garbage prefixes, so `not-a-version-hourly.202601010000` would parse.
*/
function parseStampedVersion(version: string, pattern: RegExp): Date | null {
const match = normalizeTagToVersion(version).match(pattern)
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))
const [year, month, day, hour, minute, second = 0] = match.slice(1).map(Number)
const parsed = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
// 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 (
@ -64,13 +94,46 @@ export function parseHourlyVersionStamp(version: string): Date | null {
parsed.getUTCMonth() !== month - 1 ||
parsed.getUTCDate() !== day ||
parsed.getUTCHours() !== hour ||
parsed.getUTCMinutes() !== minute
parsed.getUTCMinutes() !== minute ||
parsed.getUTCSeconds() !== second
) {
return null
}
return parsed
}
export function isHourlyVersion(version: string): boolean {
return HOURLY_VERSION.test(normalizeTagToVersion(version))
}
export function isAdhocVersion(version: string): boolean {
return ADHOC_VERSION.test(normalizeTagToVersion(version))
}
export function formatHourlyVersion(baseVersion: string, stamp: string): string {
return `${baseVersion}-${HOURLY_PRERELEASE_IDENTIFIER}.${stamp}`
}
export function formatAdhocVersion(baseVersion: string, stamp: string): string {
return `${baseVersion}-${ADHOC_PRERELEASE_IDENTIFIER}.${stamp}`
}
/** Returns the build's UTC timestamp, or null when the version isn't hourly. */
export function parseHourlyVersionStamp(version: string): Date | null {
return parseStampedVersion(version, HOURLY_VERSION)
}
/** Returns the build's UTC timestamp, or null when the version isn't adhoc. */
export function parseAdhocVersionStamp(version: string): Date | null {
return parseStampedVersion(version, ADHOC_VERSION)
}
/** The build's UTC timestamp for either dev channel, so a picker row can render
* a date without first working out which channel produced the version. */
export function parseDevBuildStamp(version: string): Date | null {
return parseHourlyVersionStamp(version) ?? parseAdhocVersionStamp(version)
}
export function getVersionChannel(version: string): ReleaseChannel | null {
const normalized = normalizeTagToVersion(version)
if (!isValidAppVersion(normalized)) {
@ -79,18 +142,23 @@ export function getVersionChannel(version: string): ReleaseChannel | null {
if (isHourlyVersion(normalized)) {
return 'hourly'
}
if (isAdhocVersion(normalized)) {
return 'adhoc'
}
// Why the dev channels are tested first: they are prereleases too, so this
// catch-all would otherwise file every one of them under rc.
return normalized.includes('-') ? 'rc' : 'stable'
}
/**
* Release-notes page for a version, in whichever repo published it. Hourly tags
* exist only in the hourly repo, so a main-repo tag URL for one 404s.
* Release-notes page for a version, in whichever repo published it. Dev-channel
* tags exist only in their own repo, so a main-repo tag URL for one 404s.
* A null version falls back to the plain releases listing (not /releases/latest
* /latest also breaks when GitHub's API is degraded).
*/
export function getReleaseNotesUrlForVersion(version: string | null): string {
const repo =
version && getVersionChannel(version) === 'hourly' ? HOURLY_RELEASE_REPO : MAIN_RELEASE_REPO
const channel = version ? getVersionChannel(version) : null
const repo = channel ? getReleaseRepoForChannel(channel) : MAIN_RELEASE_REPO
return version
? `https://github.com/${repo}/releases/tag/v${normalizeTagToVersion(version)}`
: `https://github.com/${repo}/releases`

View File

@ -8,7 +8,7 @@ import type {
} 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 { DedicatedRepoChannel, ReleaseBuild, ReleaseChannel } from './release-channel'
import type { GitHubProjectSettings } from './github-project-types'
import type {
AgentStatusState,
@ -2387,7 +2387,9 @@ export type UpdateCheckOptions = {
targetTag?: string
}
export type UpdateSource = 'local' | 'hourly'
/** Non-release origins for an update. Derived from the dev-channel list so a new
* channel with its own repo cannot be reported as an ordinary release. */
export type UpdateSource = 'local' | DedicatedRepoChannel
export type UpdateStatus = (
| { state: 'idle' }