Speed up PR CI with path skips, native caches, and fewer shards (#16863)

* Speed up PR CI with per-job path skips and native caches

Skip git-compat, xterm, packaging, and shell jobs when their inputs are
unchanged, reuse the composite install action (including Windows node-pty
cache), skip compiling the Windows CLI launcher on a cache hit, and cut the
test matrix from 16x2 to 8x2 shards without dropping coverage.

* Widen PR job skip prefixes for orcad browser and live shells

Chrome session/tab modules and zsh/fish wrapper templates are inputs to
required jobs the classifier previously skipped. Include that implementation
graph so those jobs still run when the files they load change.

* Fix CI cache safety and required gates

* Build scriptless Windows addons explicitly

* Preserve node-pty Windows support prebuild

* Remove duplicated Windows launcher unit lane
This commit is contained in:
Neil
2026-08-27 16:09:07 -07:00
committed by GitHub
parent 074339478a
commit 350423b7cb
21 changed files with 961 additions and 322 deletions
@@ -1,5 +1,5 @@
name: Install Node dependencies
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
description: Installs the Node toolchain and repository dependencies for CI jobs.
inputs:
native-runtime:
@@ -10,6 +10,18 @@ inputs:
description: Node.js version override; defaults to the version declared in package.json.
required: false
default: ''
persist-native-cache:
description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
required: false
default: 'true'
outputs:
node-version:
description: Resolved Node.js version used for the install.
value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
native-cache-scope:
description: Operating-system image scope used by the native module cache.
value: ${{ steps.native-cache-scope.outputs.scope }}
runs:
using: composite
@@ -51,7 +63,7 @@ runs:
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
- name: Use external node-gyp
if: inputs.native-runtime != 'none'
if: runner.os == 'Linux' && inputs.native-runtime != 'none'
shell: bash
run: |
npm install -g node-gyp@11.5.0
@@ -80,12 +92,42 @@ runs:
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
# Node version, and the patch whose contents the build has to match.
- name: Restore compiled native modules
# Windows extra globs are empty on Linux. No restore-keys: a partial-match key is
# an ABI-mismatched build, and ensure-native-runtime would recompile it anyway.
# Native addons built on a newer Linux image can require glibc symbols
# missing from an older runner/container. ImageOS distinguishes hosted
# Windows/macOS images; /etc/os-release also distinguishes Linux containers.
- name: Resolve native cache scope
id: native-cache-scope
if: inputs.native-runtime != 'none'
shell: bash
run: |
scope="${ImageOS:-$RUNNER_OS}"
if [ -r /etc/os-release ]; then
. /etc/os-release
scope="${ID:-linux}-${VERSION_ID:-unknown}"
fi
echo "scope=$scope" >> "$GITHUB_OUTPUT"
- name: Restore compiled native modules
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
uses: actions/cache@v5
with:
path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }}
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Restore compiled native modules without saving
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
uses: actions/cache/restore@v5
with:
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
+5 -22
View File
@@ -71,30 +71,17 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
# Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
# permission on Linux runners; node-pty's install fallback then fails
# before this smoke job can exercise the native package.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
# is no root config) and falls back to the 5s default timeout with no
# Windows worker cap, so the real csc.exe launcher-compile tests time out
# on hosted Windows. Use the shared config so this job matches pnpm test.
- run: >-
pnpm vitest run --config config/vitest.config.ts
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
@@ -159,13 +146,9 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
- uses: ./.github/actions/install-node-dependencies
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Owner-loss benchmark process cleanup
run: >-
pnpm vitest run
+13 -100
View File
@@ -40,36 +40,9 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
# Why: the E2E build compiles native modules via node-gyp. Mirrors the
# install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
# errors.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why no native-runtime: this job only produces JS bundles. Native modules
# are prepared in the consumer shards that actually launch Electron.
- uses: ./.github/actions/install-node-dependencies
# Why: building here avoids parallel builds inside Playwright globalSetup;
# paired-browser specs also need the standalone web bundle.
@@ -130,11 +103,11 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
# Why: pnpm install rebuilds native modules, and those postinstall
# scripts still need the Linux toolchain even though this shard reuses
# the prebuilt Electron output.
# Why: pnpm install used to rebuild native modules here; the composite
# action restores them from cache and only compiles on a miss. The
# toolchain is still required for that miss path, and for paired Quick
# Open coverage which exercises the resource-bounded host search.
- name: Install native build tools
# Why: paired Quick Open coverage exercises the resource-bounded host search.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
# Why: Electron on Linux needs an X display even when the app
@@ -143,33 +116,9 @@ jobs:
- name: Install xvfb
run: sudo apt-get install -y xvfb
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
- uses: ./.github/actions/install-node-dependencies
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
# release.yml so the invariant "this workaround is Linux-only" is
# consistent across all three workflows, even though this job
# currently pins runs-on: ubuntu-latest.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -222,25 +171,9 @@ jobs:
# lane now receives those specs from pr.yml's SSH source mapping.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
- uses: ./.github/actions/install-node-dependencies
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -305,29 +238,9 @@ jobs:
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
- uses: ./.github/actions/install-node-dependencies
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
# stays consistent across workflows even while this job is ubuntu-latest.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
+107 -77
View File
@@ -16,15 +16,28 @@ permissions:
contents: read
jobs:
# Why: a README/docs-only PR used to start the full matrix (32 test shards,
# Why: a README/docs-only PR used to start the full matrix (test shards,
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
# filters on `on.pull_request` would drop the `verify` check entirely; this
# detector keeps verify as the required aggregate and skips the expensive jobs.
# Per-job outputs also skip git-compat/xterm/packaging/shell when those
# inputs are unchanged; empty diffs fail closed and run everything.
code_paths:
name: detect code-relevant changes
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
static_analysis: ${{ steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
test: ${{ steps.filter.outputs.test }}
orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
package: ${{ steps.filter.outputs.package }}
package_windows: ${{ steps.filter.outputs.package_windows }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -48,14 +61,12 @@ jobs:
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
echo "Changed paths:"
printf '%s\n' "$CHANGED"
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)"
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "should_run=$SHOULD_RUN"
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
static_analysis:
name: static analysis
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.static_analysis == 'true'
runs-on: ubuntu-latest
steps:
@@ -70,6 +81,8 @@ jobs:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Lint
run: pnpm exec oxlint --format github
@@ -188,7 +201,7 @@ jobs:
typecheck:
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.typecheck == 'true'
runs-on: ubuntu-latest
steps:
@@ -216,7 +229,7 @@ jobs:
git_compatibility:
name: Git compatibility
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.git_compatibility == 'true'
runs-on: ubuntu-latest
steps:
@@ -283,7 +296,7 @@ jobs:
xterm_patch_sync:
name: xterm patch sync
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
runs-on: ubuntu-latest
steps:
@@ -313,7 +326,7 @@ jobs:
shell_contracts:
name: shell contracts
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.shell_contracts == 'true'
runs-on: ubuntu-latest
# Why: this job's cost is almost entirely package download, and a stalled mirror has
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
@@ -432,14 +445,14 @@ jobs:
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.test == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shard_total: [16]
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard_total: [8]
steps:
- name: Checkout
@@ -477,12 +490,12 @@ jobs:
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
# Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
# would pay for it 32 times to run one file in whichever shard it landed in.
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
# would pay for it on every shard to run one file in whichever shard it landed in.
orcad_browser:
name: orcad browser provider
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.orcad_browser == 'true'
runs-on: ubuntu-latest
steps:
@@ -518,7 +531,7 @@ jobs:
cross-version-wire:
name: cross-version wire compatibility
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.cross-version-wire == 'true'
runs-on: ubuntu-latest
steps:
@@ -550,7 +563,7 @@ jobs:
managed_hook_node18:
name: managed hooks on Node 18
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
runs-on: ubuntu-latest
steps:
@@ -575,7 +588,7 @@ jobs:
package:
name: package
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.package == 'true'
runs-on: ubuntu-latest
steps:
@@ -647,7 +660,7 @@ jobs:
package_windows:
name: package (windows)
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.package_windows == 'true'
runs-on: windows-2022
timeout-minutes: 30
@@ -657,17 +670,6 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
@@ -678,18 +680,22 @@ jobs:
restore-keys: |
electron-builder-windows-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why persist-native-cache false: this job later rebuilds the same path for
# Electron. A post-job save would store the Electron ABI under the Node key.
- uses: ./.github/actions/install-node-dependencies
id: deps
with:
native-runtime: node
persist-native-cache: 'false'
# Why: node-pty prefers its upstream prebuild, which does not contain
# Orca's Windows patch, so the job-object exports would be absent and the
# suite below would test an unpatched binary. build_from_source removes
# the prebuild, and the package's postinstall restores the ConPTY runtime
# files that a bare node-gyp rebuild would miss.
- name: Rebuild node-pty from patched source
env:
npm_config_build_from_source: 'true'
run: pnpm rebuild node-pty
- name: Save compiled Node native modules
uses: actions/cache/save@v5
with:
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Test Windows-specific boundaries
run: >-
@@ -724,9 +730,26 @@ jobs:
# Why the :parallel variant: identical to build:release except the three
# electron-vite targets overlap instead of running back to back. The Linux package
# job already packages and smoke-tests an AppImage built that way.
- name: Cache Windows CLI launcher
uses: actions/cache@v5
with:
path: native/windows-cli-launcher/.build
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }}
- name: Build package inputs
env:
ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1'
run: pnpm run build:release:parallel
- name: Restore compiled Electron native modules
uses: actions/cache@v5
with:
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare Electron native runtime
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
@@ -807,6 +830,7 @@ jobs:
- shell_contracts
- test
- orcad_browser
- cross-version-wire
- managed_hook_node18
- package
- package_windows
@@ -826,57 +850,63 @@ jobs:
CODE_PATHS: ${{ needs.code_paths.result }}
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }}
TEST: ${{ needs.test.result }}
TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
MANAGED_HOOK_NODE18_SHOULD_RUN: ${{ needs.code_paths.outputs.managed_hook_node18 }}
PACKAGE: ${{ needs.package.result }}
PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }}
run: |
if [ "$CODE_PATHS" != "success" ]; then
exit 1
fi
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
exit 1
fi
if [ "$SHOULD_RUN" != "true" ]; then
echo "Docs-only change; expensive PR checks skipped."
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
exit 1
fi
for result in \
"$STATIC_ANALYSIS" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$ORCAD_BROWSER" \
"$MANAGED_HOOK_NODE18" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "skipped" ]; then
exit 1
fi
done
exit 0
fi
# Require success when the PR has code-relevant changes
for result in \
"$CODE_PATHS" \
"$STATIC_ANALYSIS" \
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$ORCAD_BROWSER" \
"$MANAGED_HOOK_NODE18" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "success" ]; then
exit 1
failed=0
check_job() {
local name="$1" result="$2" should="$3"
if [ "$should" = "true" ]; then
if [ "$result" != "success" ]; then
echo "$name: expected success, got $result"
failed=1
fi
else
if [ "$result" != "skipped" ]; then
echo "$name: expected skipped, got $result"
failed=1
fi
fi
done
}
# Require success when the PR has code-relevant changes
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
check_job test "$TEST" "$TEST_SHOULD_RUN"
check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN"
exit "$failed"
+60 -33
View File
@@ -1,41 +1,24 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { existsSync, mkdirSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
if (process.platform !== 'win32') {
// Why: electron-builder treats a skipped native build like success and can
// continue toward a Windows package whose declared orca.exe does not exist.
throw new Error(
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
)
}
const repoRoot = resolve(import.meta.dirname, '../..')
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
const compilerPath = findFrameworkCompiler(process.env)
if (!compilerPath) {
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
}
mkdirSync(dirname(outputPath), { recursive: true })
const result = spawnSync(
compilerPath,
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
{ cwd: repoRoot, stdio: 'inherit' }
)
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.error) {
throw result.error
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
export function shouldReuseCompiledWindowsCliLauncher(
outputPath,
sourcePath,
{ reuseCached = false } = {}
) {
if (!existsSync(outputPath)) {
return false
}
// Why reuseCached: Actions cache keys already hash the C# source, but restore
// does not preserve mtimes, so a hit would look stale and recompile anyway.
if (reuseCached) {
return true
}
return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
}
function defaultOutputPath(projectRoot) {
@@ -58,3 +41,47 @@ function readArg(name) {
const index = process.argv.indexOf(name)
return index !== -1 ? process.argv[index + 1] : undefined
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
if (process.platform !== 'win32') {
// Why: electron-builder treats a skipped native build like success and can
// continue toward a Windows package whose declared orca.exe does not exist.
throw new Error(
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
)
}
const repoRoot = resolve(import.meta.dirname, '../..')
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
const compilerPath = findFrameworkCompiler(process.env)
if (!compilerPath) {
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
}
mkdirSync(dirname(outputPath), { recursive: true })
if (
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
})
) {
console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
process.exit(0)
}
const result = spawnSync(
compilerPath,
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
{ cwd: repoRoot, stdio: 'inherit' }
)
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.error) {
throw result.error
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
@@ -5,12 +5,15 @@ import {
mkdtempSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
@@ -37,6 +40,33 @@ function itWindows(name, test) {
}
describe('Windows CLI launcher', () => {
it('reuses a compiled launcher that is at least as new as the C# source', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
try {
const sourcePath = join(root, 'OrcaCliLauncher.cs')
const outputPath = join(root, '.build', 'orca.exe')
mkdirSync(join(root, '.build'))
writeFileSync(sourcePath, 'source\n')
writeFileSync(outputPath, 'binary\n')
const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
utimesSync(outputPath, later, later)
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
writeFileSync(sourcePath, 'changed\n')
const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
utimesSync(sourcePath, sourceLater, sourceLater)
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
expect(
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
).toBe(true)
expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
false
)
} finally {
removeFixtureTree(root)
}
})
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
@@ -76,7 +76,6 @@ describe('computer-use e2e workflow', () => {
)
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
@@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => {
const job = workflow.jobs['mac-native-owner-smoke']
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
const install = job.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(job.if).toBe("github.event_name == 'pull_request'")
expect(job['runs-on']).toBe('macos-15')
expect(checkout.with['persist-credentials']).toBe(false)
expect(install.with['native-runtime']).toBe('electron')
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
const cleanupRun = runs.find((run) =>
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
+42 -16
View File
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, readFileSync } from 'node:fs'
import { release } from 'node:os'
import { basename, resolve } from 'node:path'
import { basename, dirname, resolve } from 'node:path'
const require = createRequire(import.meta.url)
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
@@ -67,7 +67,12 @@ function ensureNodeRuntime() {
if (!initial.ok) {
printCheckError(initial)
}
runPnpm(['rebuild', 'node-pty'])
const failedModules = initial.failures.map((failure) => failure.moduleName)
const rebuildModules = [
'node-pty',
...failedModules.filter((moduleName) => moduleName !== 'node-pty')
]
rebuildNodeRuntimeModules(rebuildModules)
verifyNodeRuntimeAfterRebuild()
return
}
@@ -77,7 +82,7 @@ function ensureNodeRuntime() {
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
)
printCheckError(initial)
runPnpm(['rebuild', ...failedModules])
rebuildNodeRuntimeModules(failedModules)
verifyNodeRuntimeAfterRebuild()
}
@@ -312,14 +317,10 @@ function getPatchedNodePtyRebuildReason() {
return null
}
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
// patch only lands in the source-built build/Release artifacts.
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
// Windows patches only land in the source-built build/Release artifacts.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -329,11 +330,24 @@ function getPatchedNodePtyRebuildReason() {
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
}
function requiresPatchedNodePtySourceBuild() {
function patchedNodePtyArtifactPaths(nodePtyDir) {
if (process.platform === 'win32') {
return false
const releaseDir = resolve(nodePtyDir, 'build', 'Release')
return [
resolve(releaseDir, 'conpty.node'),
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
]
}
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
return artifactPaths
}
function requiresPatchedNodePtySourceBuild() {
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
if (!existsSync(nodePtyPatchPath)) {
return false
@@ -351,16 +365,28 @@ function getWindowsBuildNumber() {
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
}
function runPnpm(args) {
function rebuildNodeRuntimeModules(moduleNames) {
for (const moduleName of moduleNames) {
const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
if (moduleName === 'node-pty' && process.platform === 'win32') {
runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
}
}
}
function runPnpm(args, { cwd = projectDir } = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(command, args, {
cwd: projectDir,
cwd,
stdio: 'inherit',
shell: process.platform === 'win32'
shell: process.platform === 'win32',
env: process.env
})
if (result.error || result.status !== 0) {
console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
if (result.error) {
console.error(formatError(result.error))
}
+87 -10
View File
@@ -42,7 +42,8 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
expect(log).toContain('pnpm rebuild node-pty\n')
expect(log).toContain('pnpm exec node-gyp rebuild\n')
expect(log).toContain(join('node_modules', 'node-pty'))
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
@@ -52,6 +53,41 @@ describe('ensure-native-runtime', () => {
}
})
it.skipIf(process.platform !== 'win32')(
'rebuilds other failed Windows addons with patched node-pty',
() => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
expect(log).toContain(join('node_modules', 'node-pty'))
expect(log).toContain(join('node_modules', 'windows-native-registry'))
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'rebuilds patched node-pty artifacts even when the Node load check passes',
() => {
@@ -80,7 +116,7 @@ describe('ensure-native-runtime', () => {
expect(result.stderr).toContain(
'Patched node-pty build artifacts are missing; rebuilding native deps.'
)
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -114,7 +150,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -148,7 +184,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -178,9 +214,15 @@ function envWithPrependedPath(binDir, extraEnv) {
}
}
function writeFakeNativeModules(projectDir) {
function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(
join(nodePtyDir, 'package.json'),
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
)
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -200,12 +242,18 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
}
`
)
writeFakeWindowsRegistry(projectDir)
writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
}
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(
join(nodePtyDir, 'package.json'),
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
)
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -218,23 +266,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
return { dir, module: {} }
return {
dir,
module: {
listJobProcessIds: () => [],
terminateJob: () => true,
assignCurrentProcessToJob: () => true
}
}
}
`
)
writeFakeWindowsRegistry(projectDir)
}
function writeFakeWindowsRegistry(projectDir) {
function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
if (process.platform !== 'win32') {
return
}
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
mkdirSync(registryDir, { recursive: true })
writeFileSync(
join(registryDir, 'index.js'),
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
join(registryDir, 'package.json'),
'{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n'
)
const markerGate = requiresMarker
? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }`
: ''
writeFileSync(
join(registryDir, 'index.js'),
`exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n`
)
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
mkdirSync(processTreeDir, { recursive: true })
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
}
function writeNodePtyPatchFile(projectDir) {
@@ -245,6 +310,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
if (process.platform === 'win32') {
writeFileSync(join(buildDir, 'conpty.node'), '')
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
return
}
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
@@ -260,6 +332,11 @@ function writeFakePnpm(binDir) {
const { appendFileSync, writeFileSync } = require('node:fs')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
+252 -2
View File
@@ -16,6 +16,183 @@ const DOCS_ONLY_FILES = new Set([
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
export const PR_CHECK_JOBS = [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync',
'shell_contracts',
'test',
'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
const GLOBAL_FORCE_PREFIXES = [
'.github/workflows/pr.yml',
'.github/actions/install-node-dependencies/',
'config/scripts/pr-code-change-scope'
]
const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml'])
const GIT_COMPAT_PREFIXES = [
'src/shared/git-',
'src/shared/review-head-tracking-ref',
'src/main/git/',
'src/relay/git-',
'config/scripts/git-binary-compatibility'
]
const XTERM_PREFIXES = [
'config/patches/xterm-upstream.json',
'config/patches/@xterm',
'config/patches/xterm-src/',
'config/scripts/regenerate-xterm-patches'
]
const SHELL_PREFIXES = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec',
'src/main/daemon/shell-ready',
'src/main/daemon/daemon-bash-shell-ready',
'src/main/daemon/daemon-shell-ready-wrapper',
'src/main/daemon/node-pty-fd-leak',
'src/main/providers/local-pty-shell-ready',
'src/main/providers/__tests__/shell-ready-framework-example',
'src/main/pty/',
'src/main/shell-templates',
'src/main/shell-startup-',
'src/main/shell-wrapper-',
'src/main/terminal-history-fish',
'src/main/zsh-',
'src/renderer/src/components/terminal-pane/fish-color-scheme',
'src/shared/fish-',
'src/shared/pty-reply-echo-shapes',
'src/shared/startup-shell-portability',
'src/shared/posix-command-path-lookup',
'config/patches/node-pty@',
'config/scripts/ensure-native-runtime',
'config/scripts/node-pty-job-ownership'
]
const ORCAD_BROWSER_PREFIXES = [
'src/main/orcad/external-chromium-',
'src/main/orcad/orcad-browser-provider',
'src/main/orcad/orcad-agent-browser-binary',
'src/main/orcad/electron-serve-browser-process'
]
const CROSS_VERSION_WIRE_PREFIXES = [
'tests/e2e/cross-version-wire/',
'src/shared/protocol-version',
'src/shared/terminal-stream-protocol',
'src/shared/browser-client-host-protocol',
'src/shared/browser-network-tunnel-protocol',
'src/shared/browser-client-host-placement',
'src/main/runtime/rpc/dispatcher',
'src/main/runtime/rpc/methods/browser-tab-create-schema',
'src/main/runtime/rpc/methods/terminal',
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
]
const MANAGED_HOOK_PREFIXES = [
'config/scripts/smoke-managed-hook-runtime-node18',
'config/scripts/build-relay',
'src/relay/',
'src/shared/agent-hook',
'src/main/agent-hooks/'
]
const NATIVE_RUNTIME_PREFIXES = [
'config/scripts/ensure-native-runtime',
'config/scripts/rebuild-native-deps',
'config/scripts/node-pty-job-ownership',
'config/scripts/electron-builder-native-rebuild',
'config/patches/node-pty@',
'config/patches/@vscode__windows-process-tree'
]
const SHARED_PACKAGE_PREFIXES = [
'electron.vite.config.ts',
'config/electron-builder',
'config/packaged-runtime',
'config/build-plugins/',
'config/scripts/build-',
'config/scripts/smoke-packaged',
'config/scripts/install-electron-package-binary',
'config/scripts/verify-packaged',
'config/scripts/verify-linux-glibc',
'config/scripts/run-electron-vite',
'skills/',
'skill-guides/',
'resources/build/',
'resources/onboarding/',
'resources/plugins/',
'resources/skills/',
...NATIVE_RUNTIME_PREFIXES
]
const LINUX_PACKAGE_PREFIXES = [
...SHARED_PACKAGE_PREFIXES,
'native/computer-use-linux/',
'resources/linux/',
'config/scripts/run-headless-serve'
]
const WINDOWS_PACKAGE_PREFIXES = [
...SHARED_PACKAGE_PREFIXES,
'native/windows-cli-launcher/',
'native/computer-use-windows/',
'resources/win32/',
'config/scripts/build-windows-cli-launcher',
'config/scripts/windows-pty-native-capability',
'tests/tools/windows-pty-native-capability-smoke/'
]
const LINUX_PACKAGE_TESTS = [
'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts',
'src/main/browser/browser-route-tcp-egress.electron.test.ts',
'src/main/browser/browser-route-webrtc-egress.electron.test.ts',
'src/main/browser/browser-route-h3-egress.electron.test.ts',
'src/main/browser/browser-route-dns-prefetch.electron.test.ts'
]
const WINDOWS_PACKAGE_TESTS = [
...LINUX_PACKAGE_TESTS,
'config/scripts/rebuild-native-deps.test.mjs',
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
'src/shared/child-process/windows-command-line.win32.test.ts',
'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
'src/main/windows/windows-pty-job.win32.test.ts',
'src/main/windows/windows-host-job.win32.test.ts',
'src/main/wsl/wsl-runner.test.ts',
'src/main/wsl/wsl-guest-environment.test.ts',
'src/main/wsl/wsl-invocation-boundary.test.ts',
'src/main/wsl/wsl-executable-path.win32.test.ts',
'src/main/wsl/wsl-w1-w3-contract.test.ts',
'src/shared/source-scan/source-tree-scan.test.ts',
'src/main/cli/wsl-cli-powershell-boundary.test.ts',
'src/main/cursor/hook-service.test.ts',
'src/main/orca-profiles/profile-index-store.test.ts',
'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
'src/shared/secure-file-fsync-flags.test.ts',
'src/main/ipc/pty-codex-account-attribution.test.ts',
'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
]
const DESKTOP_IRRELEVANT_PREFIXES = [
'mobile/',
'.github/workflows/mobile.yml',
'.github/workflows/mobile-ios-release.yml',
'.github/workflows/mobile-android-release.yml'
]
export function isDocsOnlyPath(file) {
if (DOCS_ONLY_FILES.has(file)) {
return true
@@ -32,10 +209,83 @@ export function shouldRunPrChecks(changedFiles) {
if (changedFiles.length === 0) {
return true
}
return changedFiles.some((file) => !isDocsOnlyPath(file))
return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
}
export function classifyPrJobs(changedFiles) {
const emptyDiff = changedFiles.length === 0
const shouldRun = shouldRunPrChecks(changedFiles)
const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
const jobs = Object.fromEntries(
PR_CHECK_JOBS.map((job) => [
job,
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
])
)
return { should_run: shouldRun, ...jobs }
}
function jobDetector(job) {
switch (job) {
case 'git_compatibility':
return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
case 'xterm_patch_sync':
return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
case 'shell_contracts':
return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
case 'orcad_browser':
return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
case 'cross-version-wire':
return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
case 'managed_hook_node18':
return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES))
case 'package':
return (files) => files.some(isLinuxPackagePath)
case 'package_windows':
return (files) => files.some(isWindowsPackagePath)
default:
return () => true
}
}
function isLinuxPackagePath(file) {
return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES)
}
function isWindowsPackagePath(file) {
return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES)
}
function isProductBundlePath(file, extraPrefixes) {
if (isTestFile(file)) {
return false
}
if (file.startsWith('src/')) {
return true
}
return matchesPrefix(file, extraPrefixes)
}
function isTestFile(file) {
return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/')
}
function isDesktopIrrelevantPath(file) {
return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
}
function isGlobalForcePath(file) {
return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
}
function matchesPrefix(file, prefixes) {
return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
const classification = classifyPrJobs(files)
for (const [name, value] of Object.entries(classification)) {
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
}
}
+197 -16
View File
@@ -1,13 +1,18 @@
import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
import {
classifyPrJobs,
isDocsOnlyPath,
PR_CHECK_JOBS,
shouldRunPrChecks
} from './pr-code-change-scope.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
const expensiveJobs = [
'static_analysis',
'typecheck',
@@ -15,12 +20,34 @@ const expensiveJobs = [
'xterm_patch_sync',
'shell_contracts',
'test',
'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
function expectedJobs(overrides, { alwaysOn = true } = {}) {
return Object.fromEntries(
PR_CHECK_JOBS.map((job) => [
job,
(alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
])
)
}
function expectClassification(files, overrides) {
const result = classifyPrJobs(files)
const shouldRun = shouldRunPrChecks(files)
expect(result.should_run).toBe(shouldRun)
expect(result).toMatchObject({
should_run: shouldRun,
...expectedJobs(overrides, { alwaysOn: shouldRun })
})
}
describe('docs-only path classification', () => {
it('treats the WeChat README PR files as docs-only', () => {
expect(
@@ -54,9 +81,154 @@ describe('docs-only path classification', () => {
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
expect(shouldRunPrChecks([])).toBe(true)
})
it('does not start desktop PR Checks for mobile-only diffs', () => {
expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
})
})
describe('PR Checks docs-only skip wiring', () => {
describe('per-job path classification', () => {
it('runs every expensive job on an empty diff rather than skipping by accident', () => {
const result = classifyPrJobs([])
expect(result.should_run).toBe(true)
for (const job of PR_CHECK_JOBS) {
expect(result[job], job).toBe(true)
}
})
it('skips every expensive job for docs-only diffs', () => {
expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {})
})
it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
package: true,
package_windows: true
})
})
it('runs Git compatibility only when git capability inputs change', () => {
expectClassification(['src/shared/git-capability-cache.ts'], {
git_compatibility: true,
package: true,
package_windows: true
})
expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
git_compatibility: true
})
})
it('runs xterm patch sync only when xterm inputs change', () => {
expectClassification(['config/patches/xterm-upstream.json'], {
xterm_patch_sync: true
})
expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
xterm_patch_sync: true
})
})
it('runs native package jobs only for the platform that ships the changed native', () => {
expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], {
package_windows: true
})
expectClassification(['native/computer-use-linux/runtime.py'], {
package: true
})
expectClassification(['native/computer-use-macos/Package.swift'], {})
})
it('runs shell contracts when live-shell inputs change', () => {
expectClassification(['src/main/daemon/shell-ready.ts'], {
shell_contracts: true,
package: true,
package_windows: true
})
})
it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
expectClassification(['src/main/shell-templates.ts'], {
shell_contracts: true,
package: true,
package_windows: true
})
expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
shell_contracts: true,
package: true,
package_windows: true
})
})
it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
for (const file of [
'src/main/orcad/external-chromium-browser-session.ts',
'src/main/orcad/external-chromium-command-arguments.ts',
'src/main/orcad/external-chromium-tab-registry.ts',
'src/main/orcad/external-chromium-tab-projection.ts'
]) {
expectClassification([file], {
orcad_browser: true,
package: true,
package_windows: true
})
}
expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
package: true,
package_windows: true
})
})
it('runs cross-version wire checks for every working-tree wire module', () => {
for (const file of [
'src/shared/protocol-version.ts',
'src/shared/terminal-stream-protocol.ts',
'src/main/runtime/rpc/dispatcher.ts',
'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
'src/main/runtime/rpc/methods/terminal.ts',
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
]) {
expectClassification([file], {
'cross-version-wire': true,
package: true,
package_windows: true
})
}
expectClassification(
['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
{ 'cross-version-wire': true }
)
})
it('runs workflow-self-change and lockfile diffs as force-all', () => {
const result = classifyPrJobs(['.github/workflows/pr.yml'])
expect(result.should_run).toBe(true)
for (const job of PR_CHECK_JOBS) {
expect(result[job], job).toBe(true)
}
expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
})
it('keeps unit-test-only diffs out of packaging', () => {
expectClassification(['src/main/git/git-status.test.ts'], {
git_compatibility: true
})
})
it('emits GitHub output pairs from the shipped CLI', () => {
const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
cwd: projectDir,
encoding: 'utf8',
input: 'config/patches/xterm-upstream.json\n'
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('should_run=true\n')
expect(result.stdout).toContain('xterm_patch_sync=true\n')
expect(result.stdout).toContain('git_compatibility=false\n')
expect(result.stdout).toContain('package=false\n')
expect(result.stdout).toContain('test=true\n')
})
})
describe('PR Checks skip wiring', () => {
it('classifies the PR range with a tested script and expands renames', () => {
const classify = prWorkflow.jobs.code_paths.steps.find(
(step) => step.name === 'Classify changed paths'
@@ -65,9 +237,12 @@ describe('PR Checks docs-only skip wiring', () => {
expect(classify.run).toContain('--no-renames')
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
'${{ steps.filter.outputs.should_run }}'
)
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
for (const jobName of ['should_run', ...expensiveJobs]) {
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
`\${{ steps.filter.outputs.${jobName} }}`
)
}
})
it('keeps the cheap root-directory guard on docs-only PRs', () => {
@@ -75,10 +250,12 @@ describe('PR Checks docs-only skip wiring', () => {
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
})
it('skips expensive jobs unless the detector says the PR has code', () => {
it('gates each expensive job on its own classifier output', () => {
for (const jobName of expensiveJobs) {
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
`needs.code_paths.outputs.${jobName} == 'true'`
)
}
})
@@ -89,19 +266,23 @@ describe('PR Checks docs-only skip wiring', () => {
)
})
it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
it('lets verify pass skipped jobs the classifier turned off', () => {
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
const docsOnlyBranch = verifyStep.run.slice(
0,
verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
)
expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
expect(docsOnlyBranch).not.toContain('"$result" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes')
expect(verifyStep.run).toContain('expected skipped')
expect(verifyStep.run).toContain('expected success')
for (const job of prWorkflow.jobs.verify.needs) {
if (job === 'code_paths' || job === 'root_directory_guard') {
continue
}
const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
expect(verifyStep.run).toContain(`"$${envVar}"`)
}
})
})
+20 -6
View File
@@ -100,17 +100,19 @@ describe('PR E2E gate contract', () => {
// job without adding it to the strict loop fails here instead of silently
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
// Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
const successMarker = '# Require success when the PR has code-relevant changes'
const successLoop = verifyStep.run.slice(
verifyStep.run.indexOf(successMarker),
verifyStep.run.lastIndexOf('done')
)
const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
expect(successLoop.length).toBeGreaterThan(0)
expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
for (const job of prWorkflow.jobs.verify.needs) {
const envVar = job.toUpperCase()
const envVar = job.replaceAll('-', '_').toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
if (job === 'code_paths' || job === 'root_directory_guard') {
continue
}
expect(successLoop).toContain(`"$${envVar}"`)
expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
}
})
@@ -157,6 +159,18 @@ describe('PR E2E gate contract', () => {
expect(sshDockerRunner).toContain("'electron-headful'")
})
it('reuses the composite install action instead of duplicating pnpm setup', () => {
const installFor = (jobName) =>
e2eWorkflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(installFor('build').with).toBeUndefined()
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
}
})
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
+42 -15
View File
@@ -50,9 +50,9 @@ describe('PR workflow parallelism', () => {
it('shards the general test suite across Node 24 and Node 26', () => {
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
Array.from({ length: 16 }, (_, index) => index + 1)
Array.from({ length: 8 }, (_, index) => index + 1)
)
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8])
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
const installStep = workflow.jobs.test.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
@@ -233,21 +233,20 @@ describe('PR workflow parallelism', () => {
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync'
]) {
for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
expect(installFor('test').with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
expect(installFor('package_windows').with['native-runtime']).toBe('node')
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
expect(
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
).toBe("inputs.native-runtime != 'none'")
).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
@@ -295,14 +294,39 @@ describe('PR workflow parallelism', () => {
// overwritten and one after the rebuild would never save a hit.
expect(installIndex).toBeLessThan(cacheIndex)
expect(cacheIndex).toBeLessThan(prepareIndex)
expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
expect(steps[cacheIndex].if).toBe(
"inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
)
const restoreOnly = steps.find(
(step) => step.name === 'Restore compiled native modules without saving'
)
expect(restoreOnly.if).toBe(
"inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
)
expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
// Native artifacts are ABI-bound: a key missing either dimension serves a build
// that cannot load, and ensure-native-runtime would recompile it anyway.
expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
// No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
expect(cacheStep.with.key).toContain('${{ runner.os }}')
expect(cacheStep.with.key).toContain('${{ runner.arch }}')
expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
expect(cacheStep.with.key).toContain(
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
)
expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
expect(cacheStep.with.path).toContain('windows-native-registry@')
expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
expect(cacheStep.with['restore-keys']).toBeUndefined()
}
const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
expect(cacheScope.run).toContain('/etc/os-release')
expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
'${{ steps.native-cache-scope.outputs.scope }}'
)
})
it('reuses TypeScript incremental state across typecheck runs', () => {
@@ -346,6 +370,7 @@ describe('PR workflow parallelism', () => {
'shell_contracts',
'test',
'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
@@ -359,5 +384,7 @@ describe('PR workflow parallelism', () => {
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
})
})
+16 -10
View File
@@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() {
return null
}
// Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
// load successfully in Electron while missing the patched fd/error handling.
// Why: Orca patches node-pty's native Unix spawn path and Windows job-object
// exports; upstream prebuilds can load while missing those patches.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const artifactPaths =
rebuildPlatform === 'win32'
? [
resolve(nodePtyDir, 'build', 'Release', 'conpty.node'),
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) =>
resolve(nodePtyDir, 'build', 'Release', 'conpty', filename)
)
]
: [
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
...(osPlatform() === 'darwin'
? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')]
: [])
]
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() {
if (!onlyModules.includes('node-pty')) {
return false
}
if (rebuildPlatform === 'win32') {
return false
}
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
return false
}
@@ -586,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
if (process.platform === 'win32') {
writeFileSync(join(buildDir, 'conpty.node'), '')
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
return
}
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
@@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => {
expect(job['runs-on']).toBe('windows-2022')
expect(buildStep.run).toBe('pnpm run build:release:parallel')
expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
@@ -28,8 +28,14 @@ describe('packaged Windows PTY native capability routing', () => {
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
const job = workflow.jobs.package_windows
const sourceRebuild = job.steps.find(
(step) => step.name === 'Rebuild node-pty from patched source'
const install = job.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const nodeCacheSave = job.steps.find(
(step) => step.name === 'Save compiled Node native modules'
)
const electronCache = job.steps.find(
(step) => step.name === 'Restore compiled Electron native modules'
)
const build = job.steps.find((step) => step.name === 'Build package inputs')
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
@@ -37,10 +43,17 @@ describe('packaged Windows PTY native capability routing', () => {
const verify = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
expect(install.with['native-runtime']).toBe('node')
expect(install.with['persist-native-cache']).toBe('false')
expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
expect(nodeCacheSave.with.key).toContain('-node-node')
expect(electronCache.with.key).toContain('-electron-node')
expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
expect(build.run).toBe('pnpm run build:release:parallel')
expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
expect(workflow.jobs.verify.needs).toContain('package_windows')
@@ -326,8 +326,6 @@ the Windows CI job rebuilds from source before running the win32 suites.
it is true before asserting anything else, so an unpatched binary fails loudly
instead of passing every case vacuously. That guard is what caught this.
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
exempts win32, on the premise that the patch is Unix-only. That premise is now
false, but lifting the exemption also needs `pnpm rebuild` to force a source
build — otherwise the assertion fires and the remedy does not fix it. Left as a
follow-up rather than changed blind.
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers
win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source`
so the patched source build actually replaces the upstream prebuild.
@@ -50,6 +50,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
getNodePtySpawnHelperCandidates: () => [import.meta.filename],
resolveUnixShellPath: resolveUnixShellPathMock,
validateWorkingDirectory: validateWorkingDirectoryMock,
validateWorkingDirectoryAsync: validateWorkingDirectoryMock
@@ -10,6 +10,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as LocalPtyUtils from '../providers/local-pty-utils'
const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
@@ -39,6 +40,14 @@ vi.mock('../providers/windows-powershell-executable', () => ({
getWindowsCmdPath: () => CMD_ABS
}))
vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
getNodePtySpawnHelperCandidates: () => [import.meta.filename]
}
})
vi.mock('../providers/agent-foreground-process', () => ({
resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => {
const value = await resolveAgentForegroundProcessMock(...args)
@@ -50,6 +50,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
getNodePtySpawnHelperCandidates: () => [import.meta.filename],
resolveUnixShellPath: resolveUnixShellPathMock,
validateWorkingDirectory: validateWorkingDirectoryMock,
validateWorkingDirectoryAsync: validateWorkingDirectoryMock