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
This commit is contained in:
Neil
2026-10-20 22:30:47 -07:00
committed by GitHub
parent 546304fa11
commit 309d71c003
12 changed files with 585 additions and 119 deletions
+207 -93
View File
@@ -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.
@@ -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')
+135
View File
@@ -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
})
}
+103
View File
@@ -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()
})
})
@@ -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'")
}
)
})
+5 -4
View File
@@ -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')
)
})
@@ -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', () => {
@@ -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',
+11 -4
View File
@@ -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,
@@ -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(
@@ -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`
)
@@ -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-'))