Files
bermudalambandClaude Opus 5 936dcb60a8
Linting / lint (pull_request) Successful in 2m57s
SonarQube Analysis / sonarqube (pull_request) Successful in 24m21s
docs(scripts): record the Node pin decision at the constant (#208)
The last open item on #208, and the only one that needed a person rather than a patch. Local runs stay pinned to 26.7.0 while CI and the production image run 20, and CI on 20 is the backstop.

The comment said this was "an open decision rather than an oversight", which was true when it was written and is not any more. Left as-is it would read to the next person as something still to settle, and they would either re-litigate it or quietly change the pin.

What the decision costs is written down rather than glossed: passing locally does not mean it ships, because a post-20 syntax or node: API is caught after a push rather than before one. That is the whole of the trade, and it is acceptable precisely because it is known — the failure mode this file's own docstring warns about is the one nobody knew they were exposed to.

Items 1, 2, 3 and 5 were already done in 06933ae and the commits around it: the alias check fails closed on a positive match, three stale documents were corrected, the throw quotes `nvm install $Version`, Use-NodeLatest is gone, DEFAULT_NODE_VERSION sits beside NODE_VERSION rather than being duplicated in two scripts, and the floor check runs before the switch where it can actually fire.

Closes #208

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:48:23 -05:00

201 lines
8.4 KiB
PowerShell

<#
.SYNOPSIS
Switching the machine's Node version, shared by start-local.ps1 and
run-tests.ps1.
.DESCRIPTION
Both scripts need Node 20 or newer for the same reason: node-pg-migrate
pulls in an lru-cache that calls diagnostics_channel.tracingChannel(), which
does not exist before Node 19.9. On Node 18 the migration dies inside
minified library code with "(0 , U.tracingChannel) is not a function", a
message that says nothing about versions. ts-jest and Playwright are subject
to the same floor.
Kept in one file because two copies of a version switch would drift, and the
half that drifts is the half nobody runs.
nvm-windows rewrites a machine-global symlink (NVM_SYMLINK, typically
C:\nvm4w\nodejs) rather than changing one shell, so switching here changes
the Node version for every terminal on the machine. That is intended — the
point is to work in whatever shell is already open — but it is announced
rather than done quietly, and it is put back afterwards.
#>
function Get-NodeVersionString {
return (node --version 2>$null)
}
function Get-NodeMajor {
$raw = Get-NodeVersionString
if (-not $raw) { return 0 }
return [int](($raw -replace '^v', '') -split '\.')[0]
}
<#
Switches, then checks what is actually running.
nvm-windows exits 0 for switches that did not take: a version it cannot
find, a symlink it cannot rewrite without elevation, and — seen on this
machine — a rewrite immediately after another one, where the directory
symlink is briefly still the old target. So the result is verified rather
than trusted, and retried once, because reporting a version that is not the
one running is worse than not switching at all.
nvm's own output is captured rather than discarded. Suppressing it hid the
only message that explained a failed switch.
#>
function Use-Node {
param(
[Parameter(Mandatory)][string]$Version,
[Parameter(Mandatory)][string]$Why,
[scriptblock]$Step,
[scriptblock]$Note
)
if ($Step) { & $Step "Switching Node to $Version ($Why)" }
if ($Note) { & $Note 'nvm changes the version for every shell on this machine, not just this one' }
$output = $null
foreach ($attempt in 1..2) {
$output = (nvm use $Version 2>&1 | Out-String).Trim()
$raw = Get-NodeVersionString
if ($raw) {
# An alias resolves to whatever nvm picked, so there is no version
# string to compare it against — the caller checks the major
# instead. What is checked here is nvm's own report, because it
# announces a switch it could not make while still exiting 0.
# Treating an alias as always-successful is what turned "the version
# nvm wanted is not installed" into a claim that the newest install
# was too old, which sent the reader to `nvm install` holding a list
# that already had newer versions on it.
# A POSITIVE match on what success looks like, not a negative
# one on an allowlist of English error strings. The negative form
# returned True for empty output, for $null, and for
# 'exit status 1: Access is denied.' — so an alias switch that
# produced nothing, or failed on the symlink permission error this
# file's own header warns about, was reported as success while the
# old version kept running. That is the bug #198 was filed about,
# narrowed rather than removed. It also broke whenever nvm reworded
# an error. See #208.
$switched = if ($Version -in @('latest', 'lts', 'newest')) {
$output -match ('Now using node v' + [regex]::Escape($raw.TrimStart('v')))
}
else {
$raw.TrimStart('v') -eq $Version.TrimStart('v')
}
if ($switched) {
if ($Note) { & $Note "node $raw" }
return $raw
}
}
if ($attempt -eq 1) {
if ($Note) { & $Note 'the switch has not taken yet; retrying' }
Start-Sleep -Milliseconds 750
}
}
$running = Get-NodeVersionString
if (-not $running) {
throw "Node is not on PATH after 'nvm use $Version'. Check that nvm-windows is installed.`n`nnvm said:`n$output"
}
throw @"
Asked nvm for Node $Version, but $running is still what runs.
nvm said:
$output
nvm-windows rewrites a symlink at $env:NVM_SYMLINK, and can report success
without having rewritten it. If that version is not installed, install it; if it
is, check this shell can write that link:
nvm install $Version
nvm list
"@
}
# The version every local run uses. Pinned rather than asked for by alias, so
# two machines run the same Node instead of whatever each happens to have
# installed. Bump it here and it moves for start-local.ps1 and run-tests.ps1
# together; the floor check below is a backstop against pinning it too low, not
# the specification.
$script:NODE_VERSION = '26.7.0'
# What the machine is put back to afterwards. Beside NODE_VERSION rather than
# duplicated as a parameter default in each script, because two copies of a
# version drift and the half that drifts is the half nobody runs. Both scripts
# still take -DefaultNodeVersion to override it.
$script:DEFAULT_NODE_VERSION = '18.16.1'
# Local runs are three major lines ahead of CI (Node 20 in every workflow) and of
# the production image (node:20-bookworm-slim). That divergence is deliberate but
# not free: post-20 syntax and node: APIs pass here and fail in the pipeline, and
# Node 26 ships an npm that can touch the lockfile in ways CI's npm reads
# differently. The `engines` field in both package.json files records the floor
# machine-readably.
#
# Settled in #208 part 4: the pin stays at 26.7.0 and CI on 20 is the backstop.
# The cost is knowing that "it passed locally" does not mean it ships — a post-20
# API is caught after a push rather than before one. That is accepted rather than
# unnoticed, which is the only reason this paragraph is here.
<#
Switches to the pinned version and insists it clears the floor.
Asking nvm for an alias is what this used to do, and the two alias names are
worth recording because they are easy to swap by accident. `newest` means
the newest version installed on this machine. `latest` means the newest that
exists — nvm-windows resolves it against the remote release list — so on a
machine whose newest install is behind the current release, `nvm use latest`
reports "activation error: Version not installed" and leaves the old version
running. This asked for `latest` and reported the failure as "the newest
version nvm has installed is too old", which was false and sent the reader
to install something they already had. See #198.
A version that is not installed is Use-Node's error to report, and it now
reports nvm's own reason, so there is nothing to say about it here.
#>
function Use-PinnedNode {
param([scriptblock]$Step, [scriptblock]$Note)
# Checked BEFORE the switch, against the constant rather than against what
# ends up running. After the switch $major is whatever NODE_VERSION says,
# so the old placement could never fire — it validated the switch it had
# just made instead of the pin it exists to guard. See #208.
$pinnedMajor = [int](($script:NODE_VERSION -replace '^v', '') -split '\.')[0]
if ($pinnedMajor -lt 20) {
throw @"
NODE_VERSION in scripts/NodeVersion.ps1 is $($script:NODE_VERSION), which is below the 20 this project needs.
node-pg-migrate, ts-jest and Playwright all need 20 or newer. Nothing was
switched.
"@
}
Use-Node -Version $script:NODE_VERSION -Why 'this project needs Node 20 or newer' -Step $Step -Note $Note | Out-Null
}
<#
Best effort by design. A failure to switch back must not mask the error that
got us here, nor turn a passing run into a failing one — but it must still
say so, because leaving the machine on the wrong version silently is how the
next confusing failure starts.
#>
function Restore-Node {
param(
[Parameter(Mandatory)][string]$Version,
[scriptblock]$Step,
[scriptblock]$Note
)
try {
Use-Node -Version $Version -Why 'restoring the machine default' -Step $Step -Note $Note | Out-Null
}
catch {
if ($Note) { & $Note "could not restore Node ${Version}: $($_.Exception.Message)" }
}
}