From 531bcc9a0e2883ae4bd95bc0a1b0d786a17d7360 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:07:16 -0700 Subject: [PATCH] build: publish linux arm64 release artifacts (#4755) --- .github/workflows/release-cut.yml | 10 ++++-- config/electron-builder.config.cjs | 5 +-- .../scripts/electron-builder-config.test.mjs | 24 +++++++++++-- config/scripts/node-old-space-limit.mjs | 19 ++++++++++ config/scripts/node-old-space-limit.test.mjs | 20 +++++++++++ ...package-electron-runtime-contract.test.mjs | 12 +++++-- config/scripts/run-electron-vite-build.mjs | 10 ++---- config/scripts/run-vite-web-build.mjs | 33 +++++++++++++++++ .../verify-release-required-assets.mjs | 11 +++++- .../verify-release-required-assets.test.mjs | 36 +++++++++++++++++-- package.json | 4 +-- 11 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 config/scripts/node-old-space-limit.mjs create mode 100644 config/scripts/node-old-space-limit.test.mjs create mode 100644 config/scripts/run-vite-web-build.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index bacd66b89..3f505ca75 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -716,8 +716,14 @@ jobs: ~\AppData\Local\electron\Cache ~\AppData\Local\electron-builder\Cache - os: ubuntu-latest - platform: linux - release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always + platform: linux-x64 + release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always + eb_cache_path: | + ~/.cache/electron + ~/.cache/electron-builder + - os: ubuntu-24.04-arm + platform: linux-arm64 + release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always eb_cache_path: | ~/.cache/electron ~/.cache/electron-builder diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index f6d983062..b0fc6ab16 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -9,6 +9,7 @@ const { } = require('./packaged-runtime-node-modules.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' +const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' @@ -273,12 +274,12 @@ module.exports = { }, featureWallResources ], - target: ['AppImage', 'deb', 'rpm'], + target: ['AppImage', 'deb'], maintainer: 'stablyai', category: 'Utility' }, appImage: { - artifactName: 'orca-linux.${ext}' + artifactName: isLinuxArm64Release ? 'orca-linux-arm64.${ext}' : 'orca-linux.${ext}' }, deb: { packageName: 'orca-ide', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 8214344f3..12df323c5 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -71,8 +71,8 @@ describe('electron-builder config', () => { expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca') }) - it('builds RPMs without changing existing Linux artifact names', () => { - expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm']) + it('uses AppImage and deb as local Linux targets without changing existing artifact names', () => { + expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb']) expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}') expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}') expect(electronBuilderConfig.rpm).toMatchObject({ @@ -81,6 +81,26 @@ describe('electron-builder config', () => { }) }) + it('uses a distinct AppImage name for Linux arm64 release uploads', () => { + const configPath = require.resolve('../electron-builder.config.cjs') + const original = process.env.ORCA_LINUX_ARM64_RELEASE + try { + delete require.cache[configPath] + process.env.ORCA_LINUX_ARM64_RELEASE = '1' + expect(require('../electron-builder.config.cjs').appImage.artifactName).toBe( + 'orca-linux-arm64.${ext}' + ) + } finally { + if (original === undefined) { + delete process.env.ORCA_LINUX_ARM64_RELEASE + } else { + process.env.ORCA_LINUX_ARM64_RELEASE = original + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } + }) + it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) expect(electronBuilderConfig.npmRebuild).toBe(true) diff --git a/config/scripts/node-old-space-limit.mjs b/config/scripts/node-old-space-limit.mjs new file mode 100644 index 000000000..2c3e22400 --- /dev/null +++ b/config/scripts/node-old-space-limit.mjs @@ -0,0 +1,19 @@ +import os from 'node:os' + +const maxOldSpaceSizeMb = 4096 +const minOldSpaceSizeMb = 2048 +const reservedSystemMemoryMb = 1024 + +export function getBuildOldSpaceSizeMb(totalMemoryBytes = os.totalmem()) { + const totalMemoryMb = Math.floor(totalMemoryBytes / 1024 / 1024) + const hostSizedLimitMb = Math.max(minOldSpaceSizeMb, totalMemoryMb - reservedSystemMemoryMb) + + return Math.min(maxOldSpaceSizeMb, hostSizedLimitMb) +} + +export function appendBuildOldSpaceOption(existingNodeOptions, totalMemoryBytes = os.totalmem()) { + const requestedNodeOptions = `--max-old-space-size=${getBuildOldSpaceSizeMb(totalMemoryBytes)}` + const trimmedNodeOptions = existingNodeOptions?.trim() + + return trimmedNodeOptions ? `${trimmedNodeOptions} ${requestedNodeOptions}` : requestedNodeOptions +} diff --git a/config/scripts/node-old-space-limit.test.mjs b/config/scripts/node-old-space-limit.test.mjs new file mode 100644 index 000000000..e2c5c64b2 --- /dev/null +++ b/config/scripts/node-old-space-limit.test.mjs @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { appendBuildOldSpaceOption, getBuildOldSpaceSizeMb } from './node-old-space-limit.mjs' + +const gib = 1024 * 1024 * 1024 + +describe('node old-space build limit', () => { + it('caps larger hosts at the release-runner build heap', () => { + expect(getBuildOldSpaceSizeMb(8 * gib)).toBe(4096) + }) + + it('reserves memory for smaller arm64 hosts', () => { + expect(getBuildOldSpaceSizeMb(4 * gib)).toBe(3072) + }) + + it('keeps existing NODE_OPTIONS while appending the build heap limit last', () => { + expect(appendBuildOldSpaceOption('--trace-warnings --max-old-space-size=8192', 4 * gib)).toBe( + '--trace-warnings --max-old-space-size=8192 --max-old-space-size=3072' + ) + }) +}) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 9ac878659..f24685635 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -50,6 +50,11 @@ describe('Electron runtime package contract', () => { expect(scripts['build:release']).not.toContain('build:computer-macos') }) + it('runs the web build through the heap-sized Vite wrapper', () => { + expect(packageJson.scripts['build:web']).toContain('node config/scripts/run-vite-web-build.mjs') + expect(packageJson.scripts['build:web']).toContain('node config/scripts/verify-web-build.mjs') + }) + it('guards release publishing before electron-builder runs', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), @@ -63,7 +68,7 @@ describe('Electron runtime package contract', () => { ]) ) - expect([...releaseCommands.keys()].sort()).toEqual(['linux', 'mac', 'win']) + expect([...releaseCommands.keys()].sort()).toEqual(['linux-arm64', 'linux-x64', 'mac', 'win']) for (const command of releaseCommands.values()) { expect(command).toContain('node config/scripts/ensure-native-runtime.mjs --runtime=electron') expect(command).toContain('electron-builder') @@ -72,7 +77,10 @@ describe('Electron runtime package contract', () => { ) } expect(releaseCommands.get('mac')).toContain(' && ORCA_MAC_RELEASE=1 ') - expect(releaseCommands.get('linux')).toContain(' && pnpm exec electron-builder ') + expect(releaseCommands.get('linux-x64')).toContain(' && pnpm exec electron-builder ') + expect(releaseCommands.get('linux-x64')).toContain('--linux AppImage deb rpm --x64') + expect(releaseCommands.get('linux-arm64')).toContain('ORCA_LINUX_ARM64_RELEASE=1') + expect(releaseCommands.get('linux-arm64')).toContain('--linux AppImage deb rpm --arm64') expect(releaseCommands.get('win')).toContain( '; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder ' ) diff --git a/config/scripts/run-electron-vite-build.mjs b/config/scripts/run-electron-vite-build.mjs index db52882b3..ad33fafc0 100644 --- a/config/scripts/run-electron-vite-build.mjs +++ b/config/scripts/run-electron-vite-build.mjs @@ -1,19 +1,15 @@ import { spawn } from 'node:child_process' import { createRequire } from 'node:module' import path from 'node:path' +import { appendBuildOldSpaceOption } from './node-old-space-limit.mjs' const require = createRequire(import.meta.url) const electronVitePackageJson = require.resolve('electron-vite/package.json') const electronViteCli = path.join(path.dirname(electronVitePackageJson), 'bin', 'electron-vite.js') -const requestedNodeOptions = '--max-old-space-size=4096' -const existingNodeOptions = process.env.NODE_OPTIONS?.trim() // Release builds have started OOMing on GitHub's macOS runners during the -// renderer bundle, so we force a larger heap here in one shared entrypoint -// instead of relying on shell-specific env syntax in individual scripts. -const nodeOptions = existingNodeOptions - ? `${existingNodeOptions} ${requestedNodeOptions}` - : requestedNodeOptions +// renderer bundle. Reserve memory on smaller hosts so the OS does not kill Vite. +const nodeOptions = appendBuildOldSpaceOption(process.env.NODE_OPTIONS) const child = spawn(process.execPath, [electronViteCli, 'build', ...process.argv.slice(2)], { stdio: 'inherit', diff --git a/config/scripts/run-vite-web-build.mjs b/config/scripts/run-vite-web-build.mjs new file mode 100644 index 000000000..c25e8c557 --- /dev/null +++ b/config/scripts/run-vite-web-build.mjs @@ -0,0 +1,33 @@ +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import path from 'node:path' +import { appendBuildOldSpaceOption } from './node-old-space-limit.mjs' + +const require = createRequire(import.meta.url) +const vitePackageJson = require.resolve('vite/package.json') +const viteCli = path.join(path.dirname(vitePackageJson), 'bin', 'vite.js') + +// Why: Raspberry Pi and release runners can hit Node's default old-space +// ceiling, but smaller hosts still need memory left for the OS and bundler. +const nodeOptions = appendBuildOldSpaceOption(process.env.NODE_OPTIONS) + +const child = spawn( + process.execPath, + [viteCli, 'build', '--config', 'vite.web.config.ts', ...process.argv.slice(2)], + { + stdio: 'inherit', + env: { + ...process.env, + NODE_OPTIONS: nodeOptions + } + } +) + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + + process.exit(code ?? 1) +}) diff --git a/config/scripts/verify-release-required-assets.mjs b/config/scripts/verify-release-required-assets.mjs index 5c78533b5..f11496bc9 100644 --- a/config/scripts/verify-release-required-assets.mjs +++ b/config/scripts/verify-release-required-assets.mjs @@ -8,11 +8,15 @@ export function getRequiredReleaseAssetNames(tag) { const version = tag.replace(/^v/i, '') return [ 'latest-linux.yml', + 'latest-linux-arm64.yml', 'latest-mac.yml', 'latest.yml', 'orca-linux.AppImage', + 'orca-linux-arm64.AppImage', `orca-ide_${version}_amd64.deb`, + `orca-ide_${version}_arm64.deb`, `orca-ide-${version}.x86_64.rpm`, + `orca-ide-${version}.aarch64.rpm`, 'orca-windows-setup.exe', 'orca-windows-setup.exe.blockmap', `Orca-${version}-mac.zip`, @@ -86,7 +90,12 @@ export async function verifyRequiredReleaseAssets({ repo, tag, token }) { const assetsByName = new Map(release.assets.map((asset) => [asset.name, asset])) const requiredNames = new Set(getRequiredReleaseAssetNames(tag)) - const manifestNames = ['latest-linux.yml', 'latest-mac.yml', 'latest.yml'] + const manifestNames = [ + 'latest-linux.yml', + 'latest-linux-arm64.yml', + 'latest-mac.yml', + 'latest.yml' + ] for (const manifestName of manifestNames) { const manifestAsset = assetsByName.get(manifestName) diff --git a/config/scripts/verify-release-required-assets.test.mjs b/config/scripts/verify-release-required-assets.test.mjs index 1caac947d..9b593f571 100644 --- a/config/scripts/verify-release-required-assets.test.mjs +++ b/config/scripts/verify-release-required-assets.test.mjs @@ -45,12 +45,16 @@ describe('getRequiredReleaseAssetNames', () => { ) }) - it('includes the Linux RPM alongside the existing AppImage and deb names', () => { + it('includes x64 and arm64 Linux assets', () => { expect(getRequiredReleaseAssetNames('v1.4.27')).toEqual( expect.arrayContaining([ + 'latest-linux-arm64.yml', 'orca-linux.AppImage', + 'orca-linux-arm64.AppImage', 'orca-ide_1.4.27_amd64.deb', - 'orca-ide-1.4.27.x86_64.rpm' + 'orca-ide_1.4.27_arm64.deb', + 'orca-ide-1.4.27.x86_64.rpm', + 'orca-ide-1.4.27.aarch64.rpm' ]) ) }) @@ -100,4 +104,32 @@ describe('verifyRequiredReleaseAssets', () => { ).rejects.toThrow('Missing: Orca-1.4.27-arm64-mac.zip') expect(latestMacAsset).toBeTruthy() }) + + it('checks assets referenced by the Linux arm64 updater manifest', async () => { + const tag = 'v1.4.27' + const required = getRequiredReleaseAssetNames(tag) + const release = releaseWithAssets(tag, required) + const arm64Manifest = release.assets.find((asset) => asset.name === 'latest-linux-arm64.yml') + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse([release])) + .mockResolvedValueOnce(jsonResponse('version: 1.4.27\n')) + .mockResolvedValueOnce( + jsonResponse( + [ + 'version: 1.4.27', + 'files:', + ' - url: orca-linux-arm64.AppImage.blockmap', + 'path: orca-linux-arm64.AppImage' + ].join('\n') + ) + ) + .mockResolvedValue(jsonResponse('version: 1.4.27\n')) + vi.stubGlobal('fetch', fetchMock) + + await expect( + verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag, token: 'token' }) + ).rejects.toThrow('Missing: orca-linux-arm64.AppImage.blockmap') + expect(arm64Manifest).toBeTruthy() + }) }) diff --git a/package.json b/package.json index f7bf76480..3d03bedbf 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "audit:localization": "node config/scripts/audit-localization-coverage.mjs", "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable && node config/scripts/install-dev-cli.mjs", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", - "build:web": "vite build --config vite.web.config.ts && node config/scripts/verify-web-build.mjs", + "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", "build": "pnpm run build:desktop && pnpm run build:native", "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", @@ -68,7 +68,7 @@ "build:icons": "bash resources/icon-source/generate.sh", "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", - "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux", + "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",