mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
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>
407 lines
19 KiB
YAML
407 lines
19 KiB
YAML
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
|