Add nightly cut (#13410)

* Add daily macOS dev build release channel

Publish once-daily signed macOS builds from main at a dedicated cadence,
separate from hourly (too noisy) and release branches (too infrequent).
Builds are notarized and installable via the updater, but unvetted —
published to stablyai/orca-daily rather than the main repo to avoid
evicting stable/RC entries from the releases feed.

* fix lint

* fix commit

* Add third token mint to daily macOS build workflow

The upload step's 2x45m retry budget can outlive the one-hour token, so a third
is minted after it for verify and cleanup operations. Release notes are moved to
a file to ensure consistency between draft creation and publish. Daily channel
description updated with specific UTC release time.
This commit is contained in:
Jinjing 2026-08-09 19:01:35 -07:00 committed by GitHub
parent 403c60bbad
commit 3b1017c4fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1106 additions and 158 deletions

471
.github/workflows/daily-mac-build.yml vendored Normal file
View File

@ -0,0 +1,471 @@
name: Daily macOS Dev Build
# Why: gives developers a signed macOS build of main once a day that the in-app
# updater can install directly, without waiting for an RC cut — less noise than
# hourly.
#
# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules).
# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute
# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
# and release-cut remain the gates that matter.
# - Signed AND notarized, exactly like a release. The notary round trip is the
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
# + team rather than on its cdhash, so those grants survive an update. Without
# a ticket every daily reads as a new client and silently loses file access
# under Documents/Desktop/Downloads.
#
# Artifacts publish to stablyai/orca-daily, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so high-volume
# dev tags would evict every stable/RC entry and break updates for real users.
# Separate from orca-hourly so someone riding main's hourlies never sees the
# sparse daily series mixed into that list.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
# same GitHub App as hourly (installed on orca-daily with Contents: Read and
# write). Provision the App secrets with
# `bash config/scripts/setup-hourly-release-token.sh`, then grant the App access
# to orca-daily with `bash config/scripts/setup-daily-release-repo.sh`:
# HOURLY_RELEASE_APP_ID the App's numeric id (shared with hourly/adhoc)
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, which is why this mints three times. Install
# and build need no token at all, and notarization can hold the publish step for
# tens of minutes; minting again once the build is done starts the clock at the
# first call that actually uses it rather than burning a third of it on
# `pnpm install`. The upload step's own retry budget (2x45m) can outlive that
# second token, so a third is minted after it for verify/publish/prune/cleanup —
# without it a stuck notary run would strand a draft that cleanup gets a 401 on.
# The release stays an unpublished draft until the manifest check passes, so the
# worst case is still an invisible draft — and the build-number query counts
# drafts, so it holds its number and the next run does not reuse it.
on:
schedule:
# Once a day, early morning Pacific. Single cron — no DST twin, no clock gate.
- cron: '15 14 * * *'
workflow_dispatch:
inputs:
force:
description: Build even if main has not moved since the last daily
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: daily-mac-build
cancel-in-progress: false
env:
DAILY_REPO: stablyai/orca-daily
# Keep ~30 days so a regression can be bisected without holding a full year.
DAILY_RETAIN_COUNT: 30
jobs:
build-daily-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. A typical run
# is far shorter — this is the notary queue's tail, not its median.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the daily repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
# build step is compromised (zizmor: artipacked).
persist-credentials: false
- name: Mint daily 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-daily
# Why: main is often idle. Rebuilding an unchanged commit burns a runner
# hour and adds a redundant tag to the retention window.
- name: Check whether main moved since the last daily
id: freshness
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
run: |
set -euo pipefail
head_sha="$(git rev-parse HEAD)"
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
if [[ "$FORCED" == "true" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "Forced dispatch; building $head_sha."
exit 0
fi
# The previous daily records its source commit in the release body.
# Drafts are excluded: an unpublished leftover never shipped, so treating
# it as "the last build" would skip a build that never actually happened.
last_body="$(gh release list --repo "$DAILY_REPO" --limit 20 --json tagName,isDraft \
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
if [[ -z "$last_body" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "No prior daily release found; building $head_sha."
exit 0
fi
last_sha="$(gh release view "$last_body" --repo "$DAILY_REPO" --json body \
--jq '.body | capture("commit `(?<sha>[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)"
if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then
echo "should_build=false" >>"$GITHUB_OUTPUT"
echo "main is unchanged since $last_body ($last_sha); skipping."
else
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "main moved to $head_sha (last daily built $last_sha); building."
fi
- name: Setup pnpm
if: steps.freshness.outputs.should_build == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
if: steps.freshness.outputs.should_build == 'true'
uses: actions/cache@v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-mac-
- name: Install dependencies
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: signing is what makes a daily installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/verify-macos-release-env.mjs
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Compute daily version
id: daily
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Existing titles, which carry the build number this series continues
# from. The script picks the number, because it restarts per base version
# and only the script knows which base this build resolved to.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# separate token because GH_TOKEN above is the App's, scoped to the
# daily repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \
node config/scripts/daily-build-version.mjs \
>"$RUNNER_TEMP/daily-identity.txt"
grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
# an empty name and an untitled release — silent, and only visible once
# someone opens the releases page. Fail here instead.
if ! grep -q '^name=' "$RUNNER_TEMP/daily-identity.txt"; then
echo "::error::daily-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
exit 1
fi
cat "$RUNNER_TEMP/daily-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
if: steps.freshness.outputs.should_build == 'true'
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: daily builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted dev artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# Why a second mint: everything from here on writes to the daily repo, and
# the notary round trip inside the publish step can be tens of minutes. The
# token minted at the top has already spent install + build of its one hour
# on steps that never touched it; restarting the clock here gives the slow
# part the full budget.
- name: Re-mint daily repo token for publish
id: app_token_publish
if: steps.freshness.outputs.should_build == 'true'
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-daily
- name: Create daily release
id: release
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: v${{ steps.daily.outputs.version }}
NAME: ${{ steps.daily.outputs.name }}
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
# Kept at 12 even though the title shows 7: the freshness check above
# parses this back out of the body to decide whether main has moved.
short_sha="${SHA:0:12}"
# Why a file rather than an inline string: the publish step re-asserts
# this same body, and the freshness check only works if the two agree
# exactly. One source, written once, read twice.
notes_file="$RUNNER_TEMP/daily-release-notes.md"
cat >"$notes_file" <<EOF
Automated daily macOS dev build from commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
**Unvetted.** No tests ran. Signed and notarized like a release, so it
installs through Orca's in-app updater and opens from a manual download
without a Gatekeeper prompt — but nothing here has been reviewed.
EOF
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on. It is flipped live only
# after the manifest is verified.
gh release create "$TAG" \
--repo "$DAILY_REPO" \
--title "$NAME" \
--draft \
--notes-file "$notes_file"
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
- name: Publish daily macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
# Why 45 like the release pipeline: an attempt is pack + notarize +
# upload, and the notary queue is the unbounded part. Why 2 attempts and
# not 3: a missed daily costs a day, and a third attempt buys less than
# it costs in runner time once the notary is that stuck.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=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-daily.
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.daily.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,
# since `notarize` is now on for this path.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why a third mint: the upload step above allows two 45-minute attempts, so
# it can outlive the one-hour token minted before it. Every remaining step
# writes to the daily repo, including the failure path that discards the
# draft — a 401 there is exactly the stranded draft nobody can clean up.
#
# Why always(): a failed or cancelled upload is the case that needs this
# most, since the cleanup step below runs only on that path.
- name: Re-mint daily repo token for verify and cleanup
id: app_token_final
if: always() && steps.freshness.outputs.should_build == 'true'
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-daily
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
- name: Verify update manifest published
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$DAILY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
# Deleting inline under `set -e` would also let the delete's exit code
# preempt this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Daily draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Daily draft $TAG has no ZIP artifact for the updater to download."
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
- name: Publish the verified release
id: publish_live
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.daily.outputs.name }}
NOTES_FILE: ${{ steps.release.outputs.notes_file }}
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.
# --notes-file for the same reason, and it matters more: the next run's
# freshness check parses the source commit back out of this body, so a
# body the publisher overwrote would rebuild an unchanged main daily.
gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \
--title "$NAME" --notes-file "$NOTES_FILE"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
#
# Why it is gated on publish_live not having succeeded: a later failure (the
# prune step) must not delete a release that already went live and that users
# may already be installing. A job killed by the outer timeout runs no steps
# at all — which is exactly why the release stays a draft until verified.
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
# so without it a manual cancel mid-publish would strand the draft.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
# Fall back to the publish token: if the re-mint itself is what failed,
# the older token is the only one left and may still have time on it.
GH_TOKEN: ${{ steps.app_token_final.outputs.token || steps.app_token_publish.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 "$DAILY_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old daily releases
# Only after a live publish: $TAG is then a non-draft we must not delete,
# and failed runs should not reshuffle retention around a draft that the
# failure path is about to discard.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
# Protect the tag this run just shipped; at the retain cap, a bad sort
# can otherwise mark the newest release as stale and delete it.
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
# keep showing up in tag lists with no release or assets attached.
# Drafts are excluded so retention counts shipped builds only; a stale
# draft is handled by the failure path, not by the retention window.
#
# Sort by publishedAt (not createdAt). Many non-draft dailies can share
# one createdAt (bulk import / re-create), so createdAt ranking is
# unstable. publishedAt is real recency; tagName (...YYYYMMDDHHMM) is
# the deterministic tie-break.
#
# Why force $TAG to the front before slicing: a hard retain-window seat
# for this run's release. Dropping $TAG from the list *before* the slice
# would permanently keep retain+1 releases; skipping it only in the
# delete loop would under-prune when the sort is still wrong. Partition
# keeps relative order of every other tag.
jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse'
if [[ -n "${TAG:-}" ]]; then
jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))"
fi
jq_filter+=" | .[${DAILY_RETAIN_COUNT}:] | .[].tagName"
stale="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName,publishedAt,isDraft \
--jq "$jq_filter")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune; at or under $DAILY_RETAIN_COUNT retained builds."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
# Belt-and-suspenders: partition above should already exclude $TAG.
if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then
echo "::warning::Prune list still included just-published $tag after protect; skipping delete."
continue
fi
echo "Pruning $tag"
gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"

View File

@ -20,21 +20,32 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.
// 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 isMacDaily = process.env.ORCA_MAC_DAILY === '1'
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc
const isMacRelease =
process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const devChannelBuildVersion = isMacHourly
? process.env.ORCA_HOURLY_BUILD_VERSION
: isMacAdhoc
? process.env.ORCA_ADHOC_BUILD_VERSION
: undefined
: isMacDaily
? process.env.ORCA_DAILY_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
// to install. Keeping adhoc/daily separate from hourly too means a branch build
// or a once-a-day cut cannot be picked up by someone who only meant to ride
// main's hourlies.
const devChannelRepo = isMacHourly
? 'orca-hourly'
: isMacDaily
? 'orca-daily'
: isMacAdhoc
? 'orca-adhoc'
: null
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',

View File

@ -0,0 +1,109 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** `1.4.160-daily.202607281300` UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
export function createDailyBuildVersion(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('Daily build timestamp is invalid.')
}
const pad = (value, width = 2) => String(value).padStart(width, '0')
const stamp = [
pad(date.getUTCFullYear(), 4),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes())
].join('')
// Why: drop any -rc.N tail. Keeping it makes every daily semver-NEWER than the
// RC it was cut from (1.4.160-rc.3-daily.X > 1.4.160-rc.3), which would let an
// ordinary RC-channel check offer untested daily builds to RC users. Stripping
// to the base parks dailies below both rc.N and stable ('daily' < 'rc'
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-daily.${stamp}`
}
/**
* The next build number for `baseVersion`, counting from the titles of existing
* daily releases.
*
* Why the series restarts at 01 on every base version: the number answers "which
* build of 1.4.163 is this", so a counter shared across versions makes it
* meaningless 1.4.164 would open at 38 for no reason a reader can see.
*
* Why the maximum rather than a count: the prune step trims to
* DAILY_RETAIN_COUNT, so a count would roll backwards and reissue a number
* already in use. Titles that predate this naming simply do not match, which is
* how the first build of a version lands on 01.
*/
export function nextDailyBuildNumber(baseVersion, releaseNames = []) {
const prefix = `${baseVersion}`
const highest = releaseNames.reduce((max, entry) => {
const name = String(entry ?? '')
if (!name.startsWith(prefix)) {
return max
}
const match = /^(\d+) • /.exec(name.slice(prefix.length))
return match ? Math.max(max, Number(match[1])) : max
}, 0)
return highest + 1
}
/**
* `1.4.163 • 01 • Aug 9, 6:15AM • e698241` the human-facing release title,
* shown verbatim in both the GitHub releases list and the in-app build picker.
*/
export function formatDailyReleaseName(version, buildNumber, commit, date) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
throw new Error(`Daily build number must be a positive integer: ${buildNumber}`)
}
return [
version.split('-')[0],
String(buildNumber).padStart(2, '0'),
formatReleaseTitleTimestamp(date),
commit.slice(0, 7)
].join(' • ')
}
// Why the number is derived here rather than passed in: it counts builds of the
// base version, and the base is only known once the published tags have been
// resolved just above. Computing it outside meant numbering against whatever
// version the caller guessed.
export function getDailyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
const version = createDailyBuildVersion(base, now)
const buildNumber = nextDailyBuildNumber(base, releaseNames ?? [])
return {
commit,
version,
buildNumber,
name: formatDailyReleaseName(version, buildNumber, commit, now)
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getDailyBuildIdentity(new Date(), {
publishedVersions: readPublishedVersionsFromEnv(),
// Titles are newline separated and contain spaces, so this cannot reuse the
// whitespace split the version list gets.
releaseNames: (process.env.ORCA_DAILY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
})
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
)
}

View File

@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
createDailyBuildVersion,
formatDailyReleaseName,
nextDailyBuildNumber
} from './daily-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createDailyBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp', () => {
expect(createDailyBuildVersion('1.4.160', new Date('2026-07-28T13:00:00Z'))).toBe(
'1.4.160-daily.202607281300'
)
})
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
// every daily semver-NEWER than the RC it was cut from (1.4.160-rc.3-daily.X >
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested daily
// builds to RC users. Dropping it parks dailies below both rc.N and stable,
// reachable only by an explicit pinned jump.
it('drops an in-flight rc tail so dailies never outrank the rc series', () => {
const version = createDailyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T13:00:00Z'))
expect(version).toBe('1.4.160-daily.202607281300')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions('1.4.160-rc.3-daily.202607281300', '1.4.160-rc.3')).toBeGreaterThan(0)
})
// Why: 'daily' sorts before 'hourly' alphabetically, so a daily of the same
// base never outranks an hourly — both stay below rc/stable and only the
// channel picker offers them.
it('sorts below the hourly build of the same base version', () => {
expect(
compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400')
).toBeLessThan(0)
})
it('rejects invalid input', () => {
expect(() => createDailyBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createDailyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
describe('formatDailyReleaseName', () => {
const name = (iso, buildNumber = 1, commit = 'e698241abcde') =>
formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso))
it('renders version, number, Pacific timestamp, and short sha', () => {
// 13:15 UTC is 6:15AM PDT in July (the daily cut time).
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • 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-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241')
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
})
it('pads to two digits and grows past them', () => {
expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ')
expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ')
})
it('rejects a build number that is not a positive integer', () => {
expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/)
expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/)
expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/)
})
it('rejects an invalid timestamp', () => {
expect(() => formatDailyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow(
/invalid/
)
})
})
describe('nextDailyBuildNumber', () => {
const titles = [
'1.4.163 • 01 • Jul 28, 6:00AM • e698241',
'1.4.163 • 02 • Jul 29, 6:00AM • aaaaaaa',
'1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'
]
it('continues the series for the version being built', () => {
expect(nextDailyBuildNumber('1.4.163', titles)).toBe(10)
})
it('restarts at 1 when the base version moves', () => {
expect(nextDailyBuildNumber('1.4.164', titles)).toBe(1)
expect(
nextDailyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 6:00AM • ccccccc'])
).toBe(2)
})
// Why max and not count: pruning trims to DAILY_RETAIN_COUNT, so counting
// would roll backwards and reissue a number already used.
it('takes the highest number, not the count', () => {
expect(nextDailyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 6:00AM • e698241'])).toBe(10)
})
it('starts at 1 with no history at all', () => {
expect(nextDailyBuildNumber('1.4.163')).toBe(1)
expect(nextDailyBuildNumber('1.4.163', [])).toBe(1)
})
it('ignores titles that are not this version', () => {
expect(nextDailyBuildNumber('1.4.16', ['1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'])).toBe(1)
expect(nextDailyBuildNumber('1.4.163', ['v1.4.163-daily.202607311300', null, ''])).toBe(1)
})
})

View File

@ -19,42 +19,6 @@ const {
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
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'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
expect(electronBuilderConfig.appId).toBe(
@ -303,89 +267,6 @@ describe('electron-builder config', () => {
}
})
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why hourly must notarize despite the round trip: TCC anchors a notarized
// Developer ID app's grants on identifier + team, not on its cdhash, so they
// survive an update. An unnotarized hourly reads as a new client every build
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
it('notarizes hourly builds like releases, and neither locally', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(true)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
expect(electronBuilderConfig.mac.notarize).toBe(false)
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
// Why adhoc carries the identical mac identity to hourly: it installs over a
// real Orca through the same updater path, so the same signing and the same TCC
// argument apply. Only the destination repo differs.
it('builds adhoc artifacts with the release identity and its own repo', () => {
withAdhocEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
})
})
it('stamps adhoc packages with the adhoc version', () => {
withEnv(
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
}
)
})
// Why: the two dev channels share every packaging decision except where they
// publish, so a future edit that collapses them must not also collapse the
// repos — a branch build landing in orca-hourly would be offered to everyone
// riding main.
it('keeps the two dev channels on separate repos', () => {
withHourlyEnv((hourly) => {
withAdhocEnv((adhoc) => {
expect(hourly.publish.repo).not.toBe(adhoc.publish.repo)
})
})
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)

View File

@ -0,0 +1,152 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_DAILY',
'ORCA_MAC_ADHOC',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_DAILY_BUILD_VERSION',
'ORCA_ADHOC_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
const withDailyEnv = (assert) => withEnv({ ORCA_MAC_DAILY: '1' }, assert)
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
describe('electron-builder mac channel config', () => {
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why hourly must notarize despite the round trip: TCC anchors a notarized
// Developer ID app's grants on identifier + team, not on its cdhash, so they
// survive an update. An unnotarized hourly reads as a new client every build
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
it('notarizes hourly builds like releases, and neither locally', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(true)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
expect(electronBuilderConfig.mac.notarize).toBe(false)
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
// 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' })
}
)
})
it('builds daily artifacts with the release identity and its own repo', () => {
withDailyEnv((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-daily', releaseType: 'prerelease' })
})
})
it('stamps daily packages with the daily version', () => {
withEnv(
{ ORCA_MAC_DAILY: '1', ORCA_DAILY_BUILD_VERSION: '1.4.160-daily.202607281300' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-daily.202607281300' })
}
)
})
// Why: the 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 or daily build landing in orca-hourly would be offered to
// everyone riding main's hourlies.
it('keeps the dev channels on separate repos', () => {
withHourlyEnv((hourly) => {
withDailyEnv((daily) => {
withAdhocEnv((adhoc) => {
expect(new Set([hourly.publish.repo, daily.publish.repo, adhoc.publish.repo]).size).toBe(
3
)
})
})
})
})
})

View File

@ -86,7 +86,7 @@ Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in
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)
(keep orca-hourly/orca-daily selected; all dev channels use this one App)
4. Save.
EOF
fi

View File

@ -0,0 +1,98 @@
#!/usr/bin/env bash
#
# Creates stablyai/orca-daily and grants the existing release App write access to
# it, so daily-mac-build.yml can publish there.
#
# Why a separate repo rather than reusing orca-hourly: the daily channel is a
# once-a-day cut that people ride deliberately. Sharing hourly's list would mix a
# sparse daily series into the 72-entry hourly retention window and make both
# pickers harder to read.
#
# Why no secrets are set here: the daily workflow reuses the same GitHub App as
# hourly/adhoc — 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-daily-release-repo.sh
#
set -euo pipefail
ORG="stablyai"
DAILY_REPO="$ORG/orca-daily"
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/$DAILY_REPO" --jq '.full_name' >/dev/null 2>&1; then
echo "$DAILY_REPO already exists."
else
echo "Creating $DAILY_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 an unvetted daily in a repo nobody
# watches, where they are simply lost.
#
# Why --add-readme in a repo with no source: publishing a release creates a tag,
# and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in.
gh repo create "$DAILY_REPO" \
--public \
--description "Daily macOS dev builds of Orca, cut from main each morning. Not a source repo." \
--add-readme \
--disable-issues \
--disable-wiki ||
fail "Could not create $DAILY_REPO."
fi
# Also checked outside the create branch: a repo made before --add-readme is here.
if ! gh api "repos/$DAILY_REPO/commits" --jq 'length' >/dev/null 2>&1; then
fail "$DAILY_REPO has no commits — releases cannot be tagged. Add any file to it first."
fi
echo
echo "Granting $APP_SLUG access to $DAILY_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/$DAILY_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 $DAILY_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 $DAILY_REPO
(keep orca-hourly and orca-adhoc selected; all dev channels use this one App)
4. Save.
EOF
fi
echo
echo "Smoke-test the pipeline (after this merges):"
echo " gh workflow run daily-mac-build.yml --repo $MAIN_REPO -f force=true"
echo " gh run watch --repo $MAIN_REPO"

View File

@ -8,9 +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 same App also serves adhoc-mac-build.yml and daily-mac-build.yml, which
# read these same two secrets: one credential, one rotation, all dev channels.
# Widening it to cover stablyai/orca-adhoc / orca-daily is
# setup-adhoc-release-repo.sh / setup-daily-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

View File

@ -45,6 +45,25 @@ describe('listReleaseBuilds', () => {
])
})
it('lists daily builds from the dedicated repo, newest first', async () => {
fetchMock.mockResolvedValue(
jsonResponse([
release('v1.4.160-daily.202607271300'),
release('v1.4.160-daily.202607291300'),
release('v1.4.160-daily.202607281300')
])
)
const builds = await listReleaseBuilds('daily')
expect(fetchMock.mock.calls[0][0]).toContain('stablyai/orca-daily')
expect(builds.map((build) => build.version)).toEqual([
'1.4.160-daily.202607291300',
'1.4.160-daily.202607281300',
'1.4.160-daily.202607271300'
])
})
// Why: the main repo serves stable and rc from one endpoint, so an unfiltered
// list would offer RC tags under the Stable channel.
it('separates stable from rc in the shared main repo', async () => {
@ -124,6 +143,15 @@ describe('resolveTargetBuild', () => {
})
})
it('pins a daily tag at the daily repo download path', () => {
expect(resolveTargetBuild('daily', 'v1.4.160-daily.202607281300')).toEqual({
tag: 'v1.4.160-daily.202607281300',
version: '1.4.160-daily.202607281300',
feedUrl:
'https://github.com/stablyai/orca-daily/releases/download/v1.4.160-daily.202607281300'
})
})
it('pins a stable tag at the main repo download path', () => {
expect(resolveTargetBuild('stable', 'v1.4.159').feedUrl).toBe(
'https://github.com/stablyai/orca/releases/download/v1.4.159'

View File

@ -301,6 +301,7 @@ describe('updater', () => {
it.each([
['hourly', 'v1.4.160-hourly.202607281400', 'Hourly builds are produced only for macOS.'],
['daily', 'v1.4.160-daily.202607281300', 'Daily builds are produced only for macOS.'],
['adhoc', 'v1.4.160-adhoc.20260728140533', 'Adhoc builds are produced only for macOS.']
] as const)(
'uses the display label in the mac-only %s pinned-build error',

View File

@ -16,6 +16,7 @@ import {
hasDedicatedReleaseRepo,
isChannelSupportedOnPlatform,
parseDevBuildStamp,
type DedicatedRepoChannel,
type ReleaseBuild,
type ReleaseChannel
} from '../../../../shared/release-channel'
@ -24,9 +25,30 @@ 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.',
daily:
'macOS only. Unvetted builds from main, cut once a day at 14:15 UTC (early morning Pacific). No tests.',
adhoc: 'macOS only. One-off builds cut from a branch to try a feature before it lands.'
}
const DEDICATED_CHANNEL_WARNINGS: Record<DedicatedRepoChannel, { key: string; fallback: string }> =
{
hourly: {
key: 'auto.components.settings.ReleaseChannelSection.hourlyWarning',
fallback:
'Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.'
},
daily: {
key: 'auto.components.settings.ReleaseChannelSection.dailyWarning',
fallback:
'Daily builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.'
},
adhoc: {
key: 'auto.components.settings.ReleaseChannelSection.adhocWarning',
fallback:
'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.'
}
}
function formatBuildLabel(build: ReleaseBuild): string {
// Why the release's own title wins: the build workflows compose it (hourly
// `1.4.163 • 01 • Jul 31, 1:54PM • e698241`, adhoc `1.4.163 • wasm-terminal • …`),
@ -226,15 +248,10 @@ export function ReleaseChannelSection(): React.JSX.Element {
<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">
{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.'
)}
{translate(
DEDICATED_CHANNEL_WARNINGS[activeChannel].key,
DEDICATED_CHANNEL_WARNINGS[activeChannel].fallback
)}
</p>
</div>
) : null}

View File

@ -10389,6 +10389,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.",
"dailyWarning": "Daily 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",

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatAdhocVersion,
formatDailyVersion,
formatHourlyVersion,
getReleaseNotesUrlForVersion,
getReleaseRepoForChannel,
@ -8,9 +9,11 @@ import {
hasDedicatedReleaseRepo,
isAdhocVersion,
isChannelSupportedOnPlatform,
isDailyVersion,
isHourlyVersion,
isReleaseChannel,
parseAdhocVersionStamp,
parseDailyVersionStamp,
parseDevBuildStamp,
parseHourlyVersionStamp,
sortReleaseBuildsNewestFirst,
@ -24,6 +27,7 @@ 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-daily.202607281300')).toBe('daily')
expect(getVersionChannel('1.4.160-adhoc.20260728140533')).toBe('adhoc')
expect(getVersionChannel('not-a-version')).toBeNull()
})
@ -33,7 +37,8 @@ describe('release channel', () => {
// entry and leave real users with nothing to update to.
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
expect(getReleaseRepoForChannel('daily')).toBe('stablyai/orca-daily')
// Why adhoc gets its own 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')
@ -42,6 +47,7 @@ describe('release channel', () => {
it('marks exactly the dev channels as having their own repo', () => {
expect(hasDedicatedReleaseRepo('hourly')).toBe(true)
expect(hasDedicatedReleaseRepo('daily')).toBe(true)
expect(hasDedicatedReleaseRepo('adhoc')).toBe(true)
expect(hasDedicatedReleaseRepo('stable')).toBe(false)
expect(hasDedicatedReleaseRepo('rc')).toBe(false)
@ -53,6 +59,9 @@ describe('release channel', () => {
expect(getReleaseNotesUrlForVersion('1.4.160-hourly.202607281400')).toBe(
'https://github.com/stablyai/orca-hourly/releases/tag/v1.4.160-hourly.202607281400'
)
expect(getReleaseNotesUrlForVersion('1.4.160-daily.202607281300')).toBe(
'https://github.com/stablyai/orca-daily/releases/tag/v1.4.160-daily.202607281300'
)
expect(getReleaseNotesUrlForVersion('1.4.160')).toBe(
'https://github.com/stablyai/orca/releases/tag/v1.4.160'
)
@ -71,6 +80,12 @@ describe('release channel', () => {
expect(parseHourlyVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:00.000Z')
})
it('round-trips a daily version stamp as UTC', () => {
const version = formatDailyVersion('1.4.160', '202607281300')
expect(isDailyVersion(version)).toBe(true)
expect(parseDailyVersionStamp(version)?.toISOString()).toBe('2026-07-28T13:00:00.000Z')
})
it('rejects malformed hourly identifiers', () => {
expect(isHourlyVersion('1.4.160-hourly')).toBe(false)
expect(isHourlyVersion('1.4.160-hourly.2026')).toBe(false)
@ -91,6 +106,8 @@ describe('release channel', () => {
expect(parseHourlyVersionStamp('1.4.160-hourly.202802290000')?.toISOString()).toBe(
'2028-02-29T00:00:00.000Z'
)
expect(parseDailyVersionStamp('1.4.160-daily.202602300000')).toBeNull()
expect(parseDailyVersionStamp('not-a-version-daily.202601010000')).toBeNull()
})
// Why seconds and not hourly's minutes: adhoc builds are dispatched on demand,
@ -102,9 +119,12 @@ describe('release channel', () => {
expect(parseAdhocVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:33.000Z')
})
it('keeps the two dev stamp formats from matching each other', () => {
it('keeps the 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)
expect(isDailyVersion('1.4.160-hourly.202607281400')).toBe(false)
expect(isHourlyVersion('1.4.160-daily.202607281300')).toBe(false)
expect(isDailyVersion('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)
@ -118,13 +138,16 @@ describe('release channel', () => {
expect(parseAdhocVersionStamp('not-a-version-adhoc.20260101000000')).toBeNull()
})
// Why one entry point for both: the picker renders a row without knowing which
// Why one entry point for all: 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', () => {
it('reads the build timestamp of any dev channel', () => {
expect(parseDevBuildStamp('1.4.160-hourly.202607281405')?.toISOString()).toBe(
'2026-07-28T14:05:00.000Z'
)
expect(parseDevBuildStamp('1.4.160-daily.202607281300')?.toISOString()).toBe(
'2026-07-28T13:00:00.000Z'
)
expect(parseDevBuildStamp('1.4.160-adhoc.20260728140533')?.toISOString()).toBe(
'2026-07-28T14:05:33.000Z'
)
@ -132,11 +155,11 @@ describe('release channel', () => {
expect(parseDevBuildStamp('1.4.160')).toBeNull()
})
// Why: both dev workflows are macOS-only, so the channels have no artifact to
// Why: all 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 the dev channels only on macOS', () => {
for (const channel of ['hourly', 'adhoc'] as const) {
for (const channel of ['hourly', 'daily', 'adhoc'] as const) {
expect(isChannelSupportedOnPlatform(channel, 'darwin')).toBe(true)
expect(isChannelSupportedOnPlatform(channel, 'linux')).toBe(false)
expect(isChannelSupportedOnPlatform(channel, 'win32')).toBe(false)
@ -152,6 +175,7 @@ describe('release channel', () => {
it('accepts only known channels', () => {
expect(isReleaseChannel('hourly')).toBe(true)
expect(isReleaseChannel('daily')).toBe(true)
expect(isReleaseChannel('adhoc')).toBe(true)
expect(isReleaseChannel('stable')).toBe(true)
expect(isReleaseChannel('nightly')).toBe(false)
@ -188,6 +212,13 @@ describe('release channel', () => {
expect(compareAppVersions('1.4.160-hourly.202607281400', '1.4.160')).toBeLessThan(0)
})
it('orders a daily below its own stable release and below hourly of the same base', () => {
expect(compareAppVersions('1.4.160-daily.202607281300', '1.4.160')).toBeLessThan(0)
expect(
compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400')
).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.
@ -196,6 +227,7 @@ describe('release channel', () => {
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)
expect(compareAppVersions(adhoc, '1.4.160-daily.202607281300')).toBeLessThan(0)
})
it('sorts consecutive adhoc builds newest first', () => {

View File

@ -1,13 +1,20 @@
import { compareAppVersions, isValidAppVersion } from './app-version'
export type ReleaseChannel = 'stable' | 'rc' | 'hourly' | 'adhoc'
export type ReleaseChannel = 'stable' | 'rc' | 'hourly' | 'daily' | 'adhoc'
export const RELEASE_CHANNELS: readonly ReleaseChannel[] = ['stable', 'rc', 'hourly', 'adhoc']
export const RELEASE_CHANNELS: readonly ReleaseChannel[] = [
'stable',
'rc',
'hourly',
'daily',
'adhoc'
]
export const RELEASE_CHANNEL_LABELS: Readonly<Record<ReleaseChannel, string>> = {
stable: 'Stable',
rc: 'RC',
hourly: 'Hourly',
daily: 'Daily',
adhoc: 'Adhoc'
}
@ -15,14 +22,16 @@ export const RELEASE_CHANNEL_LABELS: Readonly<Record<ReleaseChannel, string>> =
* 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 DAILY_RELEASE_REPO = 'stablyai/orca-daily'
export const ADHOC_RELEASE_REPO = 'stablyai/orca-adhoc'
export const MAIN_RELEASE_REPO = 'stablyai/orca'
export const HOURLY_PRERELEASE_IDENTIFIER = 'hourly'
export const DAILY_PRERELEASE_IDENTIFIER = 'daily'
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
const DEDICATED_REPO_CHANNELS = ['hourly', 'daily', 'adhoc'] as const
export type DedicatedRepoChannel = (typeof DEDICATED_REPO_CHANNELS)[number]
@ -30,6 +39,7 @@ const CHANNEL_RELEASE_REPOS: Record<ReleaseChannel, string> = {
stable: MAIN_RELEASE_REPO,
rc: MAIN_RELEASE_REPO,
hourly: HOURLY_RELEASE_REPO,
daily: DAILY_RELEASE_REPO,
adhoc: ADHOC_RELEASE_REPO
}
@ -47,8 +57,8 @@ export function hasDedicatedReleaseRepo(channel: ReleaseChannel): channel is Ded
* 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
* Why this rides on the dev-channel list: all dev channels are produced only by
* macOS workflows, so none 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.
@ -72,14 +82,19 @@ export function normalizeTagToVersion(tag: string): string {
* uniquely versioned so electron-updater never reads one as "same version". */
const HOURLY_VERSION = /^\d+\.\d+\.\d+-hourly\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/
/** `1.4.160-daily.202607281415` same minute stamp as hourly. Daily cuts once
* per day, so collisions are not a concern; the stamp still carries the hour so
* a forced re-cut the same calendar day remains unique. */
const DAILY_VERSION = /^\d+\.\d+\.\d+-daily\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/
/**
* `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.
* Why seconds here and not for hourly/daily: those run 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})$/
@ -113,6 +128,10 @@ export function isHourlyVersion(version: string): boolean {
return HOURLY_VERSION.test(normalizeTagToVersion(version))
}
export function isDailyVersion(version: string): boolean {
return DAILY_VERSION.test(normalizeTagToVersion(version))
}
export function isAdhocVersion(version: string): boolean {
return ADHOC_VERSION.test(normalizeTagToVersion(version))
}
@ -121,6 +140,10 @@ export function formatHourlyVersion(baseVersion: string, stamp: string): string
return `${baseVersion}-${HOURLY_PRERELEASE_IDENTIFIER}.${stamp}`
}
export function formatDailyVersion(baseVersion: string, stamp: string): string {
return `${baseVersion}-${DAILY_PRERELEASE_IDENTIFIER}.${stamp}`
}
export function formatAdhocVersion(baseVersion: string, stamp: string): string {
return `${baseVersion}-${ADHOC_PRERELEASE_IDENTIFIER}.${stamp}`
}
@ -130,15 +153,24 @@ 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 daily. */
export function parseDailyVersionStamp(version: string): Date | null {
return parseStampedVersion(version, DAILY_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. */
/** The build's UTC timestamp for any 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)
return (
parseHourlyVersionStamp(version) ??
parseDailyVersionStamp(version) ??
parseAdhocVersionStamp(version)
)
}
export function getVersionChannel(version: string): ReleaseChannel | null {
@ -149,6 +181,9 @@ export function getVersionChannel(version: string): ReleaseChannel | null {
if (isHourlyVersion(normalized)) {
return 'hourly'
}
if (isDailyVersion(normalized)) {
return 'daily'
}
if (isAdhocVersion(normalized)) {
return 'adhoc'
}