From 309d71c003ae05bfe4ee15da33739fe45a5b0f07 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:30:47 -0700 Subject: [PATCH] Build adhoc Mac architectures in parallel (#31415) * Parallelize adhoc Mac architecture packaging * Include parallel Mac jobs in release timing reports * Cover parallel Mac publication and failure gates * Explicitly scope archive targets to each Mac architecture --- .github/workflows/adhoc-mac-build.yml | 300 ++++++++++++------ .../adhoc-build-pinned-commit.test.mjs | 2 +- config/scripts/adhoc-mac-manifests.mjs | 135 ++++++++ config/scripts/adhoc-mac-manifests.test.mjs | 103 ++++++ config/scripts/adhoc-mac-parallelism.test.mjs | 57 ++++ config/scripts/adhoc-release-waits.test.mjs | 9 +- .../adhoc-windows-parallelism.test.mjs | 15 +- ...mac-notarization-builder-contract.test.mjs | 41 ++- config/scripts/release-desktop-timing.mjs | 15 +- .../scripts/release-desktop-timing.test.mjs | 13 + .../workflow-ref-mirror-case-safety.test.mjs | 12 +- .../workflow-ref-reachability.test.mjs | 2 +- 12 files changed, 585 insertions(+), 119 deletions(-) create mode 100644 config/scripts/adhoc-mac-manifests.mjs create mode 100644 config/scripts/adhoc-mac-manifests.test.mjs create mode 100644 config/scripts/adhoc-mac-parallelism.test.mjs diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 85d941ccf92..940e47b47e7 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -235,13 +235,82 @@ jobs: dev_build: true runner: blacksmith-8vcpu-ubuntu-2404 - build-adhoc-mac: + prepare-adhoc-mac-release: needs: [resolve-ref, orcad-template-support, release-javascript] - # Template qualification gates packaging after desktop preparation. if: >- !cancelled() && github.repository == 'stablyai/orca' && needs.orcad-template-support.result == 'success' && needs.release-javascript.result == 'success' + environment: adhoc-mac-build + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 10 + permissions: + contents: read + outputs: + tag: ${{ steps.release.outputs.tag }} + steps: + - name: Mint adhoc 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-adhoc + + - name: Create adhoc release + id: release + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: v${{ needs.orcad-template-support.outputs.version }} + NAME: ${{ needs.orcad-template-support.outputs.name }} + SHA: ${{ needs.resolve-ref.outputs.sha }} + REF: ${{ inputs.ref || github.ref_name }} + # Via env, not inline `${{ }}`: both land inside a shell string, and an + # expression expanded there is substituted before bash parses the line + # (zizmor: template-injection). + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + short_sha="${SHA:0:12}" + # Why create it up front: electron-builder then uploads into a known tag + # rather than inferring one from package.json. + # + # Why --draft: everything between here and the manifest check is a window + # where the release exists but has no installable assets. A draft is + # absent from the releases list and from listReleaseBuilds, so a job that + # dies in that window — including a hard kill by the job timeout, which + # runs no cleanup step at all — leaves something invisible rather than a + # tag the picker offers and the download 404s on. + gh release create "$TAG" \ + --repo "$ADHOC_REPO" \ + --title "$NAME" \ + --draft \ + --notes "Adhoc macOS and Windows dev build of \`$REF\` at commit \`$short_sha\`. + + Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR. + + **Unlanded and unvetted.** This is somebody's branch, not main. No tests + ran. The macOS build is signed and notarized like a release, so it installs + through Orca's in-app updater and opens without a Gatekeeper prompt — but the + branch may never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days. + + **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" + + build-adhoc-mac-arch: + needs: [resolve-ref, orcad-template-support, release-javascript, prepare-adhoc-mac-release] + # Template qualification gates packaging after desktop preparation. + if: >- + !cancelled() && github.repository == 'stablyai/orca' && + needs.orcad-template-support.result == 'success' && + needs.release-javascript.result == 'success' && + needs.prepare-adhoc-mac-release.result == 'success' # Why an environment: it gives the signing/notary/App secrets somewhere to # live that a stale copy of this workflow on an old branch cannot reach. # Referencing it is a no-op until repo settings give it teeth; the intended @@ -250,15 +319,13 @@ jobs: # HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch # policy to main so only main's copy of this file can read them. environment: adhoc-mac-build - outputs: - tag: ${{ steps.release.outputs.tag }} - version: ${{ needs.orcad-template-support.outputs.version }} - head_sha: ${{ needs.resolve-ref.outputs.sha }} - published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }} - ships_orcad_template: ${{ needs.orcad-template-support.outputs.ships }} permissions: contents: read actions: read + strategy: + fail-fast: false + matrix: + arch: [x64, arm64] runs-on: blacksmith-12vcpu-macos-15 # Why 150: it must exceed the worst case the retry budgets below can produce # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or @@ -428,7 +495,7 @@ jobs: - name: Wait for the qualified orcad template if: needs.orcad-template-support.outputs.ships == 'true' uses: actions/github-script@v8 - with: &qualified-template-wait + with: script: | const deadline = Date.now() + 30 * 60_000 while (Date.now() < deadline) { @@ -461,74 +528,26 @@ jobs: # one hour, everything before this point writes nothing, and the notary round # trip inside the publish step can be tens of minutes. Minting after the build # starts the clock at the first call that actually uses it. - - name: Mint adhoc repo token - id: app_token - uses: actions/create-github-app-token@v2 + - name: Checkout workflow packaging scripts + uses: actions/checkout@v6 with: - app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} - private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} - owner: stablyai - repositories: orca-adhoc + ref: ${{ github.workflow_sha }} + path: .workflow-tools + sparse-checkout: config/scripts/adhoc-mac-manifests.mjs + sparse-checkout-cone-mode: false + persist-credentials: false - - name: Create adhoc release - id: release - shell: bash - env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} - TAG: v${{ needs.orcad-template-support.outputs.version }} - NAME: ${{ needs.orcad-template-support.outputs.name }} - SHA: ${{ needs.resolve-ref.outputs.sha }} - REF: ${{ inputs.ref || github.ref_name }} - # Via env, not inline `${{ }}`: both land inside a shell string, and an - # expression expanded there is substituted before bash parses the line - # (zizmor: template-injection). - ACTOR: ${{ github.actor }} - run: | - set -euo pipefail - short_sha="${SHA:0:12}" - # Why create it up front: electron-builder then uploads into a known tag - # rather than inferring one from package.json. - # - # Why --draft: everything between here and the manifest check is a window - # where the release exists but has no installable assets. A draft is - # absent from the releases list and from listReleaseBuilds, so a job that - # dies in that window — including a hard kill by the job timeout, which - # runs no cleanup step at all — leaves something invisible rather than a - # tag the picker offers and the download 404s on. - gh release create "$TAG" \ - --repo "$ADHOC_REPO" \ - --title "$NAME" \ - --draft \ - --notes "Adhoc macOS and Windows dev build of \`$REF\` at commit \`$short_sha\`. - - Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR. - - **Unlanded and unvetted.** This is somebody's branch, not main. No tests - ran. The macOS build is signed and notarized like a release, so it installs - through Orca's in-app updater and opens without a Gatekeeper prompt — but the - branch may never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days. - - **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 adhoc macOS artifacts + - name: Package and notarize this Mac architecture uses: nick-fields/retry@v4 with: - # Why 45: an attempt is pack + notarize + upload, and the notary queue is + # Why 45: an attempt is pack + notarize, and the notary queue is # the unbounded part. Two attempts, because a failed adhoc build has a # person waiting on it who can simply dispatch again. 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_ADHOC=1 node config/scripts/build-mac-notarization-overlap.mjs --publish always; else ORCA_MAC_ADHOC=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always; fi + 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 && ORCA_MAC_ADHOC=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac dmg zip --${{ matrix.arch }} --publish never env: - # Why: electron-builder's github publisher targets the repo named in the - # config; the token must therefore carry write access to orca-adhoc. - GH_TOKEN: ${{ steps.app_token.outputs.token }} ORCA_ADHOC_BUILD_VERSION: ${{ needs.orcad-template-support.outputs.version }} ORCA_BUILD_COMMIT: ${{ needs.orcad-template-support.outputs.commit }} # beforePack and afterPack fail the package when the template is absent. @@ -542,38 +561,133 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - # Why: the updater resolves a tag, then fetches latest-mac.yml from it. A - # release missing that manifest is a tag the picker offers and the download - # 404s on, so fail loudly instead of leaving a broken entry. - - name: Verify update manifest published + - name: Verify signed and stapled app + shell: bash + env: + MAC_ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + app_dir=dist/mac + if [[ "$MAC_ARCH" == arm64 ]]; then app_dir=dist/mac-arm64; fi + codesign --verify --deep --strict "$app_dir/Orca.app" + xcrun stapler validate "$app_dir/Orca.app" + + - name: Verify this architecture's updater files + env: + MAC_ARCH: ${{ matrix.arch }} + MAC_VERSION: ${{ needs.orcad-template-support.outputs.version }} + run: node .workflow-tools/config/scripts/adhoc-mac-manifests.mjs stage "$MAC_ARCH" "$MAC_VERSION" dist .build/mac-manifests + + - name: Mint adhoc 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-adhoc + + - name: Upload verified architecture assets to the draft shell: bash env: GH_TOKEN: ${{ steps.app_token.outputs.token }} - TAG: ${{ steps.release.outputs.tag }} + TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }} + MAC_ARCH: ${{ matrix.arch }} run: | set -euo pipefail - assets="$(gh release view "$TAG" --repo "$ADHOC_REPO" --json assets --jq '.assets[].name')" - echo "Published assets:" - echo "$assets" - # Why exit 1 without deleting here: the release is still a draft, so it is - # already invisible to users, and the failure handler below owns cleanup. - for required in latest-mac.yml; do - if ! grep -qx "$required" <<<"$assets"; then - echo "::error::Adhoc draft $TAG is missing $required; the updater could not install it." - exit 1 - fi - done - if ! grep -q '\.zip$' <<<"$assets"; then - echo "::error::Adhoc draft $TAG has no ZIP artifact for the updater to download." - exit 1 - fi + files=() + while IFS= read -r file; do files+=("dist/$file"); done < ".build/mac-manifests/$MAC_ARCH-assets.txt" + gh release upload "$TAG" "${files[@]}" --repo "$ADHOC_REPO" --clobber + + - name: Stage verified architecture manifest + uses: actions/upload-artifact@v7 + with: + name: adhoc-mac-manifest-${{ matrix.arch }} + path: .build/mac-manifests/${{ matrix.arch }}.json + if-no-files-found: error + retention-days: 1 + + build-adhoc-mac: + needs: [resolve-ref, orcad-template-support, prepare-adhoc-mac-release, build-adhoc-mac-arch] + if: always() && needs.prepare-adhoc-mac-release.result == 'success' + environment: adhoc-mac-build + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 35 + permissions: + contents: read + actions: read + outputs: + tag: ${{ needs.prepare-adhoc-mac-release.outputs.tag }} + version: ${{ needs.orcad-template-support.outputs.version }} + head_sha: ${{ needs.resolve-ref.outputs.sha }} + published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }} + ships_orcad_template: ${{ needs.orcad-template-support.outputs.ships }} + steps: + - name: Mint adhoc 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-adhoc + + - name: Require both architecture jobs + env: + ARCH_RESULT: ${{ needs.build-adhoc-mac-arch.result }} + run: test "$ARCH_RESULT" = success + + - name: Checkout manifest merger + uses: actions/checkout@v6 + with: + ref: ${{ github.workflow_sha }} + sparse-checkout: config/scripts/adhoc-mac-manifests.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Download architecture manifests + uses: actions/download-artifact@v8 + with: + pattern: adhoc-mac-manifest-* + merge-multiple: true + path: .build/mac-manifests + + - name: Merge and verify the updater manifest + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }} + MAC_VERSION: ${{ needs.orcad-template-support.outputs.version }} + run: | + set -euo pipefail + gh release view "$TAG" --repo "$ADHOC_REPO" --json assets > .build/mac-manifests/assets.json + node config/scripts/adhoc-mac-manifests.mjs merge "$MAC_VERSION" .build/mac-manifests + gh release upload "$TAG" .build/mac-manifests/latest-mac.yml --repo "$ADHOC_REPO" --clobber - name: Confirm final template qualification before publication if: needs.orcad-template-support.outputs.ships == 'true' uses: actions/github-script@v8 env: WAIT_FOR_TEMPLATE_COMPLETION: '1' - with: *qualified-template-wait + with: + script: | + const deadline = Date.now() + 30 * 60_000 + while (Date.now() < deadline) { + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + ...context.repo, run_id: context.runId, filter: 'latest', per_page: 100 + }) + const lanes = jobs.filter(job => job.name.startsWith('orcad-template / ')) + const failed = lanes.find(job => job.status === 'completed' && + ['failure', 'cancelled', 'timed_out', 'action_required', 'startup_failure'].includes(job.conclusion)) + if (failed) throw new Error(`Template qualification failed: ${failed.name}`) + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, run_id: context.runId, per_page: 100 + }) + const ready = artifacts.some(artifact => artifact.name === 'orcad-template' && !artifact.expired) + const completed = lanes.length > 0 && lanes.every(job => job.status === 'completed') + if (ready && (process.env.WAIT_FOR_TEMPLATE_COMPLETION !== '1' || completed)) return + await new Promise(resolve => setTimeout(resolve, 15_000)) + } + throw new Error('Timed out waiting for the qualified orcad template') # Why this is the last mutating step: publishing the draft is what makes the # build visible to listReleaseBuilds. Doing it only after the manifest check @@ -583,7 +697,7 @@ jobs: shell: bash env: GH_TOKEN: ${{ steps.app_token.outputs.token }} - TAG: ${{ steps.release.outputs.tag }} + TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }} NAME: ${{ needs.orcad-template-support.outputs.name }} run: | set -euo pipefail @@ -601,12 +715,12 @@ jobs: # mid-publish would strand the draft. - name: Discard the draft release on failure if: >- - (failure() || cancelled()) && steps.release.outputs.tag != '' && + (failure() || cancelled()) && needs.prepare-adhoc-mac-release.outputs.tag != '' && steps.publish_live.outcome != 'success' shell: bash env: GH_TOKEN: ${{ steps.app_token.outputs.token }} - TAG: ${{ steps.release.outputs.tag }} + TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }} run: | set -uo pipefail # No --cleanup-tag: an unpublished draft never created a git tag. @@ -623,14 +737,14 @@ jobs: GH_TOKEN: ${{ steps.app_token.outputs.token }} # Protect the tag this run just shipped, so no filter mistake can delete # a build minutes after the person who cut it was told it exists. - TAG: ${{ steps.release.outputs.tag }} + TAG: ${{ needs.prepare-adhoc-mac-release.outputs.tag }} run: | set -euo pipefail # Why compute the cutoff in bash rather than with jq's `now`: this runs # once per dispatch, and a fixed epoch makes the threshold visible in the # log when someone asks where their build went. cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 )) - echo "Pruning adhoc releases published before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')" + echo "Pruning adhoc releases published before $(date -u -d "@$cutoff" '+%Y-%m-%dT%H:%M:%SZ')" # --cleanup-tag so pruning does not leave orphan tags with no release or # assets attached. Drafts are excluded: a stale draft is the failure # path's business, not the retention window's. diff --git a/config/scripts/adhoc-build-pinned-commit.test.mjs b/config/scripts/adhoc-build-pinned-commit.test.mjs index 09333f002eb..b3523ca7f67 100644 --- a/config/scripts/adhoc-build-pinned-commit.test.mjs +++ b/config/scripts/adhoc-build-pinned-commit.test.mjs @@ -66,7 +66,7 @@ describe('adhoc build pins one commit for the whole run', () => { step.uses?.startsWith('actions/checkout@') ) expect(support.with.ref).toBe(PINNED) - const vet = jobs['build-adhoc-mac'].steps.find((step) => step.id === 'vetted') + const vet = jobs['build-adhoc-mac-arch'].steps.find((step) => step.id === 'vetted') expect(vet.env.REQUESTED_REF).toBe(PINNED) for (const name of ['orcad-template-support', 'orcad-template', 'build-adhoc-mac']) { expect([jobs[name].needs].flat(), name).toContain('resolve-ref') diff --git a/config/scripts/adhoc-mac-manifests.mjs b/config/scripts/adhoc-mac-manifests.mjs new file mode 100644 index 00000000000..99ce4fe9ce2 --- /dev/null +++ b/config/scripts/adhoc-mac-manifests.mjs @@ -0,0 +1,135 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +function expectedNames(arch, version) { + if (!['arm64', 'x64'].includes(arch) || !/^[0-9][0-9A-Za-z.+-]*$/.test(version)) { + throw new Error('Invalid Mac architecture or version') + } + return [`Orca-${version}${arch === 'arm64' ? '-arm64' : ''}-mac.zip`, `orca-macos-${arch}.dmg`] +} + +export function validateManifest(manifest, arch, version) { + const names = expectedNames(arch, version) + if ( + manifest.version !== version || + !Array.isArray(manifest.files) || + manifest.files.length !== 2 + ) { + throw new Error(`Incomplete or mismatched ${arch} updater manifest`) + } + for (const name of names) { + const matches = manifest.files.filter((file) => file.url === name) + if ( + matches.length !== 1 || + !Number.isSafeInteger(matches[0].size) || + matches[0].size <= 0 || + !/^[A-Za-z0-9+/]{86}==$/.test(matches[0].sha512) + ) { + throw new Error(`Invalid updater entry: ${name}`) + } + } + return names +} + +async function checksum(path) { + const hash = createHash('sha512') + for await (const chunk of createReadStream(path)) { + hash.update(chunk) + } + return hash.digest('base64') +} + +export async function stageManifest(manifest, arch, version, directory) { + const names = validateManifest(manifest, arch, version) + const assets = [] + for (const name of names) { + const entry = manifest.files.find((file) => file.url === name) + if ( + (await stat(join(directory, name))).size !== entry.size || + (await checksum(join(directory, name))) !== entry.sha512 + ) { + throw new Error(`Updater checksum or size mismatch: ${name}`) + } + assets.push({ name, size: entry.size }) + const blockmap = `${name}.blockmap` + // Both archive types need their matching differential-update sidecar. + assets.push({ name: blockmap, size: (await stat(join(directory, blockmap))).size }) + } + return { arch, manifest, assets } +} + +export function mergeManifests(records, version, uploaded) { + if (records.length !== 2 || new Set(records.map((record) => record.arch)).size !== 2) { + throw new Error('Both Mac architectures are required') + } + for (const arch of ['arm64', 'x64']) { + const record = records.find((item) => item.arch === arch) + if (!record) { + throw new Error(`Missing ${arch} manifest`) + } + const names = validateManifest(record.manifest, arch, version) + const required = names.flatMap((name) => [name, `${name}.blockmap`]) + if (record.assets.length !== required.length) { + throw new Error('Incomplete staged assets') + } + for (const name of required) { + const local = record.assets.filter((asset) => asset.name === name) + const remote = uploaded.filter((asset) => asset.name === name) + if ( + local.length !== 1 || + remote.length !== 1 || + !Number.isSafeInteger(local[0].size) || + local[0].size <= 0 || + local[0].size !== remote[0].size + ) { + throw new Error(`Missing or mismatched uploaded asset: ${name}`) + } + } + } + const arm = records.find((record) => record.arch === 'arm64').manifest + const intel = records.find((record) => record.arch === 'x64').manifest + const canonical = arm.files.find((file) => file.url.endsWith('.zip')) + return { + ...arm, + files: [...arm.files, ...intel.files], + path: canonical.url, + sha512: canonical.sha512 + } +} + +async function main([mode, first, second, third, fourth]) { + if (mode === 'stage') { + const { parse } = await import('yaml') + const manifest = parse(await readFile(join(third, 'latest-mac.yml'), 'utf8')) + const record = await stageManifest(manifest, first, second, third) + await mkdir(fourth, { recursive: true }) + await writeFile(join(fourth, `${first}.json`), JSON.stringify(record)) + await writeFile( + join(fourth, `${first}-assets.txt`), + `${record.assets.map((asset) => asset.name).join('\n')}\n` + ) + } else if (mode === 'merge') { + const records = await Promise.all( + ['arm64', 'x64'].map(async (arch) => + JSON.parse(await readFile(join(second, `${arch}.json`), 'utf8')) + ) + ) + const { assets } = JSON.parse(await readFile(join(second, 'assets.json'), 'utf8')) + // JSON is YAML 1.2 and preserves every updater metadata field without another dependency. + await writeFile( + join(second, 'latest-mac.yml'), + `${JSON.stringify(mergeManifests(records, first, assets), null, 2)}\n` + ) + } else { + throw new Error('Expected stage or merge') + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + main(process.argv.slice(2)).catch((error) => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/config/scripts/adhoc-mac-manifests.test.mjs b/config/scripts/adhoc-mac-manifests.test.mjs new file mode 100644 index 00000000000..77217ee742e --- /dev/null +++ b/config/scripts/adhoc-mac-manifests.test.mjs @@ -0,0 +1,103 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { mergeManifests, stageManifest, validateManifest } from './adhoc-mac-manifests.mjs' + +const version = '1.2.3-adhoc.20261021' +const content = Buffer.from('signed and stapled archive') +const sha512 = createHash('sha512').update(content).digest('base64') +function record(arch) { + const names = [ + `Orca-${version}${arch === 'arm64' ? '-arm64' : ''}-mac.zip`, + `orca-macos-${arch}.dmg` + ] + return { + arch, + manifest: { + version, + path: names[0], + sha512, + releaseDate: '2026-10-21T00:00:00Z', + files: names.map((url) => ({ url, sha512, size: content.length })) + }, + assets: names.flatMap((name) => [ + { name, size: content.length }, + { name: `${name}.blockmap`, size: 4 } + ]) + } +} +const records = () => [record('arm64'), record('x64')] +const uploaded = (items) => items.flatMap((item) => item.assets) + +describe('parallel Mac updater manifests', () => { + it('merges both architectures and roundtrips through the updater YAML parser', () => { + const items = records() + const result = parse(JSON.stringify(mergeManifests(items, version, uploaded(items)))) + expect(result.files).toHaveLength(4) + expect(result.path).toBe(items[0].manifest.path) + expect(result.sha512).toBe(sha512) + expect(result.releaseDate).toBe(items[0].manifest.releaseDate) + }) + + it.each(['missing', 'duplicate', 'version', 'path', 'checksum', 'size', 'remote', 'blockmap'])( + 'refuses incomplete or corrupt publication: %s', + (failure) => { + const items = records() + let assets = uploaded(items).map((asset) => ({ ...asset })) + if (failure === 'missing') { + items.pop() + } + if (failure === 'duplicate') { + items[1] = record('arm64') + } + if (failure === 'version') { + items[0].manifest.version = '1.0.0' + } + if (failure === 'path') { + items[0].manifest.files[0].url = '../outside.zip' + } + if (failure === 'checksum') { + items[0].manifest.files[0].sha512 = 'bad' + } + if (failure === 'size') { + assets[0].size++ + } + if (failure === 'remote') { + assets.push({ ...assets[0] }) + } + if (failure === 'blockmap') { + assets = assets.filter((asset) => !asset.name.endsWith('.blockmap')) + } + expect(() => mergeManifests(items, version, assets)).toThrow() + } + ) + + it('checks the actual archive hashes before permitting upload', async () => { + const directory = await mkdtemp(join(tmpdir(), 'mac-manifest-')) + try { + const item = record('arm64') + for (const asset of item.assets) { + await writeFile( + join(directory, asset.name), + asset.name.endsWith('.blockmap') ? 'map!' : content + ) + } + expect(await stageManifest(item.manifest, item.arch, version, directory)).toEqual(item) + await writeFile(join(directory, item.manifest.path), Buffer.alloc(content.length)) + await expect(stageManifest(item.manifest, item.arch, version, directory)).rejects.toThrow( + 'checksum or size' + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects extra manifest entries from the wrong architecture', () => { + const item = record('x64') + item.manifest.files.push(record('arm64').manifest.files[0]) + expect(() => validateManifest(item.manifest, 'x64', version)).toThrow() + }) +}) diff --git a/config/scripts/adhoc-mac-parallelism.test.mjs b/config/scripts/adhoc-mac-parallelism.test.mjs new file mode 100644 index 00000000000..c187dc120af --- /dev/null +++ b/config/scripts/adhoc-mac-parallelism.test.mjs @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { compileAggregateExpression } from './pr-aggregate-gate-context.mjs' + +const { jobs } = parse(readFileSync('.github/workflows/adhoc-mac-build.yml', 'utf8')) +const build = jobs['build-adhoc-mac-arch'] +const final = jobs['build-adhoc-mac'] + +describe('parallel Mac release publication', () => { + it('uses isolated architecture workspaces with one shared draft and one publisher', () => { + expect(build.strategy).toEqual({ 'fail-fast': false, matrix: { arch: ['x64', 'arm64'] } }) + expect(build.needs).toContain('prepare-adhoc-mac-release') + expect(final.needs).toContain('build-adhoc-mac-arch') + expect(build.steps.some((step) => step.run?.includes('gh release create'))).toBe(false) + expect(build.steps.some((step) => step.run?.includes('--draft=false'))).toBe(false) + const upload = build.steps.find( + (step) => step.name === 'Upload verified architecture assets to the draft' + ) + expect(upload.env.TAG).toBe('${{ needs.prepare-adhoc-mac-release.outputs.tag }}') + expect(upload.run).not.toContain('latest-mac.yml') + expect(build.steps.find((step) => step.name === 'Verify signed and stapled app').run).toContain( + 'codesign --verify --deep --strict' + ) + expect(build.steps.find((step) => step.name === 'Verify signed and stapled app').run).toContain( + 'xcrun stapler validate' + ) + }) + + it.each(['failure', 'cancelled', 'skipped', 'success'])( + 'runs the final gate and cleanup after architecture result %s', + (result) => { + const needs = { + 'prepare-adhoc-mac-release': { result: 'success' }, + 'build-adhoc-mac-arch': { result } + } + expect(final.if).toContain('always()') + expect( + compileAggregateExpression(final.if.replace('always()', 'true'))({ needs }).coerceString() + ).toBe('true') + const gate = final.steps.findIndex((step) => step.name === 'Require both architecture jobs') + const merge = final.steps.findIndex( + (step) => step.name === 'Merge and verify the updater manifest' + ) + const publish = final.steps.findIndex((step) => step.id === 'publish_live') + expect(final.steps[gate].env.ARCH_RESULT).toBe('${{ needs.build-adhoc-mac-arch.result }}') + expect(final.steps[gate].run).toBe('test "$ARCH_RESULT" = success') + expect(gate).toBeLessThan(merge) + expect(merge).toBeLessThan(publish) + const cleanup = final.steps.find( + (step) => step.name === 'Discard the draft release on failure' + ) + expect(cleanup.if).toContain('failure() || cancelled()') + expect(cleanup.if).toContain("steps.publish_live.outcome != 'success'") + } + ) +}) diff --git a/config/scripts/adhoc-release-waits.test.mjs b/config/scripts/adhoc-release-waits.test.mjs index a29b0732f82..cbb0ee40218 100644 --- a/config/scripts/adhoc-release-waits.test.mjs +++ b/config/scripts/adhoc-release-waits.test.mjs @@ -5,7 +5,8 @@ import { parse } from 'yaml' const adhoc = parse(readFileSync('.github/workflows/adhoc-mac-build.yml', 'utf8')) const publisher = parse(readFileSync('.github/workflows/dev-channel-win-publish.yml', 'utf8')) const windows = parse(readFileSync('.github/workflows/dev-channel-win-build.yml', 'utf8')) -const macSteps = adhoc.jobs['build-adhoc-mac'].steps +const macSteps = adhoc.jobs['build-adhoc-mac-arch'].steps +const finalSteps = adhoc.jobs['build-adhoc-mac'].steps const publishSteps = publisher.jobs['publish-win'].steps const macWait = publishSteps.find((step) => step.name === 'Wait for successful macOS publication') const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor @@ -120,7 +121,7 @@ describe.each([ }) it('keeps the release a draft until qualification and its cleanup finish', async () => { - const final = macSteps.find( + const final = finalSteps.find( (step) => step.name === 'Confirm final template qualification before publication' ) const artifact = [{ name: 'orcad-template', expired: false }] @@ -130,8 +131,8 @@ it('keeps the release a draft until qualification and its cleanup finish', async ]) await result expect(calls.filter((call) => call.endpoint === 'artifacts')).toHaveLength(2) - expect(macSteps.indexOf(final)).toBeLessThan( - macSteps.findIndex((step) => step.id === 'publish_live') + expect(finalSteps.indexOf(final)).toBeLessThan( + finalSteps.findIndex((step) => step.id === 'publish_live') ) }) diff --git a/config/scripts/adhoc-windows-parallelism.test.mjs b/config/scripts/adhoc-windows-parallelism.test.mjs index edd6df8b99f..a1096645624 100644 --- a/config/scripts/adhoc-windows-parallelism.test.mjs +++ b/config/scripts/adhoc-windows-parallelism.test.mjs @@ -5,7 +5,8 @@ import { parse } from 'yaml' import { compileAggregateExpression } from './pr-aggregate-gate-context.mjs' const { jobs } = parse(readFileSync('.github/workflows/adhoc-mac-build.yml', 'utf8')) -const mac = jobs['build-adhoc-mac'] +const mac = jobs['build-adhoc-mac-arch'] +const final = jobs['build-adhoc-mac'] const windows = jobs['build-adhoc-win'] const publish = jobs['publish-adhoc-win'] const bundleWorkflow = parse(readFileSync('.github/workflows/release-javascript.yml', 'utf8')) @@ -17,6 +18,7 @@ function qualification(template = 'success', ships = 'true') { 'resolve-ref': { result: 'success', outputs: { sha: 'a'.repeat(40) } }, 'orcad-template-support': { result: 'success', outputs: { ships } }, 'release-javascript': { result: 'success' }, + 'prepare-adhoc-mac-release': { result: 'success' }, 'orcad-template': { result: template } } } @@ -24,14 +26,14 @@ function qualification(template = 'success', ships = 'true') { describe('adhoc Windows compilation overlaps Mac without publishing early', () => { it('starts both platforms during qualification without a Mac dependency', () => { - expect(windows.needs).toEqual(mac.needs) + expect(mac.needs).toEqual([...windows.needs, 'prepare-adhoc-mac-release']) expect(mac.needs).not.toContain('orcad-template') expect(windows.needs).not.toContain('build-adhoc-mac') expect(windows.with.stage_only).toBe(true) expect(windows.with.fast_release).toBe(true) expect(windows.with.ref).toBe('${{ needs.resolve-ref.outputs.sha }}') expect(windows.with.tag).toBe('v${{ needs.orcad-template-support.outputs.version }}') - expect(windows.with.version).toBe(mac.outputs.version) + expect(windows.with.version).toBe(final.outputs.version) expect(windows.with.orcad_template).toBe( "${{ needs.orcad-template-support.outputs.ships == 'true' }}" ) @@ -39,9 +41,10 @@ describe('adhoc Windows compilation overlaps Mac without publishing early', () = expect(identity.outputs.version).toBe('${{ steps.adhoc.outputs.version }}') expect(identity.steps.filter((step) => step.id === 'adhoc')).toHaveLength(1) expect(mac.steps.some((step) => step.id === 'adhoc')).toBe(false) - expect(mac.steps.find((step) => step.name === 'Create adhoc release').env.TAG).toBe( - windows.with.tag - ) + expect( + jobs['prepare-adhoc-mac-release'].steps.find((step) => step.name === 'Create adhoc release') + .env.TAG + ).toBe(windows.with.tag) }) it('builds portable JavaScript alongside qualification and restores it on both platforms', () => { diff --git a/config/scripts/mac-notarization-builder-contract.test.mjs b/config/scripts/mac-notarization-builder-contract.test.mjs index 90848640f01..0578a379f74 100644 --- a/config/scripts/mac-notarization-builder-contract.test.mjs +++ b/config/scripts/mac-notarization-builder-contract.test.mjs @@ -11,6 +11,7 @@ const { Platform } = require('app-builder-lib') const { PlatformPackager } = require('app-builder-lib/out/platformPackager') const { ArchiveTarget } = require('app-builder-lib/out/targets/ArchiveTarget') const { DmgTarget } = require('dmg-builder/out/dmg') +const { computeArchToTargetNamesMap } = require('app-builder-lib/out/targets/targetFactory') const { AsyncTaskManager } = require('builder-util') const { CancellationToken } = require('builder-util-runtime') const workflow = (name) => parse(readFileSync(`.github/workflows/${name}-mac-build.yml`, 'utf8')) @@ -60,6 +61,25 @@ it('the pinned builder reaches both signed slices before either real archive tar expect(events.filter((event) => event.startsWith('pass:'))).toHaveLength(4) }) +it('explicit archive targets keep the configured dual-architecture defaults out of each matrix job', () => { + const packager = { + platformSpecificBuildOptions: { + target: [ + { target: 'dmg', arch: ['x64', 'arm64'] }, + { target: 'zip', arch: ['x64', 'arm64'] } + ] + } + } + for (const arch of [1, 3]) { + const targets = computeArchToTargetNamesMap( + new Map([[arch, ['dmg', 'zip']]]), + packager, + Platform.MAC + ) + expect([...targets]).toEqual([[arch, ['dmg', 'zip']]]) + } +}) + describe('macOS build workflow policy', () => { it.each(['release', 'adhoc', 'daily', 'hourly'])( '%s uses the guarded builder, preserving the serial fallback for older source refs', @@ -74,13 +94,20 @@ describe('macOS build workflow policy', () => { step.with?.command?.includes('electron-builder --config') ) const command = step.with.command - expect(command).toContain('if [ -f config/scripts/build-mac-notarization-overlap.mjs ]') - expect(command).toContain( - `ORCA_MAC_${channel.toUpperCase()}=1 node config/scripts/build-mac-notarization-overlap.mjs` - ) - expect(command).toContain( - `else ORCA_MAC_${channel.toUpperCase()}=1 pnpm exec electron-builder` - ) + if (channel === 'adhoc') { + expect(job.strategy).toEqual({ 'fail-fast': false, matrix: { arch: ['x64', 'arm64'] } }) + expect(command).toContain('--mac dmg zip --${{ matrix.arch }} --publish never') + expect(command).toContain('ORCA_MAC_ADHOC=1 pnpm exec electron-builder') + expect(command).not.toContain('build-mac-notarization-overlap.mjs') + } else { + expect(command).toContain('if [ -f config/scripts/build-mac-notarization-overlap.mjs ]') + expect(command).toContain( + `ORCA_MAC_${channel.toUpperCase()}=1 node config/scripts/build-mac-notarization-overlap.mjs` + ) + expect(command).toContain( + `else ORCA_MAC_${channel.toUpperCase()}=1 pnpm exec electron-builder` + ) + } expect(step.with.max_attempts).toBeGreaterThan(1) for (const key of [ 'CSC_LINK', diff --git a/config/scripts/release-desktop-timing.mjs b/config/scripts/release-desktop-timing.mjs index c6d222fbd52..100bec23c46 100644 --- a/config/scripts/release-desktop-timing.mjs +++ b/config/scripts/release-desktop-timing.mjs @@ -71,15 +71,22 @@ export function summarizeReleaseDesktopTiming(run) { ) const jobs = run.jobs.filter((job) => job.conclusion !== 'skipped') const mac = jobs.find((job) => job.name === 'build-adhoc-mac') + const macJobs = jobs.filter((job) => /^build-adhoc-mac(?:-arch)?(?: \(|$)/.test(job.name)) const windows = jobs.filter((job) => /^(?:build|stage|publish)-adhoc-win(?:\b|\/)/.test(job.name)) - const template = jobs.find((job) => job.name === 'orcad-template / desktop_template') + const template = jobs.find( + (job) => job.name.startsWith('orcad-template / ') && job.name.endsWith('/ desktop_template') + ) assert(mac && windows.length > 0 && template, 'Missing Mac, Windows, or qualified template job') assert( - [mac, ...windows, template].every((job) => job.conclusion === 'success'), + [...macJobs, ...windows, template].every((job) => job.conclusion === 'success'), 'Desktop or template job failed' ) const qualification = jobs.filter((job) => job.name.startsWith('orcad-template / ')) - const macInterval = interval(mac) + const macIntervals = macJobs.map(interval) + const macInterval = { + start: Math.min(...macIntervals.map(({ start }) => start)), + end: Math.max(...macIntervals.map(({ end }) => end)) + } const windowsIntervals = windows.map(interval) const desktopStart = Math.min(macInterval.start, ...windowsIntervals.map(({ start }) => start)) const desktopEnd = Math.max(macInterval.end, ...windowsIntervals.map(({ end }) => end)) @@ -113,7 +120,7 @@ export function summarizeReleaseDesktopTiming(run) { /^(?:Stage Windows artifacts for gated publishing|Download the staged Windows artifacts)$/ ) }, - desktopJobs: [mac, ...windows].map((job) => { + desktopJobs: [...macJobs, ...windows].map((job) => { const { start, end } = interval(job) return { name: job.name, diff --git a/config/scripts/release-desktop-timing.test.mjs b/config/scripts/release-desktop-timing.test.mjs index 8ab947f46ab..74ac745ce1b 100644 --- a/config/scripts/release-desktop-timing.test.mjs +++ b/config/scripts/release-desktop-timing.test.mjs @@ -106,6 +106,19 @@ describe('release desktop timing evidence', () => { expect(report.windowsPhases.artifactHandoff.seconds).toBe(20) }) + it('includes parallel Mac packaging before the final publication job', () => { + const input = run([job('build-adhoc-win / build-win', 20, 80)], 90, 100) + input.jobs[1].name = 'orcad-template / qualify / desktop_template' + input.jobs.push(job('build-adhoc-mac-arch (x64)', 15, 85, 25)) + input.jobs.push(job('build-adhoc-mac-arch (arm64)', 15, 90, 30)) + const report = summarizeReleaseDesktopTiming(input) + expect(report.measuredSeconds.macJob).toBe(75) + expect(report.measuredSeconds.desktopJobOverlap).toBe(55) + expect(report.desktopJobs).toHaveLength(4) + input.jobs.at(-1).conclusion = 'failure' + expect(() => summarizeReleaseDesktopTiming(input)).toThrow('Desktop or template job failed') + }) + it('rejects incomplete runs, failed desktop jobs, and missing timing evidence', () => { const input = run([job('build-adhoc-win / build-win', 100, 160)]) expect(() => summarizeReleaseDesktopTiming({ ...input, status: 'in_progress' })).toThrow( diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index ca4dba9810d..4bc99845e33 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -13,11 +13,15 @@ const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relat describe('ref-mirroring vet steps', () => { it.each(['daily', 'hourly', 'adhoc'])('%s builds only need the current commit', (channel) => { const { jobs } = readWorkflow(`.github/workflows/${channel}-mac-build.yml`) - const job = jobs[`build-${channel}-mac`] + const job = jobs[channel === 'adhoc' ? 'build-adhoc-mac-arch' : `build-${channel}-mac`] const identity = channel === 'adhoc' ? jobs['orcad-template-support'] : job const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6') expect(checkout.with['fetch-depth']).toBe(1) - expect(job.steps.some((step) => step.run?.includes('gh release list'))).toBe(true) + expect( + (channel === 'adhoc' ? jobs['build-adhoc-mac'] : job).steps.some((step) => + step.run?.includes('gh release list') + ) + ).toBe(true) expect( identity.steps.some((step) => step.run?.includes('ORCA_PUBLISHED_VERSIONS="$published"')) ).toBe(true) @@ -31,7 +35,9 @@ describe('ref-mirroring vet steps', () => { (channel) => { const { jobs } = readWorkflow(`.github/workflows/${channel}-mac-build.yml`) const identity = - channel === 'adhoc' ? jobs['orcad-template-support'] : jobs[`build-${channel}-mac`] + channel === 'adhoc' + ? jobs['orcad-template-support'] + : jobs[channel === 'adhoc' ? 'build-adhoc-mac-arch' : `build-${channel}-mac`] const step = identity.steps.find( (candidate) => candidate.name === `Compute ${channel} version` ) diff --git a/config/scripts/workflow-ref-reachability.test.mjs b/config/scripts/workflow-ref-reachability.test.mjs index 7c508e5b9f6..bf753c3815d 100644 --- a/config/scripts/workflow-ref-reachability.test.mjs +++ b/config/scripts/workflow-ref-reachability.test.mjs @@ -10,7 +10,7 @@ const readWorkflow = (name) => parse(readFileSync(`.github/workflows/${name}.yml const windowsVet = readWorkflow('dev-channel-win-build').jobs['build-win'].steps.find( (step) => step.id === 'vetted' ) -const macSteps = readWorkflow('adhoc-mac-build').jobs['build-adhoc-mac'].steps +const macSteps = readWorkflow('adhoc-mac-build').jobs['build-adhoc-mac-arch'].steps const macVet = macSteps.find((step) => step.id === 'vetted') const macCheckout = macSteps.find((step) => step.name === 'Checkout the requested ref') const directory = mkdtempSync(join(tmpdir(), 'workflow-ref-reachability-'))