From 83cf7cf5e28692a01efbb628c1794b1fc5714839 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:22:04 -0700
Subject: [PATCH] perf(ci): compile release JavaScript once for all packaging
hosts (#25828)
* perf(ci): share release JavaScript across packaging hosts
* fix(ci): verify the projected web entry in release archives
* fix(ci): use the Windows system archive tool for release bundles
* test(ci): retain stylesheet evidence in release build comparisons
* test(ci): verify release parity across native color rounding
* test(ci): normalize manifest asset references without changing import order
* test(ci): compare portable outputs across Windows text and color formatting
* test(ci): preserve module identity across dependent asset hashes
* fix(ci): keep SVG build inputs identical across release hosts
* fix(ci): stabilize compiler inputs and projected web bindings
* fix(ci): retain vendor minification in projected web output
* test(ci): normalize platform-specific pnpm manifest source paths
* fix(packaging): exclude shared build staging from application files
* test: align thinking-state fixtures with the current source shape
* test(mobile): reuse message fixtures within the line limit
---
.gitattributes | 6 +
.github/workflows/release-cut.yml | 49 +++--
.../release-javascript-benchmark.yml | 120 ++++++++++
.github/workflows/release-javascript.yml | 79 +++++++
.github/workflows/release-mac-build.yml | 23 +-
config/electron-builder.config.cjs | 2 +
.../scripts/electron-builder-config.test.mjs | 21 ++
.../scripts/project-renderer-web-client.mjs | 5 +-
.../project-renderer-web-client.test.mjs | 18 ++
.../release-cut-token-permissions.test.mjs | 4 +
.../scripts/release-javascript-artifact.mjs | 176 +++++++++++++++
.../release-javascript-artifact.test.mjs | 140 ++++++++++++
.../scripts/release-javascript-benchmark.mjs | 135 ++++++++++++
config/scripts/release-javascript-parity.mjs | 165 ++++++++++++++
.../release-javascript-parity.test.mjs | 208 ++++++++++++++++++
.../release-javascript-workflow.test.mjs | 91 ++++++++
...lease-mac-build-workflow-dispatch.test.mjs | 14 ++
.../run-release-mac-build-workflow.mjs | 4 +-
config/scripts/update-node-runtime-pin.mjs | 10 +-
config/scripts/zip-extractor-command.mjs | 2 +-
config/scripts/zip-extractor-command.test.mjs | 11 +-
package.json | 2 +
.../structured-agent-session-host.ts | 5 +-
.../native-chat/NativeChatToolLine.tsx | 5 +-
src/shared/zip-extractor-command.ts | 8 +-
25 files changed, 1268 insertions(+), 35 deletions(-)
create mode 100644 .github/workflows/release-javascript-benchmark.yml
create mode 100644 .github/workflows/release-javascript.yml
create mode 100644 config/scripts/release-javascript-artifact.mjs
create mode 100644 config/scripts/release-javascript-artifact.test.mjs
create mode 100644 config/scripts/release-javascript-benchmark.mjs
create mode 100644 config/scripts/release-javascript-parity.mjs
create mode 100644 config/scripts/release-javascript-parity.test.mjs
create mode 100644 config/scripts/release-javascript-workflow.test.mjs
diff --git a/.gitattributes b/.gitattributes
index d995215b26c..be42f7cfd68 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -7,6 +7,12 @@
/skill-stubs/_shared/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
+# SVG bytes feed embedded data URLs and asset hashes on every build host.
+*.svg text eol=lf
+# Compiler input line breaks must not depend on the checkout OS.
+*.ts text eol=lf
+*.tsx text eol=lf
+*.html text eol=lf
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
/resources/plugins/** text eol=lf
# Relay assets are copied verbatim into the bundle and hashed byte-for-byte into
diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index 3073d160d11..d6282a804a0 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -1212,12 +1212,25 @@ jobs:
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
+ # Compilation can overlap the release gates; signing remains behind release-preflight.
+ release-javascript:
+ needs: cut
+ if: needs.cut.outputs.should_release == 'true'
+ permissions:
+ contents: read
+ uses: ./.github/workflows/release-javascript.yml
+ with:
+ ref: refs/tags/${{ needs.cut.outputs.tag }}
+ secrets:
+ ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
+
build:
needs:
- cut
- create-release
- orcad-template
- release-preflight
+ - release-javascript
- relay-windows-process-tree
# Why not the implicit success(): a tag without the orcad template skips that job on purpose.
if: >-
@@ -1226,6 +1239,7 @@ jobs:
needs.cut.outputs.should_release == 'true' &&
needs.create-release.result == 'success' &&
needs.release-preflight.result == 'success' &&
+ needs.release-javascript.result == 'success' &&
needs.relay-windows-process-tree.result == 'success' &&
(needs.orcad-template.result == 'success' ||
(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false'))
@@ -1460,26 +1474,30 @@ jobs:
}
cargo --version
- # Why ORCA_POSTHOG_WRITE_KEY here: this is the only build that
- # produces a published binary, so this is the only place the secret
- # needs to be in scope. The key is a PostHog *project* API key, not
- # a server secret — it ships in every official binary's app.asar
- # and is therefore extractable from any release. We still keep it
- # in GitHub Actions secrets so the literal stays out of the repo
- # (and out of fork CI runs / log scrapers / casual greps).
- # Why ORCA_BUILD_IDENTITY here (not in env at the job level): the
- # value comes from the per-tag classification above and electron-vite
- # reads it from `process.env` during `pnpm build:release` only.
- # Why ORCA_DIAGNOSTICS_TOKEN_URL here: official builds pin crash
- # diagnostic uploads to Orca's endpoint at compile time, matching the
- # telemetry gate's "official binary only" behavior.
+ # Consumers verify the same official build configuration before restoring the bundle.
+ - name: Download release JavaScript
+ if: needs.release-javascript.outputs.supported == 'true'
+ uses: actions/download-artifact@v8
+ with:
+ name: release-javascript
+ path: .build/release-javascript
+
- name: Build app
- run: pnpm build:release
+ shell: bash
+ run: |
+ if [ "$SHARED_JAVASCRIPT" = true ]; then
+ node config/scripts/release-javascript-artifact.mjs restore
+ pnpm run build:release:host
+ else
+ pnpm run build:release
+ fi
env:
# Why: Vite's web build crossed Node's default old-space ceiling on
# the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft.
NODE_OPTIONS: --max-old-space-size=4096
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
+ SHARED_JAVASCRIPT: ${{ needs.release-javascript.outputs.supported }}
+ ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ needs.release-javascript.outputs.source_sha }}
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
# Fail the release rather than ship a relay that cannot launch outside
@@ -2330,6 +2348,7 @@ jobs:
- create-release
- orcad-template
- release-preflight
+ - release-javascript
# release-mac-build.yml downloads the relay addons from this run.
- relay-windows-process-tree
# Why not the implicit success(): a tag without the orcad template skips that job on purpose.
@@ -2339,6 +2358,7 @@ jobs:
needs.cut.outputs.should_release == 'true' &&
needs.create-release.result == 'success' &&
needs.release-preflight.result == 'success' &&
+ needs.release-javascript.result == 'success' &&
needs.relay-windows-process-tree.result == 'success' &&
(needs.orcad-template.result == 'success' ||
(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false'))
@@ -2364,6 +2384,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_MAC_BUILD_REF: ${{ github.ref_name }}
RELEASE_MAC_BUILD_RELEASE_RUN_ID: ${{ github.run_id }}
+ RELEASE_MAC_BUILD_JAVASCRIPT_SOURCE_SHA: ${{ needs.release-javascript.outputs.supported == 'true' && needs.release-javascript.outputs.source_sha || '' }}
RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }}
RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml
diff --git a/.github/workflows/release-javascript-benchmark.yml b/.github/workflows/release-javascript-benchmark.yml
new file mode 100644
index 00000000000..6a5fc4efa31
--- /dev/null
+++ b/.github/workflows/release-javascript-benchmark.yml
@@ -0,0 +1,120 @@
+name: Release JavaScript comparison
+
+on:
+ pull_request:
+ paths: ['.github/workflows/release-javascript-benchmark.yml']
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-javascript-comparison-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+ ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
+ ORCA_POSTHOG_WRITE_KEY: ci-build-comparison
+ NODE_OPTIONS: --max-old-space-size=4096
+
+jobs:
+ bundle:
+ uses: ./.github/workflows/release-javascript.yml
+ with:
+ ref: ${{ github.sha }}
+ secrets:
+ ORCA_POSTHOG_WRITE_KEY: ci-build-comparison
+
+ measure:
+ needs: bundle
+ name: ${{ matrix.mode }} ${{ matrix.host.platform }} ${{ matrix.host.arch }}
+ runs-on: ${{ matrix.host.os }}
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ max-parallel: 8
+ matrix:
+ mode: [baseline, shared]
+ host:
+ - os: ubuntu-latest
+ platform: linux
+ arch: x64
+ - os: ubuntu-24.04-arm
+ platform: linux
+ arch: arm64
+ - os: windows-2022
+ platform: win32
+ arch: x64
+ - os: macos-15
+ platform: darwin
+ arch: arm64
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.bundle.outputs.source_sha }}
+ persist-credentials: false
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
+ persist-native-cache: 'false'
+ - uses: ./.github/actions/install-mobile-dependencies
+ - name: Install Linux provider dependencies
+ if: runner.os == 'Linux'
+ run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool
+ - name: Resolve build identity
+ shell: bash
+ run: |
+ RELEASE_TAG="v$(node -p 'require("./package.json").version')"
+ export RELEASE_TAG
+ echo "ORCA_BUILD_IDENTITY=$(node config/scripts/release-javascript-artifact.mjs identity)" >> "$GITHUB_ENV"
+ - name: Download shared JavaScript
+ if: matrix.mode == 'shared'
+ uses: actions/download-artifact@v8
+ with:
+ name: release-javascript
+ path: .build/release-javascript
+ - name: Measure release build
+ shell: bash
+ env:
+ MEASUREMENT_MODE: ${{ matrix.mode }}
+ ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ needs.bundle.outputs.source_sha }}
+ run: node config/scripts/release-javascript-benchmark.mjs "$MEASUREMENT_MODE"
+ - uses: actions/upload-artifact@v7
+ with:
+ name: release-javascript-measurement-${{ matrix.mode }}-${{ matrix.host.platform }}-${{ matrix.host.arch }}
+ path: .build/release-javascript-measurements/
+ retention-days: 7
+
+ comparison:
+ needs: [bundle, measure]
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ actions: read
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.bundle.outputs.source_sha }}
+ persist-credentials: false
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: package.json
+ - uses: ./.github/actions/install-node-dependencies
+ - uses: actions/download-artifact@v8
+ with:
+ pattern: release-javascript-measurement-*
+ path: .build/release-javascript-measurements
+ - name: Compare build and transfer timings
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?per_page=100" > .build/release-javascript-measurements/jobs.json
+ node config/scripts/release-javascript-benchmark.mjs summary
+ cat .build/release-javascript-measurements/comparison.md >> "$GITHUB_STEP_SUMMARY"
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: release-javascript-comparison
+ path: .build/release-javascript-measurements/comparison.md
+ retention-days: 30
diff --git a/.github/workflows/release-javascript.yml b/.github/workflows/release-javascript.yml
new file mode 100644
index 00000000000..2fc33c8d30c
--- /dev/null
+++ b/.github/workflows/release-javascript.yml
@@ -0,0 +1,79 @@
+name: Release JavaScript bundle
+
+on:
+ workflow_call:
+ inputs:
+ ref:
+ required: true
+ type: string
+ outputs:
+ supported:
+ value: ${{ jobs.bundle.outputs.supported }}
+ source_sha:
+ value: ${{ jobs.bundle.outputs.source_sha }}
+ secrets:
+ ORCA_POSTHOG_WRITE_KEY:
+ required: true
+
+permissions:
+ contents: read
+
+env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+
+jobs:
+ bundle:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ outputs:
+ supported: ${{ steps.source.outputs.supported }}
+ source_sha: ${{ steps.source.outputs.sha }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref }}
+ persist-credentials: false
+
+ # Older release refs retain their original per-platform build commands.
+ - name: Resolve bundle source and support
+ id: source
+ shell: bash
+ run: |
+ echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+ node -e 'const p = require("./package.json"); console.log("supported=" + Boolean(p.scripts["build:release:javascript"] && p.scripts["build:release:host"]))' >> "$GITHUB_OUTPUT"
+
+ - uses: ./.github/actions/install-node-dependencies
+ if: steps.source.outputs.supported == 'true'
+ with:
+ native-runtime: node
+
+ - uses: ./.github/actions/install-mobile-dependencies
+ if: steps.source.outputs.supported == 'true'
+
+ - name: Resolve release build identity
+ id: identity
+ if: steps.source.outputs.supported == 'true'
+ run: |
+ RELEASE_TAG="v$(node -p 'require("./package.json").version')"
+ export RELEASE_TAG
+ echo "value=$(node config/scripts/release-javascript-artifact.mjs identity)" >> "$GITHUB_OUTPUT"
+
+ - name: Build and archive release JavaScript
+ if: steps.source.outputs.supported == 'true'
+ env:
+ NODE_OPTIONS: --max-old-space-size=4096
+ ORCA_BUILD_IDENTITY: ${{ steps.identity.outputs.value }}
+ ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
+ ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
+ run: |
+ pnpm run build:release:javascript
+ node config/scripts/release-javascript-artifact.mjs pack
+
+ - uses: actions/upload-artifact@v7
+ if: steps.source.outputs.supported == 'true'
+ with:
+ name: release-javascript
+ path: .build/release-javascript/
+ compression-level: 0
+ retention-days: 7
+ if-no-files-found: error
diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml
index 80694364bae..22558411c8c 100644
--- a/.github/workflows/release-mac-build.yml
+++ b/.github/workflows/release-mac-build.yml
@@ -13,6 +13,11 @@ on:
description: release-cut workflow run that requested this build
required: true
type: string
+ javascript_source_sha:
+ description: Commit of the shared JavaScript artifact; empty keeps the legacy build
+ required: false
+ default: ''
+ type: string
permissions:
# actions: read downloads the orcad template the parent release-cut run built.
@@ -143,13 +148,29 @@ jobs:
run-id: ${{ inputs.release_run_id }}
github-token: ${{ github.token }}
+ - name: Download release JavaScript from the release run
+ if: inputs.javascript_source_sha != ''
+ uses: actions/download-artifact@v8
+ with:
+ name: release-javascript
+ path: .build/release-javascript
+ run-id: ${{ inputs.release_run_id }}
+ github-token: ${{ github.token }}
+
- name: Build app
- run: pnpm build:release
+ run: |
+ if [ -n "$ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA" ]; then
+ node config/scripts/release-javascript-artifact.mjs restore
+ pnpm run build:release:host
+ else
+ pnpm run build:release
+ fi
env:
# Why: Vite's web build crossed Node's default old-space ceiling on
# the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft.
NODE_OPTIONS: --max-old-space-size=4096
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
+ ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ inputs.javascript_source_sha }}
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
# Fail the release rather than ship a relay that cannot launch outside
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index b0666c42a3d..60a842d0343 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -206,6 +206,8 @@ module.exports = {
'!out/runtimes{,/**/*}',
'!out/node-runtime-cache{,/**/*}',
'!config{,/**/*}',
+ // Release archives and other build staging are not runtime resources.
+ '!.build{,/**/*}',
'!docs{,/**/*}',
'!mobile{,/**/*}',
'!native{,/**/*}',
diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs
index 454693e46d7..3daa56d1aa6 100644
--- a/config/scripts/electron-builder-config.test.mjs
+++ b/config/scripts/electron-builder-config.test.mjs
@@ -47,6 +47,27 @@ describe('electron-builder config', () => {
)
})
+ it('keeps release build staging out of app.asar while preserving runtime output', () => {
+ const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
+ matcher.prependPattern('**/*')
+ const isPacked = matcher.createFilter()
+ expect(isPacked(join('/app', '.build'), { isDirectory: () => true })).toBe(false)
+ for (const stagingPath of [
+ '.build/release-javascript/release-javascript.tar.gz',
+ '.build/release-javascript/manifest.json',
+ '.build/release-javascript-123/out/main/index.js'
+ ]) {
+ expect(isPacked(join('/app', stagingPath), { isDirectory: () => false })).toBe(false)
+ }
+ for (const runtimePath of [
+ 'out/main/index.js',
+ 'out/cli/index.js',
+ 'out/renderer/index.html'
+ ]) {
+ expect(isPacked(join('/app', runtimePath), { isDirectory: () => false })).toBe(true)
+ }
+ })
+
it('keeps local agent tooling out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
matcher.prependPattern('**/*')
diff --git a/config/scripts/project-renderer-web-client.mjs b/config/scripts/project-renderer-web-client.mjs
index c7999358cbf..a3df2d9b1b5 100644
--- a/config/scripts/project-renderer-web-client.mjs
+++ b/config/scripts/project-renderer-web-client.mjs
@@ -154,7 +154,10 @@ async function minifyWebOutput() {
const result = await transform(readFileSync(targetPath, 'utf8'), {
legalComments: 'none',
loader,
- minify: true,
+ // Vite asset scripts already have short names; keep them stable across asset hash changes.
+ minifyIdentifiers: !outputPath.startsWith('assets/'),
+ minifySyntax: true,
+ minifyWhitespace: true,
target: 'es2020'
})
writeFileSync(targetPath, result.code)
diff --git a/config/scripts/project-renderer-web-client.test.mjs b/config/scripts/project-renderer-web-client.test.mjs
index 188da677924..c31cd940e19 100644
--- a/config/scripts/project-renderer-web-client.test.mjs
+++ b/config/scripts/project-renderer-web-client.test.mjs
@@ -74,6 +74,24 @@ afterEach(() => {
})
describe('renderer web client projection', () => {
+ it('preserves existing minified bindings while producing runnable compact code', async () => {
+ const root = createRendererFixture()
+ writeFixtureFile(
+ root,
+ 'out/renderer/assets/web-shared.js',
+ 'const Zq = [1, 2, 3]; export function nM() { return Zq.length; }'
+ )
+ const result = await projectFixture(root)
+ expect(result.code, result.stderr).toBe(0)
+ const code = readFileSync(join(root, 'out/web/assets/web-shared.js'), 'utf8')
+ expect(code).toContain('function nM(')
+ expect(code).not.toContain('[1, 2, 3]')
+ const output = await import(
+ `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
+ )
+ expect(output.nM()).toBe(3)
+ })
+
it('keeps the build-only manifest out of packaged apps', () => {
const builderConfig = readFileSync(resolve('config/electron-builder.config.cjs'), 'utf8')
diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs
index 938e9d86d72..7b70e2c47d0 100644
--- a/config/scripts/release-cut-token-permissions.test.mjs
+++ b/config/scripts/release-cut-token-permissions.test.mjs
@@ -61,6 +61,10 @@ const EXPECTED_MATRIX = {
},
[`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' },
[`${RELEASE_WORKFLOW}#publish-release`]: { contents: 'write' },
+ [`${RELEASE_WORKFLOW}#release-javascript`]: { contents: 'read' },
+ [`${RELEASE_WORKFLOW}#release-javascript -> .github/workflows/release-javascript.yml#bundle`]: {
+ contents: 'read'
+ },
[`${RELEASE_WORKFLOW}#release-preflight`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#relay-windows-process-tree`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#relay-windows-process-tree -> .github/workflows/relay-windows-process-tree.yml#build`]:
diff --git a/config/scripts/release-javascript-artifact.mjs b/config/scripts/release-javascript-artifact.mjs
new file mode 100644
index 00000000000..13b4eb06604
--- /dev/null
+++ b/config/scripts/release-javascript-artifact.mjs
@@ -0,0 +1,176 @@
+import assert from 'node:assert/strict'
+import { createHash } from 'node:crypto'
+import {
+ chmodSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ renameSync,
+ rmSync,
+ statSync,
+ writeFileSync
+} from 'node:fs'
+import { join, resolve } from 'node:path'
+import { runProcessSync, describeProcessFailure } from './script-child-process.mjs'
+import { isDirectInvocation } from './script-entry-detection.mjs'
+import { DESKTOP_RC_TAG, DESKTOP_STABLE_TAG } from './release-tag-patterns.mjs'
+import { getTarProgram } from './zip-extractor-command.mjs'
+
+const REQUIRED_FILES = [
+ 'cli/index.js',
+ 'main/index.js',
+ 'preload/index.js',
+ 'renderer/index.html',
+ 'renderer/.vite/manifest.json',
+ 'web/web-index.html',
+ 'mobile-web/manifest.json',
+ 'package.json'
+]
+const NATIVE_FILE = /\.(?:node|exe|dll|dylib|so(?:\.\d+)*|a)$/i
+const ARCHIVE_NAME = 'release-javascript.tar.gz'
+
+function sha256(bytes) {
+ return createHash('sha256').update(bytes).digest('hex')
+}
+
+function command(program, args, cwd) {
+ const result = runProcessSync({ program, args, cwd, timeoutMs: 120_000 })
+ assert.equal(result.code, 0, describeProcessFailure(result))
+ return result.stdout.trim()
+}
+
+export function releaseBuildIdentity(tag) {
+ if (DESKTOP_RC_TAG.test(tag)) {
+ return 'rc'
+ }
+ if (DESKTOP_STABLE_TAG.test(tag)) {
+ return 'stable'
+ }
+ throw new Error(`Invalid desktop release tag: ${tag}`)
+}
+
+export function releaseJavascriptConfiguration(root, env = process.env) {
+ const sourceSha = command('git', ['rev-parse', 'HEAD'], root)
+ assert.match(sourceSha, /^[a-f0-9]{40}$/)
+ if (env.ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA) {
+ assert.equal(
+ sourceSha,
+ env.ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA,
+ 'Release checkout differs from bundle source'
+ )
+ }
+ const { version, packageManager } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
+ const identity = releaseBuildIdentity(`v${version}`)
+ assert.equal(env.ORCA_BUILD_IDENTITY, identity, 'Release build identity differs from version')
+ assert(env.ORCA_DIAGNOSTICS_TOKEN_URL, 'Missing release diagnostics URL')
+ assert(env.ORCA_POSTHOG_WRITE_KEY, 'Missing release telemetry key')
+ return {
+ sourceSha,
+ version,
+ packageManager,
+ identity,
+ diagnosticsTokenUrl: env.ORCA_DIAGNOSTICS_TOKEN_URL,
+ telemetryKeySha256: sha256(env.ORCA_POSTHOG_WRITE_KEY)
+ }
+}
+
+export function javascriptInventory(directory, prefix = '', { excludeHostOutput = false } = {}) {
+ return readdirSync(directory, { withFileTypes: true })
+ .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
+ .flatMap((entry) => {
+ const name = prefix ? `${prefix}/${entry.name}` : entry.name
+ const hostOutput = ['relay', 'orcad-template', 'orcad-prebuilds'].includes(name.split('/')[0])
+ if (excludeHostOutput && hostOutput) {
+ return []
+ }
+ assert(!entry.isSymbolicLink(), `JavaScript artifact contains a symlink: ${name}`)
+ assert(!NATIVE_FILE.test(name), `JavaScript artifact contains a native binary: ${name}`)
+ assert(!hostOutput, `JavaScript artifact contains host output: ${name}`)
+ const file = join(directory, entry.name)
+ if (entry.isDirectory()) {
+ return javascriptInventory(file, name, { excludeHostOutput })
+ }
+ assert(entry.isFile(), `JavaScript artifact contains a special file: ${name}`)
+ const bytes = readFileSync(file)
+ return [{ path: name, bytes: bytes.length, sha256: sha256(bytes) }]
+ })
+}
+
+function verifyRequiredFiles(files) {
+ const present = new Set(files.map((file) => file.path))
+ for (const file of REQUIRED_FILES) {
+ assert(present.has(file), `Missing JavaScript output: ${file}`)
+ }
+}
+
+export function packReleaseJavascript({ root = process.cwd(), artifactDir, configuration }) {
+ configuration ??= releaseJavascriptConfiguration(root)
+ const out = join(root, 'out')
+ const files = javascriptInventory(out)
+ verifyRequiredFiles(files)
+ mkdirSync(artifactDir, { recursive: true })
+ const archive = join(artifactDir, ARCHIVE_NAME)
+ command(getTarProgram(), ['-czf', archive, '-C', root, 'out'], root)
+ const manifest = {
+ schema: 1,
+ configuration,
+ archiveSha256: sha256(readFileSync(archive)),
+ archiveBytes: statSync(archive).size,
+ files
+ }
+ writeFileSync(join(artifactDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
+ return manifest
+}
+
+export function restoreReleaseJavascript({ root = process.cwd(), artifactDir, configuration }) {
+ configuration ??= releaseJavascriptConfiguration(root)
+ const manifest = JSON.parse(readFileSync(join(artifactDir, 'manifest.json'), 'utf8'))
+ assert.equal(manifest.schema, 1, 'Unsupported JavaScript artifact schema')
+ assert.deepEqual(manifest.configuration, configuration, 'JavaScript build configuration differs')
+ const archive = join(artifactDir, ARCHIVE_NAME)
+ assert.equal(sha256(readFileSync(archive)), manifest.archiveSha256, 'JavaScript archive differs')
+ const entries = command(getTarProgram(), ['-tzf', archive], root).split(/\r?\n/)
+ for (const entry of entries) {
+ assert(entry === 'out/' || entry.startsWith('out/'), `Unexpected archive entry: ${entry}`)
+ assert(
+ !entry.includes('\\') && !entry.split('/').includes('..'),
+ `Unsafe archive entry: ${entry}`
+ )
+ }
+ mkdirSync(join(root, '.build'), { recursive: true })
+ const temporary = mkdtempSync(join(root, '.build', 'release-javascript-'))
+ try {
+ command(getTarProgram(), ['-xzf', archive, '-C', temporary], root)
+ const staged = join(temporary, 'out')
+ const files = javascriptInventory(staged)
+ verifyRequiredFiles(files)
+ assert.deepEqual(files, manifest.files, 'JavaScript file inventory differs')
+ rmSync(join(root, 'out'), { recursive: true, force: true })
+ renameSync(staged, join(root, 'out'))
+ if (process.platform !== 'win32') {
+ chmodSync(join(root, 'out', 'cli', 'index.js'), 0o755)
+ }
+ } finally {
+ rmSync(temporary, { recursive: true, force: true })
+ }
+ return manifest
+}
+
+if (isDirectInvocation(import.meta.url, process.argv[1])) {
+ const [mode, artifact] = process.argv.slice(2)
+ if (mode === 'identity') {
+ console.log(releaseBuildIdentity(process.env.RELEASE_TAG))
+ } else {
+ const artifactDir = resolve(artifact ?? '.build/release-javascript')
+ const result =
+ mode === 'pack'
+ ? packReleaseJavascript({ artifactDir })
+ : mode === 'restore'
+ ? restoreReleaseJavascript({ artifactDir })
+ : assert.fail('Expected pack, restore, or identity')
+ console.log(
+ `[release-javascript] ${mode}: ${result.files.length} files, ${result.archiveBytes} archived bytes`
+ )
+ }
+}
diff --git a/config/scripts/release-javascript-artifact.test.mjs b/config/scripts/release-javascript-artifact.test.mjs
new file mode 100644
index 00000000000..420311b90f8
--- /dev/null
+++ b/config/scripts/release-javascript-artifact.test.mjs
@@ -0,0 +1,140 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ javascriptInventory,
+ packReleaseJavascript,
+ releaseBuildIdentity,
+ restoreReleaseJavascript
+} from './release-javascript-artifact.mjs'
+
+const directories = []
+const configuration = {
+ sourceSha: 'a'.repeat(40),
+ version: '1.2.3-rc.4',
+ packageManager: 'pnpm@12.8.1',
+ identity: 'rc',
+ diagnosticsTokenUrl: 'https://example.test/diagnostics',
+ telemetryKeySha256: 'b'.repeat(64)
+}
+
+afterEach(() => {
+ for (const directory of directories.splice(0)) {
+ rmSync(directory, { recursive: true, force: true })
+ }
+})
+
+function fixture() {
+ const root = mkdtempSync(join(tmpdir(), 'orca-release-javascript-test-'))
+ directories.push(root)
+ for (const file of [
+ 'cli/index.js',
+ 'main/index.js',
+ 'preload/index.js',
+ 'renderer/index.html',
+ 'renderer/.vite/manifest.json',
+ 'web/web-index.html',
+ 'mobile-web/manifest.json',
+ 'package.json',
+ 'renderer/wasm/viewer.wasm',
+ 'main/index.js.map'
+ ]) {
+ const destination = join(root, 'out', file)
+ mkdirSync(dirname(destination), { recursive: true })
+ writeFileSync(destination, file === 'cli/index.js' ? '#!/usr/bin/env node\n' : file)
+ }
+ const artifactDir = join(root, '.build', 'artifact')
+ return { root, artifactDir, configuration }
+}
+
+describe('release JavaScript transfer', () => {
+ it('preserves hidden manifests, source maps and portable WASM while replacing stale outputs', () => {
+ const options = fixture()
+ const before = javascriptInventory(join(options.root, 'out'))
+ packReleaseJavascript(options)
+ writeFileSync(join(options.root, 'out', 'stale.js'), 'old')
+ restoreReleaseJavascript(options)
+ expect(javascriptInventory(join(options.root, 'out'))).toEqual(before)
+ })
+
+ it.each([
+ 'sourceSha',
+ 'version',
+ 'packageManager',
+ 'identity',
+ 'diagnosticsTokenUrl',
+ 'telemetryKeySha256'
+ ])('rejects a different %s before replacing the current build', (field) => {
+ const options = fixture()
+ packReleaseJavascript(options)
+ expect(() =>
+ restoreReleaseJavascript({
+ ...options,
+ configuration: { ...configuration, [field]: 'different' }
+ })
+ ).toThrow('JavaScript build configuration differs')
+ expect(readFileSync(join(options.root, 'out', 'main', 'index.js'), 'utf8')).toBe(
+ 'main/index.js'
+ )
+ })
+
+ it('rejects a damaged archive before replacing the current build', () => {
+ const options = fixture()
+ packReleaseJavascript(options)
+ writeFileSync(join(options.artifactDir, 'release-javascript.tar.gz'), 'damaged')
+ expect(() => restoreReleaseJavascript(options)).toThrow('JavaScript archive differs')
+ expect(readFileSync(join(options.root, 'out', 'main', 'index.js'), 'utf8')).toBe(
+ 'main/index.js'
+ )
+ })
+
+ it('rejects a file inventory that no longer describes the archive', () => {
+ const options = fixture()
+ const manifest = packReleaseJavascript(options)
+ manifest.files[0].sha256 = 'c'.repeat(64)
+ writeFileSync(join(options.artifactDir, 'manifest.json'), JSON.stringify(manifest))
+ expect(() => restoreReleaseJavascript(options)).toThrow('JavaScript file inventory differs')
+ })
+
+ it.each([
+ 'addon.node',
+ 'launcher.exe',
+ 'library.dll',
+ 'library.dylib',
+ 'library.so.1',
+ 'relay/relay.js'
+ ])('keeps %s out of the portable artifact', (file) => {
+ const options = fixture()
+ const destination = join(options.root, 'out', file)
+ mkdirSync(dirname(destination), { recursive: true })
+ writeFileSync(destination, 'host output')
+ expect(() => packReleaseJavascript(options)).toThrow(/native binary|host output/)
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'rejects symlinks instead of following files outside the build',
+ () => {
+ const options = fixture()
+ symlinkSync(
+ join(options.root, 'out', 'main', 'index.js'),
+ join(options.root, 'out', 'linked.js')
+ )
+ expect(() => packReleaseJavascript(options)).toThrow('contains a symlink')
+ }
+ )
+
+ it('refuses incomplete build output', () => {
+ const options = fixture()
+ rmSync(join(options.root, 'out', 'preload', 'index.js'))
+ expect(() => packReleaseJavascript(options)).toThrow(
+ 'Missing JavaScript output: preload/index.js'
+ )
+ })
+
+ it('classifies stable and suffixed RC versions and rejects other tag families', () => {
+ expect(releaseBuildIdentity('v1.2.3')).toBe('stable')
+ expect(releaseBuildIdentity('v1.2.3-rc.4.perf')).toBe('rc')
+ expect(() => releaseBuildIdentity('mobile-v1.2.3')).toThrow('Invalid desktop release tag')
+ })
+})
diff --git a/config/scripts/release-javascript-benchmark.mjs b/config/scripts/release-javascript-benchmark.mjs
new file mode 100644
index 00000000000..6cbd6e1e63f
--- /dev/null
+++ b/config/scripts/release-javascript-benchmark.mjs
@@ -0,0 +1,135 @@
+import assert from 'node:assert/strict'
+import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import {
+ javascriptInventory,
+ releaseJavascriptConfiguration,
+ restoreReleaseJavascript
+} from './release-javascript-artifact.mjs'
+import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
+import { describeProcessFailure, runProcessSync } from './script-child-process.mjs'
+import {
+ annotateJavascriptParityFiles,
+ compareJavascriptParityFiles
+} from './release-javascript-parity.mjs'
+
+const root = resolve(import.meta.dirname, '../..')
+const [mode, reportDir = '.build/release-javascript-measurements'] = process.argv.slice(2)
+
+function runBuild(script) {
+ const pnpm = resolvePnpmCliInvocation()
+ const started = performance.now()
+ const result = runProcessSync({
+ program: pnpm.command,
+ args: [...pnpm.prefixArgs, 'run', script],
+ cwd: root,
+ timeoutMs: 1_200_000,
+ maxOutputBytes: 64 * 1024 * 1024,
+ env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
+ })
+ const milliseconds = performance.now() - started
+ process.stdout.write(result.stdout)
+ process.stderr.write(result.stderr)
+ assert.equal(result.code, 0, describeProcessFailure(result))
+ return milliseconds
+}
+
+function benchmark() {
+ assert(['baseline', 'shared'].includes(mode), 'Expected baseline, shared, or summary')
+ const configuration = releaseJavascriptConfiguration(root)
+ let restoreMs = 0
+ if (mode === 'shared') {
+ const started = performance.now()
+ restoreReleaseJavascript({
+ root,
+ artifactDir: join(root, '.build', 'release-javascript'),
+ configuration
+ })
+ restoreMs = performance.now() - started
+ }
+ const buildMs = runBuild(mode === 'baseline' ? 'build:release' : 'build:release:host')
+ const files = javascriptInventory(join(root, 'out'), '', { excludeHostOutput: true }).filter(
+ (file) => !file.path.endsWith('.map')
+ )
+ const report = {
+ mode,
+ platform: process.platform,
+ arch: process.arch,
+ configuration,
+ restoreMs,
+ buildMs,
+ files: annotateJavascriptParityFiles(join(root, 'out'), files)
+ }
+ mkdirSync(reportDir, { recursive: true })
+ writeFileSync(
+ join(reportDir, `${mode}-${process.platform}-${process.arch}.json`),
+ `${JSON.stringify(report)}\n`
+ )
+ console.log(
+ `[release-javascript] ${mode}: build ${(buildMs / 1000).toFixed(2)}s, restore ${(restoreMs / 1000).toFixed(2)}s`
+ )
+}
+
+function summarize() {
+ const reports = readdirSync(reportDir, { recursive: true })
+ .filter((file) => /(?:baseline|shared)-.*\.json$/.test(file))
+ .map((file) => JSON.parse(readFileSync(join(reportDir, file), 'utf8')))
+ const { jobs } = JSON.parse(readFileSync(join(reportDir, 'jobs.json'), 'utf8'))
+ const seconds = (step) => (Date.parse(step.completed_at) - Date.parse(step.started_at)) / 1000
+ const lines = [
+ '| Host | Baseline build | Shared host build | Restore | Download | Saved per host | Runtime file differences |',
+ '| --- | ---: | ---: | ---: | ---: | ---: | ---: |'
+ ]
+ let savedMs = 0
+ let differences = 0
+ for (const baseline of reports.filter((report) => report.mode === 'baseline')) {
+ const shared = reports.find(
+ (report) =>
+ report.mode === 'shared' &&
+ report.platform === baseline.platform &&
+ report.arch === baseline.arch
+ )
+ assert(shared, `Missing shared result for ${baseline.platform}/${baseline.arch}`)
+ assert.deepEqual(shared.configuration, baseline.configuration)
+ const job = jobs.find((job) => job.name === `shared ${baseline.platform} ${baseline.arch}`)
+ assert(job, 'Missing shared job timing')
+ const download = job.steps.find((step) => step.name === 'Download shared JavaScript')
+ assert(download?.conclusion === 'success', 'Missing successful artifact download')
+ const downloadMs = seconds(download) * 1000
+ const changed = compareJavascriptParityFiles(baseline.files, shared.files)
+ differences += changed.length
+ const saved = baseline.buildMs - shared.buildMs - shared.restoreMs - downloadMs
+ savedMs += saved
+ const format = (ms) => `${(ms / 1000).toFixed(1)}s`
+ lines.push(
+ `| ${baseline.platform}/${baseline.arch} | ${format(baseline.buildMs)} | ${format(shared.buildMs)} | ${format(shared.restoreMs)} | ${format(downloadMs)} | ${format(saved)} | ${changed.length} |`
+ )
+ if (changed.length) {
+ lines.push(
+ `\nDifferences for ${baseline.platform}/${baseline.arch}: ${changed.slice(0, 20).join(', ')}\n`
+ )
+ }
+ }
+ assert.equal(reports.length, 8, 'Expected both measurements on all four packaging hosts')
+ const producer = jobs.find((job) =>
+ job.steps.some((step) => step.name === 'Build and archive release JavaScript')
+ )
+ assert(producer, 'Missing producer job timing')
+ const producerSeconds = seconds(producer)
+ lines.push(
+ '',
+ `Shared producer job: ${producerSeconds.toFixed(1)}s including setup, compilation, archiving and upload.`,
+ `Net runner time saved across four hosts after charging the producer job: ${(savedMs / 1000 - producerSeconds).toFixed(1)}s.`,
+ 'Build measurements exclude consumer checkout/install and signing. Downloads and restoration are charged to shared builds. Runtime parity normalizes text line endings, asset references and manifest key order, excludes source maps, and permits native color rounding up to 0.00000101 in Display P3 or 0.00010001 in Lab. Other content must match. Artifact restoration always verifies exact producer hashes. The producer starts alongside release gates; any unfinished producer work still delays packaging.'
+ )
+ writeFileSync(join(reportDir, 'comparison.md'), `${lines.join('\n')}\n`)
+ console.log(lines.join('\n'))
+ assert.equal(differences, 0, 'Shared runtime output differs from per-host compilation')
+}
+
+if (mode === 'summary') {
+ summarize()
+} else {
+ benchmark()
+}
diff --git a/config/scripts/release-javascript-parity.mjs b/config/scripts/release-javascript-parity.mjs
new file mode 100644
index 00000000000..a3ff9577c3b
--- /dev/null
+++ b/config/scripts/release-javascript-parity.mjs
@@ -0,0 +1,165 @@
+import assert from 'node:assert/strict'
+import { createHash } from 'node:crypto'
+import { readFileSync } from 'node:fs'
+import { basename, join } from 'node:path'
+
+const ASSET_PATH = /^(?:renderer|web)\/assets\/(.+)-[\w-]{8}\.(js|css|svg)$/
+const TEXT_FILE = /\.(?:js|css|html|json|svg)$/
+const NATIVE_COLOR = /color\(display-p3 ([^)]+)\)|(? name.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&')).join('|') ||
+ '(?!)',
+ 'g'
+ )
+ return (content) => content.replace(references, (name) => replacements.get(name) ?? name)
+}
+
+export function normalizeManifestSourcePaths(value) {
+ if (typeof value === 'string') {
+ return value.replace(/node_modules\/\.pnpm\/[^/]+\/node_modules\//g, 'node_modules/')
+ }
+ if (Array.isArray(value)) {
+ return value.map(normalizeManifestSourcePaths)
+ }
+ if (!value || typeof value !== 'object') {
+ return value
+ }
+ const entries = Object.entries(value)
+ .map(([key, child]) => [normalizeManifestSourcePaths(key), normalizeManifestSourcePaths(child)])
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
+ assert.equal(
+ new Set(entries.map(([key]) => key)).size,
+ entries.length,
+ 'Ambiguous manifest source paths'
+ )
+ return Object.fromEntries(entries)
+}
+
+export function annotateJavascriptParityFiles(root, files) {
+ const names = new Map()
+ for (const file of files) {
+ const match = file.path.match(ASSET_PATH)
+ if (match) {
+ const stem = `${match[1]}.${match[2]}`
+ const entries = names.get(stem) ?? new Set()
+ entries.add(basename(file.path))
+ names.set(stem, entries)
+ }
+ }
+ const replacements = new Map(
+ [...names]
+ .filter(([, entries]) => entries.size === 1)
+ .map(([stem, entries]) => [[...entries][0], stem])
+ )
+ const contents = new Map(
+ files
+ .filter((file) => TEXT_FILE.test(file.path))
+ .map((file) => [
+ file.path,
+ readFileSync(join(root, file.path), 'utf8').replaceAll('\r\n', '\n')
+ ])
+ )
+ const normalizeIdentity = createAssetReferenceNormalizer(
+ new Map([...names].flatMap(([stem, entries]) => [...entries].map((name) => [name, stem])))
+ )
+ const ambiguous = []
+ for (const [stem, entries] of names) {
+ if (entries.size < 2) {
+ continue
+ }
+ for (const name of entries) {
+ const file = files.find((file) => ASSET_PATH.test(file.path) && basename(file.path) === name)
+ // Dependency hashes identify the module; final checks preserve every resolved reference.
+ const signature = createHash('sha256')
+ .update(normalizeIdentity(contents.get(file.path)))
+ .digest('hex')
+ const extension = stem.slice(stem.lastIndexOf('.'))
+ ambiguous.push([name, `${stem.slice(0, -extension.length)}-${signature}${extension}`])
+ }
+ }
+ const aliases = new Map([...replacements, ...ambiguous])
+ const normalizeReferences = createAssetReferenceNormalizer(aliases)
+ return files.map((file) => {
+ const name = basename(file.path)
+ const comparablePath = aliases.has(name)
+ ? file.path.slice(0, -name.length) + aliases.get(name)
+ : file.path
+ if (!contents.has(file.path)) {
+ return { ...file, comparablePath, comparableSha256: file.sha256 }
+ }
+ let content = normalizeReferences(contents.get(file.path))
+ const manifest = file.path === 'renderer/.vite/manifest.json'
+ if (manifest) {
+ content = JSON.stringify(normalizeManifestSourcePaths(JSON.parse(content)))
+ }
+ return {
+ ...file,
+ comparablePath,
+ comparableSha256: createHash('sha256').update(content).digest('hex'),
+ ...(file.path.endsWith('.css') ? { css: content } : {}),
+ ...(manifest ? { manifest: content } : {}),
+ ...(/(?:\/assets\/(?:App|Settings|ghostty|shell-icons)-.*\.js|\/[^/]+\.html)$/.test(file.path)
+ ? { text: content }
+ : {})
+ }
+ })
+}
+
+export function equivalentStylesheets(before, after) {
+ const beforeColors = [...before.matchAll(NATIVE_COLOR)]
+ const afterColors = [...after.matchAll(NATIVE_COLOR)]
+ const marker = (color) => (color.startsWith('lab(') ? 'NATIVE_LAB' : 'NATIVE_P3')
+ if (before.replace(NATIVE_COLOR, marker) !== after.replace(NATIVE_COLOR, marker)) {
+ return false
+ }
+ if (beforeColors.length !== afterColors.length) {
+ return false
+ }
+ return beforeColors.every((color, index) => {
+ const left = (color[1] ?? color[2]).split(/\s+/)
+ const right = (afterColors[index][1] ?? afterColors[index][2]).split(/\s+/)
+ const tolerance = color[1] ? 0.00000101 : 0.00010001
+ return (
+ left.length === right.length &&
+ left.every((value, channel) => {
+ if (value === right[channel]) {
+ return true
+ }
+ // Native CSS color conversion differs by one printed decimal unit across hosts.
+ return (
+ /^-?(?:\d*\.)?\d+%?$/.test(value) &&
+ /^-?(?:\d*\.)?\d+%?$/.test(right[channel]) &&
+ value.endsWith('%') === right[channel].endsWith('%') &&
+ Math.abs(Number.parseFloat(value) - Number.parseFloat(right[channel])) <= tolerance
+ )
+ })
+ )
+ })
+}
+
+export function compareJavascriptParityFiles(before, after) {
+ const reference = new Map(before.map((file) => [file.comparablePath, file]))
+ const candidate = new Map(after.map((file) => [file.comparablePath, file]))
+ assert.equal(reference.size, before.length, 'Ambiguous baseline output paths')
+ assert.equal(candidate.size, after.length, 'Ambiguous shared output paths')
+ const changed = [...new Set([...reference.keys(), ...candidate.keys()])].filter((path) => {
+ const left = reference.get(path)
+ const right = candidate.get(path)
+ if (!left || !right) {
+ return true
+ }
+ if (left.comparableSha256 === right.comparableSha256) {
+ return false
+ }
+ if (left.manifest && right.manifest) {
+ return (
+ JSON.stringify(normalizeManifestSourcePaths(JSON.parse(left.manifest))) !==
+ JSON.stringify(normalizeManifestSourcePaths(JSON.parse(right.manifest)))
+ )
+ }
+ return !left.css || !right.css || !equivalentStylesheets(left.css, right.css)
+ })
+ return changed
+}
diff --git a/config/scripts/release-javascript-parity.test.mjs b/config/scripts/release-javascript-parity.test.mjs
new file mode 100644
index 00000000000..5a4c62a4ac6
--- /dev/null
+++ b/config/scripts/release-javascript-parity.test.mjs
@@ -0,0 +1,208 @@
+import { createHash } from 'node:crypto'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, expect, it } from 'vitest'
+import {
+ annotateJavascriptParityFiles,
+ compareJavascriptParityFiles,
+ equivalentStylesheets,
+ normalizeManifestSourcePaths
+} from './release-javascript-parity.mjs'
+
+const directories = []
+
+it('normalizes pnpm store path shortening without collapsing distinct source records', () => {
+ const shortened = '../../node_modules/.pnpm/package@1_hash/node_modules/package/index.js'
+ const full =
+ '../../node_modules/.pnpm/package@1.0.0_patch_hash=long/node_modules/package/index.js'
+ expect(
+ normalizeManifestSourcePaths({ [shortened]: { src: shortened, imports: [shortened] } })
+ ).toEqual(normalizeManifestSourcePaths({ [full]: { src: full, imports: [full] } }))
+ expect(() => normalizeManifestSourcePaths({ [shortened]: 1, [full]: 2 })).toThrow(
+ 'Ambiguous manifest source paths'
+ )
+})
+afterEach(() =>
+ directories.splice(0).forEach((directory) => rmSync(directory, { recursive: true, force: true }))
+)
+
+function inventory(entries) {
+ const root = mkdtempSync(join(tmpdir(), 'orca-release-parity-'))
+ directories.push(root)
+ const files = Object.entries(entries).map(([path, content]) => {
+ mkdirSync(dirname(join(root, path)), { recursive: true })
+ writeFileSync(join(root, path), content)
+ return { path, sha256: createHash('sha256').update(content).digest('hex') }
+ })
+ return annotateJavascriptParityFiles(root, files)
+}
+
+it('allows one decimal unit of native P3 rounding and its asset reference hashes', () => {
+ const before = inventory({
+ 'renderer/assets/theme-AAAAAAAA.css': '.x{color:color(display-p3 .134023 .230646 .695537)}',
+ 'renderer/assets/index-BBBBBBBB.js': 'import "./theme-AAAAAAAA.css";run()',
+ 'renderer/index.html': ''
+ })
+ const after = inventory({
+ 'renderer/assets/theme-CCCCCCCC.css': '.x{color:color(display-p3 .134023 .230647 .695537)}',
+ 'renderer/assets/index-DDDDDDDD.js': 'import "./theme-CCCCCCCC.css";run()',
+ 'renderer/index.html': ''
+ })
+ expect(compareJavascriptParityFiles(before, after)).toEqual([])
+})
+
+it('rejects meaningful color changes and every non-color stylesheet change', () => {
+ expect(
+ equivalentStylesheets(
+ 'a{color:color(display-p3 .1 .2 .3)}',
+ 'a{color:color(display-p3 .1 .20001 .3)}'
+ )
+ ).toBe(false)
+ expect(equivalentStylesheets('a{padding:1px}', 'a{padding:2px}')).toBe(false)
+ expect(equivalentStylesheets('a{color:red}', 'b{color:red}')).toBe(false)
+ expect(
+ equivalentStylesheets(
+ 'a{color:color(display-p3 .1 .2 .3)}',
+ 'a{color:color(display-p3 .1 .2 .3 / .5)}'
+ )
+ ).toBe(false)
+})
+
+it('rejects code changes, missing files and incorrect asset references', () => {
+ const before = inventory({ 'renderer/assets/index-AAAAAAAA.js': 'run()' })
+ const after = inventory({ 'renderer/assets/index-BBBBBBBB.js': 'other()' })
+ expect(compareJavascriptParityFiles(before, after)).toEqual(['renderer/assets/index.js'])
+ expect(compareJavascriptParityFiles(before, [])).toEqual(['renderer/assets/index.js'])
+ const reference = inventory({
+ 'renderer/assets/index-BBBBBBBB.js': 'run()',
+ 'renderer/index.html': ''
+ })
+ expect(
+ compareJavascriptParityFiles(
+ reference,
+ inventory({
+ 'renderer/assets/index-DDDDDDDD.js': 'run()',
+ 'renderer/index.html': ''
+ })
+ )
+ ).toEqual(['renderer/index.html'])
+})
+
+it('keeps ambiguous modules distinct and portable binary files exact', () => {
+ const before = inventory({
+ 'renderer/assets/App-AAAAAAAA.js': 'run()',
+ 'renderer/assets/App-BBBBBBBB.js': 'other()',
+ 'renderer/viewer.wasm': 'one'
+ })
+ expect(new Set(before.map((file) => file.comparablePath)).size).toBe(before.length)
+ expect(
+ compareJavascriptParityFiles(
+ before,
+ inventory({
+ 'renderer/assets/App-AAAAAAAA.js': 'run()',
+ 'renderer/assets/App-BBBBBBBB.js': 'other()',
+ 'renderer/viewer.wasm': 'two'
+ })
+ )
+ ).toEqual(['renderer/viewer.wasm'])
+})
+
+it('matches duplicate asset stems by content while rejecting a reference to the wrong module', () => {
+ const before = inventory({
+ 'renderer/assets/App-AAAAAAAA.js': 'desktop()',
+ 'renderer/assets/App-BBBBBBBB.js': 'web()',
+ 'renderer/index.html': ''
+ })
+ const after = inventory({
+ 'renderer/assets/App-CCCCCCCC.js': 'web()',
+ 'renderer/assets/App-DDDDDDDD.js': 'desktop()',
+ 'renderer/index.html': ''
+ })
+ expect(compareJavascriptParityFiles(before, after)).toEqual([])
+ const wrong = inventory({
+ 'renderer/assets/App-CCCCCCCC.js': 'web()',
+ 'renderer/assets/App-DDDDDDDD.js': 'desktop()',
+ 'renderer/index.html': ''
+ })
+ expect(compareJavascriptParityFiles(before, wrong)).toEqual(['renderer/index.html'])
+})
+
+it('preserves module identity across chained and cyclic dependencies with the same stem', () => {
+ const before = inventory({
+ 'renderer/assets/App-AAAAAAAA.js': 'desktop();import "./App-BBBBBBBB.js"',
+ 'renderer/assets/App-BBBBBBBB.js': 'web();import "./App-AAAAAAAA.js"',
+ 'renderer/index.html': ''
+ })
+ const after = inventory({
+ 'renderer/assets/App-CCCCCCCC.js': 'web();import "./App-DDDDDDDD.js"',
+ 'renderer/assets/App-DDDDDDDD.js': 'desktop();import "./App-CCCCCCCC.js"',
+ 'renderer/index.html': ''
+ })
+ expect(compareJavascriptParityFiles(before, after)).toEqual([])
+ const changed = inventory({
+ 'renderer/assets/App-CCCCCCCC.js': 'web();import "./App-DDDDDDDD.js"',
+ 'renderer/assets/App-DDDDDDDD.js': 'desktop();import "./App-DDDDDDDD.js"',
+ 'renderer/index.html': ''
+ })
+ expect(compareJavascriptParityFiles(before, changed)).not.toEqual([])
+})
+
+it('normalizes generated text and SVG line endings without changing escaped string values', () => {
+ const before = inventory({
+ 'renderer/assets/icon-AAAAAAAA.svg': '',
+ 'renderer/assets/index-BBBBBBBB.js': 'import "./icon-AAAAAAAA.svg";\r\nrun()'
+ })
+ const after = inventory({
+ 'renderer/assets/icon-CCCCCCCC.svg': '',
+ 'renderer/assets/index-DDDDDDDD.js': 'import "./icon-CCCCCCCC.svg";\nrun()'
+ })
+ expect(compareJavascriptParityFiles(before, after)).toEqual([])
+ expect(
+ compareJavascriptParityFiles(
+ inventory({ 'shared/template.js': 'const text = "\\r\\n"' }),
+ inventory({ 'shared/template.js': 'const text = "\\n"' })
+ )
+ ).toEqual(['shared/template.js'])
+})
+
+it('permits one printed decimal unit of native Lab rounding and rejects larger or unit changes', () => {
+ expect(
+ equivalentStylesheets(
+ 'a{color:lab(76.5514% 36.4219 15.5335)}',
+ 'a{color:lab(76.5514% 36.422 15.5335)}'
+ )
+ ).toBe(true)
+ expect(
+ equivalentStylesheets(
+ 'a{color:lab(76.5514% 36.4219 15.5335)}',
+ 'a{color:lab(76.5514% 36.4221 15.5335)}'
+ )
+ ).toBe(false)
+ expect(
+ equivalentStylesheets(
+ 'a{color:lab(76.5514% 36.4219 15.5335)}',
+ 'a{color:lab(76.5514 36.4219 15.5335)}'
+ )
+ ).toBe(false)
+})
+
+it('compares manifest keys without ordering differences while preserving import array order', () => {
+ const before = inventory({
+ 'renderer/assets/index-AAAAAAAA.js': 'run()',
+ 'renderer/.vite/manifest.json':
+ '{"_index-AAAAAAAA.js":{"imports":["a","b"],"file":"assets/index-AAAAAAAA.js"},"entry":1}'
+ })
+ const after = inventory({
+ 'renderer/assets/index-BBBBBBBB.js': 'run()',
+ 'renderer/.vite/manifest.json':
+ '{"entry":1,"_index-BBBBBBBB.js":{"file":"assets/index-BBBBBBBB.js","imports":["a","b"]}}'
+ })
+ expect(compareJavascriptParityFiles(before, after)).toEqual([])
+ const changed = inventory({
+ 'renderer/assets/index-BBBBBBBB.js': 'run()',
+ 'renderer/.vite/manifest.json':
+ '{"entry":1,"_index-BBBBBBBB.js":{"file":"assets/index-BBBBBBBB.js","imports":["b","a"]}}'
+ })
+ expect(compareJavascriptParityFiles(before, changed)).toEqual(['renderer/.vite/manifest.json'])
+})
diff --git a/config/scripts/release-javascript-workflow.test.mjs b/config/scripts/release-javascript-workflow.test.mjs
new file mode 100644
index 00000000000..720c8f3d7cd
--- /dev/null
+++ b/config/scripts/release-javascript-workflow.test.mjs
@@ -0,0 +1,91 @@
+import { readFileSync } from 'node:fs'
+import { parse } from 'yaml'
+import { describe, expect, it } from 'vitest'
+
+const release = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8'))
+const mac = parse(readFileSync('.github/workflows/release-mac-build.yml', 'utf8'))
+const javascript = parse(readFileSync('.github/workflows/release-javascript.yml', 'utf8'))
+const comparison = parse(readFileSync('.github/workflows/release-javascript-benchmark.yml', 'utf8'))
+
+describe('release JavaScript job boundaries', () => {
+ it('compiles the release tag while blocking gates run, with no publishing permission', () => {
+ const job = release.jobs['release-javascript']
+ expect(job.needs).toBe('cut')
+ expect(job.permissions).toEqual({ contents: 'read' })
+ expect(job.with.ref).toBe('refs/tags/${{ needs.cut.outputs.tag }}')
+ expect(javascript.permissions).toEqual({ contents: 'read' })
+ expect(javascript.jobs.bundle['runs-on']).toBe('ubuntu-latest')
+ expect(javascript.jobs.bundle.steps[0].with.ref).toBe('${{ inputs.ref }}')
+ expect(javascript.env.ORCA_BACKGROUND_LAUNCH).toBe('1')
+ })
+
+ it.each(['build', 'build-mac'])(
+ 'keeps %s behind signing gates and the bundle verdict',
+ (name) => {
+ const job = release.jobs[name]
+ expect(job.needs).toEqual(
+ expect.arrayContaining([
+ 'cut',
+ 'create-release',
+ 'release-preflight',
+ 'relay-windows-process-tree',
+ 'release-javascript'
+ ])
+ )
+ expect(job.if).toContain("needs.release-preflight.result == 'success'")
+ expect(job.if).toContain("needs.release-javascript.result == 'success'")
+ }
+ )
+
+ it('retains full compilation for old refs but fails rather than rebuilding a broken shared artifact', () => {
+ const job = javascript.jobs.bundle
+ const support = job.steps.find((step) => step.id === 'source')
+ expect(support.run).toContain('build:release:javascript')
+ expect(support.run).toContain('build:release:host')
+ const compile = job.steps.find((step) => step.name === 'Build and archive release JavaScript')
+ expect(compile.if).toBe("steps.source.outputs.supported == 'true'")
+ const download = release.jobs.build.steps.find(
+ (step) => step.name === 'Download release JavaScript'
+ )
+ expect(download.if).toBe("needs.release-javascript.outputs.supported == 'true'")
+ expect(download['continue-on-error']).toBeUndefined()
+ const build = release.jobs.build.steps.find((step) => step.name === 'Build app')
+ expect(build.run).toMatch(/artifact\.mjs restore\s+pnpm run build:release:host/)
+ expect(build.run).toMatch(/else\s+pnpm run build:release/)
+ expect(build['continue-on-error']).toBeUndefined()
+ })
+
+ it('restores the same parent-run bundle on macOS and retains native and runtime checks', () => {
+ const download = mac.jobs['build-mac'].steps.find(
+ (step) => step.name === 'Download release JavaScript from the release run'
+ )
+ expect(download.with['run-id']).toBe('${{ inputs.release_run_id }}')
+ expect(download.with.name).toBe('release-javascript')
+ const build = mac.jobs['build-mac'].steps.find((step) => step.name === 'Build app')
+ expect(build.env.ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA).toBe('${{ inputs.javascript_source_sha }}')
+ expect(build.run).toContain('pnpm run build:release:host')
+ for (const job of [release.jobs.build, mac.jobs['build-mac']]) {
+ expect(job.steps.map((step) => step.name)).toEqual(
+ expect.arrayContaining([
+ 'Gate runtime file-watcher process isolation',
+ 'Gate SSH relay watcher process isolation'
+ ])
+ )
+ }
+ })
+
+ it('compares both build paths on all packaging hosts without publishing credentials', () => {
+ expect(comparison.jobs.measure.strategy.matrix.mode).toEqual(['baseline', 'shared'])
+ expect(comparison.jobs.measure.strategy.matrix.host).toHaveLength(4)
+ expect(comparison.permissions).toEqual({ contents: 'read' })
+ expect(comparison.env.ORCA_POSTHOG_WRITE_KEY).toBe('ci-build-comparison')
+ expect(comparison.jobs.bundle.secrets.ORCA_POSTHOG_WRITE_KEY).toBe('ci-build-comparison')
+ const steps = comparison.jobs.measure.steps
+ expect(steps.find((step) => step.name === 'Download shared JavaScript').if).toBe(
+ "matrix.mode == 'shared'"
+ )
+ expect(steps.find((step) => step.name === 'Measure release build').run).toContain(
+ 'release-javascript-benchmark.mjs'
+ )
+ })
+})
diff --git a/config/scripts/release-mac-build-workflow-dispatch.test.mjs b/config/scripts/release-mac-build-workflow-dispatch.test.mjs
index 62e6b66c933..00a8c21a592 100644
--- a/config/scripts/release-mac-build-workflow-dispatch.test.mjs
+++ b/config/scripts/release-mac-build-workflow-dispatch.test.mjs
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
+ dispatchReleaseMacBuildWorkflow,
expectedReleaseMacBuildRunTitle,
readReleaseMacBuildWorkflowOptions,
runReleaseMacBuildWorkflow
@@ -19,6 +20,19 @@ const baseOptions = {
}
describe('release mac build workflow dispatch', () => {
+ it('passes the shared JavaScript commit only when the parent produced a bundle', async () => {
+ const request = vi.fn(async () => ({}))
+ const api = { owner: 'stablyai', repo: 'orca', request }
+ await dispatchReleaseMacBuildWorkflow(api, {
+ ...baseOptions,
+ javascriptSourceSha: 'a'.repeat(40)
+ })
+ expect(request.mock.calls[0][2].inputs).toEqual({
+ release_run_id: '777',
+ tag: 'v1.2.3-rc.4',
+ javascript_source_sha: 'a'.repeat(40)
+ })
+ })
it('dispatches the mac workflow and waits for the returned run id', async () => {
const { fetch, requests } = createGitHubFetch([
jsonResponse(200, {
diff --git a/config/scripts/run-release-mac-build-workflow.mjs b/config/scripts/run-release-mac-build-workflow.mjs
index e74564965b3..058647b1d93 100644
--- a/config/scripts/run-release-mac-build-workflow.mjs
+++ b/config/scripts/run-release-mac-build-workflow.mjs
@@ -8,6 +8,7 @@ export function readReleaseMacBuildWorkflowOptions(env = process.env) {
apiBaseUrl: env.GITHUB_API_URL ?? 'https://api.github.com',
pollSeconds: readPositiveInteger(env.RELEASE_MAC_BUILD_POLL_SECONDS, DEFAULT_POLL_SECONDS),
ref: requiredEnv(env.RELEASE_MAC_BUILD_REF, 'RELEASE_MAC_BUILD_REF'),
+ javascriptSourceSha: env.RELEASE_MAC_BUILD_JAVASCRIPT_SOURCE_SHA || undefined,
releaseRunId: requiredEnv(
env.RELEASE_MAC_BUILD_RELEASE_RUN_ID ?? env.GITHUB_RUN_ID,
'RELEASE_MAC_BUILD_RELEASE_RUN_ID'
@@ -66,7 +67,8 @@ export async function dispatchReleaseMacBuildWorkflow(api, options) {
const body = {
inputs: {
release_run_id: options.releaseRunId,
- tag: options.tag
+ tag: options.tag,
+ ...(options.javascriptSourceSha ? { javascript_source_sha: options.javascriptSourceSha } : {})
},
ref: options.ref
}
diff --git a/config/scripts/update-node-runtime-pin.mjs b/config/scripts/update-node-runtime-pin.mjs
index 76011ec1550..86f8ce75bb9 100644
--- a/config/scripts/update-node-runtime-pin.mjs
+++ b/config/scripts/update-node-runtime-pin.mjs
@@ -27,7 +27,7 @@ import {
import { currentTarget } from './server-build-target.mjs'
import { nodeDistArchiveName, windowsImportLibFile } from './node-dist-archive-name.mjs'
import { runProcessSync } from './script-child-process.mjs'
-import { getZipExtractorCommand } from './zip-extractor-command.mjs'
+import { getTarProgram, getZipExtractorCommand } from './zip-extractor-command.mjs'
const root = resolve(import.meta.dirname, '../..')
const PIN_FILE = join(root, 'src/shared/node-runtime-pin.ts')
@@ -182,12 +182,6 @@ function run(program, args) {
return result.stdout
}
-function tarProgram() {
- return process.platform === 'win32'
- ? join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe')
- : 'tar'
-}
-
export function extract(archivePath, destination, member) {
mkdirSync(destination, { recursive: true })
if (archivePath.endsWith('.zip')) {
@@ -195,7 +189,7 @@ export function extract(archivePath, destination, member) {
run(command.file, command.args)
return
}
- run(tarProgram(), ['-xzf', archivePath, '-C', destination, member])
+ run(getTarProgram(), ['-xzf', archivePath, '-C', destination, member])
}
function gpgAvailable() {
diff --git a/config/scripts/zip-extractor-command.mjs b/config/scripts/zip-extractor-command.mjs
index fbd4c696748..2bbe38a8cad 100644
--- a/config/scripts/zip-extractor-command.mjs
+++ b/config/scripts/zip-extractor-command.mjs
@@ -1 +1 @@
-export { getZipExtractorCommand } from '../../src/shared/zip-extractor-command.ts'
+export { getTarProgram, getZipExtractorCommand } from '../../src/shared/zip-extractor-command.ts'
diff --git a/config/scripts/zip-extractor-command.test.mjs b/config/scripts/zip-extractor-command.test.mjs
index 9cc574f87ec..fde9521c101 100644
--- a/config/scripts/zip-extractor-command.test.mjs
+++ b/config/scripts/zip-extractor-command.test.mjs
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { runProcessSync } from './script-child-process.mjs'
-import { getZipExtractorCommand } from './zip-extractor-command.mjs'
+import { getTarProgram, getZipExtractorCommand } from './zip-extractor-command.mjs'
const directories = []
afterEach(() => {
@@ -27,6 +27,15 @@ function extract(bytes) {
}
describe('native archive extraction', () => {
+ it('pins Windows tar to the system tool even when Git Bash or an unzip override is present', () => {
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+ vi.stubEnv('SystemRoot', 'C:\\Windows')
+ vi.stubEnv('ORCA_UNZIP_BIN', 'custom-unzip')
+ expect(getTarProgram()).toBe(join('C:\\Windows', 'System32', 'tar.exe'))
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
+ expect(getTarProgram()).toBe('tar')
+ })
+
it('uses the system archive reader on Windows unless an override is configured', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.stubEnv('SystemRoot', 'C:\\Windows')
diff --git a/package.json b/package.json
index 29fa622a1d1..1e04f1f49b7 100644
--- a/package.json
+++ b/package.json
@@ -107,6 +107,8 @@
"build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-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 verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web",
+ "build:release:javascript": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web",
+ "build:release:host": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run verify:built-skills-cli",
"build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web",
"postinstall": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
index e2f59123e86..95aaffba5d7 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
@@ -52,6 +52,7 @@ import {
} from './structured-agent-session-restart-resume-host'
import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring'
import { createStructuredAgentSessionConversationDelivery } from './structured-agent-session-host-delivery'
+import type * as conversation from './structured-agent-session-host-delivery'
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring'
import * as sessionLogger from './structured-agent-session-logger'
@@ -91,9 +92,7 @@ export class StructuredAgentSessionHost {
private readonly reconcileLeases: ReturnType
private readonly restore: ReturnType
private readonly lifetime: StructuredAgentSessionConversationLifetime
- private readonly conversationDelivery: ReturnType<
- typeof createStructuredAgentSessionConversationDelivery
- >
+ private readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery
private readonly eventRecovery: StructuredAgentSessionEventRecovery
private readonly backgroundTasks: StructuredAgentSessionBackgroundTaskChannel
/** Public because the RPC surface addresses it directly; see the restart-resume collaborator. */
diff --git a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx
index 015e20aebed..ae8273e7bc7 100644
--- a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx
@@ -18,10 +18,7 @@ import { NativeChatDiffView } from './NativeChatDiffView'
import { nativeChatToolLineLabel } from './native-chat-tool-line-label'
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
-import {
- createToolInputDisplay,
- truncateToolDetail
-} from './native-chat-tool-summary'
+import { createToolInputDisplay, truncateToolDetail } from './native-chat-tool-summary'
/** A tool sentence with input and output behind its own remembered disclosure. */
export function NativeChatToolLine({
diff --git a/src/shared/zip-extractor-command.ts b/src/shared/zip-extractor-command.ts
index 1f6564e22f6..1a8a900fbf6 100644
--- a/src/shared/zip-extractor-command.ts
+++ b/src/shared/zip-extractor-command.ts
@@ -1,5 +1,11 @@
import { join } from 'node:path'
+export function getTarProgram(): string {
+ return process.platform === 'win32'
+ ? join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe')
+ : 'tar'
+}
+
/** The destination must already exist; callers extract only checksum-verified archives. */
export function getZipExtractorCommand(
zipPath: string,
@@ -7,7 +13,7 @@ export function getZipExtractorCommand(
): { file: string; args: string[]; label: string } {
if (process.platform === 'win32' && !process.env.ORCA_UNZIP_BIN) {
return {
- file: join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe'),
+ file: getTarProgram(),
args: ['-xf', zipPath, '-C', extractDir],
label: 'tar'
}