From 8d41132adeb35e813df5e4dc91208c9fcaf4ef59 Mon Sep 17 00:00:00 2001 From: Alexander Miller Date: Thu, 22 Oct 2026 13:28:01 -0400 Subject: [PATCH] LOCAL: build each macOS slice on its own architecture's runner Since upstream #27021, packaging boots each slice's signed terminal host and requires a PTY within 20 s. The fork packaged both slices on one hosted arm64 runner, where the x64 host runs under Rosetta and missed that window on both attempts of build 23 (run 39820225306), so 23cb211af5's release shipped without macOS assets. Upstream packages on faster Blacksmith runners. build-mac is now a matrix: x64 on macos-15-intel, arm64 on macos-15. Each leg packages, zips, signs, uploads and verifies only its own slice. A leg whose manifest fails verification removes only that slice's manifest, so one bad leg no longer takes the other's verified update away. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/swaplabs-build.yml | 122 ++++++++++-------- ...aplabs-build-workflow-mac-signing.test.mjs | 121 ++++++++++------- .../scripts/swaplabs-build-workflow.test.mjs | 17 ++- 3 files changed, 157 insertions(+), 103 deletions(-) diff --git a/.github/workflows/swaplabs-build.yml b/.github/workflows/swaplabs-build.yml index 8627ef7d8ff3..b670123cefd2 100644 --- a/.github/workflows/swaplabs-build.yml +++ b/.github/workflows/swaplabs-build.yml @@ -428,12 +428,27 @@ jobs: # exists, so electron-updater never tries Squirrel.Mac on a fork build. build-mac: needs: [preflight, draft] - runs-on: macos-15 + strategy: + # Each leg ships and verifies only its own slice, so one failure never hides the other. + fail-fast: false + matrix: + # Why a native runner per slice: packaging boots each slice's signed terminal host and + # requires a PTY within 20 s (upstream #27021). Under Rosetta on a hosted arm64 runner + # the x64 host misses that window (run 23); upstream's faster Blacksmith runners don't. + include: + - arch: x64 + runner: macos-15-intel + app: dist/mac/Orca.app + - arch: arm64 + runner: macos-15 + app: dist/mac-arm64/Orca.app + runs-on: ${{ matrix.runner }} timeout-minutes: 90 permissions: contents: write env: NODE_OPTIONS: --max-old-space-size=4096 + MAC_ARCH: ${{ matrix.arch }} ORCA_LOCAL_BUILD_VERSION: ${{ needs.preflight.outputs.version }} ORCA_BUILD_COMMIT: ${{ needs.preflight.outputs.commit }} ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token @@ -495,9 +510,9 @@ jobs: path: | ~/Library/Caches/electron ~/Library/Caches/electron-builder - key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }} + key: electron-builder-mac-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | - electron-builder-mac- + electron-builder-mac-${{ matrix.arch }}- # Why a throwaway keychain, like the iOS release: the .p12 is imported into a # keychain that exists only for this job, unlocked with a random password @@ -548,8 +563,8 @@ jobs: echo "SWAPLABS_SIGNING_CERT_PEM=$cert_pem" } >>"$GITHUB_ENV" - # Why both CPUs: the mac config packages x64 and arm64 from this arm64 - # runner, so the install must carry both variants of the native optional deps. + # Why install:release on a native leg: it is the install upstream's release packaging + # is written and guarded for; the extra CPU variant costs only download time. - name: Install dependencies uses: nick-fields/retry@v4 with: @@ -583,9 +598,9 @@ jobs: timeout_minutes: 30 max_attempts: 2 retry_wait_seconds: 30 - command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --mac dmg --x64 --arm64 --publish never + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --mac dmg --${{ matrix.arch }} --publish never - # Per arch: zip the signed bundle the DMG was built from, then write the + # This leg's slice: zip the signed bundle the DMG was built from, then write the # manifest (size, sha512, designated-requirement hash) and sign it. The # manifest script refuses an ad-hoc bundle, so a silently unsigned build can # never ship as installable. The local verify runs with the public key the @@ -596,29 +611,28 @@ jobs: shell: bash env: SWAPLABS_UPDATE_SIGNING_KEY: ${{ secrets.SWAPLABS_UPDATE_SIGNING_KEY }} + APP: ${{ matrix.app }} run: | set -euo pipefail - for arch in x64 arm64; do - app="dist/mac/Orca.app" - [[ "$arch" == "arm64" ]] && app="dist/mac-arm64/Orca.app" - if [[ ! -d "$app" ]]; then - echo "::error::electron-builder left no $app; the $arch bundle was not packaged." - exit 1 - fi - zip="dist/orca-macos-$arch.zip" - rm -f "$zip" - # Why ditto: the updater extracts with `ditto -x -k`, and the framework - # symlinks and the code signature must survive the round trip. - ditto -c -k --keepParent "$app" "$zip" - node config/scripts/swaplabs-mac-update-manifest.mjs emit \ - --arch "$arch" --app "$app" --zip "$zip" \ - --version "$ORCA_LOCAL_BUILD_VERSION" --commit "$ORCA_BUILD_COMMIT" --out-dir dist - node config/scripts/swaplabs-mac-update-manifest.mjs verify \ - --arch "$arch" --zip "$zip" \ - --manifest "dist/swaplabs-update-mac-$arch.json" \ - --signature "dist/swaplabs-update-mac-$arch.json.sig" \ - --version "$ORCA_LOCAL_BUILD_VERSION" --commit "$ORCA_BUILD_COMMIT" - done + arch="$MAC_ARCH" + app="$APP" + if [[ ! -d "$app" ]]; then + echo "::error::electron-builder left no $app; the $arch bundle was not packaged." + exit 1 + fi + zip="dist/orca-macos-$arch.zip" + rm -f "$zip" + # Why ditto: the updater extracts with `ditto -x -k`, and the framework + # symlinks and the code signature must survive the round trip. + ditto -c -k --keepParent "$app" "$zip" + node config/scripts/swaplabs-mac-update-manifest.mjs emit \ + --arch "$arch" --app "$app" --zip "$zip" \ + --version "$ORCA_LOCAL_BUILD_VERSION" --commit "$ORCA_BUILD_COMMIT" --out-dir dist + node config/scripts/swaplabs-mac-update-manifest.mjs verify \ + --arch "$arch" --zip "$zip" \ + --manifest "dist/swaplabs-update-mac-$arch.json" \ + --signature "dist/swaplabs-update-mac-$arch.json.sig" \ + --version "$ORCA_LOCAL_BUILD_VERSION" --commit "$ORCA_BUILD_COMMIT" - name: Confirm the release still exists shell: bash @@ -660,7 +674,7 @@ jobs: timeout_minutes: 20 max_attempts: 3 retry_wait_seconds: 30 - command: gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber dist/orca-macos-x64.zip dist/orca-macos-arm64.zip + command: gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber "dist/orca-macos-$MAC_ARCH.zip" env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.draft.outputs.tag }} @@ -673,7 +687,7 @@ jobs: max_attempts: 3 retry_wait_seconds: 30 # One file per call, in order: gh uploads several files concurrently. - command: gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber dist/swaplabs-update-mac-x64.json.sig && gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber dist/swaplabs-update-mac-x64.json && gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber dist/swaplabs-update-mac-arm64.json.sig && gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber dist/swaplabs-update-mac-arm64.json + command: gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber "dist/swaplabs-update-mac-$MAC_ARCH.json.sig" && gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber "dist/swaplabs-update-mac-$MAC_ARCH.json" env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.draft.outputs.tag }} @@ -705,8 +719,8 @@ jobs: --jq '.assets[] | select(.state == "uploaded" and .size > 0) | .name')" echo "Uploaded assets:" echo "$assets" - if ! grep -q '\.dmg$' <<<"$assets"; then - echo "::error::Fork release $TAG is missing a DMG." + if ! grep -qx "orca-macos-$MAC_ARCH.dmg" <<<"$assets"; then + echo "::error::Fork release $TAG is missing orca-macos-$MAC_ARCH.dmg." exit 1 fi if grep -qx 'latest-mac.yml' <<<"$assets"; then @@ -718,17 +732,15 @@ jobs: echo "::error::Fork release $TAG carries an update manifest although this build is ad-hoc signed and download-only." exit 1 fi - echo "Download-only macOS assets verified." + echo "Download-only $MAC_ARCH macOS assets verified." exit 0 fi failed=0 - for arch in x64 arm64; do - for required in "orca-macos-$arch.zip" "swaplabs-update-mac-$arch.json" "swaplabs-update-mac-$arch.json.sig"; do - if ! grep -qx "$required" <<<"$assets"; then - echo "::error::Fork release $TAG is missing $required." - failed=1 - fi - done + for required in "orca-macos-$MAC_ARCH.zip" "swaplabs-update-mac-$MAC_ARCH.json" "swaplabs-update-mac-$MAC_ARCH.json.sig"; do + if ! grep -qx "$required" <<<"$assets"; then + echo "::error::Fork release $TAG is missing $required." + failed=1 + fi done downloads="$RUNNER_TEMP/swaplabs-mac-verify" outcome="did not verify" @@ -740,32 +752,30 @@ jobs: downloaded=0 for attempt in 1 2 3; do (( attempt == 1 )) || sleep 10 - if gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'orca-macos-*.zip' --pattern 'swaplabs-update-mac-*' --dir "$downloads" --clobber; then + if gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "orca-macos-$MAC_ARCH.zip" --pattern "swaplabs-update-mac-$MAC_ARCH.json*" --dir "$downloads" --clobber; then downloaded=1 break fi echo "::warning::Attempt $attempt could not download the macOS update assets of $TAG for verification." done if (( downloaded )); then - for arch in x64 arm64; do - node config/scripts/swaplabs-mac-update-manifest.mjs verify \ - --arch "$arch" --zip "$downloads/orca-macos-$arch.zip" \ - --manifest "$downloads/swaplabs-update-mac-$arch.json" \ - --signature "$downloads/swaplabs-update-mac-$arch.json.sig" \ - --version "$VERSION" --commit "$COMMIT" || failed=1 - done + node config/scripts/swaplabs-mac-update-manifest.mjs verify \ + --arch "$MAC_ARCH" --zip "$downloads/orca-macos-$MAC_ARCH.zip" \ + --manifest "$downloads/swaplabs-update-mac-$MAC_ARCH.json" \ + --signature "$downloads/swaplabs-update-mac-$MAC_ARCH.json.sig" \ + --version "$VERSION" --commit "$COMMIT" || failed=1 else outcome="could not be downloaded from the release for verification" failed=1 fi fi if (( failed )); then - # Every manifest or signature still attached, in any upload state; a - # listing failure is an unknown, never an empty result. + # This slice's manifest or signature still attached, in any upload state; the + # other leg owns its own. A listing failure is an unknown, never an empty result. stray_manifests() { local names names="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name')" || return 1 - grep -E '^swaplabs-update-mac-.*\.json(\.sig)?$' <<<"$names" || true + grep -xF -e "swaplabs-update-mac-$MAC_ARCH.json" -e "swaplabs-update-mac-$MAC_ARCH.json.sig" <<<"$names" || true } live="unknown" attached=0 @@ -782,17 +792,17 @@ jobs: [[ -n "$live" ]] || break done if [[ "$live" == "unknown" ]]; then - echo "::error::Fork release $TAG: the macOS update manifests $outcome, and whether they were removed could not be confirmed; check the release's assets by hand." + echo "::error::Fork release $TAG: the $MAC_ARCH macOS update manifests $outcome, and whether they were removed could not be confirmed; check the release's assets by hand." elif [[ -n "$live" ]]; then - echo "::error::Fork release $TAG: the macOS update manifests $outcome and could not be removed; still attached: ${live//$'\n'/ }. Delete them by hand before an installed build fetches them." + echo "::error::Fork release $TAG: the $MAC_ARCH macOS update manifests $outcome and could not be removed; still attached: ${live//$'\n'/ }. Delete them by hand before an installed build fetches them." elif (( attached )); then - echo "::error::Fork release $TAG: the macOS update manifests $outcome and were removed; the DMGs stay as download-only assets." + echo "::error::Fork release $TAG: the $MAC_ARCH macOS update manifests $outcome and were removed; its DMG stays as a download-only asset." else - echo "::error::Fork release $TAG: the macOS update manifests $outcome; none was attached, so the DMGs stay as download-only assets." + echo "::error::Fork release $TAG: the $MAC_ARCH macOS update manifests $outcome; none was attached, so its DMG stays as a download-only asset." fi exit 1 fi - echo "Signed macOS update assets verified for $VERSION." + echo "Signed $MAC_ARCH macOS update assets verified for $VERSION." # The VM is discarded anyway; this keeps the key from outliving the job if # that ever changes. Why no trust removal: every signed run (36462741113, diff --git a/config/scripts/swaplabs-build-workflow-mac-signing.test.mjs b/config/scripts/swaplabs-build-workflow-mac-signing.test.mjs index f8d18dcaafcb..90aadb1f6875 100644 --- a/config/scripts/swaplabs-build-workflow-mac-signing.test.mjs +++ b/config/scripts/swaplabs-build-workflow-mac-signing.test.mjs @@ -161,13 +161,16 @@ describe('swaplabs fork build macOS signing', () => { const produce = stepNamed(mac, 'Produce the update zips and signed manifests') expect(produce.if).toBe(SIGNING_GATE) expect(produce.env).toEqual({ - [SWAPLABS_UPDATE_SIGNING_KEY_ENV]: `\${{ secrets.${SWAPLABS_SIGNING_SECRETS.signingKey} }}` + [SWAPLABS_UPDATE_SIGNING_KEY_ENV]: `\${{ secrets.${SWAPLABS_SIGNING_SECRETS.signingKey} }}`, + APP: '${{ matrix.app }}' }) expect(index(produce.name)).toBeGreaterThan(index('Package macOS artifacts')) expect(index(produce.name)).toBeLessThan(index('Confirm the release still exists')) - expect(produce.run).toContain('for arch in x64 arm64; do') + // One slice per leg: the matrix names the arch and the bundle electron-builder left. + expect(produce.run).toContain('arch="$MAC_ARCH"') + expect(produce.run).toContain('app="$APP"') + expect(produce.run).not.toMatch(/for arch in/) expect(produce.run).toContain('ditto -c -k --keepParent "$app" "$zip"') - expect(produce.run).toContain('dist/mac-arm64/Orca.app') expect(produce.run).toContain('swaplabs-mac-update-manifest.mjs emit') expect(produce.run).toContain('--app "$app" --zip "$zip"') expect(produce.run).toContain( @@ -205,18 +208,21 @@ describe('swaplabs fork build macOS signing', () => { expect(step.with.command).not.toContain('latest-mac.yml') } expect(zips.with.command).toBe( - 'gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber dist/orca-macos-x64.zip dist/orca-macos-arm64.zip' + 'gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber "dist/orca-macos-$MAC_ARCH.zip"' ) const uploads = manifests.with.command.split(' && ') + // Signature first: a manifest the app can see always has its signature. expect(uploads).toEqual( - MAC_ARCHES.flatMap((arch) => { - const names = swaplabsMacUpdateAssetNames(arch) - // Signature first: a manifest the app can see always has its signature. - return [names.signature, names.manifest].map( - (name) => `gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber dist/${name}` - ) - }) + ['swaplabs-update-mac-$MAC_ARCH.json.sig', 'swaplabs-update-mac-$MAC_ARCH.json'].map( + (name) => `gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber "dist/${name}"` + ) ) + for (const arch of MAC_ARCHES) { + const names = swaplabsMacUpdateAssetNames(arch) + expect(names.manifest).toBe(`swaplabs-update-mac-${arch}.json`) + expect(names.signature).toBe(`swaplabs-update-mac-${arch}.json.sig`) + expect(names.zip).toBe(`orca-macos-${arch}.zip`) + } expect(verify.env).toMatchObject({ VERSION: '${{ needs.preflight.outputs.version }}', COMMIT: '${{ needs.preflight.outputs.commit }}', @@ -371,6 +377,7 @@ describe('swaplabs fork build macOS asset verification', () => { VERSION: version, COMMIT: commit, SIGNING_ENABLED: 'true', + MAC_ARCH: 'arm64', ASSETS: signedAssets.join(' '), FIXTURES: fixtures.directory, [SWAPLABS_UPDATE_PUBLIC_KEY_ENV]: fixtures.publicKey, @@ -378,33 +385,42 @@ describe('swaplabs fork build macOS asset verification', () => { } }) - it('passes when the uploaded zips, manifests and signatures verify against the compiled-in key', async () => { - const fixtures = await signedFixtures() - try { - const result = await check(fixtures) - expect(result.exitCode, `${result.stdout} ${result.stderr}`).toBe(0) - expect(result.stdout).toContain(`Signed macOS update assets verified for ${version}`) - expect(result.stdout).not.toContain('deleted') - } finally { - fixtures.cleanup() + it.each(MAC_ARCHES)( + 'passes the %s leg when its zip, manifest and signature verify against the compiled-in key', + async (arch) => { + const fixtures = await signedFixtures() + try { + const result = await check(fixtures, { MAC_ARCH: arch }) + expect(result.exitCode, `${result.stdout} ${result.stderr}`).toBe(0) + expect(result.stdout).toContain( + `Signed ${arch} macOS update assets verified for ${version}` + ) + expect(result.stdout).not.toContain('deleted') + } finally { + fixtures.cleanup() + } } - }) + ) // The reconciliation the plan requires: a manifest that does not verify must // not stay where a live release's updater can fetch it. - it('removes both manifests and their signatures when an uploaded zip does not match', async () => { + // Each leg owns its slice: a bad arm64 upload costs arm64 its manifest and leaves the + // x64 leg's verified one alone. + it('removes its own manifest and signature, leaving the other slice alone, when its zip does not match', async () => { const fixtures = await signedFixtures() try { writeFileSync(join(fixtures.directory, 'orca-macos-arm64.zip'), 'corrupted upload') const result = await check(fixtures) expect(result.exitCode).not.toBe(0) expect(result.stderr).toContain('sha512 does not match') - for (const arch of MAC_ARCHES) { - const names = swaplabsMacUpdateAssetNames(arch) - expect(result.stdout).toContain(`deleted ${names.manifest}`) - expect(result.stdout).toContain(`deleted ${names.signature}`) - } + const names = swaplabsMacUpdateAssetNames('arm64') + expect(result.stdout).toContain(`deleted ${names.manifest}`) + expect(result.stdout).toContain(`deleted ${names.signature}`) + expect(result.stdout).not.toContain('deleted swaplabs-update-mac-x64') expect(result.stdout).toContain('did not verify and were removed') + const x64 = await check(fixtures, { MAC_ARCH: 'x64' }) + expect(x64.exitCode, `${x64.stdout} ${x64.stderr}`).toBe(0) + expect(x64.stdout).not.toContain('deleted') } finally { fixtures.cleanup() } @@ -414,31 +430,39 @@ describe('swaplabs fork build macOS asset verification', () => { const fixtures = await signedFixtures() try { const other = exportSwaplabsUpdatePublicKey(generateKeyPairSync('ed25519').privateKey) - const result = await check(fixtures, { [SWAPLABS_UPDATE_PUBLIC_KEY_ENV]: other }) + const result = await check(fixtures, { + MAC_ARCH: 'x64', + [SWAPLABS_UPDATE_PUBLIC_KEY_ENV]: other + }) expect(result.exitCode).not.toBe(0) expect(result.stderr).toContain('signature does not verify') expect(result.stdout).toContain('deleted swaplabs-update-mac-x64.json') + expect(result.stdout).not.toContain('deleted swaplabs-update-mac-arm64') } finally { fixtures.cleanup() } }) it.each(signedAssets.filter((name) => !name.endsWith('.dmg')))( - 'fails, removing the manifests that did upload, when %s never uploaded', + 'fails, removing the manifests of this slice that did upload, when %s never uploaded', async (missing) => { const fixtures = await signedFixtures() try { + const arch = MAC_ARCHES.find((candidate) => missing.includes(`-${candidate}.`)) const result = await check(fixtures, { + MAC_ARCH: arch, ASSETS: signedAssets.filter((name) => name !== missing).join(' ') }) expect(result.exitCode).not.toBe(0) expect(result.stdout).toContain(`missing ${missing}`) - // Only what the release holds is deleted: a missing name is already gone. + // Only what the release holds is deleted, and only this slice's: a missing name is + // already gone, and the other leg owns its own. + const names = swaplabsMacUpdateAssetNames(arch) const deleted = result.stdout.split('\n').filter((line) => line.startsWith('deleted ')) expect(new Set(deleted)).toEqual( new Set( - signedAssets - .filter((name) => /\.json(\.sig)?$/.test(name) && name !== missing) + [names.manifest, names.signature] + .filter((name) => name !== missing) .map((name) => `deleted ${name}`) ) ) @@ -485,11 +509,12 @@ describe('swaplabs fork build macOS asset verification', () => { '::warning::Attempt 3 could not delete swaplabs-update-mac-arm64.json' ) expect(result.stdout).toContain( - '::error::Fork release swaplabs-v1.4.197+202609241530: the macOS update manifests did not verify and could not be removed; still attached: swaplabs-update-mac-arm64.json.' + '::error::Fork release swaplabs-v1.4.197+202609241530: the arm64 macOS update manifests did not verify and could not be removed; still attached: swaplabs-update-mac-arm64.json.' ) expect(result.stdout).not.toContain('were removed') - // The other three were removed and are not reported as attached. - expect(result.stdout).toContain('deleted swaplabs-update-mac-x64.json') + // Its signature was removed and is not reported as attached; x64 is not this leg's. + expect(result.stdout).toContain('deleted swaplabs-update-mac-arm64.json.sig') + expect(result.stdout).not.toContain('deleted swaplabs-update-mac-x64') expect(result.stdout).not.toContain('still attached: swaplabs-update-mac-x64') } finally { fixtures.cleanup() @@ -516,12 +541,12 @@ describe('swaplabs fork build macOS asset verification', () => { it('reports that no manifest was attached when the uploads never got that far', async () => { const fixtures = await signedFixtures() try { - const result = await check(fixtures, { ASSETS: dmgs.join(' ') }) + const result = await check(fixtures, { MAC_ARCH: 'x64', ASSETS: dmgs.join(' ') }) expect(result.exitCode).not.toBe(0) expect(result.stdout).toContain('missing orca-macos-x64.zip') expect(result.stdout).not.toContain('deleted') expect(result.stdout).toContain( - 'did not verify; none was attached, so the DMGs stay as download-only assets.' + 'the x64 macOS update manifests did not verify; none was attached, so its DMG stays as a download-only asset.' ) expect(result.stdout).not.toContain('were removed') } finally { @@ -537,7 +562,7 @@ describe('swaplabs fork build macOS asset verification', () => { expect(result.stdout).toContain( '::warning::Attempt 1 could not download the macOS update assets of swaplabs-v1.4.197+202609241530 for verification.' ) - expect(result.stdout).toContain(`Signed macOS update assets verified for ${version}`) + expect(result.stdout).toContain(`Signed arm64 macOS update assets verified for ${version}`) expect(result.stdout).not.toContain('deleted') } finally { fixtures.cleanup() @@ -552,11 +577,10 @@ describe('swaplabs fork build macOS asset verification', () => { const result = await check(fixtures, { DOWNLOAD_FAILURES: '99' }) expect(result.exitCode).not.toBe(0) expect(result.stdout).toContain('::warning::Attempt 3 could not download') - for (const arch of MAC_ARCHES) { - const names = swaplabsMacUpdateAssetNames(arch) - expect(result.stdout).toContain(`deleted ${names.manifest}`) - expect(result.stdout).toContain(`deleted ${names.signature}`) - } + const names = swaplabsMacUpdateAssetNames('arm64') + expect(result.stdout).toContain(`deleted ${names.manifest}`) + expect(result.stdout).toContain(`deleted ${names.signature}`) + expect(result.stdout).not.toContain('deleted swaplabs-update-mac-x64') expect(result.stdout).toContain( 'could not be downloaded from the release for verification and were removed' ) @@ -571,7 +595,14 @@ describe('swaplabs fork build macOS asset verification', () => { try { const ok = await check(fixtures, { SIGNING_ENABLED: 'false', ASSETS: dmgs.join(' ') }) expect(ok.exitCode, ok.stdout).toBe(0) - expect(ok.stdout).toContain('Download-only macOS assets verified') + expect(ok.stdout).toContain('Download-only arm64 macOS assets verified') + // The other leg's DMG does not stand in for this one's. + const otherDmg = await check(fixtures, { + SIGNING_ENABLED: 'false', + ASSETS: 'orca-macos-x64.dmg' + }) + expect(otherDmg.exitCode).not.toBe(0) + expect(otherDmg.stdout).toContain('missing orca-macos-arm64.dmg') const stray = await check(fixtures, { SIGNING_ENABLED: 'false', ASSETS: [...dmgs, 'swaplabs-update-mac-x64.json'].join(' ') @@ -585,7 +616,7 @@ describe('swaplabs fork build macOS asset verification', () => { expect(squirrel.stdout).toContain('carries latest-mac.yml') const noDmg = await check(fixtures, { SIGNING_ENABLED: 'false', ASSETS: '' }) expect(noDmg.exitCode).not.toBe(0) - expect(noDmg.stdout).toContain('missing a DMG') + expect(noDmg.stdout).toContain('missing orca-macos-arm64.dmg') } finally { fixtures.cleanup() } diff --git a/config/scripts/swaplabs-build-workflow.test.mjs b/config/scripts/swaplabs-build-workflow.test.mjs index eaf4d2504d54..3a9c4a4dc86c 100644 --- a/config/scripts/swaplabs-build-workflow.test.mjs +++ b/config/scripts/swaplabs-build-workflow.test.mjs @@ -63,7 +63,7 @@ describe('swaplabs fork build triggers and guards', () => { expect(runner).not.toMatch(/blacksmith|windows/i) } expect(runners).toEqual( - expect.arrayContaining(['ubuntu-latest', 'ubuntu-24.04-arm', 'macos-15']) + expect.arrayContaining(['ubuntu-latest', 'ubuntu-24.04-arm', 'macos-15', 'macos-15-intel']) ) for (const job of Object.values(jobs)) { expect(commandsOf(job)).not.toMatch(/--win\b|windows-setup/) @@ -158,12 +158,25 @@ describe('swaplabs fork build identity', () => { // Why --publish never everywhere: the orca-fork-ops tag is not `v`, // which is the only tag electron-builder's publisher can upload into. + // Packaging boots each slice's signed terminal host; an x64 slice under Rosetta on a + // hosted arm64 runner misses that boot window, so every slice builds on its own CPU. + it('builds each macOS slice on a runner of its own architecture', () => { + const mac = jobs['build-mac'] + expect(mac['runs-on']).toBe('${{ matrix.runner }}') + expect(mac.strategy['fail-fast']).toBe(false) + expect(mac.strategy.matrix.include).toEqual([ + { arch: 'x64', runner: 'macos-15-intel', app: 'dist/mac/Orca.app' }, + { arch: 'arm64', runner: 'macos-15', app: 'dist/mac-arm64/Orca.app' } + ]) + expect(mac.env.MAC_ARCH).toBe('${{ matrix.arch }}') + }) + it('packages with --publish never and uploads into the draft with gh', () => { const linuxPackage = stepNamed(jobs['build-linux'], 'Package Linux artifacts').with.command expect(linuxPackage).toContain('--linux AppImage deb rpm --${{ matrix.arch }} --publish never') expect(linuxPackage).not.toContain('--publish always') const macPackage = stepNamed(jobs['build-mac'], 'Package macOS artifacts').with.command - expect(macPackage).toContain('--mac dmg --x64 --arm64 --publish never') + expect(macPackage).toContain('--mac dmg --${{ matrix.arch }} --publish never') for (const [name, upload] of [ ['build-linux', 'Upload Linux artifacts'], ['build-mac', 'Upload macOS artifacts']