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:
parent
5e9186f5ff
commit
484273844a
|
|
@ -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"
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
)
|
||||
}
|
||||
|
|
@ -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/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(' • ')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
Loading…
Reference in New Issue