Files
orca/.github/workflows/daily-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

469 lines
22 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: Daily macOS Dev Build
# Publish daily main builds to orca-daily, separate from hourly and stable/RC feeds. Sign/notarize for update-preserved macOS TCC grants; PR/release pipelines own blocking validation, with E2E detached after publish. Cron 18:15 UTC means 10:15 PST/11:15 PDT, offset from hourly. Use the shared hourly App with write access to orca-daily (setup-hourly-release-token.sh/setup-daily-release-repo.sh). Mint tokens again after build and upload because 2×45min retry can exceed their 1h life. Keep releases draft until manifests verify; drafts retain build numbers.
on:
schedule:
# Once a day, late morning Pacific. Single cron — no DST twin, no clock gate.
- cron: '15 18 * * *'
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:
# Why: the app and the orcad template it ships must come from one commit, so resolve main once.
resolve-main:
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
head_sha: ${{ steps.resolve.outputs.head_sha }}
steps:
- name: Resolve main
id: resolve
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
head_sha="$(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"
# As release-cut does: every SSH host runs orcad, deployed from the template this build ships.
orcad-template:
needs: resolve-main
permissions:
contents: read
actions: read
uses: ./.github/workflows/node-server-tests.yml
with:
ref: ${{ needs.resolve-main.outputs.head_sha }}
build_template: true
fast_runners: false
build-daily-mac:
needs:
- resolve-main
- orcad-template
if: github.repository == 'stablyai/orca'
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.daily.outputs.version }}
head_sha: ${{ steps.freshness.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, so outer timeout cannot strand cleanup mid-retry.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve-main.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-daily through env-passed App token (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
# Skip unchanged main to avoid duplicate builds/tags and runner cost.
- 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
# Drafts never shipped; exclude them when comparing the previous daily’s source commit.
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/setup@v2
with:
install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
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
if: steps.freshness.outputs.should_build == 'true'
shell: bash
run: echo "ORCA_NODE_RUNTIME_CACHE_DIR=$HOME/Library/Caches/orca-node-runtime" >>"$GITHUB_ENV"
- name: Cache the pinned Node runtime
if: steps.freshness.outputs.should_build == 'true'
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
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-
# Install x64 and arm64 optional natives for both packaged macOS targets.
- 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 --cpu=current,x64,arm64
# Build required out/mobile-web using mobile’s React Native/Expo install.
- uses: ./.github/actions/install-mobile-dependencies
if: steps.freshness.outputs.should_build == 'true'
# 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
# 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 "$DAILY_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.
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 are a second floor so unpublishing a
# buggy main release cannot drag this series below a daily already out.
channel_tags="$(gh release list --repo "$DAILY_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_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"
# 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/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
# Leave dev identity unset 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
if: steps.freshness.outputs.should_build == 'true'
uses: actions/download-artifact@v8
with:
name: orcad-template
path: out/orcad-template
# Refresh the one-hour token after read-only install/build, before long publish/notary work.
- 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
# Freshness parses the 12-character source SHA from the body even though titles show seven.
short_sha="${SHA:0:12}"
# Write one body file for creation and reassertion; freshness parsing requires exact agreement.
notes_file="$RUNNER_TEMP/daily-release-notes.md"
cat >"$notes_file" <<EOF
Automated daily 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.
EOF
# 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 "$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:
# Allow two 45min pack/notarize/upload attempts; a third costs more runner time than a missed daily.
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_DAILY=1 node config/scripts/build-mac-notarization-overlap.mjs --publish always; else ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always; fi
env:
# Give the publisher App write access to its configured orca-daily target.
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.daily.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 notary authentication, 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 }}
# Always refresh after upload: 2×45min can outlive the earlier token, including failed/cancelled uploads needing draft cleanup.
- 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
# Verify latest-mac.yml before exposing its release to prevent picker downloads returning 404.
- 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"
# 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::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
# Publish only after manifest validation so the picker cannot offer incomplete assets.
- 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
# Builder may rewrite title/body on upload; reassert both so the picker name and next run’s source-SHA freshness check stay correct.
gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \
--title "$NAME" --notes-file "$NOTES_FILE"
echo "Published $TAG as \"$NAME\""
# Remove failed/cancelled stranded drafts, never releases already published by publish_live, including after prune failure. Outer timeout skips cleanup, so retain draft status until verified.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
# If token refresh fails, let cleanup try the older token while it may still be valid.
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
# Prune only after publication; failed runs leave draft disposal to cleanup.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
# Never prune this run’s just-published tag at the retention cap.
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+=" | .[${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"
# Dispatch E2E separately so main’s red full suite cannot fail an already-published daily. Use main’s workflow plus exact built SHA because daily tags live in another repo.
post-daily-e2e:
needs: build-daily-mac
if: ${{ needs.build-daily-mac.outputs.published == 'true' && needs.build-daily-mac.outputs.head_sha != '' }}
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Dispatch cut-scoped E2E
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ needs.build-daily-mac.outputs.head_sha }}
run: |
for attempt in 1 2 3; do
if gh workflow run e2e.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
--raw-field "ref=$SHA"; then
echo "Dispatched post-daily E2E for $SHA."
exit 0
fi
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
done
echo "::warning::Failed to dispatch post-daily E2E for $SHA after 3 attempts."
# Wait for mac release identity before Windows uploads; local ./ workflow follows the dispatch ref consistently.
build-daily-win:
needs: build-daily-mac
# Start Windows publication only after the mac release is live; its workflow refuses to create a missing release.
if: needs.build-daily-mac.outputs.published == 'true'
permissions:
contents: read
actions: read
uses: ./.github/workflows/dev-channel-win-build.yml
secrets: inherit
with:
channel: daily
tag: ${{ needs.build-daily-mac.outputs.tag }}
ref: ${{ needs.build-daily-mac.outputs.head_sha }}
version: ${{ needs.build-daily-mac.outputs.version }}
orcad_template: true
fast_release: false