diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 91c9681d5..fc018bdb3 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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: diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml new file mode 100644 index 000000000..e82502baa --- /dev/null +++ b/.github/workflows/release-mac-build.yml @@ -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 diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 589a485aa..e657d4bca 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.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'), diff --git a/config/scripts/release-mac-build-workflow-dispatch.test.mjs b/config/scripts/release-mac-build-workflow-dispatch.test.mjs new file mode 100644 index 000000000..62e6b66c9 --- /dev/null +++ b/config/scripts/release-mac-build-workflow-dispatch.test.mjs @@ -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) + } + } +} diff --git a/config/scripts/run-release-mac-build-workflow.mjs b/config/scripts/run-release-mac-build-workflow.mjs new file mode 100644 index 000000000..e74564965 --- /dev/null +++ b/config/scripts/run-release-mac-build-workflow.mjs @@ -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 + }) +}