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']