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 e28d6b0..d860c22 100755 --- a/scripts/choco-install.ps1 +++ b/scripts/choco-install.ps1 @@ -1,60 +1,47 @@ 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" } @@ -62,169 +49,195 @@ function Find-ToolcacheNodeBin { 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)" }