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:
parent
403c60bbad
commit
3b1017c4fb
|
|
@ -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"
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue