ci: isolate Blacksmith mac release build (#6962)

This commit is contained in:
Neil 2026-06-30 16:36:59 -07:00 committed by GitHub
parent f9d8b532f1
commit 7cfae28115
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 612 additions and 127 deletions

View File

@ -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:

163
.github/workflows/release-mac-build.yml vendored Normal file
View File

@ -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

View File

@ -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'),

View File

@ -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)
}
}
}

View File

@ -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
})
}