Skip to content

Commit c916b97

Browse files
os-zhuangclaude
andauthored
ci: validate on the runtime we publish from — pin every workflow to Node 22 (#3825) (#3830)
CI ran two Node versions at once, and nobody had decided that. All 12 PR gates — Build Core, Test Core, TypeScript Type Check, Dogfood, ESLint, spec liveness, dep validation — were on Node 20, EOL since 2026-04-30, while release.yml, publish-smoke.yml, scaffold-e2e.yml and showcase-smoke.yml were on 22. Code was verified on one runtime and shipped from another, and the runtime guarding every merge no longer received security patches. The split was drift, not policy. release.yml carried the receipt in a comment — "22 (not 20 like the other workflows)" — because a downstream clone pins engines.node >=22 and pnpm aborted on 20. One workflow got bumped to clear one error; the other twelve stayed behind. Node 20 was in fact used for nothing else: every shipped Docker image is already node:22-slim. It surfaced only by accident in #3812, where a test imported better-sqlite3@13 (engines >=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 "Test Files 22 passed (23)" while 17 cases silently never ran. A green check that had stopped running the tests. All 18 setup-node steps now run Node 22, and quoting is normalised (lint.yml used "20", showcase-smoke.yml a bare 22, so a node-version: '\d+' grep missed both). .nvmrc becomes the single source of truth. It pins contributors' local runtime via nvm use — there was no pin at all before, so a contributor on Node 24 could not reproduce a Node 20 gate failure — and the new check:node-version holds every workflow to it. A version pin is otherwise 18 independent string literals, which is why this drifted unseen; bumping Node is now a one-line .nvmrc edit plus whatever the guard reports. The guard also fails a setup-node step that pins nothing (silently inheriting the runner default) and one that resolves its version from an unevaluatable expression. It lives in lint.yml's ESLint job, which carries no paths filter, so it cannot go dormant. engines.node stays >=18.0.0 across all 49 packages: that is a promise to users about what ships, independent of what CI validates on, and tightening it is a breaking change left for its own decision. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88ef03e commit c916b97

13 files changed

Lines changed: 235 additions & 17 deletions

.changeset/ci-node-22-pin.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
---
3+
4+
ci: validate on the runtime we publish from — pin every workflow to Node 22 (#3825)
5+
6+
CI ran two Node versions at once, and nobody had decided that. All 12 PR gates —
7+
Build Core, Test Core, TypeScript Type Check, Dogfood, ESLint, spec liveness,
8+
dep validation — were on **Node 20**, which reached EOL on **2026-04-30**, while
9+
`release.yml`, `publish-smoke.yml`, `scaffold-e2e.yml` and `showcase-smoke.yml`
10+
were on **22**. So code was verified on one runtime and shipped from another,
11+
and the runtime guarding every merge no longer received security patches.
12+
13+
The split was drift, not policy. `release.yml` carried the receipt in a comment
14+
*"22 (not 20 like the other workflows)"* — because a downstream clone pinned
15+
`engines.node >=22` and pnpm aborted on 20. One workflow got bumped to clear one
16+
error; the other twelve stayed behind, and nothing in CI could see the gap.
17+
18+
It surfaced only by accident in #3812: a test imported `better-sqlite3@13`,
19+
whose `engines` say `>=22`. `engines` is a declaration, not enforcement, so it
20+
loaded on Node 20 and then killed the vitest worker with a **process-level
21+
abort** — no JS error, so the suite reported `Test Files 22 passed (23)` while
22+
**17 cases silently never ran**. A green check that had quietly stopped running
23+
the tests.
24+
25+
All 18 `setup-node` steps now run Node 22, matching what release already used.
26+
27+
**`.nvmrc` is now the single source of truth.** It pins contributors' local
28+
runtime via `nvm use` — previously there was no pin at all, so a contributor on
29+
Node 24 could not reproduce a Node 20 gate failure — and `check:node-version`
30+
(new, wired into the unfiltered, always-required `lint` job) holds every
31+
workflow to it. A version pin is otherwise 18 independent string literals, which
32+
is why this drifted invisibly for so long; bumping Node is now a one-line edit
33+
to `.nvmrc` plus whatever the guard reports. The guard also fails a `setup-node`
34+
step that pins *nothing*, which would silently inherit the runner default.
35+
36+
Nothing about the published packages changes: `engines.node` stays `>=18.0.0`
37+
across all 49 of them. That is a promise to users about what ships, independent
38+
of what CI validates on, and tightening it is a breaking change — left for its
39+
own decision rather than folded in here.

.github/workflows/ci.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ jobs:
9090
- name: Setup Node.js
9191
uses: actions/setup-node@v7
9292
with:
93-
node-version: '20'
93+
node-version: '22'
9494

9595
- name: Enable Corepack
9696
run: corepack enable
@@ -192,7 +192,7 @@ jobs:
192192
- name: Setup Node.js
193193
uses: actions/setup-node@v7
194194
with:
195-
node-version: '20'
195+
node-version: '22'
196196

197197
- name: Enable Corepack
198198
run: corepack enable
@@ -319,7 +319,7 @@ jobs:
319319
- name: Setup Node.js
320320
uses: actions/setup-node@v7
321321
with:
322-
node-version: '20'
322+
node-version: '22'
323323

324324
- name: Enable Corepack
325325
run: corepack enable
@@ -433,7 +433,7 @@ jobs:
433433
- name: Setup Node.js
434434
uses: actions/setup-node@v7
435435
with:
436-
node-version: '20'
436+
node-version: '22'
437437

438438
- name: Enable Corepack
439439
run: corepack enable
@@ -491,7 +491,7 @@ jobs:
491491
- name: Setup Node.js
492492
uses: actions/setup-node@v7
493493
with:
494-
node-version: '20'
494+
node-version: '22'
495495

496496
- name: Enable Corepack
497497
run: corepack enable

.github/workflows/coverage-nightly.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
- name: Setup Node.js
2727
uses: actions/setup-node@v7
2828
with:
29-
node-version: '20'
29+
node-version: '22'
3030

3131
- name: Enable Corepack
3232
run: corepack enable

.github/workflows/docs-drift-check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
- name: Setup Node.js
3030
uses: actions/setup-node@v7
3131
with:
32-
node-version: '20'
32+
node-version: '22'
3333

3434
- name: Fetch base branch
3535
run: git fetch --no-tags origin "${{ github.base_ref }}"

.github/workflows/lint.yml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
- name: Setup Node.js
3131
uses: actions/setup-node@v7
3232
with:
33-
node-version: "20"
33+
node-version: '22'
3434

3535
- name: Enable Corepack
3636
run: corepack enable
@@ -106,6 +106,19 @@ jobs:
106106
- name: Release-notes drift guard
107107
run: pnpm check:release-notes
108108

109+
# #3825 Node-version drift guard: a runtime pin is 18 separate string
110+
# literals across .github/workflows, so a split is invisible until someone
111+
# greps for it. One did open — every PR gate sat on Node 20 (EOL
112+
# 2026-04-30) while release.yml and publish-smoke.yml ran 22, so code was
113+
# verified on one runtime and shipped from another. Nobody chose that; one
114+
# workflow got bumped to clear one error and the rest stayed behind. It
115+
# surfaced only when a dependency needing >=22 aborted the vitest worker
116+
# at the process level, which vitest reported as a PASSING suite with 17
117+
# cases silently skipped (#3812). .nvmrc is now the single source of
118+
# truth, and this holds every workflow to it.
119+
- name: Node-version drift guard
120+
run: pnpm check:node-version
121+
109122
typecheck:
110123
name: TypeScript Type Check
111124
runs-on: ubuntu-latest
@@ -119,7 +132,7 @@ jobs:
119132
- name: Setup Node.js
120133
uses: actions/setup-node@v7
121134
with:
122-
node-version: '20'
135+
node-version: '22'
123136

124137
- name: Enable Corepack
125138
run: corepack enable

.github/workflows/pr-automation.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ jobs:
6363
- name: Setup Node.js
6464
uses: actions/setup-node@v7
6565
with:
66-
node-version: '20'
66+
node-version: '22'
6767

6868
- name: Enable Corepack
6969
run: corepack enable

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ jobs:
2424
- name: Setup Node.js
2525
uses: actions/setup-node@v7
2626
with:
27-
# 22 (not 20 like the other workflows): the downstream hotcrm smoke
28-
# below clones hotcrm@v1.2.0, whose manifest pins engines.node >=22.
29-
# pnpm install aborts with ERR_PNPM_UNSUPPORTED_ENGINE on Node 20.
27+
# Cannot go below 22: the downstream hotcrm smoke below clones
28+
# hotcrm@v1.2.0, whose manifest pins engines.node >=22. pnpm install
29+
# aborts with ERR_PNPM_UNSUPPORTED_ENGINE under that.
3030
node-version: '22'
3131

3232
- name: Enable Corepack

.github/workflows/showcase-smoke.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
- uses: pnpm/action-setup@v6
2323
- uses: actions/setup-node@v7
2424
with:
25-
node-version: 22
25+
node-version: '22'
2626
cache: pnpm
2727
- run: pnpm install --frozen-lockfile
2828
# The smoke's webServer runs `os serve --dev`, which loads the showcase

.github/workflows/spec-liveness-check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
- name: Setup Node.js
3434
uses: actions/setup-node@v7
3535
with:
36-
node-version: '20'
36+
node-version: '22'
3737

3838
- name: Enable Corepack
3939
run: corepack enable

.github/workflows/validate-deps.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
- name: Setup Node.js
3333
uses: actions/setup-node@v7
3434
with:
35-
node-version: '20'
35+
node-version: '22'
3636

3737
- name: Enable Corepack
3838
run: corepack enable

0 commit comments

Comments
 (0)