mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Prepare Windows natives with scoped compiler settings and measure servicing (#28881)
* Scope Windows native compiler setup and measure ARM inbox preparation * Add a controlled Windows native preparation comparison * Test native preparation through an injected process runner * Guard reviewed Windows native batch runners
This commit is contained in:
@@ -54,7 +54,7 @@ runs:
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
NODE_VERSION: ${{ inputs.node-version }}
|
||||
NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }}
|
||||
NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/initialize-windows-native-toolchain.mjs', 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }}
|
||||
run: |
|
||||
scope="${ImageOS:-$RUNNER_OS}"
|
||||
if [ -r /etc/os-release ]; then
|
||||
@@ -120,4 +120,9 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Windows ] && [ "$NATIVE_RUNTIME" = node ]; then
|
||||
node config/scripts/initialize-windows-native-toolchain.mjs
|
||||
else
|
||||
node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
|
||||
fi
|
||||
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
run: node config/scripts/ensure-native-runtime.mjs --check-only
|
||||
|
||||
- name: Cache Windows CLI launcher
|
||||
if: runner.arch == 'X64'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
@@ -65,6 +66,7 @@ jobs:
|
||||
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'native/windows-agent-hook/**', 'config/scripts/build-windows-cli-launcher.mjs', 'config/scripts/build-windows-agent-hook.mjs', 'config/scripts/windows-rust-executable-build.mjs', 'resources/build/icon.ico', 'package.json') }}
|
||||
|
||||
- name: Build the Windows native executables
|
||||
if: runner.arch == 'X64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
||||
@@ -76,6 +78,12 @@ jobs:
|
||||
node config/scripts/build-windows-cli-launcher.mjs
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Warm the Windows ARM server launcher
|
||||
if: runner.arch == 'ARM64'
|
||||
uses: ./.github/actions/prepare-windows-cli-launcher
|
||||
with:
|
||||
arches: arm64
|
||||
|
||||
warm-linux-package-fixtures:
|
||||
concurrency:
|
||||
group: ci-cache-warmup-linux-package-fixtures-${{ github.event.pull_request.number || github.ref }}
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/restore-pnpm-verification/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/actions/prepare-windows-cli-launcher/**'
|
||||
- '.github/actions/prepare-git-compatibility/**'
|
||||
- '.github/actions/prepare-linux-package-fixture/**'
|
||||
- 'config/docker/headless-serve-shutdown/**'
|
||||
@@ -21,6 +22,8 @@ on:
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.cjs'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/initialize-windows-native-toolchain.mjs'
|
||||
- 'config/scripts/capture-windows-native-environment.cmd'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'config/scripts/node-pty-job-ownership.cjs'
|
||||
- 'config/scripts/windows-pe-machine.cjs'
|
||||
|
||||
@@ -14,6 +14,7 @@ on:
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/restore-pnpm-verification/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/actions/prepare-windows-cli-launcher/**'
|
||||
- '.github/actions/prepare-git-compatibility/**'
|
||||
- '.github/actions/prepare-headless-compiler/**'
|
||||
- 'config/scripts/headless-detector-compiler-cache.mjs'
|
||||
@@ -30,6 +31,8 @@ on:
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.cjs'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/initialize-windows-native-toolchain.mjs'
|
||||
- 'config/scripts/capture-windows-native-environment.cmd'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'config/scripts/node-pty-job-ownership.cjs'
|
||||
- 'config/scripts/windows-pe-machine.cjs'
|
||||
|
||||
@@ -62,12 +62,23 @@ on:
|
||||
description: Comma-separated cell ids from src/main/ssh/ssh-windows-host-cells.ts; empty runs the default set (orcad-cli-* cells run only when named).
|
||||
required: false
|
||||
default: ''
|
||||
preparation_mode:
|
||||
description: Compare inbox capability preparation overlapping builds or running after them.
|
||||
type: choice
|
||||
options: [overlap, serial]
|
||||
required: false
|
||||
default: overlap
|
||||
arm_inbox_only:
|
||||
description: Run only the native ARM inbox lane for a preparation timing comparison.
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ssh-windows-hosts-${{ github.event.pull_request.number || github.ref }}
|
||||
group: ssh-windows-hosts-${{ github.event.pull_request.number || github.ref }}${{ github.event_name == 'workflow_dispatch' && inputs.arm_inbox_only && format('-{0}', inputs.preparation_mode || 'overlap') || '' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -77,21 +88,10 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: windows-2022
|
||||
server: inbox
|
||||
- arch: x64
|
||||
runner: windows-2022
|
||||
server: preview
|
||||
archive: OpenSSH-Win64.zip
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
server: inbox
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
server: preview
|
||||
archive: OpenSSH-ARM64.zip
|
||||
include: >-
|
||||
${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.arm_inbox_only &&
|
||||
'[{"arch":"arm64","runner":"windows-11-arm","server":"inbox"}]' ||
|
||||
'[{"arch":"x64","runner":"windows-2022","server":"inbox"},{"arch":"x64","runner":"windows-2022","server":"preview","archive":"OpenSSH-Win64.zip"},{"arch":"arm64","runner":"windows-11-arm","server":"inbox"},{"arch":"arm64","runner":"windows-11-arm","server":"preview","archive":"OpenSSH-ARM64.zip"}]') }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# Installing the inbox capability alone can take several minutes on a fresh image; the CLI matrix
|
||||
# cells launch the e2e app several times each.
|
||||
@@ -99,6 +99,7 @@ jobs:
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
ORCA_ISOLATED_SSH_CI: '1'
|
||||
INBOX_PREPARATION_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.preparation_mode == 'serial' && 'serial' || 'overlap' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -127,11 +128,11 @@ jobs:
|
||||
background: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){
|
||||
if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64' -and $env:INBOX_PREPARATION_MODE -eq 'overlap'){
|
||||
$receipts=Join-Path $pwd '.build/ssh-windows-host-receipts'
|
||||
New-Item -ItemType Directory -Force -Path $receipts | Out-Null
|
||||
. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1
|
||||
Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json')
|
||||
Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE
|
||||
}
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
id: dependencies
|
||||
@@ -164,6 +165,14 @@ jobs:
|
||||
pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}"
|
||||
node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}"
|
||||
- wait: inbox-capability
|
||||
- name: Prepare the Windows inbox SSH capability after the builds
|
||||
if: ${{ matrix.server == 'inbox' && matrix.arch == 'arm64' && env.INBOX_PREPARATION_MODE == 'serial' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$receipts=Join-Path $pwd '.build/ssh-windows-host-receipts'
|
||||
New-Item -ItemType Directory -Force -Path $receipts | Out-Null
|
||||
. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1
|
||||
Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE
|
||||
- name: Run the Windows host cells against a private ${{ matrix.server }} sshd
|
||||
shell: pwsh
|
||||
timeout-minutes: ${{ contains(github.event.inputs.cells || '', 'orcad-cli') && 130 || 50 }}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Windows native preparation comparison
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_sha:
|
||||
description: Exact commit containing this benchmark and the Windows compiler candidate.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: windows-native-preparation-comparison-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
|
||||
jobs:
|
||||
compare:
|
||||
name: Native preparation comparison (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
os: [windows-2022, windows-11-arm]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.source_sha }}
|
||||
persist-credentials: false
|
||||
- name: Record measurement start and validate source
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_sha }}
|
||||
run: |
|
||||
[[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
mkdir -p .build/windows-native-benchmark
|
||||
node -e 'require("node:fs").writeFileSync(".build/windows-native-benchmark/job-start.json", JSON.stringify({startedAt:new Date().toISOString()}))'
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
native-runtime: none
|
||||
cache-pnpm-store: 'false'
|
||||
cache-pnpm-verification: 'false'
|
||||
- name: Compare isolated cold and same-build warm preparation
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_sha }}
|
||||
BENCHMARK_RUNNER: ${{ matrix.os }}
|
||||
run: node config/scripts/benchmark-windows-native-preparation.mjs
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: windows-native-preparation-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .build/windows-native-benchmark/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -168,7 +168,7 @@ try {
|
||||
$report.nativeInputs=$verified
|
||||
Write-Stage 'preview-native-input-verification-complete'
|
||||
} else {
|
||||
Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage}
|
||||
Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage} ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Receipt)))
|
||||
$verified=@()
|
||||
foreach($binary in @('sshd.exe','ssh.exe','ssh-keygen.exe','sftp.exe','sftp-server.exe')){
|
||||
$path=Join-Path $sshDir $binary
|
||||
|
||||
@@ -38,22 +38,52 @@ try {
|
||||
function Assert-IsolatedWindowsSshCi([string]$Arch){if($script:refuseCapabilityHost){throw 'Injected host refusal'}}
|
||||
function Assert-WindowsSshGlobalServerDormant([string]$Server){$script:globalChecks++;if($script:globalChecks -eq $script:refuseGlobalCheck){throw 'Injected global server refusal'}}
|
||||
function Assert-WindowsSshStockShell {$script:shellChecks++;if($script:shellChecks -eq $script:refuseShellCheck){throw 'Injected shell refusal'}}
|
||||
function Get-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityQueries++;return @{State=$script:capabilityState}}
|
||||
function Add-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityAdds++;if($script:failCapabilityInstall){throw 'Injected capability install failure'}}
|
||||
function Assert-CapabilityCommand([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction,[string]$Operation){
|
||||
if(-not $Online -or $Name -ne 'OpenSSH.Server~~~~0.0.1.0' -or $ErrorAction -ne 'Stop' -or [IO.Path]::GetDirectoryName($LogPath) -ne $script:capabilityLogs -or [IO.Path]::GetFileName($LogPath) -notlike "inbox-capability-$Operation-*.log"){throw 'Capability command changed its online identity, error handling or receipt log boundary'}
|
||||
$script:capabilityLogPaths.Add($LogPath)
|
||||
[IO.File]::WriteAllText($LogPath,"$Operation fixture")
|
||||
}
|
||||
function Get-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){
|
||||
$script:capabilityQueries++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'query'
|
||||
if($script:failCapabilityQuery){Write-Error 'Injected capability query failure' -ErrorAction $ErrorAction}
|
||||
return @{State=$script:capabilityState}
|
||||
}
|
||||
function Add-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){
|
||||
$script:capabilityAdds++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'install'
|
||||
if($script:activeCapabilityReceipt){
|
||||
$script:installObservedReceipt=Get-Content -LiteralPath $script:activeCapabilityReceipt -Raw | ConvertFrom-Json
|
||||
if($script:installObservedReceipt.inboxCapabilityInitialState -ne 'NotPresent' -or $script:installObservedReceipt.inboxCapabilityOperations[0].status -ne 'passed' -or $script:installObservedReceipt.inboxCapabilityOperations[1].status -ne 'running'){throw 'Initial capability state was not durably recorded before installation'}
|
||||
}
|
||||
if($script:failCapabilityInstall){Write-Error 'Injected capability install failure' -ErrorAction $ErrorAction}
|
||||
return @{Path='C:\';Online=$true;RestartNeeded=$script:capabilityRestartNeeded}
|
||||
}
|
||||
function Reset-CapabilityControl([string]$State){
|
||||
$script:capabilityState=$State;$script:capabilityQueries=0;$script:capabilityAdds=0
|
||||
$script:globalChecks=0;$script:shellChecks=0;$script:refuseGlobalCheck=0;$script:refuseShellCheck=0
|
||||
$script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false
|
||||
$script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false;$script:failCapabilityQuery=$false
|
||||
$script:activeCapabilityReceipt=$null;$script:installObservedReceipt=$null;$script:capabilityRestartNeeded=$false
|
||||
$script:capabilityStages=[Collections.Generic.List[string]]::new()
|
||||
$script:capabilityLogPaths=[Collections.Generic.List[string]]::new()
|
||||
}
|
||||
$script:capabilityLogs=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N'))
|
||||
try {
|
||||
foreach($state in @('Installed','NotPresent')){
|
||||
Reset-CapabilityControl $state
|
||||
$capabilityReport=@{}
|
||||
Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)}
|
||||
Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs
|
||||
$expectedAdds=if($state -eq 'Installed'){0}else{1}
|
||||
if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne 'inbox-capability-start,inbox-capability-complete'){throw 'Shared capability preparation did not preserve the install and guard boundaries'}
|
||||
$expectedStages=@('inbox-capability-start','inbox-capability-query-start','inbox-capability-query-finished')
|
||||
if($expectedAdds){$expectedStages+=@('inbox-capability-install-start','inbox-capability-install-finished')}
|
||||
$expectedStages+=@('inbox-capability-guards-start','inbox-capability-complete')
|
||||
if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityQueries -ne 1 -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne ($expectedStages -join ',')){throw 'Shared capability preparation did not preserve the install and guard boundaries'}
|
||||
if($capabilityReport.inboxCapabilityOperations.Count -ne 1+$expectedAdds){throw 'Capability operations missing'}
|
||||
foreach($operation in $capabilityReport.inboxCapabilityOperations){
|
||||
if($operation.status -ne 'passed' -or $operation.elapsedMs -isnot [long] -or $operation.elapsedMs -lt 0 -or -not [IO.File]::Exists((Join-Path $script:capabilityLogs $operation.log))){throw 'Capability operation did not retain its timing, verdict or diagnostic log'}
|
||||
}
|
||||
if($expectedAdds -and ($capabilityReport.inboxCapabilityInstallResult.online -ne $true -or $capabilityReport.inboxCapabilityInstallResult.path -ne 'C:\' -or $capabilityReport.inboxCapabilityInstallResult.restartNeeded -ne $false)){throw 'Capability install result omitted'}
|
||||
if(-not $expectedAdds -and $capabilityReport.ContainsKey('inboxCapabilityInstallResult')){throw 'Installed capability invented an install result'}
|
||||
}
|
||||
foreach($fault in @('host','global-before','shell-before','global-after','shell-after','install')){
|
||||
foreach($fault in @('host','global-before','shell-before','global-after','shell-after','query','install')){
|
||||
Reset-CapabilityControl 'NotPresent'
|
||||
switch($fault){
|
||||
'host' {$script:refuseCapabilityHost=$true}
|
||||
@@ -61,26 +91,45 @@ foreach($fault in @('host','global-before','shell-before','global-after','shell-
|
||||
'shell-before' {$script:refuseShellCheck=1}
|
||||
'global-after' {$script:refuseGlobalCheck=2}
|
||||
'shell-after' {$script:refuseShellCheck=2}
|
||||
'query' {$script:failCapabilityQuery=$true}
|
||||
'install' {$script:failCapabilityInstall=$true}
|
||||
}
|
||||
$rejected=$false
|
||||
try {Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)}} catch {$rejected=$true}
|
||||
$faultReport=@{}
|
||||
try {Install-WindowsInboxSshCapability 'x64' $faultReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs} catch {$rejected=$true}
|
||||
if(-not $rejected -or $script:capabilityStages.Contains('inbox-capability-complete')){throw "Capability fault did not fail closed: $fault"}
|
||||
if($fault -in @('host','global-before','shell-before') -and $script:capabilityAdds){throw 'Capability mutation preceded its host guards'}
|
||||
if($fault -eq 'query' -and $script:capabilityAdds){throw 'Failed query proceeded to installation'}
|
||||
if($fault -in @('query','install') -and ($faultReport.inboxCapabilityOperations[-1].status -ne 'failed' -or $faultReport.inboxCapabilityOperations[-1].elapsedMs -isnot [long])){throw 'Failed servicing operation lost its verdict or duration'}
|
||||
}
|
||||
Reset-CapabilityControl 'NotPresent'
|
||||
foreach($invocation in 1..2){Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs}
|
||||
if($script:capabilityLogPaths.Count -ne 4 -or ($script:capabilityLogPaths | Select-Object -Unique).Count -ne 4){throw 'Separate servicing operations or invocations reused a diagnostic log'}
|
||||
} finally {Remove-Item -LiteralPath $script:capabilityLogs -Recurse -Force -ErrorAction SilentlyContinue}
|
||||
$capabilityRoot=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N'))
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $capabilityRoot | Out-Null
|
||||
$capabilityReceipt=Join-Path $capabilityRoot 'capability.json'
|
||||
$script:capabilityLogs=$capabilityRoot
|
||||
Reset-CapabilityControl 'Installed'
|
||||
Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt
|
||||
$completed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
|
||||
if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed'){throw 'Capability success receipt missing its observed initial state'}
|
||||
Reset-CapabilityControl 'NotPresent';$script:failCapabilityInstall=$true
|
||||
if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed' -or $completed.preparationMode -ne 'overlap'){throw 'Capability success receipt missing its observed initial state or default mode'}
|
||||
Reset-CapabilityControl 'NotPresent';$script:capabilityLogs=$capabilityRoot;$script:activeCapabilityReceipt=$capabilityReceipt;$script:capabilityRestartNeeded=$true
|
||||
Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt 'serial'
|
||||
$installed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
|
||||
if($installed.status -ne 'passed' -or $installed.preparationMode -ne 'serial' -or $installed.inboxCapabilityInstallResult.restartNeeded -ne $true -or -not $script:installObservedReceipt){throw 'Serial preparation lost its mode, restart result or pre-install receipt'}
|
||||
Reset-CapabilityControl 'NotPresent';$script:activeCapabilityReceipt=$capabilityReceipt;$script:failCapabilityInstall=$true
|
||||
$rejected=$false
|
||||
try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true}
|
||||
$failed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
|
||||
if(-not $rejected -or $failed.status -ne 'failed' -or $failed.inboxCapabilityInitialState -ne 'NotPresent' -or $failed.error -ne 'Injected capability install failure'){throw 'Failed capability preparation masqueraded as a passing receipt'}
|
||||
if($failed.inboxCapabilityOperations[-1].status -ne 'failed' -or -not $failed.stages.stage.Contains('inbox-capability-install-finished')){throw 'Failed preparation omitted its servicing result'}
|
||||
Reset-CapabilityControl 'NotPresent';$script:failCapabilityQuery=$true
|
||||
$rejected=$false
|
||||
try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true}
|
||||
$failedQuery=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
|
||||
if(-not $rejected -or $failedQuery.status -ne 'failed' -or $failedQuery.inboxCapabilityOperations[0].status -ne 'failed' -or $failedQuery.inboxCapabilityOperations.Count -ne 1 -or $script:capabilityAdds -or $failedQuery.inboxCapabilityInitialState){throw 'Failed query receipt invented a capability state or proceeded to installation'}
|
||||
} finally {Remove-Item -LiteralPath $capabilityRoot -Recurse -Force -ErrorAction SilentlyContinue}
|
||||
& {
|
||||
param($ProofAst)
|
||||
|
||||
@@ -33,26 +33,48 @@ function Write-WindowsSshReceiptStage([hashtable]$Report,[string]$Receipt,[strin
|
||||
return $written
|
||||
}
|
||||
|
||||
function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage) {
|
||||
function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage,[Parameter(Mandatory=$true)][string]$LogDirectory) {
|
||||
Assert-IsolatedWindowsSshCi $Arch
|
||||
Assert-WindowsSshGlobalServerDormant 'inbox'
|
||||
Assert-WindowsSshStockShell
|
||||
& $Stage 'inbox-capability-start'
|
||||
$capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0'
|
||||
$Report.inboxCapabilityInitialState=[string]$capability.State
|
||||
if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null}
|
||||
New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null
|
||||
$invocation=[Guid]::NewGuid().ToString('N')
|
||||
$query=@{operation='query';status='running';log="inbox-capability-query-$invocation.log"}
|
||||
$Report.inboxCapabilityOperations=@($query)
|
||||
& $Stage 'inbox-capability-query-start'
|
||||
$watch=[Diagnostics.Stopwatch]::StartNew()
|
||||
try {
|
||||
$capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -LogPath (Join-Path $LogDirectory $query.log) -ErrorAction Stop
|
||||
$Report.inboxCapabilityInitialState=[string]$capability.State
|
||||
$query.status='passed'
|
||||
} catch {$query.status='failed';$query.error=$_.Exception.Message;throw}
|
||||
finally {$watch.Stop();$query.elapsedMs=$watch.ElapsedMilliseconds;& $Stage 'inbox-capability-query-finished'}
|
||||
if($capability.State -ne 'Installed'){
|
||||
$install=@{operation='install';status='running';log="inbox-capability-install-$invocation.log"}
|
||||
$Report.inboxCapabilityOperations+=@($install)
|
||||
& $Stage 'inbox-capability-install-start'
|
||||
$watch=[Diagnostics.Stopwatch]::StartNew()
|
||||
try {
|
||||
$result=Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -LogPath (Join-Path $LogDirectory $install.log) -ErrorAction Stop
|
||||
$Report.inboxCapabilityInstallResult=@{path=$result.Path;online=$result.Online;restartNeeded=$result.RestartNeeded}
|
||||
$install.status='passed'
|
||||
} catch {$install.status='failed';$install.error=$_.Exception.Message;throw}
|
||||
finally {$watch.Stop();$install.elapsedMs=$watch.ElapsedMilliseconds;& $Stage 'inbox-capability-install-finished'}
|
||||
}
|
||||
& $Stage 'inbox-capability-guards-start'
|
||||
Assert-WindowsSshGlobalServerDormant 'inbox'
|
||||
Assert-WindowsSshStockShell
|
||||
& $Stage 'inbox-capability-complete'
|
||||
}
|
||||
|
||||
function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt) {
|
||||
$report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'}
|
||||
function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt,[ValidateSet('overlap','serial')][string]$PreparationMode='overlap') {
|
||||
$report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;preparationMode=$PreparationMode;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'}
|
||||
try {
|
||||
if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-start')){throw 'Initial progress receipt unavailable; refuse provisioning'}
|
||||
Install-WindowsInboxSshCapability $Arch $report {param($name)
|
||||
if(-not (Write-WindowsSshReceiptStage $report $Receipt $name)){throw 'Capability preparation progress receipt unavailable'}
|
||||
}
|
||||
} ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Receipt)))
|
||||
$report.status='passed'
|
||||
} catch {$report.status='failed';$report.error=$_.Exception.Message;throw}
|
||||
finally {
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
|
||||
import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs'
|
||||
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
|
||||
import { hasWindowsCompilerSelection } from './initialize-windows-native-toolchain.mjs'
|
||||
import {
|
||||
compilerEnvironmentKey,
|
||||
environmentReceipt,
|
||||
hash
|
||||
} from './windows-native-benchmark-observer.mjs'
|
||||
|
||||
const addonNames = [
|
||||
'node-pty',
|
||||
'@orca/windows-registry',
|
||||
'@orca/windows-security',
|
||||
'@vscode/windows-process-tree'
|
||||
]
|
||||
const candidate = 'initialize-windows-native-toolchain.mjs'
|
||||
const control = 'ensure-native-runtime.mjs'
|
||||
const unchangedFiles = [
|
||||
`.github/actions/prepare-native-runtime/action.yml`,
|
||||
`config/scripts/${control}`,
|
||||
`config/scripts/${candidate}`,
|
||||
'config/scripts/capture-windows-native-environment.cmd',
|
||||
'config/scripts/windows-native-benchmark-observer.mjs',
|
||||
'config/scripts/benchmark-windows-native-preparation.mjs'
|
||||
]
|
||||
|
||||
export function validateObservedPreparation(receipt, variant, warm = false) {
|
||||
assert.equal(
|
||||
receipt.parentBefore.hash,
|
||||
receipt.parentAfter.hash,
|
||||
'Outer compiler environment changed'
|
||||
)
|
||||
const calls = (phase) => receipt.calls.filter((call) => call.phase === phase)
|
||||
const captured = variant !== 'control' && !warm
|
||||
assert.equal(calls('discovery').length, captured ? 1 : 0, 'Unexpected compiler discovery count')
|
||||
assert.equal(calls('capture').length, captured ? 1 : 0, 'Unexpected developer command count')
|
||||
assert.equal(
|
||||
calls('probe').length,
|
||||
variant === 'control' ? 0 : 1,
|
||||
'Missing helper preflight observation'
|
||||
)
|
||||
for (const call of receipt.calls) {
|
||||
assert(
|
||||
!call.error && !call.timedOut && !call.outputTruncated,
|
||||
'Incomplete compiler observation'
|
||||
)
|
||||
}
|
||||
if (variant === 'control') {
|
||||
assert.equal(calls('addon-build').length, warm ? 0 : 4, 'Expected all four cold addon builds')
|
||||
assert(
|
||||
receipt.calls.every((call) => call.code === 0),
|
||||
'Control preparation failed'
|
||||
)
|
||||
return
|
||||
}
|
||||
const [prepare] = calls('preparation')
|
||||
assert.equal(calls('preparation').length, 1, 'Missing unchanged preparation child')
|
||||
assert.equal(prepare.code, 0, 'Native preparation failed')
|
||||
assert.equal(calls('probe')[0].code, warm ? 0 : 1, 'Unexpected helper cold/warm probe')
|
||||
if (warm || variant === 'fallback') {
|
||||
assert.equal(
|
||||
prepare.environment.hash,
|
||||
receipt.parentBefore.hash,
|
||||
'Fallback/warm changed compiler selection'
|
||||
)
|
||||
assert.deepEqual(prepare.changedNames, [], 'Fallback/warm altered child environment')
|
||||
} else {
|
||||
assert.equal(calls('discovery')[0].code, 0)
|
||||
assert.equal(calls('capture')[0].code, 0)
|
||||
assert(prepare.changedNames.length > 0, 'Captured environment was not applied')
|
||||
assert(
|
||||
prepare.changedNames.every((name) => compilerEnvironmentKey.test(name)),
|
||||
'Unrelated environment changed'
|
||||
)
|
||||
assert(prepare.environment.compiler.vcinstalldir, 'Prepared child lacks compiler selection')
|
||||
}
|
||||
if (variant === 'fallback' && !warm) {
|
||||
assert.equal(calls('discovery')[0].code, 0)
|
||||
assert.equal(calls('capture')[0].code, 7, 'Fixed failure fixture did not run')
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNativeColdProbe(result) {
|
||||
assert.equal(result.code, 1, 'Cold fixture unexpectedly passed')
|
||||
for (const name of addonNames) {
|
||||
assert(result.stderr.includes(`${name}:`), `Cold fixture did not fail ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNoHeaderDownloads(result) {
|
||||
assert(
|
||||
!/gyp http (GET|200|download)/i.test(`${result.stdout}\n${result.stderr}`),
|
||||
'Timed preparation downloaded headers/libraries'
|
||||
)
|
||||
}
|
||||
|
||||
function digestFiles(root, paths) {
|
||||
return Object.fromEntries(
|
||||
paths
|
||||
.filter((path) => existsSync(join(root, path)))
|
||||
.map((path) => [path, hash(readFileSync(join(root, path)))])
|
||||
)
|
||||
}
|
||||
|
||||
function treeDigest(root) {
|
||||
const digest = createHash('sha256')
|
||||
const files = []
|
||||
const visit = (path) => {
|
||||
for (const name of readdirSync(path).sort()) {
|
||||
const child = join(path, name)
|
||||
if (statSync(child).isDirectory()) {
|
||||
visit(child)
|
||||
} else {
|
||||
files.push(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
for (const file of files) {
|
||||
digest.update(relative(root, file)).update('\0').update(readFileSync(file))
|
||||
}
|
||||
return { sha256: digest.digest('hex'), files: files.length }
|
||||
}
|
||||
|
||||
function nativeDirectories(snapshot) {
|
||||
const require = createRequire(join(snapshot, 'package.json'))
|
||||
const root = realpathSync(snapshot)
|
||||
return addonNames.map((name) => {
|
||||
const directory = realpathSync(dirname(require.resolve(`${name}/package.json`)))
|
||||
assert(directory.startsWith(`${root}${sep}`), `Addon escapes its isolated copy: ${name}`)
|
||||
return { name, directory }
|
||||
})
|
||||
}
|
||||
|
||||
function binaryReceipt(snapshot) {
|
||||
const require = createRequire(join(snapshot, 'package.json'))
|
||||
const paths = [
|
||||
join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty.node'),
|
||||
join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty/conpty.dll'),
|
||||
join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty/OpenConsole.exe'),
|
||||
join(snapshot, 'native/windows-registry/build/Release/orca_windows_registry.node'),
|
||||
join(snapshot, 'native/windows-security/build/Release/orca_windows_security.node'),
|
||||
join(
|
||||
dirname(require.resolve('@vscode/windows-process-tree/package.json')),
|
||||
'build/Release/windows_process_tree.node'
|
||||
)
|
||||
]
|
||||
return paths.map((path) => {
|
||||
assert(existsSync(path), `Missing prepared artifact: ${path}`)
|
||||
return {
|
||||
path: relative(snapshot, path),
|
||||
bytes: statSync(path).size,
|
||||
sha256: hash(readFileSync(path))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function benchmarkWindowsNativePreparation(
|
||||
root = resolve(import.meta.dirname, '../..')
|
||||
) {
|
||||
assert.equal(process.platform, 'win32', 'This hosted comparison requires Windows')
|
||||
assert(['x64', 'arm64'].includes(process.arch), 'Unsupported Windows runner')
|
||||
assert.equal(process.versions.node, '24.21.0', 'Comparison requires pinned Node')
|
||||
assert(
|
||||
!hasWindowsCompilerSelection(process.env),
|
||||
'Explicit compiler selection prevents a qualified automatic-capture comparison'
|
||||
)
|
||||
assert(/^[0-9a-f]{40}$/.test(process.env.SOURCE_SHA ?? ''), 'SOURCE_SHA must be an exact commit')
|
||||
const output = join(root, '.build/windows-native-benchmark')
|
||||
mkdirSync(output, { recursive: true })
|
||||
const temporary = mkdtempSync(
|
||||
join(process.env.RUNNER_TEMP || tmpdir(), 'orca-native-comparison-')
|
||||
)
|
||||
const archive = join(temporary, 'source.tar')
|
||||
const headers = join(temporary, 'headers')
|
||||
const pnpm = resolvePnpmCliInvocation()
|
||||
const environment = {
|
||||
...process.env,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
npm_config_devdir: headers,
|
||||
npm_package_config_node_gyp_devdir: headers
|
||||
}
|
||||
const parentBefore = environmentReceipt(process.env)
|
||||
const githubEnvironmentBefore = existsSync(process.env.GITHUB_ENV ?? '')
|
||||
? hash(readFileSync(process.env.GITHUB_ENV))
|
||||
: null
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
startedAt: new Date().toISOString(),
|
||||
qualification: 'incomplete',
|
||||
firstRecordedJobStep: JSON.parse(readFileSync(join(output, 'job-start.json'), 'utf8')),
|
||||
phases: [],
|
||||
samples: [],
|
||||
scope: {
|
||||
warm: 'Same-build warm preparation; no Actions native-cache restore/save is performed.',
|
||||
metric:
|
||||
'Cold addon preparation with identical diagnostic preload; installs, header downloads, standalone qualification probes and artifact upload are recorded separately; the helper preflight remains included.',
|
||||
occupancy:
|
||||
'Use complete hosted job started_at/completed_at, including setup and upload/post steps, for actual runner occupancy. This artifact only measures its explicit window.',
|
||||
excluded:
|
||||
'No Node-to-Electron, cross-target template, exact-key restore, or CI workload gain is claimed.'
|
||||
}
|
||||
}
|
||||
const writeReport = () =>
|
||||
writeFileSync(join(output, 'receipt.json'), `${JSON.stringify(report, null, 2)}\n`)
|
||||
const command = async (label, program, args, cwd = root, env = environment, accepted = [0]) => {
|
||||
const startedAt = new Date().toISOString()
|
||||
const start = performance.now()
|
||||
const result = await runProcess({
|
||||
program,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
timeoutMs: 1_200_000,
|
||||
maxOutputBytes: 64 * 1024 * 1024,
|
||||
terminationBarrier: true
|
||||
})
|
||||
const elapsedMs = performance.now() - start
|
||||
const stdoutFile = `${label}.stdout.log`
|
||||
const stderrFile = `${label}.stderr.log`
|
||||
writeFileSync(join(output, stdoutFile), result.stdout)
|
||||
writeFileSync(join(output, stderrFile), result.stderr)
|
||||
report.phases.push({
|
||||
label,
|
||||
program,
|
||||
args,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: elapsedMs,
|
||||
code: result.code,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
outputTruncated: result.outputTruncated,
|
||||
stdoutFile,
|
||||
stderrFile,
|
||||
stdoutSha256: hash(result.stdout),
|
||||
stderrSha256: hash(result.stderr)
|
||||
})
|
||||
writeReport()
|
||||
assert(
|
||||
!result.timedOut &&
|
||||
!result.signal &&
|
||||
!result.outputTruncated &&
|
||||
accepted.includes(result.code),
|
||||
`${label} failed: ${result.stderr.slice(-4000)}`
|
||||
)
|
||||
return { ...result, durationMs: elapsedMs }
|
||||
}
|
||||
const pnpmCommand = (label, args, cwd) =>
|
||||
command(label, pnpm.command, [...pnpm.prefixArgs, ...args], cwd)
|
||||
try {
|
||||
const head = (await command('source-head', 'git', ['rev-parse', 'HEAD'])).stdout.trim()
|
||||
assert.equal(head, process.env.SOURCE_SHA, 'Checkout differs from frozen source')
|
||||
await command('source-clean', 'git', ['diff', '--exit-code', 'HEAD', '--'])
|
||||
const tree = (await command('source-tree', 'git', ['rev-parse', 'HEAD^{tree}'])).stdout.trim()
|
||||
const pnpmVersion = (await pnpmCommand('pnpm-version', ['--version'], root)).stdout.trim()
|
||||
assert.equal(pnpmVersion, '12.8.1', 'Comparison requires pinned pnpm')
|
||||
const action = readFileSync(
|
||||
join(root, '.github/actions/prepare-native-runtime/action.yml'),
|
||||
'utf8'
|
||||
)
|
||||
const hashExpression = action.match(/NATIVE_SOURCE_HASH: (.+)/)?.[1]
|
||||
assert(hashExpression, 'Missing production source identity')
|
||||
const inputs = [...hashExpression.matchAll(/'([^']+)'/g)].map((match) => match[1])
|
||||
assert(
|
||||
inputs.includes(`config/scripts/${candidate}`),
|
||||
'Frozen source lacks the candidate native-cache fingerprint'
|
||||
)
|
||||
const requiredScriptHashes = digestFiles(root, unchangedFiles)
|
||||
assert.equal(
|
||||
Object.keys(requiredScriptHashes).length,
|
||||
unchangedFiles.length,
|
||||
'Frozen source lacks required scripts'
|
||||
)
|
||||
const sourceInputs = digestFiles(root, inputs)
|
||||
report.identity = {
|
||||
sourceSha: head,
|
||||
tree,
|
||||
runtime: 'node',
|
||||
node: process.versions.node,
|
||||
nodeAbi: process.versions.modules,
|
||||
arch: process.arch,
|
||||
pnpm: pnpmVersion,
|
||||
runner: process.env.BENCHMARK_RUNNER,
|
||||
imageOS: process.env.ImageOS,
|
||||
imageVersion: process.env.ImageVersion,
|
||||
workflowRef: process.env.GITHUB_WORKFLOW_REF,
|
||||
workflowSha: process.env.GITHUB_WORKFLOW_SHA,
|
||||
runId: process.env.GITHUB_RUN_ID,
|
||||
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
|
||||
sourceInputs,
|
||||
requiredScriptHashes,
|
||||
sourceInputDigest: hash(JSON.stringify(sourceInputs)),
|
||||
productionHashExpression: hashExpression,
|
||||
parentCompilerEnvironment: parentBefore.compiler
|
||||
}
|
||||
await command('archive-source', 'git', ['archive', '--format=tar', '--output', archive, 'HEAD'])
|
||||
report.identity.archiveSha256 = hash(readFileSync(archive))
|
||||
const require = createRequire(join(root, 'package.json'))
|
||||
const nodeGyp = process.env.npm_config_node_gyp || require.resolve('node-gyp/bin/node-gyp.js')
|
||||
report.identity.nodeGyp = JSON.parse(
|
||||
readFileSync(resolve(dirname(nodeGyp), '..', 'package.json'), 'utf8')
|
||||
).version
|
||||
report.identity.nodeGypEntrySha256 = hash(readFileSync(nodeGyp))
|
||||
await command('download-headers-before-measurement', process.execPath, [
|
||||
nodeGyp,
|
||||
'install',
|
||||
`--target=${process.versions.node}`,
|
||||
`--arch=${process.arch}`,
|
||||
`--devdir=${headers}`
|
||||
])
|
||||
const headersBefore = treeDigest(headers)
|
||||
report.identity.headers = headersBefore
|
||||
const physicalDirectories = new Set()
|
||||
const schedule = buildCounterbalancedSchedule(2, 'control', 'candidate').flat()
|
||||
report.schedule = schedule
|
||||
for (const [index, variant] of [...schedule, 'fallback'].entries()) {
|
||||
const label = `sample-${String(index + 1).padStart(2, '0')}`
|
||||
const snapshot = join(temporary, label)
|
||||
mkdirSync(snapshot)
|
||||
await command(`${label}-extract`, 'tar', ['-xf', archive, '-C', snapshot])
|
||||
await pnpmCommand(
|
||||
`${label}-install`,
|
||||
[
|
||||
'install',
|
||||
'--offline',
|
||||
'--frozen-lockfile',
|
||||
'--ignore-scripts',
|
||||
'--package-import-method=copy',
|
||||
'--side-effects-cache=false'
|
||||
],
|
||||
snapshot
|
||||
)
|
||||
await pnpmCommand(`${label}-internal-packages`, ['run', 'build:packages'], snapshot)
|
||||
const directories = nativeDirectories(snapshot)
|
||||
for (const { directory } of directories) {
|
||||
assert(
|
||||
!physicalDirectories.has(directory.toLowerCase()),
|
||||
'Native copies share a build directory'
|
||||
)
|
||||
physicalDirectories.add(directory.toLowerCase())
|
||||
rmSync(join(directory, 'build'), { recursive: true, force: true })
|
||||
}
|
||||
const initial = await command(
|
||||
`${label}-cold-probe`,
|
||||
process.execPath,
|
||||
[join(snapshot, 'config/scripts', control), '--check-only'],
|
||||
snapshot,
|
||||
environment,
|
||||
[1]
|
||||
)
|
||||
assertNativeColdProbe(initial)
|
||||
const snapshotScriptHashes = digestFiles(snapshot, unchangedFiles)
|
||||
assert.deepEqual(
|
||||
snapshotScriptHashes,
|
||||
requiredScriptHashes,
|
||||
'Ordinary snapshot source/config drift'
|
||||
)
|
||||
const originalCapture = readFileSync(
|
||||
join(snapshot, 'config/scripts/capture-windows-native-environment.cmd')
|
||||
)
|
||||
if (variant === 'fallback') {
|
||||
writeFileSync(
|
||||
join(snapshot, 'config/scripts/capture-windows-native-environment.cmd'),
|
||||
'@echo off\r\nexit /b 7\r\n'
|
||||
)
|
||||
}
|
||||
const sample = {
|
||||
label,
|
||||
variant,
|
||||
sourceHashesBeforeInjection: snapshotScriptHashes,
|
||||
originalCaptureSha256: hash(originalCapture),
|
||||
addonDirectories: directories,
|
||||
measurements: []
|
||||
}
|
||||
report.samples.push(sample)
|
||||
for (const warm of [false, true]) {
|
||||
const stage = `${label}-${warm ? 'warm' : 'cold'}`
|
||||
const traceFile = join(output, `${stage}.trace.json`)
|
||||
const script = join(snapshot, 'config/scripts', variant === 'control' ? control : candidate)
|
||||
const args = [
|
||||
'--import',
|
||||
pathToFileURL(join(snapshot, 'config/scripts/windows-native-benchmark-observer.mjs'))
|
||||
.href,
|
||||
script,
|
||||
...(variant === 'control' ? ['--runtime=node'] : [])
|
||||
]
|
||||
const result = await command(stage, process.execPath, args, snapshot, {
|
||||
...environment,
|
||||
ORCA_NATIVE_BENCHMARK_TRACE: traceFile
|
||||
})
|
||||
assertNoHeaderDownloads(result)
|
||||
const receipt = JSON.parse(readFileSync(traceFile, 'utf8'))
|
||||
validateObservedPreparation(receipt, variant, warm)
|
||||
if (variant === 'fallback' && !warm) {
|
||||
assert(result.stderr.includes('retaining node-gyp discovery'), 'Fallback warning missing')
|
||||
}
|
||||
const final = await command(
|
||||
`${stage}-final-probe`,
|
||||
process.execPath,
|
||||
[join(snapshot, 'config/scripts', control), '--check-only'],
|
||||
snapshot
|
||||
)
|
||||
assert.equal(final.code, 0)
|
||||
const calls = receipt.calls
|
||||
const milliseconds = (phase) =>
|
||||
calls
|
||||
.filter((call) => call.phase === phase)
|
||||
.reduce((total, call) => total + call.durationMs, 0)
|
||||
sample.measurements.push({
|
||||
warm,
|
||||
totalPreparationMs: result.durationMs,
|
||||
helperDiscoveryMs: milliseconds('discovery'),
|
||||
helperCaptureMs: milliseconds('capture'),
|
||||
helperPreprobeMs: milliseconds('probe'),
|
||||
unchangedPreparationChildMs: variant === 'control' ? null : milliseconds('preparation'),
|
||||
helperOutsidePreparationChildMs:
|
||||
variant === 'control' ? null : result.durationMs - milliseconds('preparation'),
|
||||
traceFile: relative(output, traceFile),
|
||||
traceSha256: hash(readFileSync(traceFile)),
|
||||
binaries: binaryReceipt(snapshot)
|
||||
})
|
||||
assert.deepEqual(
|
||||
treeDigest(headers),
|
||||
headersBefore,
|
||||
'Timed preparation changed prefilled headers/libraries'
|
||||
)
|
||||
assert.equal(
|
||||
environmentReceipt(process.env).hash,
|
||||
parentBefore.hash,
|
||||
'Parent environment changed'
|
||||
)
|
||||
assert.equal(
|
||||
existsSync(process.env.GITHUB_ENV ?? '')
|
||||
? hash(readFileSync(process.env.GITHUB_ENV))
|
||||
: null,
|
||||
githubEnvironmentBefore,
|
||||
'Compiler setup wrote GITHUB_ENV'
|
||||
)
|
||||
writeReport()
|
||||
}
|
||||
sample.isolatedCopyWindowMs = report.phases
|
||||
.filter((phase) => phase.label.startsWith(`${label}-`))
|
||||
.reduce((total, phase) => total + phase.durationMs, 0)
|
||||
sample.injectedCaptureSha256 =
|
||||
variant === 'fallback'
|
||||
? hash(
|
||||
readFileSync(join(snapshot, 'config/scripts/capture-windows-native-environment.cmd'))
|
||||
)
|
||||
: null
|
||||
}
|
||||
report.comparison = Object.fromEntries(
|
||||
[false, true].map((warm) => {
|
||||
const timings = (variant) =>
|
||||
report.samples
|
||||
.filter((sample) => sample.variant === variant)
|
||||
.map(
|
||||
(sample) =>
|
||||
sample.measurements.find((measurement) => measurement.warm === warm)
|
||||
.totalPreparationMs
|
||||
)
|
||||
const baseline = timings('control')
|
||||
const proposed = timings('candidate')
|
||||
return [
|
||||
warm ? 'sameBuildWarm' : 'cold',
|
||||
{
|
||||
control: summarizeBenchmarkSamples(baseline),
|
||||
candidate: summarizeBenchmarkSamples(proposed),
|
||||
pairDeltaMs: baseline.map((value, index) => proposed[index] - value),
|
||||
role: 'Two counterbalanced pairs are descriptive; no significance or ordinary CI hit-rate estimate.'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
report.parentEnvironment = {
|
||||
before: parentBefore,
|
||||
after: environmentReceipt(process.env),
|
||||
githubEnvironmentBefore,
|
||||
githubEnvironmentAfter: githubEnvironmentBefore
|
||||
}
|
||||
report.qualification = 'passed'
|
||||
} catch (error) {
|
||||
report.failure = error.message
|
||||
report.qualification = 'failed'
|
||||
throw error
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true })
|
||||
report.finishedAt = new Date().toISOString()
|
||||
report.explicitWindowMs = Date.parse(report.finishedAt) - Date.parse(report.startedAt)
|
||||
report.explicitJobWindowMs =
|
||||
Date.parse(report.finishedAt) - Date.parse(report.firstRecordedJobStep.startedAt)
|
||||
writeReport()
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await benchmarkWindowsNativePreparation()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
call "%ORCA_WINDOWS_VSDEVCMD%" -no_logo -arch=%ORCA_WINDOWS_NATIVE_ARCH% -host_arch=%ORCA_WINDOWS_NATIVE_ARCH%
|
||||
if errorlevel 1 exit /b 1
|
||||
echo ORCA_WINDOWS_NATIVE_ENVIRONMENT
|
||||
set
|
||||
@@ -0,0 +1,159 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
|
||||
const ENVIRONMENT_MARKER = 'ORCA_WINDOWS_NATIVE_ENVIRONMENT'
|
||||
const compilerEnvironmentKey =
|
||||
/^(PATH|INCLUDE|LIB|LIBPATH|VCINSTALLDIR|VSINSTALLDIR|VisualStudioVersion|VSCMD_.+|VCTools.+|WindowsSDK.+|UniversalCRT.+|UCRTVersion)$/i
|
||||
|
||||
export function parseWindowsCompilerEnvironment(output, arch) {
|
||||
const lines = output.split(/\r?\n/)
|
||||
const marker = lines.indexOf(ENVIRONMENT_MARKER)
|
||||
if (marker === -1) {
|
||||
throw new Error('Developer environment receipt is missing')
|
||||
}
|
||||
const environment = {}
|
||||
for (const line of lines.slice(marker + 1)) {
|
||||
const separator = line.indexOf('=')
|
||||
if (separator <= 0) {
|
||||
continue
|
||||
}
|
||||
const name = line.slice(0, separator)
|
||||
if (compilerEnvironmentKey.test(name)) {
|
||||
environment[name] = line.slice(separator + 1)
|
||||
}
|
||||
}
|
||||
const value = (name) => {
|
||||
const key = Object.keys(environment).find((key) => key.toLowerCase() === name.toLowerCase())
|
||||
return key ? environment[key] : undefined
|
||||
}
|
||||
if (!value('VCINSTALLDIR') || !value('VSCMD_VER') || !value('WindowsSDKVersion')) {
|
||||
throw new Error('Developer environment lacks the compiler or Windows SDK')
|
||||
}
|
||||
if (
|
||||
!/^\d+\.\d+\./.test(value('VSCMD_VER')) ||
|
||||
!/^\d+\.\d+\.\d+\.\d+\\?$/.test(value('WindowsSDKVersion'))
|
||||
) {
|
||||
throw new Error('Developer environment has an invalid compiler or Windows SDK version')
|
||||
}
|
||||
const target = value('VSCMD_ARG_TGT_ARCH')?.toLowerCase()
|
||||
if (!(target === arch || (arch === 'x64' && target === 'amd64'))) {
|
||||
throw new Error('Developer environment targets a different architecture')
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
export function hasWindowsCompilerSelection(environment) {
|
||||
return Object.entries(environment).some(
|
||||
([name, value]) =>
|
||||
/^(VCINSTALLDIR|GYP_MSVS_VERSION|npm_config_msvs_version|npm_package_config_node_gyp_msvs_version)$/i.test(
|
||||
name
|
||||
) && value
|
||||
)
|
||||
}
|
||||
|
||||
export function mergeWindowsCompilerEnvironment(environment, compilerEnvironment) {
|
||||
const compilerKeys = new Set(Object.keys(compilerEnvironment).map((key) => key.toLowerCase()))
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(environment).filter(([key]) => !compilerKeys.has(key.toLowerCase()))
|
||||
),
|
||||
...compilerEnvironment
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeWindowsNativeToolchain() {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
if (hasWindowsCompilerSelection(process.env)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!['x64', 'arm64'].includes(process.arch)) {
|
||||
throw new Error('Windows compiler setup requires a supported runner architecture')
|
||||
}
|
||||
const programFiles = Object.entries(process.env)
|
||||
.filter(([key]) => /^(ProgramFiles|ProgramFiles\(x86\))$/i.test(key))
|
||||
.map(([, value]) => value)
|
||||
const vswhere = programFiles
|
||||
.map((directory) => join(directory, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe'))
|
||||
.find(existsSync)
|
||||
if (!vswhere) {
|
||||
throw new Error('Visual Studio discovery tool is unavailable')
|
||||
}
|
||||
const discovery = runProcessSync({
|
||||
program: vswhere,
|
||||
args: [
|
||||
'-latest',
|
||||
'-products',
|
||||
'*',
|
||||
'-utf8',
|
||||
'-requires',
|
||||
process.arch === 'arm64'
|
||||
? 'Microsoft.VisualStudio.Component.VC.Tools.ARM64'
|
||||
: 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
|
||||
'-property',
|
||||
'installationPath'
|
||||
],
|
||||
stdio: 'pipe',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
const installations = discovery.stdout.trim().split(/\r?\n/).filter(Boolean)
|
||||
if (discovery.code !== 0 || installations.length !== 1) {
|
||||
throw new Error('Visual Studio discovery did not select one compiler')
|
||||
}
|
||||
const devcmd = join(installations[0], 'Common7', 'Tools', 'VsDevCmd.bat')
|
||||
if (!existsSync(devcmd)) {
|
||||
throw new Error('Visual Studio developer command is unavailable')
|
||||
}
|
||||
// process-host owns the fixed batch runner's quoting and hidden console.
|
||||
const result = runProcessSync({
|
||||
program: join(import.meta.dirname, 'capture-windows-native-environment.cmd'),
|
||||
args: [],
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_WINDOWS_VSDEVCMD: devcmd,
|
||||
ORCA_WINDOWS_NATIVE_ARCH: process.arch
|
||||
},
|
||||
stdio: 'pipe',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw new Error('Visual Studio developer command failed')
|
||||
}
|
||||
const environment = parseWindowsCompilerEnvironment(result.stdout, process.arch)
|
||||
console.log(`[native-runtime] Initialized Windows ${process.arch} compiler environment`)
|
||||
return environment
|
||||
} catch {
|
||||
console.warn(
|
||||
'[native-runtime] Compiler environment setup unavailable; retaining node-gyp discovery'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareWindowsNodeRuntime(runProcess = runProcessSync) {
|
||||
const script = join(import.meta.dirname, 'ensure-native-runtime.mjs')
|
||||
const check = runProcess({
|
||||
program: process.execPath,
|
||||
args: [script, '--check-only'],
|
||||
stdio: 'pipe',
|
||||
timeoutMs: null
|
||||
})
|
||||
const compilerEnvironment = check.code === 0 ? undefined : initializeWindowsNativeToolchain()
|
||||
const result = runProcess({
|
||||
program: process.execPath,
|
||||
args: [script, '--runtime=node'],
|
||||
env: compilerEnvironment
|
||||
? mergeWindowsCompilerEnvironment(process.env, compilerEnvironment)
|
||||
: process.env,
|
||||
stdio: 'inherit',
|
||||
timeoutMs: null
|
||||
})
|
||||
return result.code ?? 1
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exitCode = prepareWindowsNodeRuntime()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
hasWindowsCompilerSelection,
|
||||
mergeWindowsCompilerEnvironment,
|
||||
parseWindowsCompilerEnvironment,
|
||||
prepareWindowsNodeRuntime
|
||||
} from './initialize-windows-native-toolchain.mjs'
|
||||
|
||||
const receipt = [
|
||||
'Developer Command Prompt banner, not an environment variable',
|
||||
'TOKEN=should-not-be-exported',
|
||||
'ORCA_WINDOWS_NATIVE_ENVIRONMENT',
|
||||
'VCINSTALLDIR=C:\\Visual Studio\\VC\\',
|
||||
'VSCMD_VER=18.10.12224.181',
|
||||
'WindowsSDKVersion=10.0.28000.0\\',
|
||||
'VSCMD_ARG_TGT_ARCH=arm64',
|
||||
'Path=C:\\工具;C:\\Program Files\\Visual Studio',
|
||||
'INCLUDE=C:\\SDK=Custom\\Include',
|
||||
'TOKEN=should-not-be-exported',
|
||||
'=C:=C:\\',
|
||||
''
|
||||
].join('\r\n')
|
||||
|
||||
describe('Windows compiler environment receipt', () => {
|
||||
it('keeps compiler paths and equals signs while excluding unrelated variables and banners', () => {
|
||||
expect(parseWindowsCompilerEnvironment(receipt, 'arm64')).toEqual({
|
||||
VCINSTALLDIR: 'C:\\Visual Studio\\VC\\',
|
||||
VSCMD_VER: '18.10.12224.181',
|
||||
WindowsSDKVersion: '10.0.28000.0\\',
|
||||
VSCMD_ARG_TGT_ARCH: 'arm64',
|
||||
Path: 'C:\\工具;C:\\Program Files\\Visual Studio',
|
||||
INCLUDE: 'C:\\SDK=Custom\\Include'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
receipt.replace('ORCA_WINDOWS_NATIVE_ENVIRONMENT', 'missing'),
|
||||
receipt.replace('VCINSTALLDIR=', 'unrelated='),
|
||||
receipt.replace('VSCMD_VER=', 'unrelated='),
|
||||
receipt.replace('WindowsSDKVersion=', 'unrelated='),
|
||||
receipt.replace('10.0.28000.0\\', 'unavailable'),
|
||||
receipt.replace('18.10.12224.181', 'unavailable'),
|
||||
receipt.replace('VSCMD_ARG_TGT_ARCH=arm64', 'VSCMD_ARG_TGT_ARCH=x64')
|
||||
])('rejects incomplete, invalid or wrong-architecture setup before use', (output) => {
|
||||
expect(() => parseWindowsCompilerEnvironment(output, 'arm64')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Windows native compiler environment scope', () => {
|
||||
it('replaces path keys case-insensitively without changing the parent environment', () => {
|
||||
const original = { PATH: 'original-path', LIB: 'original-lib', TOKEN: 'unchanged' }
|
||||
expect(
|
||||
mergeWindowsCompilerEnvironment(original, { Path: 'compiler-path', Lib: 'compiler-lib' })
|
||||
).toEqual({ Path: 'compiler-path', Lib: 'compiler-lib', TOKEN: 'unchanged' })
|
||||
expect(original).toEqual({ PATH: 'original-path', LIB: 'original-lib', TOKEN: 'unchanged' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'VCINSTALLDIR',
|
||||
'GYP_MSVS_VERSION',
|
||||
'npm_config_msvs_version',
|
||||
'npm_package_config_node_gyp_msvs_version'
|
||||
])('retains an existing compiler selection through %s', (name) => {
|
||||
expect(hasWindowsCompilerSelection({ [name.toLowerCase()]: 'requested' })).toBe(true)
|
||||
expect(hasWindowsCompilerSelection({ [name]: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the original preparation and environment after a passing warm probe', () => {
|
||||
const runProcess = vi.fn(() => ({ code: 0 }))
|
||||
expect(prepareWindowsNodeRuntime(runProcess)).toBe(0)
|
||||
expect(runProcess).toHaveBeenCalledTimes(2)
|
||||
expect(runProcess).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
args: [expect.stringContaining('ensure-native-runtime.mjs'), '--runtime=node'],
|
||||
env: process.env,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates native preparation failure after a passing warm probe', () => {
|
||||
const runProcess = vi.fn().mockReturnValueOnce({ code: 0 }).mockReturnValueOnce({ code: 9 })
|
||||
expect(prepareWindowsNodeRuntime(runProcess)).toBe(9)
|
||||
})
|
||||
})
|
||||
@@ -41,6 +41,8 @@ const ALWAYS_FILES = new Set([
|
||||
'config/scripts/workspace-source-exports.mjs',
|
||||
'config/scripts/workspace-source-exports.test.mjs',
|
||||
'config/scripts/headless-detector-compiler-cache.mjs',
|
||||
'config/scripts/initialize-windows-native-toolchain.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd',
|
||||
'config/scripts/node-server-qualification.mjs',
|
||||
// The source graph sees process-host source, not the build that emits the dist servers load.
|
||||
'src/packages/process-host/.gitignore',
|
||||
|
||||
@@ -59,6 +59,8 @@ function context(os, arch, event, ref, template = false) {
|
||||
},
|
||||
success: () => true,
|
||||
fromJSON: JSON.parse,
|
||||
format: (template, ...values) =>
|
||||
template.replace(/\{(\d+)\}/g, (_match, index) => String(values[Number(index)])),
|
||||
contains: (values, value) => values.includes(value)
|
||||
}
|
||||
}
|
||||
@@ -173,6 +175,38 @@ describe('SSH Windows consumers of qualified server slots', () => {
|
||||
const sshWorkflow = parse(readFileSync('.github/workflows/ssh-windows-hosts.yml', 'utf8'))
|
||||
const sshSteps = sshWorkflow.jobs.hosts.steps
|
||||
const sshPrepare = sshSteps.find((step) => step.uses === prepare.uses)
|
||||
const inboxBackground = sshSteps.find((step) => step.id === 'inbox-capability')
|
||||
const inboxSerial = sshSteps.find((step) => step.name?.endsWith('after the builds'))
|
||||
const allLanes = [
|
||||
{ arch: 'x64', runner: 'windows-2022', server: 'inbox' },
|
||||
{ arch: 'x64', runner: 'windows-2022', server: 'preview', archive: 'OpenSSH-Win64.zip' },
|
||||
{ arch: 'arm64', runner: 'windows-11-arm', server: 'inbox' },
|
||||
{ arch: 'arm64', runner: 'windows-11-arm', server: 'preview', archive: 'OpenSSH-ARM64.zip' }
|
||||
]
|
||||
|
||||
function sshContext(event, inputs = {}) {
|
||||
const ctx = context('Windows', 'ARM64', event, 'refs/heads/main')
|
||||
ctx.github.event = { inputs, pull_request: { number: undefined } }
|
||||
ctx.inputs = inputs
|
||||
ctx.env = {
|
||||
INBOX_PREPARATION_MODE: sshValue(sshWorkflow.jobs.hosts.env.INBOX_PREPARATION_MODE, ctx)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function sshValue(value, ctx) {
|
||||
return evaluate(value.slice(3, -2).trim(), ctx)
|
||||
}
|
||||
|
||||
function backgroundPreparationRuns(ctx) {
|
||||
const condition = inboxBackground.run
|
||||
.match(/^\s*if\((.*)\)\{\s*$/m)[1]
|
||||
.replace(/'\$\{\{ matrix\.(arch|server) \}\}'/g, (_match, name) => `matrix.${name}`)
|
||||
.replaceAll('$env:INBOX_PREPARATION_MODE', 'env.INBOX_PREPARATION_MODE')
|
||||
.replaceAll(' -eq ', ' === ')
|
||||
.replaceAll(' -and ', ' && ')
|
||||
return evaluate(condition, ctx)
|
||||
}
|
||||
|
||||
it.each(['pull_request', 'workflow_dispatch'])(
|
||||
'restores only PR slots and keeps manual %s qualification fresh',
|
||||
@@ -188,13 +222,8 @@ describe('SSH Windows consumers of qualified server slots', () => {
|
||||
|
||||
it('keeps both architectures, both sshd versions and the existing build order', () => {
|
||||
expect(
|
||||
sshWorkflow.jobs.hosts.strategy.matrix.include.map(({ arch, server }) => [arch, server])
|
||||
).toEqual([
|
||||
['x64', 'inbox'],
|
||||
['x64', 'preview'],
|
||||
['arm64', 'inbox'],
|
||||
['arm64', 'preview']
|
||||
])
|
||||
sshValue(sshWorkflow.jobs.hosts.strategy.matrix.include, sshContext('pull_request'))
|
||||
).toEqual(allLanes)
|
||||
const addon = sshSteps.findIndex((step) =>
|
||||
step.run?.includes('build-windows-process-tree-addon.mjs')
|
||||
)
|
||||
@@ -215,6 +244,92 @@ describe('SSH Windows consumers of qualified server slots', () => {
|
||||
'config/scripts/orcad-windows-prebuild-cache.mjs'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['pull_request', {}, 'overlap', false],
|
||||
['pull_request', { preparation_mode: 'serial', arm_inbox_only: true }, 'overlap', false],
|
||||
['push', { preparation_mode: 'serial', arm_inbox_only: true }, 'overlap', false],
|
||||
['workflow_dispatch', {}, 'overlap', false],
|
||||
['workflow_dispatch', { preparation_mode: 'overlap', arm_inbox_only: false }, 'overlap', false],
|
||||
['workflow_dispatch', { preparation_mode: 'serial', arm_inbox_only: false }, 'serial', false],
|
||||
['workflow_dispatch', { preparation_mode: 'overlap', arm_inbox_only: true }, 'overlap', true],
|
||||
['workflow_dispatch', { preparation_mode: 'serial', arm_inbox_only: true }, 'serial', true]
|
||||
])('%s inputs=%j mode=%s ARM-only=%s', (event, inputs, mode, armOnly) => {
|
||||
const ctx = sshContext(event, inputs)
|
||||
const lanes = sshValue(sshWorkflow.jobs.hosts.strategy.matrix.include, ctx)
|
||||
expect(lanes).toEqual(armOnly ? [allLanes[2]] : allLanes)
|
||||
expect(ctx.env.INBOX_PREPARATION_MODE).toBe(mode)
|
||||
for (const matrix of lanes) {
|
||||
ctx.matrix = matrix
|
||||
const inboxArm = matrix.server === 'inbox' && matrix.arch === 'arm64'
|
||||
expect(backgroundPreparationRuns(ctx)).toBe(inboxArm && mode === 'overlap')
|
||||
expect(sshValue(inboxSerial.if, ctx)).toBe(inboxArm && mode === 'serial')
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults to the existing overlap schedule and keeps serial after the successful wait', () => {
|
||||
expect(sshWorkflow.on.workflow_dispatch.inputs.preparation_mode).toMatchObject({
|
||||
type: 'choice',
|
||||
default: 'overlap',
|
||||
options: ['overlap', 'serial']
|
||||
})
|
||||
expect(sshWorkflow.on.workflow_dispatch.inputs.arm_inbox_only).toMatchObject({
|
||||
type: 'boolean',
|
||||
default: false
|
||||
})
|
||||
const dependencies = sshSteps.findIndex((step) => step.id === 'dependencies')
|
||||
const template = sshSteps.findIndex((step) => step.run?.includes('build-orcad-template.mjs'))
|
||||
const wait = sshSteps.findIndex((step) => step.wait === 'inbox-capability')
|
||||
const serial = sshSteps.indexOf(inboxSerial)
|
||||
const hosts = sshSteps.findIndex((step) => step.name?.startsWith('Run the Windows host cells'))
|
||||
expect(inboxBackground.background).toBe(true)
|
||||
expect(inboxBackground.if).toBeUndefined()
|
||||
expect(sshSteps.indexOf(inboxBackground)).toBeLessThan(dependencies)
|
||||
expect(template).toBeLessThan(wait)
|
||||
expect(serial).toBe(wait + 1)
|
||||
expect(hosts).toBe(serial + 1)
|
||||
expect(inboxSerial.background).toBeUndefined()
|
||||
for (const preparation of [inboxBackground, inboxSerial]) {
|
||||
expect(preparation['continue-on-error']).toBeUndefined()
|
||||
expect(preparation.run).toContain(
|
||||
'. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1'
|
||||
)
|
||||
expect(preparation.run).toContain(
|
||||
"-Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE"
|
||||
)
|
||||
}
|
||||
expect(sshSteps[hosts].run).toContain('-InboxPreparationReceipt $preparation')
|
||||
expect(sshWorkflow.jobs.hosts.env.ORCA_BACKGROUND_LAUNCH).toBe('1')
|
||||
expect(sshWorkflow.jobs.hosts.env.ORCA_ISOLATED_SSH_CI).toBe('1')
|
||||
for (const event of ['pull_request', 'workflow_dispatch']) {
|
||||
for (const preparation_mode of ['overlap', 'serial']) {
|
||||
for (const [cells, jobMinutes, hostMinutes] of [
|
||||
['', 75, 50],
|
||||
['orcad-cli-managed', 160, 130]
|
||||
]) {
|
||||
const ctx = sshContext(event, { preparation_mode, arm_inbox_only: true, cells })
|
||||
expect(sshValue(sshWorkflow.jobs.hosts['timeout-minutes'], ctx)).toBe(jobMinutes)
|
||||
expect(sshValue(sshSteps[hosts]['timeout-minutes'], ctx)).toBe(hostMinutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates manual comparison modes while preserving ordinary cancellation groups', () => {
|
||||
const group = (ctx) =>
|
||||
sshWorkflow.concurrency.group.replace(/\$\{\{(.*?)\}\}/g, (_match, expression) =>
|
||||
String(evaluate(expression.trim(), ctx))
|
||||
)
|
||||
const regular = sshContext('pull_request')
|
||||
regular.github.event.pull_request = { number: 28669 }
|
||||
expect(group(regular)).toBe('ssh-windows-hosts-28669')
|
||||
expect(group(sshContext('workflow_dispatch'))).toBe('ssh-windows-hosts-refs/heads/main')
|
||||
for (const mode of ['overlap', 'serial']) {
|
||||
expect(
|
||||
group(sshContext('workflow_dispatch', { arm_inbox_only: true, preparation_mode: mode }))
|
||||
).toBe(`ssh-windows-hosts-refs/heads/main-${mode}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const commandMocks = `
|
||||
|
||||
@@ -267,6 +267,8 @@ const MANAGED_HOOK_PREFIXES = [
|
||||
|
||||
const NATIVE_RUNTIME_PREFIXES = [
|
||||
'config/scripts/ensure-native-runtime',
|
||||
'config/scripts/initialize-windows-native-toolchain',
|
||||
'config/scripts/capture-windows-native-environment',
|
||||
'config/scripts/rebuild-native-deps',
|
||||
'config/scripts/node-pty-job-ownership',
|
||||
'config/scripts/windows-pe-machine',
|
||||
@@ -288,6 +290,8 @@ const NATIVE_CACHE_FILES = new Set([
|
||||
'.npmrc',
|
||||
'.pnpmfile.cjs',
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/initialize-windows-native-toolchain.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd',
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
'config/scripts/node-pty-job-ownership.cjs',
|
||||
'config/scripts/windows-pe-machine.cjs',
|
||||
|
||||
@@ -2,10 +2,11 @@ import { readdirSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs'
|
||||
import { hasWindowsNativeBatchReferenceViolation } from './windows-native-batch-reference-policy'
|
||||
|
||||
/**
|
||||
* Bare JS scripts cannot use the TS runProcess chokepoint; resolve package bins
|
||||
* under process.execPath instead (see oxc-cli-invocation.mjs). The list only shrinks.
|
||||
* Resolve package bins under process.execPath (see oxc-cli-invocation.mjs).
|
||||
* The legacy list only shrinks; reviewed native compiler references use process-host.
|
||||
* The literal check catches quoted .cmd/.bat paths, excluding drive letters and
|
||||
* templates; comment-only lines are dropped, but trailing comments fail closed.
|
||||
* The AST check catches spawn/spawnSync/execFile/execFileSync first arguments
|
||||
@@ -13,10 +14,10 @@ import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs'
|
||||
* local variable initializers and ternaries. It does not follow imports, function
|
||||
* returns, assignments, destructuring, aliased spawn names or computed members.
|
||||
* Names are matched without scope/import resolution; shadowed names and non-path
|
||||
* join/resolve calls can fail closed. Paths are joined textually, without reducing
|
||||
* dot segments. Neither check proves a Windows branch or an actual unsafe spawn.
|
||||
* join/resolve calls can fail closed. Package paths fold dot segments.
|
||||
* Neither check proves a Windows branch or an actual unsafe spawn.
|
||||
*/
|
||||
const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/i
|
||||
const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/gi
|
||||
|
||||
const SCANNED_ROOTS = ['config/scripts', 'tests/tools']
|
||||
|
||||
@@ -83,7 +84,13 @@ describe('windows batch shim spawn boundary', () => {
|
||||
)
|
||||
const offenders = scripts.filter((relativePath) => {
|
||||
const contents = readFileSync(path.join(repoRoot, relativePath), 'utf8')
|
||||
return WINDOWS_SHIM_LITERAL.test(codeText(contents)) || hasNodeModulesBinSpawn(contents)
|
||||
const literals = [...codeText(contents).matchAll(WINDOWS_SHIM_LITERAL)].map((match) =>
|
||||
match[0].slice(1, -1)
|
||||
)
|
||||
return (
|
||||
hasWindowsNativeBatchReferenceViolation(relativePath, contents, literals) ||
|
||||
hasNodeModulesBinSpawn(contents)
|
||||
)
|
||||
})
|
||||
|
||||
it('scans a plausible number of scripts', () => {
|
||||
@@ -95,7 +102,7 @@ describe('windows batch shim spawn boundary', () => {
|
||||
const unlisted = offenders.filter((name) => !WINDOWS_SHIM_SPAWN_ALLOWLIST.includes(name))
|
||||
expect(
|
||||
unlisted,
|
||||
'Node cannot spawn a Windows batch shim without a shell. Resolve the real executable — see oxc-cli-invocation.mjs.'
|
||||
'Resolve package shims to their real executable. Fixed native compiler batch runners must pass the scoped process-host policy.'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasWindowsNativeBatchReferenceViolation } from './windows-native-batch-reference-policy'
|
||||
|
||||
const initializer = 'config/scripts/initialize-windows-native-toolchain.mjs'
|
||||
const benchmark = 'config/scripts/benchmark-windows-native-preparation.mjs'
|
||||
const observer = 'config/scripts/windows-native-benchmark-observer.mjs'
|
||||
const metadataCallers = [
|
||||
'config/scripts/node-server-change-scope.mjs',
|
||||
'config/scripts/pr-code-change-scope.mjs',
|
||||
'config/scripts/windows-native-toolchain-routing.test.mjs'
|
||||
]
|
||||
const capture = 'capture-windows-native-environment.cmd'
|
||||
const initializerSource = `
|
||||
import { runProcessSync as captureEnvironment } from '@orca/process-host'
|
||||
const devcmd = join(installation, 'Common7', 'Tools', 'VsDevCmd.bat')
|
||||
existsSync(devcmd)
|
||||
captureEnvironment({
|
||||
program: join(import.meta.dirname, '${capture}'),
|
||||
env: { ORCA_WINDOWS_VSDEVCMD: devcmd }
|
||||
})
|
||||
`
|
||||
const benchmarkSource = `
|
||||
import { runProcess } from '@orca/process-host'
|
||||
const source = 'config/scripts/${capture}'
|
||||
await runProcess({ program: process.execPath, args: [source] })
|
||||
`
|
||||
|
||||
function hasViolation(relativePath: string, contents: string): boolean {
|
||||
const literals = [...contents.matchAll(/['"][\w./\\-]*\.(?:cmd|bat)['"]/gi)].map((match) =>
|
||||
match[0].slice(1, -1)
|
||||
)
|
||||
return hasWindowsNativeBatchReferenceViolation(relativePath, contents, literals)
|
||||
}
|
||||
|
||||
describe('reviewed Windows native batch references', () => {
|
||||
it.each([...metadataCallers, initializer, benchmark, observer])(
|
||||
'accepts the current reviewed source in %s',
|
||||
(relativePath) => {
|
||||
const root = path.resolve(import.meta.dirname, '..', '..')
|
||||
expect(hasViolation(relativePath, readFileSync(path.join(root, relativePath), 'utf8'))).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(metadataCallers)('allows the fixed source path as metadata in %s', (relativePath) => {
|
||||
expect(hasViolation(relativePath, `const source = 'config/scripts/${capture}'`)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts the process-host executor through its imported alias', () => {
|
||||
expect(hasViolation(initializer, initializerSource)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts the benchmark executor', () => {
|
||||
expect(hasViolation(benchmark, benchmarkSource)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'config/scripts/other-native-preparation.mjs',
|
||||
'tests/tools/initialize-windows-native-toolchain.mjs',
|
||||
`config/scripts/nested/${path.basename(initializer)}`
|
||||
])('rejects the fixed runner in another caller: %s', (relativePath) => {
|
||||
expect(hasViolation(relativePath, initializerSource)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'elsewhere/capture-windows-native-environment.cmd',
|
||||
'./capture-windows-native-environment.cmd',
|
||||
'CAPTURE-WINDOWS-NATIVE-ENVIRONMENT.cmd',
|
||||
'other.cmd',
|
||||
'other.bat'
|
||||
])('rejects another batch path in a reviewed executor: %s', (literal) => {
|
||||
expect(hasViolation(initializer, `${initializerSource}\nconst other = '${literal}'`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(metadataCallers)('rejects other batch metadata in %s', (relativePath) => {
|
||||
expect(
|
||||
hasViolation(relativePath, `const sources = ['config/scripts/${capture}', 'other.cmd']`)
|
||||
).toBe(true)
|
||||
expect(hasViolation(relativePath, `const command = 'VsDevCmd.bat'`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[initializer, initializerSource],
|
||||
[benchmark, benchmarkSource],
|
||||
[observer, `require('@orca/process-host'); const capture = '${capture}'`]
|
||||
])('rejects a missing process-host load in %s', (relativePath, contents) => {
|
||||
expect(hasViolation(relativePath, contents.replace('@orca/process-host', 'other-runner'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an unused process-host import beside an unverified executor', () => {
|
||||
expect(
|
||||
hasViolation(initializer, initializerSource.replace('captureEnvironment({', 'otherRunner({'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects another executor for the fixed capture script beside a valid call', () => {
|
||||
expect(
|
||||
hasViolation(
|
||||
initializer,
|
||||
`${initializerSource}\notherRunner({ program: join(import.meta.dirname, '${capture}') })`
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"import { spawnSync as execute } from 'node:child_process'",
|
||||
"import * as cp from 'child_process'",
|
||||
"await import('node:child_process')",
|
||||
"const builtins = 'node:child_process'; process.getBuiltinModule(builtins)",
|
||||
"const require = createRequire(import.meta.url); const cp = require('child_process')"
|
||||
])('rejects raw child-process module loads: %s', (load) => {
|
||||
expect(hasViolation(initializer, `${initializerSource}\n${load}`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"spawn('program', [])",
|
||||
"cp.spawnSync('program', [])",
|
||||
"cp['execFile']('program', [])",
|
||||
"execFileSync('program', [])",
|
||||
"exec('program')",
|
||||
"execSync('program')",
|
||||
"fork('program')"
|
||||
])('rejects raw process calls: %s', (call) => {
|
||||
expect(hasViolation(initializer, `${initializerSource}\n${call}`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"runProcess({ program: 'node_modules/.bin/pnpm' })",
|
||||
"const bin = join(root, 'node_modules', '.bin', 'pnpm'); runProcess({ program: bin })",
|
||||
"runProcess({ program: join(root, 'node_modules', 'tools', '..', '.bin', 'pnpm') })",
|
||||
"const spec = { 'program': 'root\\\\node_modules\\\\.bin\\\\pnpm' }; runProcess(spec)",
|
||||
"const program = join(root, 'node_modules', '.bin', 'pnpm'); runProcess({ program })",
|
||||
"runProcess({ ['program']: join(root, 'node_modules', '.bin', 'pnpm') })"
|
||||
])('rejects package-bin programs through process-host: %s', (call) => {
|
||||
expect(hasViolation(benchmark, `${benchmarkSource}\n${call}`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(metadataCallers)('rejects process execution added to metadata in %s', (relativePath) => {
|
||||
expect(
|
||||
hasViolation(
|
||||
relativePath,
|
||||
`const capture = 'config/scripts/${capture}'; runProcess({ program: capture })`
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasViolation(
|
||||
relativePath,
|
||||
`import { runProcess as execute } from '@orca/process-host'; const capture = 'config/scripts/${capture}'; execute({ program: capture })`
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"captureEnvironment({ program: 'VsDevCmd.bat' })",
|
||||
'captureEnvironment({ program: devcmd })',
|
||||
'const program = devcmd; captureEnvironment({ program })',
|
||||
"const devcmd = join(other, 'VsDevCmd.bat')"
|
||||
])('keeps the discovered developer command as metadata: %s', (extra) => {
|
||||
expect(hasViolation(initializer, `${initializerSource}\n${extra}`)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import ts from 'typescript-api'
|
||||
import {
|
||||
collectModuleSpecifiers,
|
||||
literalString,
|
||||
propertyName
|
||||
} from './static-module-specifiers.mjs'
|
||||
import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs'
|
||||
|
||||
const references = new Map([
|
||||
[
|
||||
'config/scripts/node-server-change-scope.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd'
|
||||
],
|
||||
[
|
||||
'config/scripts/pr-code-change-scope.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd'
|
||||
],
|
||||
[
|
||||
'config/scripts/windows-native-toolchain-routing.test.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd'
|
||||
],
|
||||
[
|
||||
'config/scripts/windows-native-benchmark-observer.mjs',
|
||||
'capture-windows-native-environment.cmd'
|
||||
],
|
||||
[
|
||||
'config/scripts/initialize-windows-native-toolchain.mjs',
|
||||
'capture-windows-native-environment.cmd'
|
||||
],
|
||||
[
|
||||
'config/scripts/benchmark-windows-native-preparation.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd'
|
||||
]
|
||||
])
|
||||
const executors = new Map([
|
||||
['config/scripts/initialize-windows-native-toolchain.mjs', 'runProcessSync'],
|
||||
['config/scripts/benchmark-windows-native-preparation.mjs', 'runProcess']
|
||||
])
|
||||
const initializer = 'config/scripts/initialize-windows-native-toolchain.mjs'
|
||||
const observer = 'config/scripts/windows-native-benchmark-observer.mjs'
|
||||
const rawProcessMethods = new Set([
|
||||
'spawn',
|
||||
'spawnSync',
|
||||
'exec',
|
||||
'execSync',
|
||||
'execFile',
|
||||
'execFileSync',
|
||||
'fork'
|
||||
])
|
||||
const processHostMethods = new Set(['runProcess', 'runProcessSync', 'spawnProcess'])
|
||||
|
||||
function callName(node: ts.Expression): string {
|
||||
return ts.isIdentifier(node) ? node.text : (propertyName(node) ?? '')
|
||||
}
|
||||
|
||||
function isDeveloperCommandMetadata(node: ts.StringLiteral): boolean {
|
||||
const call = node.parent
|
||||
const declaration = call.parent
|
||||
return (
|
||||
ts.isCallExpression(call) &&
|
||||
callName(call.expression) === 'join' &&
|
||||
call.arguments.at(-1) === node &&
|
||||
ts.isVariableDeclaration(declaration) &&
|
||||
ts.isIdentifier(declaration.name) &&
|
||||
declaration.name.text === 'devcmd'
|
||||
)
|
||||
}
|
||||
|
||||
function isDeveloperCommandUse(node: ts.Identifier): boolean {
|
||||
const parent = node.parent
|
||||
return (
|
||||
(ts.isVariableDeclaration(parent) && parent.name === node) ||
|
||||
(ts.isCallExpression(parent) &&
|
||||
callName(parent.expression) === 'existsSync' &&
|
||||
parent.arguments[0] === node) ||
|
||||
(ts.isPropertyAssignment(parent) &&
|
||||
parent.initializer === node &&
|
||||
ts.isIdentifier(parent.name) &&
|
||||
parent.name.text === 'ORCA_WINDOWS_VSDEVCMD')
|
||||
)
|
||||
}
|
||||
|
||||
function isProcessHostCapture(node: ts.StringLiteral, importedMethods: Set<string>): boolean {
|
||||
const path = node.parent
|
||||
if (
|
||||
!ts.isCallExpression(path) ||
|
||||
callName(path.expression) !== 'join' ||
|
||||
path.arguments.length !== 2 ||
|
||||
path.arguments[0].getText() !== 'import.meta.dirname' ||
|
||||
path.arguments[1] !== node
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const program = path.parent
|
||||
if (
|
||||
!ts.isPropertyAssignment(program) ||
|
||||
!ts.isIdentifier(program.name) ||
|
||||
program.name.text !== 'program'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const spec = program.parent
|
||||
const call = spec.parent
|
||||
return (
|
||||
ts.isObjectLiteralExpression(spec) &&
|
||||
ts.isCallExpression(call) &&
|
||||
call.arguments[0] === spec &&
|
||||
ts.isIdentifier(call.expression) &&
|
||||
importedMethods.has(call.expression.text)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasWindowsNativeBatchReferenceViolation(
|
||||
relativePath: string,
|
||||
contents: string,
|
||||
literals: readonly string[]
|
||||
): boolean {
|
||||
const capture = references.get(relativePath)
|
||||
if (!capture) {
|
||||
return literals.length > 0
|
||||
}
|
||||
if (
|
||||
literals.some(
|
||||
(literal) =>
|
||||
literal !== capture && !(relativePath === initializer && literal === 'VsDevCmd.bat')
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const modules = collectModuleSpecifiers(relativePath, contents)
|
||||
if (modules.some((module) => module === 'child_process' || module === 'node:child_process')) {
|
||||
return true
|
||||
}
|
||||
const expectedMethod = executors.get(relativePath)
|
||||
if ((expectedMethod || relativePath === observer) && !modules.includes('@orca/process-host')) {
|
||||
return true
|
||||
}
|
||||
if (!expectedMethod && relativePath !== observer && modules.includes('@orca/process-host')) {
|
||||
return true
|
||||
}
|
||||
const source = ts.createSourceFile(relativePath, contents, ts.ScriptTarget.Latest, true)
|
||||
const importedMethods = new Set<string>()
|
||||
const calls: ts.CallExpression[] = []
|
||||
const programs: ts.Expression[] = []
|
||||
const captureReferences: ts.StringLiteral[] = []
|
||||
let violation = false
|
||||
let developerCommandDeclarations = 0
|
||||
function visit(node: ts.Node): void {
|
||||
if (
|
||||
ts.isImportDeclaration(node) &&
|
||||
ts.isStringLiteral(node.moduleSpecifier) &&
|
||||
node.moduleSpecifier.text === '@orca/process-host'
|
||||
) {
|
||||
const bindings = node.importClause?.namedBindings
|
||||
if (bindings && ts.isNamedImports(bindings)) {
|
||||
for (const binding of bindings.elements) {
|
||||
if ((binding.propertyName ?? binding.name).text === expectedMethod) {
|
||||
importedMethods.add(binding.name.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ts.isCallExpression(node)) {
|
||||
calls.push(node)
|
||||
const method = callName(node.expression)
|
||||
if (rawProcessMethods.has(method)) {
|
||||
violation = true
|
||||
}
|
||||
if (!expectedMethod && relativePath !== observer && processHostMethods.has(method)) {
|
||||
violation = true
|
||||
}
|
||||
}
|
||||
if (
|
||||
ts.isPropertyAssignment(node) &&
|
||||
((ts.isIdentifier(node.name) && node.name.text === 'program') ||
|
||||
(ts.isStringLiteral(node.name) && node.name.text === 'program') ||
|
||||
(ts.isComputedPropertyName(node.name) && literalString(node.name.expression) === 'program'))
|
||||
) {
|
||||
programs.push(node.initializer)
|
||||
}
|
||||
if (ts.isShorthandPropertyAssignment(node) && node.name.text === 'program') {
|
||||
programs.push(node.name)
|
||||
}
|
||||
if (relativePath === initializer) {
|
||||
if (ts.isStringLiteral(node) && node.text === capture) {
|
||||
captureReferences.push(node)
|
||||
}
|
||||
if (
|
||||
ts.isStringLiteral(node) &&
|
||||
node.text === 'VsDevCmd.bat' &&
|
||||
!isDeveloperCommandMetadata(node)
|
||||
) {
|
||||
violation = true
|
||||
}
|
||||
if (ts.isIdentifier(node) && node.text === 'devcmd') {
|
||||
if (!isDeveloperCommandUse(node)) {
|
||||
violation = true
|
||||
}
|
||||
if (ts.isVariableDeclaration(node.parent) && node.parent.name === node) {
|
||||
developerCommandDeclarations += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
if (
|
||||
expectedMethod &&
|
||||
!calls.some(
|
||||
(call) => ts.isIdentifier(call.expression) && importedMethods.has(call.expression.text)
|
||||
)
|
||||
) {
|
||||
violation = true
|
||||
}
|
||||
if (relativePath === initializer && developerCommandDeclarations !== 1) {
|
||||
violation = true
|
||||
}
|
||||
if (captureReferences.some((node) => !isProcessHostCapture(node, importedMethods))) {
|
||||
violation = true
|
||||
}
|
||||
// Reuse package-path folding for process-host's named program option.
|
||||
return (
|
||||
violation ||
|
||||
hasNodeModulesBinSpawn(contents) ||
|
||||
programs.some((program) =>
|
||||
hasNodeModulesBinSpawn(`${contents}\nspawnSync(${program.getText(source)})`)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { basename } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
export const compilerEnvironmentKey =
|
||||
/^(PATH|INCLUDE|LIB|LIBPATH|VCINSTALLDIR|VSINSTALLDIR|VisualStudioVersion|VSCMD_.+|VCTools.+|WindowsSDK.+|UniversalCRT.+|UCRTVersion)$/i
|
||||
export const hash = (value) => createHash('sha256').update(value).digest('hex')
|
||||
|
||||
export function environmentReceipt(environment) {
|
||||
const entries = Object.entries(environment)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([name, value]) => [name.toLowerCase(), String(value)])
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
assert.equal(
|
||||
new Set(entries.map(([name]) => name)).size,
|
||||
entries.length,
|
||||
'Duplicate environment keys'
|
||||
)
|
||||
return {
|
||||
hash: hash(JSON.stringify(entries)),
|
||||
versions: Object.fromEntries(
|
||||
entries.filter(
|
||||
([name, value]) =>
|
||||
/^(visualstudioversion|vscmd_ver|windowssdkversion|vctoolsversion|vscmd_arg_tgt_arch)$/.test(
|
||||
name
|
||||
) && /^(?:[\d.]+\\?|x64|amd64|arm64)$/.test(value)
|
||||
)
|
||||
),
|
||||
compiler: Object.fromEntries(
|
||||
entries
|
||||
.filter(([name]) => compilerEnvironmentKey.test(name))
|
||||
.map(([name, value]) => [name, hash(value)])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function changedEnvironmentNames(before, after) {
|
||||
const normalize = (environment) =>
|
||||
new Map(Object.entries(environment).map(([name, value]) => [name.toLowerCase(), value]))
|
||||
const original = normalize(before)
|
||||
const prepared = normalize(after)
|
||||
return [...new Set([...original.keys(), ...prepared.keys()])]
|
||||
.filter((name) => original.get(name) !== prepared.get(name))
|
||||
.sort()
|
||||
}
|
||||
|
||||
export function processPhase(spec) {
|
||||
const name = basename(spec.program).toLowerCase()
|
||||
if (name === 'vswhere.exe') {
|
||||
return 'discovery'
|
||||
}
|
||||
if (name === 'capture-windows-native-environment.cmd') {
|
||||
return 'capture'
|
||||
}
|
||||
if (spec.args?.some((arg) => arg.endsWith('ensure-native-runtime.mjs'))) {
|
||||
return spec.args.includes('--check-only') ? 'probe' : 'preparation'
|
||||
}
|
||||
return spec.args?.includes('rebuild') ? 'addon-build' : 'other'
|
||||
}
|
||||
|
||||
export function installNativeBenchmarkObserver(api, output, environment = process.env) {
|
||||
const original = api.runProcessSync
|
||||
const parent = environmentReceipt(environment)
|
||||
const receipt = { parentBefore: parent, parentAfter: parent, calls: [] }
|
||||
writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`)
|
||||
api.runProcessSync = (spec) => {
|
||||
const startedAt = new Date().toISOString()
|
||||
const start = performance.now()
|
||||
const childEnvironment = spec.env ?? environment
|
||||
const call = {
|
||||
phase: processPhase(spec),
|
||||
program: basename(spec.program),
|
||||
args: spec.args,
|
||||
startedAt,
|
||||
environment: environmentReceipt(childEnvironment),
|
||||
changedNames: changedEnvironmentNames(environment, childEnvironment)
|
||||
}
|
||||
try {
|
||||
const result = original(spec)
|
||||
Object.assign(call, {
|
||||
code: result.code,
|
||||
timedOut: result.timedOut,
|
||||
outputTruncated: result.outputTruncated,
|
||||
stdoutSha256: hash(result.stdout ?? ''),
|
||||
stderrSha256: hash(result.stderr ?? ''),
|
||||
stdoutBytes: Buffer.byteLength(result.stdout ?? ''),
|
||||
stderrBytes: Buffer.byteLength(result.stderr ?? '')
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
call.error = error.message
|
||||
throw error
|
||||
} finally {
|
||||
call.durationMs = performance.now() - start
|
||||
call.finishedAt = new Date().toISOString()
|
||||
receipt.calls.push(call)
|
||||
receipt.parentAfter = environmentReceipt(environment)
|
||||
assert.equal(
|
||||
receipt.parentAfter.hash,
|
||||
parent.hash,
|
||||
'Compiler setup changed its parent environment'
|
||||
)
|
||||
writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`)
|
||||
}
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
if (process.env.ORCA_NATIVE_BENCHMARK_TRACE) {
|
||||
const require = createRequire(import.meta.url)
|
||||
installNativeBenchmarkObserver(
|
||||
require('@orca/process-host'),
|
||||
process.env.ORCA_NATIVE_BENCHMARK_TRACE
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { test } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import {
|
||||
assertNativeColdProbe,
|
||||
assertNoHeaderDownloads,
|
||||
validateObservedPreparation
|
||||
} from './benchmark-windows-native-preparation.mjs'
|
||||
import {
|
||||
changedEnvironmentNames,
|
||||
environmentReceipt,
|
||||
installNativeBenchmarkObserver
|
||||
} from './windows-native-benchmark-observer.mjs'
|
||||
|
||||
const original = { Path: 'unchanged', TOKEN: 'private' }
|
||||
const parent = environmentReceipt(original)
|
||||
const captureEnvironment = { ...original, VCINSTALLDIR: 'compiler' }
|
||||
const call = (phase, code = 0, prepared = false) => ({
|
||||
phase,
|
||||
code,
|
||||
environment: prepared ? environmentReceipt(captureEnvironment) : parent,
|
||||
changedNames: prepared ? ['vcinstalldir'] : []
|
||||
})
|
||||
const receipt = (calls) => ({ parentBefore: parent, parentAfter: parent, calls })
|
||||
const candidate = () =>
|
||||
receipt([call('probe', 1), call('discovery'), call('capture'), call('preparation', 0, true)])
|
||||
|
||||
for (const [variant, warm, calls] of [
|
||||
[
|
||||
'control',
|
||||
false,
|
||||
[call('addon-build'), call('addon-build'), call('addon-build'), call('addon-build')]
|
||||
],
|
||||
['control', true, []],
|
||||
['candidate', false, candidate().calls],
|
||||
['candidate', true, [call('probe'), call('preparation')]],
|
||||
[
|
||||
'fallback',
|
||||
false,
|
||||
[call('probe', 1), call('discovery'), call('capture', 7), call('preparation')]
|
||||
],
|
||||
['fallback', true, [call('probe'), call('preparation')]]
|
||||
]) {
|
||||
test(`qualifies ${variant} ${warm ? 'warm' : 'cold'} receipts`, () =>
|
||||
validateObservedPreparation(receipt(calls), variant, warm))
|
||||
}
|
||||
|
||||
test('fails closed on an unobserved or unchanged candidate', () => {
|
||||
assert.throws(() => validateObservedPreparation(receipt([]), 'candidate'), /discovery count/)
|
||||
const missingCapture = candidate()
|
||||
missingCapture.calls.splice(2, 1)
|
||||
assert.throws(
|
||||
() => validateObservedPreparation(missingCapture, 'candidate'),
|
||||
/developer command count/
|
||||
)
|
||||
const unchanged = candidate()
|
||||
unchanged.calls[3] = call('preparation')
|
||||
assert.throws(() => validateObservedPreparation(unchanged, 'candidate'), /not applied/)
|
||||
})
|
||||
|
||||
test('rejects failed preparation and unrelated or persistent environment changes', () => {
|
||||
for (const mutate of [
|
||||
(value) => {
|
||||
value.calls[3].code = 2
|
||||
},
|
||||
(value) => {
|
||||
value.calls[3].changedNames = ['token']
|
||||
},
|
||||
(value) => {
|
||||
value.parentAfter = environmentReceipt(captureEnvironment)
|
||||
},
|
||||
(value) => {
|
||||
value.calls[2].timedOut = true
|
||||
}
|
||||
]) {
|
||||
const value = candidate()
|
||||
mutate(value)
|
||||
assert.throws(() => validateObservedPreparation(value, 'candidate'))
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
validateObservedPreparation(
|
||||
receipt([call('probe'), call('preparation', 0, true)]),
|
||||
'candidate',
|
||||
true
|
||||
),
|
||||
/changed compiler/
|
||||
)
|
||||
})
|
||||
|
||||
test('requires all four initial addon failures and excludes timed header downloads', () => {
|
||||
const result = {
|
||||
code: 1,
|
||||
stderr: [
|
||||
'node-pty',
|
||||
'@orca/windows-registry',
|
||||
'@orca/windows-security',
|
||||
'@vscode/windows-process-tree'
|
||||
]
|
||||
.map((name) => `${name}: unavailable`)
|
||||
.join('\n')
|
||||
}
|
||||
assertNativeColdProbe(result)
|
||||
assert.throws(() =>
|
||||
assertNativeColdProbe({ ...result, stderr: result.stderr.replace('node-pty:', 'missing:') })
|
||||
)
|
||||
assertNoHeaderDownloads({ stdout: '', stderr: 'gyp info find VS using VS2026' })
|
||||
assert.throws(
|
||||
() =>
|
||||
assertNoHeaderDownloads({
|
||||
stdout: '',
|
||||
stderr: 'gyp http GET https://nodejs.org/header.tar.gz'
|
||||
}),
|
||||
/downloaded/
|
||||
)
|
||||
})
|
||||
|
||||
test('uses case-insensitive environment comparisons and never publishes values', () => {
|
||||
assert.equal(
|
||||
environmentReceipt(original).hash,
|
||||
environmentReceipt({ PATH: 'unchanged', token: 'private' }).hash
|
||||
)
|
||||
assert.deepEqual(
|
||||
changedEnvironmentNames(original, {
|
||||
PATH: 'unchanged',
|
||||
TOKEN: 'private',
|
||||
VCINSTALLDIR: 'compiler'
|
||||
}),
|
||||
['vcinstalldir']
|
||||
)
|
||||
assert(!JSON.stringify(parent).includes('private'))
|
||||
assert.throws(() => environmentReceipt({ PATH: 'first', Path: 'second' }), /Duplicate/)
|
||||
})
|
||||
|
||||
test('writes an empty warm trace and observes calls without changing execution', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-native-observer-test-'))
|
||||
try {
|
||||
const output = join(directory, 'trace.json')
|
||||
const result = { code: 0, stdout: 'original stdout', stderr: '', timedOut: false }
|
||||
const seen = []
|
||||
const api = {
|
||||
runProcessSync: (spec) => {
|
||||
seen.push(spec)
|
||||
return result
|
||||
}
|
||||
}
|
||||
installNativeBenchmarkObserver(api, output, original)
|
||||
assert.deepEqual(JSON.parse(readFileSync(output, 'utf8')).calls, [])
|
||||
const spec = { program: 'vswhere.exe', args: ['-latest'], stdio: 'pipe' }
|
||||
assert.equal(api.runProcessSync(spec), result)
|
||||
assert.deepEqual(seen, [spec])
|
||||
const value = JSON.parse(readFileSync(output, 'utf8'))
|
||||
assert.equal(value.calls[0].phase, 'discovery')
|
||||
assert.equal(value.calls[0].stdoutBytes, result.stdout.length)
|
||||
assert.equal(value.parentBefore.hash, value.parentAfter.hash)
|
||||
assert(!readFileSync(output, 'utf8').includes('original stdout'))
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('preload observes the CJS process-host export imported by production ESM code', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-native-observer-esm-test-'))
|
||||
try {
|
||||
const trace = join(directory, 'trace.json')
|
||||
const program = `import { runProcessSync } from '@orca/process-host'; const value = runProcessSync({program:process.execPath,args:['-e','process.stdout.write("proof")']}); if(value.stdout !== 'proof') process.exit(2);`
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
'--import',
|
||||
pathToFileURL(join(import.meta.dirname, 'windows-native-benchmark-observer.mjs')).href,
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
program
|
||||
],
|
||||
cwd: join(import.meta.dirname, '../..'),
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_NATIVE_BENCHMARK_TRACE: trace }
|
||||
})
|
||||
assert.equal(result.code, 0, result.stderr)
|
||||
const value = JSON.parse(readFileSync(trace, 'utf8'))
|
||||
assert.equal(value.calls.length, 1)
|
||||
assert.equal(value.calls[0].code, 0)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('manual workflow freezes source and limits Windows occupancy without changing a PR gate', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(
|
||||
join(import.meta.dirname, '../../.github/workflows/windows-native-preparation-benchmark.yml'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
assert.deepEqual(Object.keys(workflow.on), ['workflow_dispatch'])
|
||||
assert.equal(workflow.jobs.compare.strategy['max-parallel'], 2)
|
||||
assert.deepEqual(workflow.jobs.compare.strategy.matrix.os, ['windows-2022', 'windows-11-arm'])
|
||||
assert.equal(workflow.jobs.compare.steps[0].with.ref, '${{ inputs.source_sha }}')
|
||||
const install = workflow.jobs.compare.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
assert.equal(install.with['native-runtime'], 'none')
|
||||
assert.equal(install.with['node-version'], '24.21.0')
|
||||
assert.equal(install.with['cache-pnpm-store'], 'false')
|
||||
assert.equal(workflow.jobs.compare.steps.at(-1).if, 'always()')
|
||||
assert(!Object.hasOwn(workflow.jobs, 'verify'))
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { classifyPrJobs } from './pr-code-change-scope.mjs'
|
||||
import { classifyNodeServerChanges } from './node-server-change-scope.mjs'
|
||||
import { nodeServerQualification } from './node-server-qualification.mjs'
|
||||
|
||||
const toolchainSources = [
|
||||
'config/scripts/initialize-windows-native-toolchain.mjs',
|
||||
'config/scripts/capture-windows-native-environment.cmd'
|
||||
]
|
||||
|
||||
describe('Windows native toolchain CI routing', () => {
|
||||
it.each(toolchainSources)(
|
||||
'prepares native caches and packages for a change to %s alone',
|
||||
(file) => {
|
||||
expect(classifyPrJobs([file])).toMatchObject({
|
||||
should_run: true,
|
||||
native_cache_changed: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(toolchainSources)(
|
||||
'selects both Windows server runners for a change to %s alone',
|
||||
async (file) => {
|
||||
const collect = vi.fn(async () => new Set())
|
||||
for (const options of [
|
||||
{},
|
||||
{ deferGraph: true },
|
||||
{ modifiedFiles: [file] },
|
||||
{ deferGraph: true, modifiedFiles: [file] }
|
||||
]) {
|
||||
const scope = await classifyNodeServerChanges([file], collect, options)
|
||||
expect(scope).toEqual({ shouldRun: true, reason: `Build or CI input changed: ${file}` })
|
||||
expect(nodeServerQualification([file], scope)).toEqual({
|
||||
qualification: false,
|
||||
runners: ['ubuntu-22.04', 'windows-2022', 'windows-11-arm']
|
||||
})
|
||||
}
|
||||
expect(collect).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves unrelated build-script routing', async () => {
|
||||
const file = 'config/scripts/unrelated-toolchain.mjs'
|
||||
expect(classifyPrJobs([file])).toMatchObject({
|
||||
test: true,
|
||||
native_cache_changed: false,
|
||||
package: false,
|
||||
package_windows: false
|
||||
})
|
||||
expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user