From 484273844aeef8310e58122d2e134510cf2eacdb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:46:51 -0700 Subject: [PATCH] feat(updater): add an adhoc release channel for branch builds (#12051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Orca --- .github/workflows/adhoc-mac-build.yml | 343 ++++++++++++++++++ config/electron-builder.config.cjs | 32 +- config/scripts/adhoc-build-version.mjs | 106 ++++++ config/scripts/adhoc-build-version.test.mjs | 113 ++++++ .../scripts/electron-builder-config.test.mjs | 37 ++ config/scripts/hourly-build-version.mjs | 26 +- config/scripts/release-title-timestamp.mjs | 30 ++ config/scripts/setup-adhoc-release-repo.sh | 89 +++++ config/scripts/setup-hourly-release-token.sh | 4 + .../runtime/rpc/methods/client-ui-schemas.ts | 6 +- src/main/updater.ts | 13 +- .../settings/ReleaseChannelSection.tsx | 53 ++- src/renderer/src/i18n/locales/en.json | 5 +- src/shared/release-channel.test.ts | 104 +++++- src/shared/release-channel.ts | 122 +++++-- src/shared/types.ts | 6 +- 16 files changed, 989 insertions(+), 100 deletions(-) create mode 100644 .github/workflows/adhoc-mac-build.yml create mode 100644 config/scripts/adhoc-build-version.mjs create mode 100644 config/scripts/adhoc-build-version.test.mjs create mode 100644 config/scripts/release-title-timestamp.mjs create mode 100755 config/scripts/setup-adhoc-release-repo.sh diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml new file mode 100644 index 000000000..325ab73a8 --- /dev/null +++ b/.github/workflows/adhoc-mac-build.yml @@ -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" diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index c8b788769..e581cd7a4 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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' } } diff --git a/config/scripts/adhoc-build-version.mjs b/config/scripts/adhoc-build-version.mjs new file mode 100644 index 000000000..db7c95676 --- /dev/null +++ b/config/scripts/adhoc-build-version.mjs @@ -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` + ) +} diff --git a/config/scripts/adhoc-build-version.test.mjs b/config/scripts/adhoc-build-version.test.mjs new file mode 100644 index 000000000..4824720f2 --- /dev/null +++ b/config/scripts/adhoc-build-version.test.mjs @@ -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/ + ) + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 546384b48..b7ea67875 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -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) diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs index 8d8dafc22..f4545af08 100644 --- a/config/scripts/hourly-build-version.mjs +++ b/config/scripts/hourly-build-version.mjs @@ -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(' • ') } diff --git a/config/scripts/release-title-timestamp.mjs b/config/scripts/release-title-timestamp.mjs new file mode 100644 index 000000000..56d3608cc --- /dev/null +++ b/config/scripts/release-title-timestamp.mjs @@ -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}` +} diff --git a/config/scripts/setup-adhoc-release-repo.sh b/config/scripts/setup-adhoc-release-repo.sh new file mode 100755 index 000000000..d239d9307 --- /dev/null +++ b/config/scripts/setup-adhoc-release-repo.sh @@ -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 < $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= -f label=" +echo " gh run watch --repo $MAIN_REPO" diff --git a/config/scripts/setup-hourly-release-token.sh b/config/scripts/setup-hourly-release-token.sh index f21ce7b5b..3d846bc01 100755 --- a/config/scripts/setup-hourly-release-token.sh +++ b/config/scripts/setup-hourly-release-token.sh @@ -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. diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 2e38b4848..af254ca02 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -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(isReleaseChannel).nullable().optional(), notificationPermissionRequested: z.boolean().optional(), updateReassuranceSeen: z.boolean().optional(), osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), diff --git a/src/main/updater.ts b/src/main/updater.ts index 1551720b7..d5b5065af 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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() diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index 3834fe75e..99cc97961 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -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 = { stable: 'Stable', rc: 'RC', - hourly: 'Hourly' + hourly: 'Hourly', + adhoc: 'Adhoc' } const CHANNEL_DESCRIPTIONS: Record = { 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 {

{CHANNEL_DESCRIPTIONS[activeChannel]}

- {activeChannel === 'hourly' ? ( + {hasDedicatedReleaseRepo(activeChannel) ? (

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

) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 146005286..0354113eb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts index dde3109bb..a12cc94c9 100644 --- a/src/shared/release-channel.test.ts +++ b/src/shared/release-channel.test.ts @@ -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' + ]) + }) }) diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts index a700bc719..87dd7665d 100644 --- a/src/shared/release-channel.ts +++ b/src/shared/release-channel.ts @@ -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 = { + 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` diff --git a/src/shared/types.ts b/src/shared/types.ts index 475470dd4..faf50e6eb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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' }