diff --git a/.changeset/ci-node-eol-guard.md b/.changeset/ci-node-eol-guard.md new file mode 100644 index 0000000000..91d1111e34 --- /dev/null +++ b/.changeset/ci-node-eol-guard.md @@ -0,0 +1,34 @@ +--- +--- + +ci: fail the Node pin guard when the pinned runtime is out of support (#3825) + +`check-node-version` proved all 18 workflows agree on a Node version. It would +have said `OK` just as cheerfully when all 18 agreed on **Node 20, three months +after that line went EOL** — which is exactly the state #3825 found the repo in. +Consistency was only half the invariant; the other half is that the thing they +agree on is still receiving patches. + +The guard now also checks the pin's lifecycle: + +- **past EOL → error.** An unpatched runtime is guarding every merge. +- **within 180 days of EOL → `::warning::`** on the PR, so the bump is scheduled + work rather than an emergency. It stays a warning until support actually ends — + failing months early would block unrelated PRs. +- **a major the guard has no dates for → error.** Adopting Node 26 forces you to + record its dates rather than silently validating on an unknown runtime. + +Dates come from `nodejs/Release` `schedule.json`, hardcoded deliberately: a +required gate must not depend on the network, and they move once a year. + +The success line now reports the runway, which surfaces something worth knowing: + +``` +check-node-version: OK (18 setup-node step(s) across 16 workflow(s), all on Node 22). + Node 22 is in maintenance; supported until 2027-04-30 (276 days). +``` + +**Node 22 entered maintenance on 2025-10-21** — Node 24 has been Active LTS +since 2025-10-28. Moving to 24 is now a one-line `.nvmrc` edit, and this guard +will list the workflows to follow. That is a separate decision; nothing here +forces it. diff --git a/scripts/check-node-version.mjs b/scripts/check-node-version.mjs index 3a4717359d..eaf3f021e2 100644 --- a/scripts/check-node-version.mjs +++ b/scripts/check-node-version.mjs @@ -1,7 +1,8 @@ #!/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. +// 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, @@ -51,6 +52,77 @@ if (!pin) { 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(); @@ -140,8 +212,10 @@ for (const file of files) { } 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}).`, + `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); }