|
1 | 1 | #!/usr/bin/env node |
2 | 2 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
3 | 3 | // |
4 | | -// check-node-version -- every workflow must run the Node version in .nvmrc. |
| 4 | +// check-node-version -- every workflow must run the Node version in .nvmrc, |
| 5 | +// and that version must still be supported by Node. |
5 | 6 | // |
6 | 7 | // Before #3825 the repo ran two Node versions at once, and nobody had decided |
7 | 8 | // that: all 12 PR gates were on Node 20 while release.yml, publish-smoke.yml, |
@@ -51,6 +52,77 @@ if (!pin) { |
51 | 52 | process.exit(1); |
52 | 53 | } |
53 | 54 |
|
| 55 | +// --- Lifecycle: the pin must not be a runtime Node has stopped patching. ------ |
| 56 | +// |
| 57 | +// Consistency alone does not make the pin correct. The guard below proves all |
| 58 | +// 18 workflows agree; it would have said OK just as cheerfully when all 18 |
| 59 | +// agreed on Node 20, three months after that line went EOL. That is the exact |
| 60 | +// state #3825 found the repo in, so "they match" is only half the invariant -- |
| 61 | +// the other half is "and the thing they match is still supported". |
| 62 | +// |
| 63 | +// Dates are from nodejs/Release schedule.json, hardcoded on purpose: a required |
| 64 | +// gate must not depend on the network, and these move once a year. An |
| 65 | +// unrecognised major is an ERROR rather than a pass, so adopting Node 26 forces |
| 66 | +// you to record its dates here instead of silently validating on a runtime this |
| 67 | +// guard knows nothing about. |
| 68 | +const NODE_LIFECYCLE = { |
| 69 | + 18: { maintenance: '2023-10-18', end: '2025-04-30' }, |
| 70 | + 20: { maintenance: '2024-10-22', end: '2026-04-30' }, |
| 71 | + 22: { maintenance: '2025-10-21', end: '2027-04-30' }, |
| 72 | + 24: { maintenance: '2026-10-20', end: '2028-04-30' }, |
| 73 | + 26: { maintenance: '2027-10-20', end: '2029-04-30' }, |
| 74 | +}; |
| 75 | + |
| 76 | +// Warn this far ahead of EOL. Long enough that the bump is scheduled work |
| 77 | +// rather than an emergency, and it stays a warning until the day support |
| 78 | +// actually ends -- a hard failure months early would block unrelated PRs. |
| 79 | +const WARN_WITHIN_DAYS = 180; |
| 80 | +const DAY = 24 * 60 * 60 * 1000; |
| 81 | + |
| 82 | +const major = Number.parseInt(String(pin).replace(/^v/, ''), 10); |
| 83 | +const lifecycle = NODE_LIFECYCLE[major]; |
| 84 | + |
| 85 | +if (!Number.isInteger(major) || !lifecycle) { |
| 86 | + console.error( |
| 87 | + `check-node-version: ${PIN_FILE} pins Node "${pin}", which this guard has no support dates for.\n\n` + |
| 88 | + `Add it to NODE_LIFECYCLE in ${'scripts/check-node-version.mjs'} using the dates from\n` + |
| 89 | + `https://github.com/nodejs/Release/blob/main/schedule.json, then re-run.\n` + |
| 90 | + `Known: ${Object.keys(NODE_LIFECYCLE).join(', ')}.`, |
| 91 | + ); |
| 92 | + process.exit(1); |
| 93 | +} |
| 94 | + |
| 95 | +const today = new Date(); |
| 96 | +const eol = new Date(`${lifecycle.end}T00:00:00Z`); |
| 97 | +const daysLeft = Math.round((eol - today) / DAY); |
| 98 | +const inMaintenance = today >= new Date(`${lifecycle.maintenance}T00:00:00Z`); |
| 99 | + |
| 100 | +if (daysLeft <= 0) { |
| 101 | + console.error( |
| 102 | + `check-node-version: ${PIN_FILE} pins Node ${major}, which reached end-of-life on ${lifecycle.end} ` + |
| 103 | + `(${Math.abs(daysLeft)} days ago).\n\n` + |
| 104 | + `An EOL runtime receives no security patches, and every PR gate in this repo runs on it --\n` + |
| 105 | + `so the runtime guarding each merge is the one nobody is fixing. That is #3825 verbatim.\n\n` + |
| 106 | + `Bump ${PIN_FILE} to a supported major and update the workflows this guard then lists.\n` + |
| 107 | + `Supported today: ${Object.entries(NODE_LIFECYCLE) |
| 108 | + .filter(([, l]) => new Date(`${l.end}T00:00:00Z`) > today) |
| 109 | + .map(([m, l]) => `${m} (until ${l.end})`) |
| 110 | + .join(', ')}.`, |
| 111 | + ); |
| 112 | + process.exit(1); |
| 113 | +} |
| 114 | + |
| 115 | +// GitHub renders ::warning:: in the job summary and on the PR, so this is |
| 116 | +// visible without opening logs -- unlike a plain console.warn. |
| 117 | +if (daysLeft <= WARN_WITHIN_DAYS) { |
| 118 | + console.log( |
| 119 | + `::warning file=${PIN_FILE}::Node ${major} reaches end-of-life on ${lifecycle.end} ` + |
| 120 | + `(${daysLeft} days). Plan the bump before then -- when it lands, every PR gate will be ` + |
| 121 | + `validating on an unpatched runtime. Bumping is a one-line ${PIN_FILE} edit plus the ` + |
| 122 | + `workflows check-node-version lists.`, |
| 123 | + ); |
| 124 | +} |
| 125 | + |
54 | 126 | const files = readdirSync(join(root, WORKFLOW_DIR)) |
55 | 127 | .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) |
56 | 128 | .sort(); |
@@ -140,8 +212,10 @@ for (const file of files) { |
140 | 212 | } |
141 | 213 |
|
142 | 214 | if (offenders.length === 0) { |
| 215 | + const phase = inMaintenance ? 'maintenance' : 'active LTS'; |
143 | 216 | console.log( |
144 | | - `check-node-version: OK (${steps} setup-node step(s) across ${files.length} workflow(s), all on Node ${pin}).`, |
| 217 | + `check-node-version: OK (${steps} setup-node step(s) across ${files.length} workflow(s), all on Node ${pin}).\n` + |
| 218 | + ` Node ${major} is in ${phase}; supported until ${lifecycle.end} (${daysLeft} days).`, |
145 | 219 | ); |
146 | 220 | process.exit(0); |
147 | 221 | } |
|
0 commit comments