Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

285 changes: 149 additions & 136 deletions scripts/choco-install.ps1
Original file line number Diff line number Diff line change
@@ -1,230 +1,243 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# ---- Config ----
# 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 = 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"
New-Item -Path $NodeCache -ItemType Directory -Force | Out-Null
# Allow selecting an existing Node only if explicitly permitted; default is to prefer toolcache/portable/download
$AllowExistingNode = $false

# (Optional) keep your original Chocolatey cache source spirit (harmless if you later add more choco pkgs)
if ($env:ChocolateyInstall) {
New-Item -Path $CacheRoot -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
}
# 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

# ---- Helpers ----
function Get-NodeVersionString {
try { return (& node -v).Trim() } catch { return $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 { $_ }
}

# ---------------------------
# 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 Get-CommandPath([string] $name) {
try { return (Get-Command $name -ErrorAction Stop).Source } catch { return $null }
}

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 Find-ToolcacheNodeBin {
param([Parameter(Mandatory)][int] $Major, [ValidateSet('x64','x86')][string] $Arch = 'x64')

function Find-ToolcacheNodeBin([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"
}

$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.*" } |
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 }
$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
}

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

$zipName = "node-v$Version-win-$Arch.zip"
$zipPath = Join-Path $CacheDir $zipName
$dirPath = Join-Path $CacheDir "node-v$Version-win-$Arch"
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 $exePath) { return $dirPath }
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
}

# 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) {
$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"
}

Write-Host "Extracting Node $Version ..."
Expand-Archive -LiteralPath $zipPath -DestinationPath $CacheDir -Force
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 $exePath) -and (Test-Path -LiteralPath $npmPath))) {
throw "Portable Node extraction missing node/npm: $dirPath"
}

return $dirPath
}

function Find-BestCachedNodeDir {
param([Parameter(Mandatory)][string] $CacheDir, [Parameter(Mandatory)][int] $Major, [string] $Arch = 'x64')

$dirs = 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]
if ($ver -and (Test-Path -LiteralPath (Join-Path $_.FullName "node.exe"))) {
[pscustomobject]@{ Dir = $_.FullName; Ver = $ver }
$exe = Join-Path $_.FullName "node.exe"
$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 |
Select-Object -First 1

if ($dirs) { return $dirs.Dir }
if ($best) { return $best.Dir }
return $null
}

# ---- Main selection logic ----

# 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
function Add-ToGitHubPath([string] $dir) {
if ($env:GITHUB_PATH) {
$dir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
}
$env:Path = "$dir;$env:Path"
}

# 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

$v = Parse-Version (Get-NodeVersionString)
if ($v -and $v.Major -eq $NodeMajor) {
Write-Host "Using Node: $(& node -v)"
exit 0
# ---------------------------
# 1) Optional choco installs
# ---------------------------
if ($ChocoPackages.Count -gt 0) {
if (-not $env:ChocolateyInstall) { throw "CHOCO_PACKAGES set but Chocolatey not available." }

Write-Host "Installing choco packages: $($ChocoPackages -join ', ')"
foreach ($p in $ChocoPackages) {
& choco install $p -y --no-progress
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}

# 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
}
}
throw "Toolcache selection failed: node is not v$NodeMajor after PATH prepend."
$cmd = Get-Command Update-SessionEnvironment -ErrorAction SilentlyContinue
if ($cmd) { Update-SessionEnvironment }
}

# ---------------------------
# 2) Select Node (paired node/npm)
# ---------------------------
$selected = $null

# 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 {}
}

# 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."
# Prefer toolcache
if (-not $selected) {
$bin = Find-ToolcacheNodeBin -major $NodeMajor -arch $Arch
if ($bin) {
$selected = $bin
Write-Host "Selecting Node from toolcache: $selected"
}
Write-Host "Selecting Node from cache: $selectedDir"
}

Prepend-Path $selectedDir
Patch-RefreshEnvToKeepNodeFirst $selectedDir
# 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"
}
}

# 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)"
# 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"
}

Write-Host "Using Node: $afterStr"
# Apply selection to PATH and GITHUB_PATH (next steps) and current step
Add-ToGitHubPath $selected

# 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()
$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 version: $npmV"
Write-Host "npm runtime node: $npmRuntimeNodeV"

$nv = Parse-Version $nodeV
if (-not $nv -or $nv.Major -ne $NodeMajor) { throw "node is not v$NodeMajor (got $nodeV)" }
if (-not $npmRuntimeParsed -or $npmRuntimeParsed.Major -ne $NodeMajor) { throw "npm is not running under Node $NodeMajor (got $npmRuntimeNodeV)" }
Loading