From cb48cae29f06991d024a951085460fc8473753b7 Mon Sep 17 00:00:00 2001 From: Roger Qiu Date: Sun, 15 Feb 2026 15:19:59 +0000 Subject: [PATCH 1/5] ci: attempt to robustly get node v20 on windows CI with npm support this time --- scripts/choco-install.ps1 | 251 ++++++++++++++++++-------------------- 1 file changed, 120 insertions(+), 131 deletions(-) diff --git a/scripts/choco-install.ps1 b/scripts/choco-install.ps1 index e28d6b0..233a48c 100755 --- a/scripts/choco-install.ps1 +++ b/scripts/choco-install.ps1 @@ -1,122 +1,91 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -# ---- Config ---- -$NodeMajor = 20 +# Make native command failures behave like errors in PS7+ (prevents "choco failed but we continued" style issues) +if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -Scope Global -ErrorAction SilentlyContinue) { + $global:PSNativeCommandUseErrorActionPreference = $true +} + +# --------------------------- +# Config +# --------------------------- +$NodeMajor = if ($env:NODE_MAJOR) { [int]$env:NODE_MAJOR } else { 20 } $Arch = "x64" -# Keep using your repo tmp area so it's easy to cache between runs -$CacheRoot = Join-Path $PSScriptRoot "..\tmp\chocolatey" -$NodeCache = Join-Path $CacheRoot "node" +# Cache under repo tmp (so your CI cache can persist it) +$CacheRoot = Join-Path $PSScriptRoot "..\tmp" +$NodeCache = Join-Path $CacheRoot "node-cache" New-Item -Path $NodeCache -ItemType Directory -Force | Out-Null -# (Optional) keep your original Chocolatey cache source spirit (harmless if you later add more choco pkgs) +# Keep the spirit of the original: ensure choco cache dir exists + source (harmless even if unused here) if ($env:ChocolateyInstall) { - New-Item -Path $CacheRoot -ItemType Directory -Force | Out-Null + $ChocoCache = Join-Path $CacheRoot "chocolatey" + New-Item -Path $ChocoCache -ItemType Directory -Force | Out-Null & choco source remove --name="cache" -y --no-progress 2>$null | Out-Null - & choco source add --name="cache" --source="$CacheRoot" --priority=1 -y --no-progress | Out-Null -} - -# ---- Helpers ---- -function Get-NodeVersionString { - try { return (& node -v).Trim() } catch { return $null } + & choco source add --name="cache" --source="$ChocoCache" --priority=1 -y --no-progress | Out-Null } +# --------------------------- +# Helpers +# --------------------------- function Parse-Version([string] $v) { if (-not $v) { return $null } $vv = $v.Trim().TrimStart('v') try { return [version]$vv } catch { return $null } } -function Prepend-Path([string] $dir) { - $env:Path = "$dir;$env:Path" - if ($env:GITHUB_PATH) { Add-Content -LiteralPath $env:GITHUB_PATH -Value $dir } -} - -function Patch-RefreshEnvToKeepNodeFirst([string] $nodeBinDir) { - # Your workflow calls refreshenv AFTER this script. - # Wrap it so our chosen node stays first. - $cmd = Get-Command refreshenv -ErrorAction SilentlyContinue - if (-not $cmd) { return } - if ($cmd.CommandType -ne 'Function') { return } - - $global:__ORIG_REFRESHENV = $cmd.ScriptBlock - $global:__NODE_BIN_DIR = $nodeBinDir - - function global:refreshenv { - if ($global:__ORIG_REFRESHENV) { & $global:__ORIG_REFRESHENV } - if ($global:__NODE_BIN_DIR) { $env:Path = "$($global:__NODE_BIN_DIR);$env:Path" } - } +function Get-CommandPath([string] $name) { + try { return (Get-Command $name -ErrorAction Stop).Source } catch { return $null } } -function Find-ToolcacheNodeBin { - param([Parameter(Mandatory)][int] $Major, [ValidateSet('x64','x86')][string] $Arch = 'x64') - +function Find-ToolcacheNodeHome([int] $Major, [string] $Arch) { $toolcache = $env:RUNNER_TOOL_CACHE if (-not $toolcache -or -not (Test-Path -LiteralPath $toolcache)) { - # common GH windows hosted path; best-effort fallback $toolcache = "C:\hostedtoolcache\windows" } + $root = Join-Path $toolcache "node" + if (-not (Test-Path -LiteralPath $root)) { return $null } - $nodeRoot = Join-Path $toolcache "node" - if (-not (Test-Path -LiteralPath $nodeRoot)) { return $null } - - $best = Get-ChildItem -LiteralPath $nodeRoot -Directory -ErrorAction SilentlyContinue | + $best = Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "$Major.*" } | ForEach-Object { $ver = Parse-Version $_.Name - if ($ver) { [pscustomobject]@{ Dir = $_.FullName; Ver = $ver } } + if ($ver) { [pscustomobject]@{ Ver = $ver; Dir = $_.FullName } } } | Sort-Object Ver -Descending | Select-Object -First 1 if (-not $best) { return $null } - $bin = Join-Path $best.Dir $Arch - if (Test-Path -LiteralPath (Join-Path $bin "node.exe")) { return $bin } + $home = Join-Path $best.Dir $Arch + if (Test-Path -LiteralPath (Join-Path $home "node.exe")) { return $home } return $null } -function Get-LatestNodeMajorFromIndex { - param([Parameter(Mandatory)][int] $Major) - - $indexUrl = "https://nodejs.org/dist/index.json" +function Get-LatestNodeMajorVersion([int] $Major) { $ProgressPreference = 'SilentlyContinue' - - # best-effort TLS nudge - try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} - - $index = Invoke-RestMethod -Uri $indexUrl -MaximumRedirection 5 - if (-not $index) { throw "Failed to fetch Node dist index." } + $index = Invoke-RestMethod -Uri "https://nodejs.org/dist/index.json" -MaximumRedirection 5 $best = $index | Where-Object { $_.version -match "^v$Major\." } | ForEach-Object { $ver = Parse-Version $_.version - if ($ver) { [pscustomobject]@{ Ver = $ver; VersionStr = $_.version } } + if ($ver) { [pscustomobject]@{ Ver = $ver; S = $_.version } } } | Sort-Object Ver -Descending | Select-Object -First 1 - if (-not $best) { throw "Could not find Node v$Major.* in dist index." } + if (-not $best) { throw "No Node v$Major.* found in dist index." } return $best.Ver.ToString() } -function Ensure-NodeZip { - param( - [Parameter(Mandatory)][string] $Version, - [Parameter(Mandatory)][string] $CacheDir, - [ValidateSet('x64','x86')][string] $Arch = 'x64' - ) - - New-Item -Path $CacheDir -ItemType Directory -Force | Out-Null - +function Ensure-PortableNodeHome([string] $Version, [string] $Arch, [string] $CacheDir) { $zipName = "node-v$Version-win-$Arch.zip" $zipPath = Join-Path $CacheDir $zipName - $dirPath = Join-Path $CacheDir "node-v$Version-win-$Arch" - $exePath = Join-Path $dirPath "node.exe" + $homeDir = Join-Path $CacheDir "node-v$Version-win-$Arch" + $nodeExe = Join-Path $homeDir "node.exe" - if (Test-Path -LiteralPath $exePath) { return $dirPath } + if (Test-Path -LiteralPath $nodeExe) { return $homeDir } $base = "https://nodejs.org/dist/v$Version" $zipUrl = "$base/$zipName" @@ -124,107 +93,127 @@ function Ensure-NodeZip { $shaPath = Join-Path $CacheDir "SHASUMS256-v$Version.txt" if (-not (Test-Path -LiteralPath $zipPath -PathType Leaf)) { - Write-Host "Downloading Node $Version ($zipName) ..." + Write-Host "Downloading Node $Version ($zipName)" $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -MaximumRedirection 5 } - if (-not (Test-Path -LiteralPath $shaPath -PathType Leaf)) { Invoke-WebRequest -Uri $shaUrl -OutFile $shaPath -MaximumRedirection 5 } - # Verify SHA256 (robust against bad caches / partial downloads) - $expected = (Select-String -LiteralPath $shaPath -Pattern [regex]::Escape($zipName) | Select-Object -First 1).Line - if (-not $expected) { throw "SHA file missing entry for $zipName" } - $expectedHash = ($expected -split '\s+')[0].Trim().ToLowerInvariant() - - $actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actualHash -ne $expectedHash) { + # Integrity check (hash from SHASUMS256.txt) + $line = (Select-String -LiteralPath $shaPath -Pattern ([regex]::Escape($zipName)) | Select-Object -First 1).Line + if (-not $line) { throw "No hash entry for $zipName in SHASUMS256.txt" } + $expected = ($line -split '\s+')[0].Trim().ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue - throw "SHA256 mismatch for $zipName (expected $expectedHash, got $actualHash)" + throw "SHA256 mismatch for $zipName (expected $expected got $actual)" } - Write-Host "Extracting Node $Version ..." + Write-Host "Extracting Node $Version" Expand-Archive -LiteralPath $zipPath -DestinationPath $CacheDir -Force - if (-not (Test-Path -LiteralPath $exePath)) { - throw "Node exe not found after extraction: $exePath" + if (-not (Test-Path -LiteralPath $nodeExe)) { + throw "node.exe missing after extraction: $nodeExe" } - - return $dirPath + return $homeDir } -function Find-BestCachedNodeDir { - param([Parameter(Mandatory)][string] $CacheDir, [Parameter(Mandatory)][int] $Major, [string] $Arch = 'x64') - - $dirs = Get-ChildItem -LiteralPath $CacheDir -Directory -ErrorAction SilentlyContinue | +function Find-BestCachedPortableNodeHome([int] $Major, [string] $Arch, [string] $CacheDir) { + $best = Get-ChildItem -LiteralPath $CacheDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "node-v$Major.*-win-$Arch" } | ForEach-Object { if ($_.Name -match "^node-v(\d+\.\d+\.\d+)-win-$Arch$") { $ver = Parse-Version $Matches[1] - if ($ver -and (Test-Path -LiteralPath (Join-Path $_.FullName "node.exe"))) { - [pscustomobject]@{ Dir = $_.FullName; Ver = $ver } - } + $exe = Join-Path $_.FullName "node.exe" + if ($ver -and (Test-Path -LiteralPath $exe)) { [pscustomobject]@{ Ver = $ver; Dir = $_.FullName } } } } | Sort-Object Ver -Descending | Select-Object -First 1 - if ($dirs) { return $dirs.Dir } + if ($best) { return $best.Dir } return $null } -# ---- Main selection logic ---- +function Install-NodeShims([string] $NodeHome) { + $nodeExe = Join-Path $NodeHome "node.exe" + $npmCmd = Join-Path $NodeHome "npm.cmd" + $npxCmd = Join-Path $NodeHome "npx.cmd" + + if (-not (Test-Path -LiteralPath $nodeExe)) { throw "Missing $nodeExe" } + if (-not (Test-Path -LiteralPath $npmCmd)) { throw "Missing $npmCmd" } -# 0) If already Node 20 on PATH, keep it (simple + fastest) -$currentStr = Get-NodeVersionString -$currentVer = Parse-Version $currentStr -if ($currentVer -and $currentVer.Major -eq $NodeMajor) { - Write-Host "Node already on PATH: $currentStr" - exit 0 + # Shims make PATH irrelevant (survives refreshenv + system PATH ordering) + function global:node { & $using:nodeExe @args; exit $LASTEXITCODE } + function global:npm { & $using:npmCmd @args; exit $LASTEXITCODE } + if (Test-Path -LiteralPath $npxCmd) { + function global:npx { & $using:npxCmd @args; exit $LASTEXITCODE } + } + + # Also prepend PATH (useful for child processes launched by other tooling) + $env:Path = "$NodeHome;$env:Path" + if ($env:GITHUB_PATH) { Add-Content -LiteralPath $env:GITHUB_PATH -Value $NodeHome } + + Write-Host "Pinned Node home: $NodeHome" } -# 1) Prefer GH toolcache Node 20 if available -$toolBin = Find-ToolcacheNodeBin -Major $NodeMajor -Arch $Arch -if ($toolBin) { - Write-Host "Selecting Node from toolcache: $toolBin" - Prepend-Path $toolBin - Patch-RefreshEnvToKeepNodeFirst $toolBin +# --------------------------- +# Select Node home +# --------------------------- +$selectedHome = $null - $v = Parse-Version (Get-NodeVersionString) +# 0) If node already exists and is correct major AND has npm next to it, reuse it +$nodePath = Get-CommandPath "node" +if ($nodePath) { + $v = Parse-Version (& node -v) if ($v -and $v.Major -eq $NodeMajor) { - Write-Host "Using Node: $(& node -v)" - exit 0 + $home = Split-Path -Parent $nodePath + if (Test-Path -LiteralPath (Join-Path $home "npm.cmd")) { + $selectedHome = $home + Write-Host "Using existing Node v$($v.ToString()) from $selectedHome" + } } - throw "Toolcache selection failed: node is not v$NodeMajor after PATH prepend." } -# 2) If no toolcache, attempt to download latest Node 20.x (portable zip) -$selectedDir = $null -$onlineVersion = $null -try { - $onlineVersion = Get-LatestNodeMajorFromIndex -Major $NodeMajor - $selectedDir = Ensure-NodeZip -Version $onlineVersion -CacheDir $NodeCache -Arch $Arch - Write-Host "Selecting Node from downloaded zip: $selectedDir" -} catch { - Write-Host "Online fetch/download failed: $($_.Exception.Message)" - Write-Host "Falling back to cached Node if available..." - $selectedDir = Find-BestCachedNodeDir -CacheDir $NodeCache -Major $NodeMajor -Arch $Arch - if (-not $selectedDir) { - throw "No toolcache Node v$NodeMajor found, and no cached Node v$NodeMajor zip available." +# 1) Prefer GH toolcache highest patch for the major +if (-not $selectedHome) { + $tcHome = Find-ToolcacheNodeHome -Major $NodeMajor -Arch $Arch + if ($tcHome) { + $selectedHome = $tcHome + Write-Host "Selecting Node from toolcache: $selectedHome" } - Write-Host "Selecting Node from cache: $selectedDir" } -Prepend-Path $selectedDir -Patch-RefreshEnvToKeepNodeFirst $selectedDir - -# 3) Hard verify (never silently run tests on Node 22 again) -$afterStr = Get-NodeVersionString -$afterVer = Parse-Version $afterStr -if (-not $afterVer -or $afterVer.Major -ne $NodeMajor) { - $which = (Get-Command node -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue) - throw "Expected Node v$NodeMajor.*, got '$afterStr' (node at: $which)" +# 2) Portable fallback (cache -> download latest) +if (-not $selectedHome) { + $cached = Find-BestCachedPortableNodeHome -Major $NodeMajor -Arch $Arch -CacheDir $NodeCache + if ($cached) { + $selectedHome = $cached + Write-Host "Selecting Node from cached portable zip: $selectedHome" + } else { + $allowDownload = ($env:ALLOW_NODE_DOWNLOAD -ne "0") + if (-not $allowDownload) { + throw "No toolcache Node v$NodeMajor found and ALLOW_NODE_DOWNLOAD=0. Failing." + } + $latest = Get-LatestNodeMajorVersion -Major $NodeMajor + $selectedHome = Ensure-PortableNodeHome -Version $latest -Arch $Arch -CacheDir $NodeCache + Write-Host "Selecting Node from downloaded portable zip: $selectedHome" + } } -Write-Host "Using Node: $afterStr" +# Pin node/npm regardless of refreshenv/PATH +Install-NodeShims -NodeHome $selectedHome + +# Final verification: ensure npm is actually running under Node major +$nodeV = & node -v +Write-Host "Using Node: $nodeV" +# This is the key proof: npm is backed by the same Node home +$npmNodeV = & npm exec --yes node -v +Write-Host "npm-backed Node: $npmNodeV" + +$nv = Parse-Version $nodeV +$nnv = Parse-Version $npmNodeV +if (-not $nv -or $nv.Major -ne $NodeMajor) { throw "node is not v$NodeMajor (got $nodeV)" } +if (-not $nnv -or $nnv.Major -ne $NodeMajor) { throw "npm is not backed by v$NodeMajor (got $npmNodeV)" } From abf8249eb26d66277d2b1d8fe0eadca33c3aef85 Mon Sep 17 00:00:00 2001 From: Roger Qiu Date: Mon, 16 Feb 2026 07:38:35 +0000 Subject: [PATCH 2/5] ci: attempt using new reusable workflows with fixed windows runners --- scripts/choco-install.ps1 | 188 ++++++++++++++++++++------------------ 1 file changed, 98 insertions(+), 90 deletions(-) diff --git a/scripts/choco-install.ps1 b/scripts/choco-install.ps1 index 233a48c..76f09f0 100755 --- a/scripts/choco-install.ps1 +++ b/scripts/choco-install.ps1 @@ -9,7 +9,7 @@ if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -Scope Global -Er # --------------------------- # Config # --------------------------- -$NodeMajor = if ($env:NODE_MAJOR) { [int]$env:NODE_MAJOR } else { 20 } +$NodeMajor = 20 $Arch = "x64" # Cache under repo tmp (so your CI cache can persist it) @@ -17,12 +17,10 @@ $CacheRoot = Join-Path $PSScriptRoot "..\tmp" $NodeCache = Join-Path $CacheRoot "node-cache" New-Item -Path $NodeCache -ItemType Directory -Force | Out-Null -# Keep the spirit of the original: ensure choco cache dir exists + source (harmless even if unused here) -if ($env:ChocolateyInstall) { - $ChocoCache = Join-Path $CacheRoot "chocolatey" - New-Item -Path $ChocoCache -ItemType Directory -Force | Out-Null - & choco source remove --name="cache" -y --no-progress 2>$null | Out-Null - & choco source add --name="cache" --source="$ChocoCache" --priority=1 -y --no-progress | Out-Null +# Optional: semicolon-separated list of choco packages to install, e.g. "git;7zip;cmake" +$ChocoPackages = @() +if ($env:CHOCO_PACKAGES) { + $ChocoPackages = $env:CHOCO_PACKAGES.Split(';') | ForEach-Object { $_.Trim() } | Where-Object { $_ } } # --------------------------- @@ -38,16 +36,17 @@ function Get-CommandPath([string] $name) { try { return (Get-Command $name -ErrorAction Stop).Source } catch { return $null } } -function Find-ToolcacheNodeHome([int] $Major, [string] $Arch) { +function Find-ToolcacheNodeBin([int] $major, [string] $arch) { $toolcache = $env:RUNNER_TOOL_CACHE if (-not $toolcache -or -not (Test-Path -LiteralPath $toolcache)) { $toolcache = "C:\hostedtoolcache\windows" } - $root = Join-Path $toolcache "node" - if (-not (Test-Path -LiteralPath $root)) { return $null } - $best = Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like "$Major.*" } | + $nodeRoot = Join-Path $toolcache "node" + if (-not (Test-Path -LiteralPath $nodeRoot)) { return $null } + + $best = Get-ChildItem -LiteralPath $nodeRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "$major.*" } | ForEach-Object { $ver = Parse-Version $_.Name if ($ver) { [pscustomobject]@{ Ver = $ver; Dir = $_.FullName } } @@ -57,8 +56,10 @@ function Find-ToolcacheNodeHome([int] $Major, [string] $Arch) { if (-not $best) { return $null } - $home = Join-Path $best.Dir $Arch - if (Test-Path -LiteralPath (Join-Path $home "node.exe")) { return $home } + $bin = Join-Path $best.Dir $arch + if (Test-Path -LiteralPath (Join-Path $bin "node.exe") -and Test-Path -LiteralPath (Join-Path $bin "npm.cmd")) { + return $bin + } return $null } @@ -79,55 +80,55 @@ function Get-LatestNodeMajorVersion([int] $Major) { return $best.Ver.ToString() } -function Ensure-PortableNodeHome([string] $Version, [string] $Arch, [string] $CacheDir) { - $zipName = "node-v$Version-win-$Arch.zip" - $zipPath = Join-Path $CacheDir $zipName - $homeDir = Join-Path $CacheDir "node-v$Version-win-$Arch" - $nodeExe = Join-Path $homeDir "node.exe" +function Ensure-PortableNode([string] $version, [string] $arch, [string] $cacheDir) { + $zipName = "node-v$version-win-$arch.zip" + $zipPath = Join-Path $cacheDir $zipName + $dirPath = Join-Path $cacheDir "node-v$version-win-$arch" + $exePath = Join-Path $dirPath "node.exe" + $npmPath = Join-Path $dirPath "npm.cmd" - if (Test-Path -LiteralPath $nodeExe) { return $homeDir } + if (Test-Path -LiteralPath $exePath -and Test-Path -LiteralPath $npmPath) { return $dirPath } - $base = "https://nodejs.org/dist/v$Version" + $base = "https://nodejs.org/dist/v$version" $zipUrl = "$base/$zipName" $shaUrl = "$base/SHASUMS256.txt" - $shaPath = Join-Path $CacheDir "SHASUMS256-v$Version.txt" + $shaPath = Join-Path $cacheDir "SHASUMS256-v$version.txt" if (-not (Test-Path -LiteralPath $zipPath -PathType Leaf)) { - Write-Host "Downloading Node $Version ($zipName)" - $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -MaximumRedirection 5 } if (-not (Test-Path -LiteralPath $shaPath -PathType Leaf)) { Invoke-WebRequest -Uri $shaUrl -OutFile $shaPath -MaximumRedirection 5 } - # Integrity check (hash from SHASUMS256.txt) $line = (Select-String -LiteralPath $shaPath -Pattern ([regex]::Escape($zipName)) | Select-Object -First 1).Line if (-not $line) { throw "No hash entry for $zipName in SHASUMS256.txt" } $expected = ($line -split '\s+')[0].Trim().ToLowerInvariant() $actual = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $expected) { Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue - throw "SHA256 mismatch for $zipName (expected $expected got $actual)" + throw "SHA256 mismatch for $zipName" } - Write-Host "Extracting Node $Version" - Expand-Archive -LiteralPath $zipPath -DestinationPath $CacheDir -Force + Expand-Archive -LiteralPath $zipPath -DestinationPath $cacheDir -Force - if (-not (Test-Path -LiteralPath $nodeExe)) { - throw "node.exe missing after extraction: $nodeExe" + if (-not (Test-Path -LiteralPath $exePath -and Test-Path -LiteralPath $npmPath)) { + throw "Portable Node extraction missing node/npm: $dirPath" } - return $homeDir + return $dirPath } -function Find-BestCachedPortableNodeHome([int] $Major, [string] $Arch, [string] $CacheDir) { - $best = Get-ChildItem -LiteralPath $CacheDir -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like "node-v$Major.*-win-$Arch" } | +function Find-BestCachedPortableNodeBin([int] $major, [string] $arch, [string] $cacheDir) { + $best = Get-ChildItem -LiteralPath $cacheDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "node-v$major.*-win-$arch" } | ForEach-Object { - if ($_.Name -match "^node-v(\d+\.\d+\.\d+)-win-$Arch$") { + if ($_.Name -match "^node-v(\d+\.\d+\.\d+)-win-$arch$") { $ver = Parse-Version $Matches[1] $exe = Join-Path $_.FullName "node.exe" - if ($ver -and (Test-Path -LiteralPath $exe)) { [pscustomobject]@{ Ver = $ver; Dir = $_.FullName } } + $npm = Join-Path $_.FullName "npm.cmd" + if ($ver -and (Test-Path -LiteralPath $exe) -and (Test-Path -LiteralPath $npm)) { + [pscustomobject]@{ Ver = $ver; Dir = $_.FullName } + } } } | Sort-Object Ver -Descending | @@ -137,80 +138,87 @@ function Find-BestCachedPortableNodeHome([int] $Major, [string] $Arch, [string] return $null } -function Install-NodeShims([string] $NodeHome) { - $nodeExe = Join-Path $NodeHome "node.exe" - $npmCmd = Join-Path $NodeHome "npm.cmd" - $npxCmd = Join-Path $NodeHome "npx.cmd" +function Add-ToGitHubPath([string] $dir) { + if ($env:GITHUB_PATH) { + $dir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + } + $env:Path = "$dir;$env:Path" +} - if (-not (Test-Path -LiteralPath $nodeExe)) { throw "Missing $nodeExe" } - if (-not (Test-Path -LiteralPath $npmCmd)) { throw "Missing $npmCmd" } +# --------------------------- +# 1) Optional choco installs +# --------------------------- +if ($ChocoPackages.Count -gt 0) { + if (-not $env:ChocolateyInstall) { throw "CHOCO_PACKAGES set but Chocolatey not available." } - # Shims make PATH irrelevant (survives refreshenv + system PATH ordering) - function global:node { & $using:nodeExe @args; exit $LASTEXITCODE } - function global:npm { & $using:npmCmd @args; exit $LASTEXITCODE } - if (Test-Path -LiteralPath $npxCmd) { - function global:npx { & $using:npxCmd @args; exit $LASTEXITCODE } + Write-Host "Installing choco packages: $($ChocoPackages -join ', ')" + foreach ($p in $ChocoPackages) { + & choco install $p -y --no-progress + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - # Also prepend PATH (useful for child processes launched by other tooling) - $env:Path = "$NodeHome;$env:Path" - if ($env:GITHUB_PATH) { Add-Content -LiteralPath $env:GITHUB_PATH -Value $NodeHome } - - Write-Host "Pinned Node home: $NodeHome" + # Pull registry env var changes from those installs into this session + if ($env:ChocolateyInstall) { + $profile = Join-Path $env:ChocolateyInstall "helpers\chocolateyProfile.psm1" + if (Test-Path -LiteralPath $profile) { + Import-Module $profile -Force -ErrorAction SilentlyContinue | Out-Null + } + } + $cmd = Get-Command Update-SessionEnvironment -ErrorAction SilentlyContinue + if ($cmd) { Update-SessionEnvironment } } # --------------------------- -# Select Node home +# 2) Select Node (paired node/npm) # --------------------------- -$selectedHome = $null - -# 0) If node already exists and is correct major AND has npm next to it, reuse it -$nodePath = Get-CommandPath "node" -if ($nodePath) { - $v = Parse-Version (& node -v) - if ($v -and $v.Major -eq $NodeMajor) { - $home = Split-Path -Parent $nodePath - if (Test-Path -LiteralPath (Join-Path $home "npm.cmd")) { - $selectedHome = $home - Write-Host "Using existing Node v$($v.ToString()) from $selectedHome" - } +$selected = $null + +# If existing node is already correct major AND npm is present, keep it +try { + $nodeExe = (Get-Command node -ErrorAction Stop).Source + $existingBin = Split-Path -Parent $nodeExe + $v = Parse-Version ((& node -v).Trim()) + if ($v -and $v.Major -eq $NodeMajor -and (Test-Path -LiteralPath (Join-Path $existingBin "npm.cmd"))) { + $selected = $existingBin + Write-Host "Using existing Node v$($v.ToString()) from $selected" + } +} catch {} + +# Prefer toolcache +if (-not $selected) { + $bin = Find-ToolcacheNodeBin -major $NodeMajor -arch $Arch + if ($bin) { + $selected = $bin + Write-Host "Selecting Node from toolcache: $selected" } } -# 1) Prefer GH toolcache highest patch for the major -if (-not $selectedHome) { - $tcHome = Find-ToolcacheNodeHome -Major $NodeMajor -Arch $Arch - if ($tcHome) { - $selectedHome = $tcHome - Write-Host "Selecting Node from toolcache: $selectedHome" +# Cached portable +if (-not $selected) { + $cached = Find-BestCachedPortableNodeBin -major $NodeMajor -arch $Arch -cacheDir $NodeCache + if ($cached) { + $selected = $cached + Write-Host "Selecting Node from cached portable zip: $selected" } } -# 2) Portable fallback (cache -> download latest) -if (-not $selectedHome) { - $cached = Find-BestCachedPortableNodeHome -Major $NodeMajor -Arch $Arch -CacheDir $NodeCache - if ($cached) { - $selectedHome = $cached - Write-Host "Selecting Node from cached portable zip: $selectedHome" - } else { - $allowDownload = ($env:ALLOW_NODE_DOWNLOAD -ne "0") - if (-not $allowDownload) { - throw "No toolcache Node v$NodeMajor found and ALLOW_NODE_DOWNLOAD=0. Failing." - } - $latest = Get-LatestNodeMajorVersion -Major $NodeMajor - $selectedHome = Ensure-PortableNodeHome -Version $latest -Arch $Arch -CacheDir $NodeCache - Write-Host "Selecting Node from downloaded portable zip: $selectedHome" +# Optional download +if (-not $selected) { + if ($env:ALLOW_NODE_DOWNLOAD -eq "0") { + throw "No Node $NodeMajor.x found in toolcache/cache and ALLOW_NODE_DOWNLOAD=0" } + $latest = Get-LatestNodeMajorVersion -Major $NodeMajor + $selected = Ensure-PortableNode -version $latest -arch $Arch -cacheDir $NodeCache + Write-Host "Selecting Node from downloaded portable zip: $selected" } -# Pin node/npm regardless of refreshenv/PATH -Install-NodeShims -NodeHome $selectedHome +# Apply selection to PATH and GITHUB_PATH (next steps) and current step +Add-ToGitHubPath $selected # Final verification: ensure npm is actually running under Node major -$nodeV = & node -v +$nodeV = (& node -v).Trim() +$npmNodeV = (& npm exec --yes node -v).Trim() Write-Host "Using Node: $nodeV" -# This is the key proof: npm is backed by the same Node home -$npmNodeV = & npm exec --yes node -v Write-Host "npm-backed Node: $npmNodeV" $nv = Parse-Version $nodeV From bc0a6e5d95cf2d2028d1184cca9eff8885dd1272 Mon Sep 17 00:00:00 2001 From: Roger Qiu Date: Mon, 16 Feb 2026 07:52:26 +0000 Subject: [PATCH 3/5] =?UTF-8?q?ci:=20fixed=20npm=20exec=20verification=20t?= =?UTF-8?q?o=20include=20the=20terminator=20--=20so=20npm=20v10=20doesn?= =?UTF-8?q?=E2=80=99t=20treat=20the=20command=20as=20an=20option:=20update?= =?UTF-8?q?d=20in=20js-lint=20script=20and=20all=20shared=20workflows.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/staging.yml | 1 - scripts/choco-install.ps1 | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 84413de..61843ee 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -24,4 +24,3 @@ jobs: GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }} GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }} GIT_COMMITTER_NAME: ${{ secrets.GIT_COMMITTER_NAME }} - diff --git a/scripts/choco-install.ps1 b/scripts/choco-install.ps1 index 76f09f0..b51bd67 100755 --- a/scripts/choco-install.ps1 +++ b/scripts/choco-install.ps1 @@ -217,7 +217,7 @@ Add-ToGitHubPath $selected # Final verification: ensure npm is actually running under Node major $nodeV = (& node -v).Trim() -$npmNodeV = (& npm exec --yes node -v).Trim() +$npmNodeV = (& npm exec --yes -- node -v).Trim() Write-Host "Using Node: $nodeV" Write-Host "npm-backed Node: $npmNodeV" From 321b97409e0b971a9a1691aab3c3a933a900c51f Mon Sep 17 00:00:00 2001 From: Roger Qiu Date: Mon, 16 Feb 2026 08:25:01 +0000 Subject: [PATCH 4/5] =?UTF-8?q?ci:=20after=20selecting=20Node=20(toolcache?= =?UTF-8?q?=20=E2=86=92=20portable=20=E2=86=92=20download,=20no=20reuse=20?= =?UTF-8?q?of=20Program=20Files),=20it=20reads=20npm=20version=20--json,?= =?UTF-8?q?=20extracts=20the=20node=20field,=20and=20fails=20if=20either?= =?UTF-8?q?=20node=20-v=20or=20the=20npm=20runtime=20node=20is=20not=20maj?= =?UTF-8?q?or=2020.=20It=20still=20enforces=20node/npm=20colocation=20(sam?= =?UTF-8?q?e=20directory)=20and=20logs=20node=20path,=20npm=20path,=20node?= =?UTF-8?q?=20-v,=20npm=20-v,=20and=20the=20npm=20runtime=20node.=20This?= =?UTF-8?q?=20removes=20the=20user-agent=20heuristic.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/choco-install.ps1 | 46 ++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/scripts/choco-install.ps1 b/scripts/choco-install.ps1 index b51bd67..ebdd1df 100755 --- a/scripts/choco-install.ps1 +++ b/scripts/choco-install.ps1 @@ -12,6 +12,9 @@ if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -Scope Global -Er $NodeMajor = 20 $Arch = "x64" +# Allow selecting an existing Node only if explicitly permitted; default is to prefer toolcache/portable/download +$AllowExistingNode = $false + # Cache under repo tmp (so your CI cache can persist it) $CacheRoot = Join-Path $PSScriptRoot "..\tmp" $NodeCache = Join-Path $CacheRoot "node-cache" @@ -173,16 +176,18 @@ if ($ChocoPackages.Count -gt 0) { # --------------------------- $selected = $null -# If existing node is already correct major AND npm is present, keep it -try { - $nodeExe = (Get-Command node -ErrorAction Stop).Source - $existingBin = Split-Path -Parent $nodeExe - $v = Parse-Version ((& node -v).Trim()) - if ($v -and $v.Major -eq $NodeMajor -and (Test-Path -LiteralPath (Join-Path $existingBin "npm.cmd"))) { - $selected = $existingBin - Write-Host "Using existing Node v$($v.ToString()) from $selected" - } -} catch {} +# If explicitly allowed, reuse existing node only if it matches major and has npm colocated +if ($AllowExistingNode) { + try { + $nodeExe = (Get-Command node -ErrorAction Stop).Source + $existingBin = Split-Path -Parent $nodeExe + $v = Parse-Version ((& node -v).Trim()) + if ($v -and $v.Major -eq $NodeMajor -and (Test-Path -LiteralPath (Join-Path $existingBin "npm.cmd"))) { + $selected = $existingBin + Write-Host "Using existing Node v$($v.ToString()) from $selected" + } + } catch {} +} # Prefer toolcache if (-not $selected) { @@ -215,13 +220,24 @@ if (-not $selected) { # Apply selection to PATH and GITHUB_PATH (next steps) and current step Add-ToGitHubPath $selected -# Final verification: ensure npm is actually running under Node major +# Final verification: ensure npm is colocated and running under Node major +$nodeCmd = Get-Command node -ErrorAction Stop +$npmCmd = Get-Command npm -ErrorAction Stop +$nodeDir = Split-Path -Parent $nodeCmd.Source +$npmDir = Split-Path -Parent $npmCmd.Source +Write-Host "node path: $($nodeCmd.Source)" +Write-Host "npm path: $($npmCmd.Source)" +if ($nodeDir -ne $npmDir) { throw "node and npm are not colocated: $nodeDir vs $npmDir" } + $nodeV = (& node -v).Trim() -$npmNodeV = (& npm exec --yes -- node -v).Trim() +$npmV = (& npm -v).Trim() +$npmInfo = npm version --json | ConvertFrom-Json +$npmRuntimeNodeV = $npmInfo.node +$npmRuntimeParsed = Parse-Version $npmRuntimeNodeV Write-Host "Using Node: $nodeV" -Write-Host "npm-backed Node: $npmNodeV" +Write-Host "npm version: $npmV" +Write-Host "npm runtime node: $npmRuntimeNodeV" $nv = Parse-Version $nodeV -$nnv = Parse-Version $npmNodeV if (-not $nv -or $nv.Major -ne $NodeMajor) { throw "node is not v$NodeMajor (got $nodeV)" } -if (-not $nnv -or $nnv.Major -ne $NodeMajor) { throw "npm is not backed by v$NodeMajor (got $npmNodeV)" } +if (-not $npmRuntimeParsed -or $npmRuntimeParsed.Major -ne $NodeMajor) { throw "npm is not running under Node $NodeMajor (got $npmRuntimeNodeV)" } From 0291b4c32e782fab9f286361864c79a02ff4dadf Mon Sep 17 00:00:00 2001 From: Roger Qiu Date: Mon, 16 Feb 2026 08:36:48 +0000 Subject: [PATCH 5/5] =?UTF-8?q?ci:=20fixed=20the=20PowerShell=20parsing=20?= =?UTF-8?q?bug=20in=20../js-lint/scripts/choco-install.ps1=20by=20wrapping?= =?UTF-8?q?=20each=20Test-Path=20call=20in=20parentheses=20so=20-LiteralPa?= =?UTF-8?q?th=20isn=E2=80=99t=20double-bound.=20The=20Windows=20bootstrap?= =?UTF-8?q?=20script=20should=20now=20proceed=20to=20Node=20selection/veri?= =?UTF-8?q?fication=20(toolcache=20=E2=86=92=20portable=20=E2=86=92=20down?= =?UTF-8?q?load),=20enforce=20node/npm=20colocation,=20and=20fail=20if=20n?= =?UTF-8?q?pm=E2=80=99s=20runtime=20node=20isn=E2=80=99t=20major=2020.=20P?= =?UTF-8?q?lease=20rerun=20the=20Windows=20job=20to=20validate.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/choco-install.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/choco-install.ps1 b/scripts/choco-install.ps1 index ebdd1df..d860c22 100755 --- a/scripts/choco-install.ps1 +++ b/scripts/choco-install.ps1 @@ -60,7 +60,7 @@ function Find-ToolcacheNodeBin([int] $major, [string] $arch) { if (-not $best) { return $null } $bin = Join-Path $best.Dir $arch - if (Test-Path -LiteralPath (Join-Path $bin "node.exe") -and Test-Path -LiteralPath (Join-Path $bin "npm.cmd")) { + if ((Test-Path -LiteralPath (Join-Path $bin "node.exe")) -and (Test-Path -LiteralPath (Join-Path $bin "npm.cmd"))) { return $bin } return $null @@ -90,7 +90,7 @@ function Ensure-PortableNode([string] $version, [string] $arch, [string] $cacheD $exePath = Join-Path $dirPath "node.exe" $npmPath = Join-Path $dirPath "npm.cmd" - if (Test-Path -LiteralPath $exePath -and Test-Path -LiteralPath $npmPath) { return $dirPath } + if ((Test-Path -LiteralPath $exePath) -and (Test-Path -LiteralPath $npmPath)) { return $dirPath } $base = "https://nodejs.org/dist/v$version" $zipUrl = "$base/$zipName" @@ -115,7 +115,7 @@ function Ensure-PortableNode([string] $version, [string] $arch, [string] $cacheD Expand-Archive -LiteralPath $zipPath -DestinationPath $cacheDir -Force - if (-not (Test-Path -LiteralPath $exePath -and Test-Path -LiteralPath $npmPath)) { + if (-not ((Test-Path -LiteralPath $exePath) -and (Test-Path -LiteralPath $npmPath))) { throw "Portable Node extraction missing node/npm: $dirPath" } return $dirPath