-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathcheck-node-version.mjs
More file actions
238 lines (214 loc) · 10.3 KB
/
Copy pathcheck-node-version.mjs
File metadata and controls
238 lines (214 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
#!/usr/bin/env node
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
//
// check-node-version -- every workflow must run the Node version in .nvmrc,
// and that version must still be supported by Node.
//
// Before #3825 the repo ran two Node versions at once, and nobody had decided
// that: all 12 PR gates were on Node 20 while release.yml, publish-smoke.yml,
// scaffold-e2e.yml and showcase-smoke.yml were on 22. So code was verified on
// one runtime and shipped from another, and the verifying one had been EOL
// since 2026-04-30 -- no security patches on the runtime guarding every merge.
//
// The split was never a policy, it was drift. release.yml carried the receipt
// in a comment: "22 (not 20 like the other workflows)" because a downstream
// clone pinned engines.node >=22 and pnpm aborted on 20. One workflow got
// bumped to clear one error; the other twelve stayed behind. That is how the
// gates-vs-release gap opened, and nothing in CI could see it -- a version pin
// is 18 independent string literals, so drift is invisible until someone greps.
//
// It surfaced only by accident (#3812): a test imported better-sqlite3@13,
// whose engines say >=22. `engines` is a declaration, not enforcement, so it
// loaded on 20 and then killed the vitest worker with a process-level abort --
// no JS error, so the suite reported "22 passed (23)" while 17 cases silently
// never ran. A green check that had stopped running the tests.
//
// node scripts/check-node-version.mjs
//
// .nvmrc is the single source of truth: it pins contributors' local runtime via
// `nvm use` AND is what this guard holds every workflow to. Bumping Node is
// therefore a one-line edit to .nvmrc plus whatever this guard then reports.
//
// Deliberately NOT checked: `engines.node` in package.json. That is a promise
// to users about what the published packages support, which is independent of
// what CI validates on, and tightening it is a breaking change. See #3825.
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const WORKFLOW_DIR = '.github/workflows';
const PIN_FILE = '.nvmrc';
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
encoding: 'utf8',
}).trim();
// The pin, e.g. "22". Tolerates the "v22" and "lts/jod" forms nvm also accepts,
// but this repo writes the bare major -- that is what setup-node wants too.
const pin = readFileSync(join(root, PIN_FILE), 'utf8').trim();
if (!pin) {
console.error(`check-node-version: ${PIN_FILE} is empty -- it must pin a Node major, e.g. 22.`);
process.exit(1);
}
// --- Lifecycle: the pin must not be a runtime Node has stopped patching. ------
//
// Consistency alone does not make the pin correct. The guard below proves all
// 18 workflows agree; it would have said OK just as cheerfully when all 18
// agreed on Node 20, three months after that line went EOL. That is the exact
// state #3825 found the repo in, so "they match" is only half the invariant --
// the other half is "and the thing they match is still supported".
//
// Dates are from nodejs/Release schedule.json, hardcoded on purpose: a required
// gate must not depend on the network, and these move once a year. An
// unrecognised major is an ERROR rather than a pass, so adopting Node 26 forces
// you to record its dates here instead of silently validating on a runtime this
// guard knows nothing about.
const NODE_LIFECYCLE = {
18: { maintenance: '2023-10-18', end: '2025-04-30' },
20: { maintenance: '2024-10-22', end: '2026-04-30' },
22: { maintenance: '2025-10-21', end: '2027-04-30' },
24: { maintenance: '2026-10-20', end: '2028-04-30' },
26: { maintenance: '2027-10-20', end: '2029-04-30' },
};
// Warn this far ahead of EOL. Long enough that the bump is scheduled work
// rather than an emergency, and it stays a warning until the day support
// actually ends -- a hard failure months early would block unrelated PRs.
const WARN_WITHIN_DAYS = 180;
const DAY = 24 * 60 * 60 * 1000;
const major = Number.parseInt(String(pin).replace(/^v/, ''), 10);
const lifecycle = NODE_LIFECYCLE[major];
if (!Number.isInteger(major) || !lifecycle) {
console.error(
`check-node-version: ${PIN_FILE} pins Node "${pin}", which this guard has no support dates for.\n\n` +
`Add it to NODE_LIFECYCLE in ${'scripts/check-node-version.mjs'} using the dates from\n` +
`https://github.com/nodejs/Release/blob/main/schedule.json, then re-run.\n` +
`Known: ${Object.keys(NODE_LIFECYCLE).join(', ')}.`,
);
process.exit(1);
}
const today = new Date();
const eol = new Date(`${lifecycle.end}T00:00:00Z`);
const daysLeft = Math.round((eol - today) / DAY);
const inMaintenance = today >= new Date(`${lifecycle.maintenance}T00:00:00Z`);
if (daysLeft <= 0) {
console.error(
`check-node-version: ${PIN_FILE} pins Node ${major}, which reached end-of-life on ${lifecycle.end} ` +
`(${Math.abs(daysLeft)} days ago).\n\n` +
`An EOL runtime receives no security patches, and every PR gate in this repo runs on it --\n` +
`so the runtime guarding each merge is the one nobody is fixing. That is #3825 verbatim.\n\n` +
`Bump ${PIN_FILE} to a supported major and update the workflows this guard then lists.\n` +
`Supported today: ${Object.entries(NODE_LIFECYCLE)
.filter(([, l]) => new Date(`${l.end}T00:00:00Z`) > today)
.map(([m, l]) => `${m} (until ${l.end})`)
.join(', ')}.`,
);
process.exit(1);
}
// GitHub renders ::warning:: in the job summary and on the PR, so this is
// visible without opening logs -- unlike a plain console.warn.
if (daysLeft <= WARN_WITHIN_DAYS) {
console.log(
`::warning file=${PIN_FILE}::Node ${major} reaches end-of-life on ${lifecycle.end} ` +
`(${daysLeft} days). Plan the bump before then -- when it lands, every PR gate will be ` +
`validating on an unpatched runtime. Bumping is a one-line ${PIN_FILE} edit plus the ` +
`workflows check-node-version lists.`,
);
}
const files = readdirSync(join(root, WORKFLOW_DIR))
.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
.sort();
// A step ends at the next YAML list item; `with:` keys live between the
// `uses: actions/setup-node` line and that boundary.
const SETUP_NODE = /^\s*(?:-\s+)?uses:\s*actions\/setup-node@/;
const NEXT_ITEM = /^\s*-\s/;
const NODE_VERSION = /^\s*node-version:\s*(.+?)\s*$/;
const NODE_VERSION_FILE = /^\s*node-version-file:\s*(.+?)\s*$/;
const unquote = (v) => v.replace(/^['"]|['"]$/g, '').trim();
const offenders = [];
let steps = 0;
for (const file of files) {
const lines = readFileSync(join(root, WORKFLOW_DIR, file), 'utf8').split('\n');
for (let i = 0; i < lines.length; i++) {
if (!SETUP_NODE.test(lines[i])) continue;
steps++;
let found = null;
for (let j = i + 1; j < lines.length; j++) {
// Stop at the next step -- but not on the `- uses:` line we started from.
if (NEXT_ITEM.test(lines[j])) break;
const v = lines[j].match(NODE_VERSION);
if (v) {
found = { line: j + 1, kind: 'node-version', value: unquote(v[1]) };
break;
}
const f = lines[j].match(NODE_VERSION_FILE);
if (f) {
found = { line: j + 1, kind: 'node-version-file', value: unquote(f[1]) };
break;
}
}
const where = `${WORKFLOW_DIR}/${file}`;
if (!found) {
// No pin at all: the step silently inherits whatever Node the runner
// image ships, which GitHub bumps without telling us.
offenders.push({
where: `${where}:${i + 1}`,
problem: 'declares no Node version -- inherits the runner default',
fix: `add "node-version: '${pin}'"`,
});
continue;
}
if (found.kind === 'node-version-file') {
// Pointing at the pin file is the ideal form; anything else is a second
// source of truth.
if (found.value.replace(/^\.\//, '') !== PIN_FILE) {
offenders.push({
where: `${where}:${found.line}`,
problem: `reads its version from "${found.value}", not ${PIN_FILE}`,
fix: `use "node-version-file: ${PIN_FILE}"`,
});
}
continue;
}
// A `${{ }}` expression (matrix input, env, workflow input) cannot be
// resolved from the file, so the guard cannot tell 22 from 20 here. Fail
// closed and say why, rather than either waving it through or reporting the
// raw expression as if it were a version number. A deliberate multi-version
// compatibility matrix is a real thing to want -- it just needs deciding
// out loud, since it is exactly the gates-vs-release split done on purpose.
if (found.value.includes('${{')) {
offenders.push({
where: `${where}:${found.line}`,
problem: `resolves its version from an expression (${found.value}) that this guard cannot evaluate`,
fix: `pin it literally as '${pin}', or extend this guard if a multi-version matrix is intended`,
});
continue;
}
if (found.value !== pin) {
offenders.push({
where: `${where}:${found.line}`,
problem: `pins Node ${found.value}, but ${PIN_FILE} says ${pin}`,
fix: `change it to '${pin}', or bump ${PIN_FILE} if the whole repo should move`,
});
}
}
}
if (offenders.length === 0) {
const phase = inMaintenance ? 'maintenance' : 'active LTS';
console.log(
`check-node-version: OK (${steps} setup-node step(s) across ${files.length} workflow(s), all on Node ${pin}).\n` +
` Node ${major} is in ${phase}; supported until ${lifecycle.end} (${daysLeft} days).`,
);
process.exit(0);
}
const plural = offenders.length === 1 ? 'step disagrees' : 'steps disagree';
console.error(`check-node-version: ${offenders.length} setup-node ${plural} with ${PIN_FILE} (Node ${pin})\n`);
for (const o of offenders) {
console.error(` • ${o.where} -- ${o.problem}`);
console.error(` ${o.fix}`);
}
console.error(`
Every workflow must run the Node version in ${PIN_FILE}, so that what CI
verifies is what release publishes from. When those drift apart, PR gates
validate code on a runtime nothing ships from -- and a dependency that needs the
newer one can abort the test worker mid-run, which vitest reports as a PASSING
suite with silently missing cases (#3812).
To move the whole repo to a new Node version, edit ${PIN_FILE} and then update
every step this guard lists.`);
process.exit(1);