Skip to content

Commit cd9a210

Browse files
os-zhuangclaude
andauthored
ci: fail the Node pin guard when the pinned runtime is out of support (#3825) (#3851)
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 -- 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 is an error, within 180 days of EOL emits a ::warning:: on the PR, and a major it has no dates for is an error rather than a pass -- so adopting Node 26 forces recording its dates instead of silently validating on a runtime the guard knows nothing about. Warn-then-fail rather than fail-early: a hard failure months ahead of EOL would block unrelated PRs, while 180 days of visible warnings make the bump scheduled work. Dates are 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: Node 22 entered maintenance on 2025-10-21, and Node 24 has been Active LTS since 2025-10-28. Moving to 24 is a one-line .nvmrc edit; that is a separate decision and nothing here forces it. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent a227ed7 commit cd9a210

2 files changed

Lines changed: 110 additions & 2 deletions

File tree

.changeset/ci-node-eol-guard.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
---
3+
4+
ci: fail the Node pin guard when the pinned runtime is out of support (#3825)
5+
6+
`check-node-version` proved all 18 workflows agree on a Node version. It would
7+
have said `OK` just as cheerfully when all 18 agreed on **Node 20, three months
8+
after that line went EOL** — which is exactly the state #3825 found the repo in.
9+
Consistency was only half the invariant; the other half is that the thing they
10+
agree on is still receiving patches.
11+
12+
The guard now also checks the pin's lifecycle:
13+
14+
- **past EOL → error.** An unpatched runtime is guarding every merge.
15+
- **within 180 days of EOL → `::warning::`** on the PR, so the bump is scheduled
16+
work rather than an emergency. It stays a warning until support actually ends —
17+
failing months early would block unrelated PRs.
18+
- **a major the guard has no dates for → error.** Adopting Node 26 forces you to
19+
record its dates rather than silently validating on an unknown runtime.
20+
21+
Dates come from `nodejs/Release` `schedule.json`, hardcoded deliberately: a
22+
required gate must not depend on the network, and they move once a year.
23+
24+
The success line now reports the runway, which surfaces something worth knowing:
25+
26+
```
27+
check-node-version: OK (18 setup-node step(s) across 16 workflow(s), all on Node 22).
28+
Node 22 is in maintenance; supported until 2027-04-30 (276 days).
29+
```
30+
31+
**Node 22 entered maintenance on 2025-10-21** — Node 24 has been Active LTS
32+
since 2025-10-28. Moving to 24 is now a one-line `.nvmrc` edit, and this guard
33+
will list the workflows to follow. That is a separate decision; nothing here
34+
forces it.

scripts/check-node-version.mjs

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
#!/usr/bin/env node
22
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
33
//
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.
56
//
67
// Before #3825 the repo ran two Node versions at once, and nobody had decided
78
// that: all 12 PR gates were on Node 20 while release.yml, publish-smoke.yml,
@@ -51,6 +52,77 @@ if (!pin) {
5152
process.exit(1);
5253
}
5354

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+
54126
const files = readdirSync(join(root, WORKFLOW_DIR))
55127
.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
56128
.sort();
@@ -140,8 +212,10 @@ for (const file of files) {
140212
}
141213

142214
if (offenders.length === 0) {
215+
const phase = inMaintenance ? 'maintenance' : 'active LTS';
143216
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).`,
145219
);
146220
process.exit(0);
147221
}

0 commit comments

Comments
 (0)