ci: isolate Blacksmith mac release build (#6962)
This commit is contained in:
parent
f9d8b532f1
commit
7cfae28115
|
|
@ -1102,142 +1102,30 @@ jobs:
|
|||
- cut
|
||||
- create-release
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
# Why: SignPath validates the release workflow provenance before signing
|
||||
# Windows. Keep every release-producing job on GitHub-hosted runners.
|
||||
runs-on: macos-15
|
||||
# Why: SignPath requires every job in this signing workflow to be
|
||||
# GitHub-hosted. The actual mac build runs in release-mac-build.yml so
|
||||
# Blacksmith stays outside Windows artifact provenance.
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Cache the Electron binary + electron-builder tool downloads (notarytool,
|
||||
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
|
||||
- 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-
|
||||
|
||||
# Why: pnpm install triggers electron's postinstall, which downloads the
|
||||
# Electron binary from GitHub release assets. GitHub's download CDN
|
||||
# occasionally returns 504s that fail the whole release. Retry on
|
||||
# failure so transient network errors don't require a manual re-run.
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify macOS signing environment
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
- name: Run isolated macOS release build
|
||||
run: node config/scripts/run-release-mac-build-workflow.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 }}
|
||||
|
||||
# Why: `plutil -lint` accepts duplicate plist keys, but `codesign`
|
||||
# rejects duplicate entitlements after the expensive app build.
|
||||
- name: Verify macOS entitlements
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
|
||||
# requires the build identity to be the literal string `stable` or `rc`,
|
||||
# substituted by electron-vite's `define` block at build time.
|
||||
- name: Classify release tag for telemetry build identity
|
||||
id: tag-classify
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
identity=rc
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
identity=stable
|
||||
else
|
||||
echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact"
|
||||
exit 1
|
||||
fi
|
||||
echo "identity=$identity" >>"$GITHUB_OUTPUT"
|
||||
echo "Classified $TAG as $identity"
|
||||
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
env:
|
||||
# Why: Vite's web build crossed Node's default old-space ceiling on
|
||||
# the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft.
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
|
||||
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
|
||||
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
|
||||
|
||||
- name: Publish release artifacts (macOS)
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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: Verify release remains draft after artifact upload
|
||||
# Why: the macOS build must never be the actor that exposes a partial
|
||||
# release. If an uploader or GitHub transition flips draft early, fail
|
||||
# this job and block publish-release.
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
|
||||
# Why: release upload must validate the draft before it is publicly visible.
|
||||
draft="$(jq -e -r --arg tag "$TAG" '
|
||||
map(select(.tag_name == $tag))
|
||||
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
|
||||
' <<<"$releases_json")" || {
|
||||
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
|
||||
exit 1
|
||||
}
|
||||
if [[ "$draft" != "true" ]]; then
|
||||
echo "::error::Release $TAG was published during the mac artifact upload."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Why post-publish for macOS: electron-builder packs and uploads in a
|
||||
# single `--publish always` invocation, so there is no cheap insertion
|
||||
# point between pack and upload without splitting those steps.
|
||||
- name: Verify telemetry constants present in app.asar
|
||||
run: node config/scripts/verify-telemetry-constants.mjs
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_MAC_BUILD_REF: ${{ github.ref_name }}
|
||||
RELEASE_MAC_BUILD_RELEASE_RUN_ID: ${{ github.run_id }}
|
||||
RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }}
|
||||
RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml
|
||||
|
||||
publish-release:
|
||||
needs:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
name: Release macOS Build
|
||||
|
||||
run-name: Mac release build ${{ inputs.tag }} (${{ inputs.release_run_id }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag whose draft should receive macOS artifacts
|
||||
required: true
|
||||
type: string
|
||||
release_run_id:
|
||||
description: release-cut workflow run that requested this build
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-mac-build-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-mac:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
# Why: this workflow is outside the SignPath signing run, so Blacksmith
|
||||
# cannot enter Windows artifact provenance while mac notarization gets the
|
||||
# faster runner.
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Cache the Electron binary + electron-builder tool downloads (notarytool,
|
||||
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
|
||||
- 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-
|
||||
|
||||
# Why: pnpm install triggers electron's postinstall, which downloads the
|
||||
# Electron binary from GitHub release assets. GitHub's download CDN
|
||||
# occasionally returns 504s that fail the whole release. Retry on
|
||||
# failure so transient network errors don't require a manual re-run.
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
- 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 }}
|
||||
|
||||
# Why: `plutil -lint` accepts duplicate plist keys, but `codesign`
|
||||
# rejects duplicate entitlements after the expensive app build.
|
||||
- name: Verify macOS entitlements
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
|
||||
# requires the build identity to be the literal string `stable` or `rc`,
|
||||
# substituted by electron-vite's `define` block at build time.
|
||||
- name: Classify release tag for telemetry build identity
|
||||
id: tag-classify
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
identity=rc
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
identity=stable
|
||||
else
|
||||
echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact"
|
||||
exit 1
|
||||
fi
|
||||
echo "identity=$identity" >>"$GITHUB_OUTPUT"
|
||||
echo "Classified $TAG as $identity"
|
||||
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
env:
|
||||
# Why: Vite's web build crossed Node's default old-space ceiling on
|
||||
# the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft.
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
|
||||
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
|
||||
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
|
||||
|
||||
- name: Publish release artifacts (macOS)
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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: Verify release remains draft after artifact upload
|
||||
# Why: the macOS build must never be the actor that exposes a partial
|
||||
# release. If an uploader or GitHub transition flips draft early, fail
|
||||
# this job so release-cut never publishes the release.
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
|
||||
# Why: release upload must validate the draft before it is publicly visible.
|
||||
draft="$(jq -e -r --arg tag "$TAG" '
|
||||
map(select(.tag_name == $tag))
|
||||
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
|
||||
' <<<"$releases_json")" || {
|
||||
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
|
||||
exit 1
|
||||
}
|
||||
if [[ "$draft" != "true" ]]; then
|
||||
echo "::error::Release $TAG was published during the mac artifact upload."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Why post-publish for macOS: electron-builder packs and uploads in a
|
||||
# single `--publish always` invocation, so there is no cheap insertion
|
||||
# point between pack and upload without splitting those steps.
|
||||
- name: Verify telemetry constants present in app.asar
|
||||
run: node config/scripts/verify-telemetry-constants.mjs
|
||||
|
|
@ -60,13 +60,16 @@ describe('Electron runtime package contract', () => {
|
|||
'utf8'
|
||||
)
|
||||
const parsedWorkflow = parse(releaseWorkflow)
|
||||
const macWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8')
|
||||
)
|
||||
const releaseCommands = new Map(
|
||||
parsedWorkflow.jobs.build.strategy.matrix.include.map(({ platform, release_command }) => [
|
||||
platform,
|
||||
release_command
|
||||
])
|
||||
)
|
||||
const macReleaseCommand = parsedWorkflow.jobs['build-mac'].steps.find(
|
||||
const macReleaseCommand = macWorkflow.jobs['build-mac'].steps.find(
|
||||
(step) => step.name === 'Publish release artifacts (macOS)'
|
||||
).with.command
|
||||
|
||||
|
|
@ -110,14 +113,47 @@ describe('Electron runtime package contract', () => {
|
|||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
)
|
||||
const macDispatchStep = releaseWorkflow.jobs['build-mac'].steps.find(
|
||||
(step) => step.name === 'Run isolated macOS release build'
|
||||
)
|
||||
|
||||
expect(releaseWorkflowText).not.toContain('blacksmith-')
|
||||
expect(releaseWorkflow.jobs['build-mac']['runs-on']).toBe('macos-15')
|
||||
expect(releaseWorkflow.jobs['build-mac']['runs-on']).toBe('ubuntu-latest')
|
||||
expect(releaseWorkflow.jobs['build-mac'].permissions.actions).toBe('write')
|
||||
expect(macDispatchStep.run).toBe('node config/scripts/run-release-mac-build-workflow.mjs')
|
||||
expect(macDispatchStep.env.RELEASE_MAC_BUILD_WORKFLOW).toBe('release-mac-build.yml')
|
||||
expect(macDispatchStep.env.RELEASE_MAC_BUILD_TAG).toBe('${{ needs.cut.outputs.tag }}')
|
||||
expect(buildMatrixRunners).not.toContain('blacksmith-6vcpu-macos-15')
|
||||
expect(releaseWorkflow.jobs['publish-release'].needs).toContain('build')
|
||||
expect(releaseWorkflow.jobs['publish-release'].needs).toContain('build-mac')
|
||||
})
|
||||
|
||||
it('runs the macOS release build in an isolated Blacksmith workflow', () => {
|
||||
const releaseMacWorkflowText = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-mac-build.yml'),
|
||||
'utf8'
|
||||
)
|
||||
const releaseMacWorkflow = parse(releaseMacWorkflowText)
|
||||
const buildMacJob = releaseMacWorkflow.jobs['build-mac']
|
||||
const checkoutStep = buildMacJob.steps.find((step) => step.name === 'Checkout')
|
||||
const publishStep = buildMacJob.steps.find(
|
||||
(step) => step.name === 'Publish release artifacts (macOS)'
|
||||
)
|
||||
|
||||
expect(releaseMacWorkflow['run-name']).toBe(
|
||||
'Mac release build ${{ inputs.tag }} (${{ inputs.release_run_id }})'
|
||||
)
|
||||
expect(releaseMacWorkflow.on.workflow_dispatch.inputs.tag.required).toBe(true)
|
||||
expect(releaseMacWorkflow.on.workflow_dispatch.inputs.release_run_id.required).toBe(true)
|
||||
expect(buildMacJob['runs-on']).toBe('blacksmith-6vcpu-macos-15')
|
||||
expect(checkoutStep.with.ref).toBe('refs/tags/${{ inputs.tag }}')
|
||||
expect(publishStep.with.command).toContain('ORCA_MAC_RELEASE=1')
|
||||
expect(publishStep.with.command).toContain('electron-builder')
|
||||
expect(publishStep.with.command).toContain('--mac --publish always')
|
||||
expect(releaseMacWorkflowText).not.toContain('signpath/')
|
||||
expect(releaseMacWorkflowText).not.toContain('SIGNPATH_')
|
||||
})
|
||||
|
||||
it('preflights SignPath module install before Windows signing side effects', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
expectedReleaseMacBuildRunTitle,
|
||||
readReleaseMacBuildWorkflowOptions,
|
||||
runReleaseMacBuildWorkflow
|
||||
} from './run-release-mac-build-workflow.mjs'
|
||||
|
||||
const baseOptions = {
|
||||
apiBaseUrl: 'https://api.github.test',
|
||||
pollSeconds: 1,
|
||||
ref: 'main',
|
||||
releaseRunId: '777',
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.2.3-rc.4',
|
||||
timeoutMinutes: 2,
|
||||
token: 'token',
|
||||
workflow: 'release-mac-build.yml'
|
||||
}
|
||||
|
||||
describe('release mac build workflow dispatch', () => {
|
||||
it('dispatches the mac workflow and waits for the returned run id', async () => {
|
||||
const { fetch, requests } = createGitHubFetch([
|
||||
jsonResponse(200, {
|
||||
html_url: 'https://github.test/stablyai/orca/actions/runs/123',
|
||||
workflow_run_id: 123
|
||||
}),
|
||||
jsonResponse(200, {
|
||||
conclusion: 'success',
|
||||
html_url: 'https://github.test/stablyai/orca/actions/runs/123',
|
||||
id: 123,
|
||||
status: 'completed'
|
||||
})
|
||||
])
|
||||
|
||||
const completedRun = await runReleaseMacBuildWorkflow(baseOptions, {
|
||||
fetch,
|
||||
now: () => Date.parse('2026-06-30T12:00:00Z'),
|
||||
sleep: vi.fn()
|
||||
})
|
||||
|
||||
expect(completedRun.conclusion).toBe('success')
|
||||
expect(requests[0].method).toBe('POST')
|
||||
expect(requests[0].path).toBe(
|
||||
'/repos/stablyai/orca/actions/workflows/release-mac-build.yml/dispatches'
|
||||
)
|
||||
expect(requests[0].body).toEqual({
|
||||
inputs: {
|
||||
release_run_id: '777',
|
||||
tag: 'v1.2.3-rc.4'
|
||||
},
|
||||
ref: 'main'
|
||||
})
|
||||
expect(requests[1].path).toBe('/repos/stablyai/orca/actions/runs/123')
|
||||
})
|
||||
|
||||
it('finds the dispatched run by release tag and parent run id when GitHub returns no body', async () => {
|
||||
const runTitle = expectedReleaseMacBuildRunTitle(baseOptions)
|
||||
const { fetch, requests } = createGitHubFetch([
|
||||
jsonResponse(204, null),
|
||||
jsonResponse(200, {
|
||||
workflow_runs: [
|
||||
{
|
||||
created_at: '2026-06-30T11:59:00Z',
|
||||
display_title: runTitle,
|
||||
id: 122
|
||||
},
|
||||
{
|
||||
created_at: '2026-06-30T12:00:01Z',
|
||||
display_title: runTitle,
|
||||
html_url: 'https://github.test/stablyai/orca/actions/runs/124',
|
||||
id: 124
|
||||
}
|
||||
]
|
||||
}),
|
||||
jsonResponse(200, {
|
||||
conclusion: 'success',
|
||||
html_url: 'https://github.test/stablyai/orca/actions/runs/124',
|
||||
id: 124,
|
||||
status: 'completed'
|
||||
})
|
||||
])
|
||||
|
||||
const completedRun = await runReleaseMacBuildWorkflow(baseOptions, {
|
||||
fetch,
|
||||
now: () => Date.parse('2026-06-30T12:00:05Z'),
|
||||
sleep: vi.fn()
|
||||
})
|
||||
|
||||
expect(completedRun.id).toBe(124)
|
||||
expect(requests[1].path).toBe(
|
||||
'/repos/stablyai/orca/actions/workflows/release-mac-build.yml/runs'
|
||||
)
|
||||
expect(requests[1].query.get('event')).toBe('workflow_dispatch')
|
||||
})
|
||||
|
||||
it('fails when the isolated mac workflow does not succeed', async () => {
|
||||
const { fetch } = createGitHubFetch([
|
||||
jsonResponse(200, {
|
||||
html_url: 'https://github.test/stablyai/orca/actions/runs/125',
|
||||
workflow_run_id: 125
|
||||
}),
|
||||
jsonResponse(200, {
|
||||
conclusion: 'failure',
|
||||
html_url: 'https://github.test/stablyai/orca/actions/runs/125',
|
||||
id: 125,
|
||||
status: 'completed'
|
||||
})
|
||||
])
|
||||
|
||||
await expect(
|
||||
runReleaseMacBuildWorkflow(baseOptions, {
|
||||
fetch,
|
||||
now: () => Date.parse('2026-06-30T12:00:00Z'),
|
||||
sleep: vi.fn()
|
||||
})
|
||||
).rejects.toThrow(/concluded failure/)
|
||||
})
|
||||
|
||||
it('reads required workflow settings from the GitHub Actions environment', () => {
|
||||
const options = readReleaseMacBuildWorkflowOptions({
|
||||
GITHUB_REPOSITORY: 'stablyai/orca',
|
||||
GITHUB_RUN_ID: '987',
|
||||
GITHUB_TOKEN: 'token',
|
||||
RELEASE_MAC_BUILD_REF: 'main',
|
||||
RELEASE_MAC_BUILD_TAG: 'v1.2.3'
|
||||
})
|
||||
|
||||
expect(options.releaseRunId).toBe('987')
|
||||
expect(options.workflow).toBe('release-mac-build.yml')
|
||||
expect(options.pollSeconds).toBe(30)
|
||||
expect(options.timeoutMinutes).toBe(90)
|
||||
})
|
||||
})
|
||||
|
||||
function createGitHubFetch(responses) {
|
||||
const requests = []
|
||||
const fetch = vi.fn(async (rawUrl, init) => {
|
||||
const url = new URL(rawUrl)
|
||||
const response = responses.shift()
|
||||
|
||||
if (response == null) {
|
||||
throw new Error(`Unexpected request: ${init.method} ${rawUrl}`)
|
||||
}
|
||||
|
||||
requests.push({
|
||||
body: init.body == null ? undefined : JSON.parse(init.body),
|
||||
method: init.method,
|
||||
path: url.pathname,
|
||||
query: url.searchParams
|
||||
})
|
||||
|
||||
return response
|
||||
})
|
||||
|
||||
return { fetch, requests }
|
||||
}
|
||||
|
||||
function jsonResponse(status, body) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
async text() {
|
||||
return body == null ? '' : JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
const DEFAULT_API_VERSION = '2026-03-10'
|
||||
const DEFAULT_TIMEOUT_MINUTES = 90
|
||||
const DEFAULT_POLL_SECONDS = 30
|
||||
const RUN_DISCOVERY_TIMEOUT_SECONDS = 120
|
||||
|
||||
export function readReleaseMacBuildWorkflowOptions(env = process.env) {
|
||||
return {
|
||||
apiBaseUrl: env.GITHUB_API_URL ?? 'https://api.github.com',
|
||||
pollSeconds: readPositiveInteger(env.RELEASE_MAC_BUILD_POLL_SECONDS, DEFAULT_POLL_SECONDS),
|
||||
ref: requiredEnv(env.RELEASE_MAC_BUILD_REF, 'RELEASE_MAC_BUILD_REF'),
|
||||
releaseRunId: requiredEnv(
|
||||
env.RELEASE_MAC_BUILD_RELEASE_RUN_ID ?? env.GITHUB_RUN_ID,
|
||||
'RELEASE_MAC_BUILD_RELEASE_RUN_ID'
|
||||
),
|
||||
repo: requiredEnv(env.GITHUB_REPOSITORY, 'GITHUB_REPOSITORY'),
|
||||
tag: requiredEnv(env.RELEASE_MAC_BUILD_TAG, 'RELEASE_MAC_BUILD_TAG'),
|
||||
timeoutMinutes: readPositiveInteger(
|
||||
env.RELEASE_MAC_BUILD_TIMEOUT_MINUTES,
|
||||
DEFAULT_TIMEOUT_MINUTES
|
||||
),
|
||||
token: requiredEnv(env.GITHUB_TOKEN ?? env.GH_TOKEN, 'GITHUB_TOKEN'),
|
||||
workflow: env.RELEASE_MAC_BUILD_WORKFLOW ?? 'release-mac-build.yml'
|
||||
}
|
||||
}
|
||||
|
||||
export function expectedReleaseMacBuildRunTitle({ releaseRunId, tag }) {
|
||||
return `Mac release build ${tag} (${releaseRunId})`
|
||||
}
|
||||
|
||||
export async function runReleaseMacBuildWorkflow(options, deps = {}) {
|
||||
const api = createGitHubApiClient(options, deps)
|
||||
const now = deps.now ?? Date.now
|
||||
const sleep = deps.sleep ?? sleepMilliseconds
|
||||
const dispatchStartedAtMs = now() - 10_000
|
||||
const dispatchResult = await dispatchReleaseMacBuildWorkflow(api, options)
|
||||
const workflowRun =
|
||||
readWorkflowRunFromDispatchResult(dispatchResult) ??
|
||||
(await findDispatchedReleaseMacBuildRun(api, options, {
|
||||
dispatchStartedAtMs,
|
||||
now,
|
||||
sleep
|
||||
}))
|
||||
|
||||
console.log(
|
||||
`Waiting for mac release build workflow run ${workflowRun.id}: ${workflowRun.html_url}`
|
||||
)
|
||||
|
||||
const completedRun = await waitForReleaseMacBuildRun(api, workflowRun.id, options, {
|
||||
now,
|
||||
sleep
|
||||
})
|
||||
|
||||
if (completedRun.conclusion !== 'success') {
|
||||
throw new Error(
|
||||
`Mac release build workflow ${completedRun.html_url ?? completedRun.id} concluded ${
|
||||
completedRun.conclusion ?? 'without a conclusion'
|
||||
}.`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`Mac release build workflow succeeded: ${completedRun.html_url}`)
|
||||
return completedRun
|
||||
}
|
||||
|
||||
export async function dispatchReleaseMacBuildWorkflow(api, options) {
|
||||
const body = {
|
||||
inputs: {
|
||||
release_run_id: options.releaseRunId,
|
||||
tag: options.tag
|
||||
},
|
||||
ref: options.ref
|
||||
}
|
||||
|
||||
return await api.request(
|
||||
'POST',
|
||||
`/repos/${api.owner}/${api.repo}/actions/workflows/${encodeURIComponent(
|
||||
options.workflow
|
||||
)}/dispatches`,
|
||||
body
|
||||
)
|
||||
}
|
||||
|
||||
export async function findDispatchedReleaseMacBuildRun(api, options, deps = {}) {
|
||||
const now = deps.now ?? Date.now
|
||||
const sleep = deps.sleep ?? sleepMilliseconds
|
||||
const deadlineMs = now() + RUN_DISCOVERY_TIMEOUT_SECONDS * 1000
|
||||
const expectedTitle = expectedReleaseMacBuildRunTitle(options)
|
||||
|
||||
while (now() <= deadlineMs) {
|
||||
const response = await api.request(
|
||||
'GET',
|
||||
`/repos/${api.owner}/${api.repo}/actions/workflows/${encodeURIComponent(
|
||||
options.workflow
|
||||
)}/runs?event=workflow_dispatch&per_page=20`
|
||||
)
|
||||
const workflowRuns = Array.isArray(response?.workflow_runs) ? response.workflow_runs : []
|
||||
const match = workflowRuns.find((run) => {
|
||||
const createdAtMs = Date.parse(run.created_at ?? '')
|
||||
|
||||
return createdAtMs >= deps.dispatchStartedAtMs && run.display_title === expectedTitle
|
||||
})
|
||||
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
|
||||
await sleep(options.pollSeconds * 1000)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out finding dispatched mac release build workflow run named "${expectedTitle}".`
|
||||
)
|
||||
}
|
||||
|
||||
export async function waitForReleaseMacBuildRun(api, workflowRunId, options, deps = {}) {
|
||||
const now = deps.now ?? Date.now
|
||||
const sleep = deps.sleep ?? sleepMilliseconds
|
||||
const deadlineMs = now() + options.timeoutMinutes * 60 * 1000
|
||||
|
||||
while (now() <= deadlineMs) {
|
||||
const run = await api.request(
|
||||
'GET',
|
||||
`/repos/${api.owner}/${api.repo}/actions/runs/${workflowRunId}`
|
||||
)
|
||||
|
||||
if (run.status === 'completed') {
|
||||
return run
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Mac release build workflow is ${run.status}; polling again in ${options.pollSeconds}s`
|
||||
)
|
||||
await sleep(options.pollSeconds * 1000)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out after ${options.timeoutMinutes}m waiting for mac release build workflow ${workflowRunId}.`
|
||||
)
|
||||
}
|
||||
|
||||
export function createGitHubApiClient(options, deps = {}) {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
throw new Error('A fetch implementation is required.')
|
||||
}
|
||||
|
||||
const [owner, repo] = options.repo.split('/')
|
||||
if (!owner || !repo) {
|
||||
throw new Error(`GITHUB_REPOSITORY must be in owner/repo form, got "${options.repo}".`)
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
async request(method, path, body) {
|
||||
const response = await fetchImpl(`${options.apiBaseUrl}${path}`, {
|
||||
body: body == null ? undefined : JSON.stringify(body),
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${options.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-GitHub-Api-Version': DEFAULT_API_VERSION
|
||||
},
|
||||
method
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = text.length > 0 ? JSON.parse(text) : null
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API ${method} ${path} failed with ${response.status}: ${formatApiError(data)}`
|
||||
)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readWorkflowRunFromDispatchResult(result) {
|
||||
if (Number.isInteger(result?.workflow_run_id)) {
|
||||
return {
|
||||
html_url: result.html_url,
|
||||
id: result.workflow_run_id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function formatApiError(data) {
|
||||
if (typeof data?.message === 'string') {
|
||||
return data.message
|
||||
}
|
||||
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
|
||||
function readPositiveInteger(rawValue, defaultValue) {
|
||||
if (rawValue == null || rawValue === '') {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const value = Number(rawValue)
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`Expected a positive integer, got "${rawValue}".`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function requiredEnv(value, name) {
|
||||
if (value == null || value === '') {
|
||||
throw new Error(`${name} is required.`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function sleepMilliseconds(milliseconds) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, milliseconds)
|
||||
})
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runReleaseMacBuildWorkflow(readReleaseMacBuildWorkflowOptions()).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue