Files
orca/.github/workflows/hourly-mac-build.yml
T
beb53a7f9e ci: keep hourly and daily macOS builds on Blacksmith (#34640)
Hosted macos-15 fails afterPack's packaged x64 terminal host boot check
(hourly run 40608643418: timed out waiting for the PTY), so no
latest-mac.yml is published. Windows, template qualification and routing
jobs stay on hosted runners.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-27 18:18:20 -07:00

407 lines
19 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Hourly macOS Dev Build
# Publish signed/notarized main builds to orca-hourly; notarization preserves TCC grants across updates. PR/release gates own validation. Separate repo avoids hourly tags crowding stable/RC Atom entries. Provision shared HOURLY_RELEASE_APP_ID/private key with setup-hourly-release-token.sh and App write access there. Refresh one-hour token after read-only build; a retry may still outlive it, leaving only an invisible draft whose build number remains reserved until manifests validate.
on:
schedule:
# Top of every hour. Skipped automatically when main has not moved.
- cron: '0 * * * *'
workflow_dispatch:
inputs:
force:
description: Build even if main has not moved since the last hourly
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: hourly-mac-build
cancel-in-progress: false
env:
HOURLY_REPO: stablyai/orca-hourly
# Keep ~3 days of history so a regression can be bisected across a weekend.
HOURLY_RETAIN_COUNT: 72
jobs:
# Avoid occupying the limited Mac pool when main has not moved.
preflight:
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
should_build: ${{ steps.freshness.outputs.should_build }}
head_sha: ${{ steps.freshness.outputs.head_sha }}
steps:
- name: Mint hourly 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-hourly
permission-contents: read
- name: Check whether main moved since the last hourly
id: freshness
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
run: |
set -euo pipefail
head_sha="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)"
[[ "$head_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Could not resolve main"; exit 1; }
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
# Drafts never shipped; exclude them when comparing the previous hourly’s source commit.
last_body="$(gh release list --repo "$HOURLY_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 hourly release found; building $head_sha."
exit 0
fi
last_sha="$(gh release view "$last_body" --repo "$HOURLY_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 hourly built $last_sha); building."
fi
# As release-cut does: every SSH host runs orcad, deployed from the template this build ships.
orcad-template:
needs: preflight
if: needs.preflight.outputs.should_build == 'true'
permissions:
contents: read
actions: read
uses: ./.github/workflows/node-server-tests.yml
with:
ref: ${{ needs.preflight.outputs.head_sha }}
build_template: true
fast_runners: false
build-hourly-mac:
needs:
- preflight
- orcad-template
if: needs.preflight.outputs.should_build == 'true'
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.hourly.outputs.version }}
head_sha: ${{ needs.preflight.outputs.head_sha }}
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
# Hosted macos-15 cannot boot the packaged x64 terminal host that afterPack verifies.
runs-on: blacksmith-6vcpu-macos-15
# Allow 150min for install 3×10 + publish 2×45 + ~25 setup/build/verify; outer timeout otherwise interrupts cleanup.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.head_sha }}
# Version helpers only read HEAD; published versions come from git tags.
fetch-depth: 1
# Read this repo without persisted credentials; write orca-hourly via env App token (zizmor: artipacked).
persist-credentials: false
- name: Mint hourly 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-hourly
- name: Setup pnpm
uses: ./.github/actions/setup-pnpm
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
check-latest: true
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Cache bundled host Node by exact pin without restore keys.
- name: Locate the pinned Node runtime cache
shell: bash
run: echo "ORCA_NODE_RUNTIME_CACHE_DIR=$HOME/Library/Caches/orca-node-runtime" >>"$GITHUB_ENV"
- name: Cache the pinned Node runtime
uses: actions/cache@v5
with:
path: ~/Library/Caches/orca-node-runtime
key: orca-node-runtime-mac-${{ hashFiles('src/shared/node-runtime-pin.ts') }}
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-mac-
# Install both x64/arm64 optional natives for both macOS packages.
- name: Install dependencies
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
# Build required out/mobile-web from mobile’s React Native/Expo install.
- uses: ./.github/actions/install-mobile-dependencies
# Require cert before expensive build; signing enables installation over existing Orca.
- name: Verify macOS signing environment
run: node config/scripts/verify-macos-release-env.mjs
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Compute hourly version
id: hourly
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# The script continues numbering for its resolved base version; stranded drafts still reserve numbers,
# although freshness checks exclude them because they never shipped.
names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Tags survive release deletion, preventing updater version regression. Use a separate token because
# GH_TOKEN’s App grant is scoped to the channel repo; lookup failure falls back to package.json.
# 2026-09-14: deleting v1.4.202 made release-list fallback ship 1.4.202-hourly below installed 1.4.203-hourly.
main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \
"repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \
--jq '.[].ref | sub("^refs/tags/"; "")' || true)"
# Already-shipped channel tags also floor versions: never follow installed 1.4.203-hourly with 1.4.202-hourly.
channel_tags="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName \
--jq '.[].tagName' || true)"
published="$main_tags"$'\n'"$channel_tags"
echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \
node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-identity.txt"
# Dispatch YAML can be newer than the pinned source’s version script; reject missing output before publishing an untitled release.
if ! grep -q '^name=' "$RUNNER_TEMP/hourly-identity.txt"; then
echo "::error::hourly-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/hourly-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Unset dev identity so official-only stable/rc telemetry stays silent.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
- name: Download the orcad deployment template
uses: actions/download-artifact@v8
with:
name: orcad-template
path: out/orcad-template
# Refresh write token after build to give long notary/upload work a full hour.
- name: Re-mint hourly repo token for publish
id: app_token_publish
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-hourly
- name: Create hourly release
id: release
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: v${{ steps.hourly.outputs.version }}
NAME: ${{ steps.hourly.outputs.name }}
SHA: ${{ needs.preflight.outputs.head_sha }}
run: |
set -euo pipefail
# Freshness parses the 12-character source SHA from the body even though titles show seven.
short_sha="${SHA:0:12}"
# Precreate a known tag as draft so builder cannot infer it from package.json.
# Keep incomplete assets invisible until manifest verification, even if timeout kills the job without cleanup.
gh release create "$TAG" \
--repo "$HOURLY_REPO" \
--title "$NAME" \
--draft \
--notes "Automated hourly macOS and Windows dev build from commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
**Unvetted.** No tests ran. The macOS build is 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.
**Windows builds are unsigned.** They install and update normally once you
are on one, but a signed Stable or RC build cannot install one through the
in-app updater — download \`orca-windows-setup.exe\` below and run it once
(SmartScreen will warn about an unknown publisher). Every later switch,
including back to Stable, works in-app from there."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish hourly macOS artifacts
uses: nick-fields/retry@v4
with:
# Allow two 45min pack/notarize/upload attempts; next hourly picks the same commit rather than paying for a third.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/pinned-node-downloads.mjs darwin-arm64 darwin-x64 && node config/scripts/ensure-native-runtime.mjs --runtime=electron && if [ -f config/scripts/build-mac-notarization-overlap.mjs ]; then ORCA_MAC_HOURLY=1 node config/scripts/build-mac-notarization-overlap.mjs --publish always; else ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always; fi
env:
# Give publisher token write access to configured orca-hourly repo.
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }}
# beforePack and afterPack fail the package when the template is absent.
ORCA_REQUIRE_ORCAD_TEMPLATE: '1'
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Require Apple id/app-specific password/team for notarization, separate from signing cert.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Verify latest-mac.yml before exposing release; missing manifests cause picker download 404s.
- name: Verify update manifest published
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$HOURLY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Drafts are already invisible; let failure cleanup delete them so set -e cannot mask this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Hourly draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Hourly draft $TAG has no ZIP artifact for the updater to download."
exit 1
fi
# Publish only after manifests verify so picker never offers incomplete assets.
- name: Publish the verified release
id: publish_live
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.hourly.outputs.name }}
run: |
set -euo pipefail
# Reassert the picker title after electron-builder may rewrite the tag’s draft on upload.
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease --title "$NAME"
echo "Published $TAG as \"$NAME\""
# Delete failed/cancelled drafts but never publish_live releases, including after prune failure; outer timeouts run no cleanup.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_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 "$HOURLY_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old hourly releases
# Prune only after live publish; failed runs leave draft disposal to cleanup.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
# Exclude the just-published tag from retention pruning.
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
# Delete pruned tags too; exclude drafts owned by failure cleanup. Rank by publishedAt/tagName:
# createdAt ties (bulk import/recreation) can evict the just-published release. Partition $TAG to
# the front before slicing so it owns exactly one retained seat without under-pruning or keeping retain+1.
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+=" | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName"
stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,publishedAt,isDraft \
--jq "$jq_filter")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune; at or under $HOURLY_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 "$HOURLY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
# Wait for mac tag/version before Windows upload; local ./ workflow consistently follows the dispatch ref.
build-hourly-win:
needs: build-hourly-mac
# Publish Windows only into the existing live mac release.
if: needs.build-hourly-mac.outputs.published == 'true'
permissions:
contents: read
actions: read
uses: ./.github/workflows/dev-channel-win-build.yml
secrets: inherit
with:
channel: hourly
tag: ${{ needs.build-hourly-mac.outputs.tag }}
ref: ${{ needs.build-hourly-mac.outputs.head_sha }}
version: ${{ needs.build-hourly-mac.outputs.version }}
orcad_template: true
fast_release: false