Feat: declare the supported Node.js range (engines.node >=20) and pin the toolchain via .nvmrc - #508
Open
AmaadMartin wants to merge 5 commits into
Open
Feat: declare the supported Node.js range (engines.node >=20) and pin the toolchain via .nvmrc#508AmaadMartin wants to merge 5 commits into
AmaadMartin wants to merge 5 commits into
Conversation
added 4 commits
August 2, 2026 01:13
…published packages The three published manifests carried no engines field, so npm could not emit EBADENGINE and nothing in the ecosystem could read the supported runtime. >=20 is the tightest floor the runtime dependency closure already imposes (commander requires >=20; the @azure/* group reached via @mikro-orm/mssql -> tedious -> @azure/identity requires >=20.0.0), so this declares a constraint that is already true rather than inventing one.
…rom it Both actions/setup-node@v6 steps had no with: block at all. The action does not auto-detect version files, so CI ran on whatever Node the runner image happened to ship that month. Pinning via node-version-file gives the repo one source of truth that nvm/fnm and CI both read. .nvmrc is 22 rather than 20: the dev toolchain needs it (vite requires ^20.19.0 || >=22.12.0, eslint-visitor-keys ^20.19.0 || ^22.13.0 || >=24, lint-staged >=20.17). The published floor stays >=20 -- a library's support floor is about what consumers may run, not what its build tools need.
README quoted a vague 'current Node.js LTS release' that no tool could check; it now names the declared floor. CONTRIBUTING never mentioned a Node version at all, so a contributor had no way to know which one CI uses.
Pins the three values this change introduces so they cannot silently diverge: every published manifest declares the same range, the .nvmrc pin satisfies that range, and every setup-node step in the repo resolves its version from .nvmrc. The last assertion is what keeps .nvmrc from degenerating into a decorative file, and it counts the steps it found so it cannot pass vacuously.
…ing it back SUPPORTED_NODE_RANGE is a file-local literal, so re-deriving the floor from it with a regex could never fail -- the guard around that parse was unreachable by construction. Making the major the source constant and building the range string from it removes the dead branch and the duplicated number.
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A — no existing issue.
Problem: A consumer installing
@google/adk,@google/adk-devtoolsor@google/adk-integrationsonto an unsupported Node.js runtime gets no signal at all. The only statement of the requirement was prose inREADME.md:Prose is not machine-checkable. With no
enginesfield on any published manifest,npm installcannot emitEBADENGINE,engine-strictusers cannot fail fast, and tooling that readsengines(Renovate/Dependabot runtime detection, Cloud Run / Cloud Functions runtime inference,volta, policy scanners) has nothing to read. Separately, bothactions/setup-node@v6steps had nowith:block at all — the action does not auto-detect version files, so CI ran on whatever Node the GitHub runner image happened to ship that month.Solution: Two complementary changes, plus doc alignment and one guard test.
"engines": {"node": ">=20"}on the three published manifests (core,dev,integrations), byte-identical in all three — they are versioned in lockstep and installed together, so they must not advertise different floors..nvmrcpinning22, read by both workflows vianode-version-file.These two numbers are deliberately different.
engines.nodeis the minimum a consumer may install onto;.nvmrcis the exact major contributors and CI build with. The pin may be stricter than the published floor, never looser.Why
>=20and not>=22. The floor is derived frompackage-lock.json, not chosen. Splitting the runtime closure from the dev-only closure by the lockfile'sdevmarkers, the strongest runtime constraints are>=20.0.0(13 entries:@azure/core-auth,@azure/core-client,@azure-rest/core-client,@azure/core-rest-pipeline) and>=20(2 entries:@azure/msal-node,commander).commanderis a direct dependency of@google/adk-devtools; the@azure/*group reaches the runtime closure via@mikro-orm/mssql→tedious→@azure/identity. So>=20is the tightest floor the runtime graph already imposes — this declares a constraint that is already true rather than inventing one. Source code imposes nothing higher: noimport.meta.dirname,node:sqlite,fs.glob,process.loadEnvFile, orrequire(esm)anywhere undercore/src,dev/src,integrations/src, andtsconfig.jsontargets ES2020/ES2022.Raising the floor to
>=22would drop Node 20 — a supported LTS line that every runtime dependency works on — for no reason, and that would be a genuine breaking change.Why
.nvmrcis22. The dev toolchain floor is genuinely higher than the published floor:vite(viavitest) declares^20.19.0 || >=22.12.0, five copies ofeslint-visitor-keys(viatypescript-eslint) declare^20.19.0 || ^22.13.0 || >=24, andlint-stageddeclares>=20.17. A bare Node20.0.0satisfies the publishedenginesbut not the dev toolchain.22resolves to the latest 22.x and satisfies all of them.Behaviour change. Not breaking in practice; disclosing it anyway:
enginesas advisory by default, so the overwhelming majority of installs are unaffected and simply gain annpm WARN EBADENGINE ... required: { node: '>=20' }on Node <20.engine-strict=trueand is on Node <20,npm install @google/adkchanges from succeeding to failing. That population is small and, by construction, asked to be told. Node 18 reached EOL on 2025-04-30, and the runtime graph (commander,@azure/*) already requires>=20, so such an install is already broken — this makes it fail early and legibly instead of late and cryptically.npm installon Node <20 now prints threeEBADENGINEwarnings. Install still succeeds. This is intended; it is not suppressed.core/src/**,dev/src/**,integrations/src/**change.Deliberately out of scope (each a real thing a reviewer might expect): no
engineson the root manifest (it is a workspace root, not a published artifact, and npm validates the three workspace manifests during a root install anyway); noengines.npm; noengineson thetests/integration/**fixtures or thecli_create.tsscaffold template; noengine-strict=truein a repo.npmrc(that converts a warning into a hard failure for every contributor); nonode-versionmatrix axis in CI;dev/src/cli/deploy/deploy_utils.ts(FROM node:lts-alpine) untouched.Collision check. Run before writing any code, per contribution triage:
This area is contended — five open PRs on the fork touch adjacent files: #133 (
.nvmrc+engineson all three manifests and the root + both workflows, floor>=22.0.0), #445 (engines>=22.12.0+ a guard test), #416 (.nvmrc+ both workflows), #443 (README only, quotes20.19), #428 (CONTRIBUTING + both workflows, pinsnode-version: '24'directly rather than via.nvmrc). This PR is not a duplicate of any of them and differs substantively rather than cosmetically:>=22.0.0/>=22.12.0, which would warn Node 20 consumers off a runtime the dependency graph demonstrably supports. This PR publishes the evidence-derived>=20.enginesto the root manifest and carries apackage-lock.jsonhunk; this PR does neither..nvmrc+ both workflows + README + CONTRIBUTING + a drift guard; the five siblings currently quote four different Node numbers across README, CONTRIBUTING,.nvmrcandengines.Whichever of these lands first, the rest will need rebasing onto it; the guard test added here is the thing that makes a divergence between those four numbers fail loudly instead of silently.
Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
New file
tests/integration/packaging/engines_test.ts(5 cases). It lives intests/integration/because the assertion spans all three workspaces so it belongs to no single package, and becauseintegrationis the only repo-level Vitest project actually executed bynpm run test:coverage(noteunit:integrationsis not in that project list, so a test placed inintegrations/test/would never run). No existing test was modified or deleted — this change alters no behaviour any current test observes.Proof each test can fail. Coverage of a declarative change is vacuous (zero new lines under the coverage
includeglobs, thresholds untouched), so each assertion was run against a mutated input and confirmed to fail with the targeted message. Every mutation was reverted and the suite re-confirmed green afterwards.core/package.jsonengines.node→">=22"AssertionError: expected '>=22' to be '>=20'enginesblock fromintegrations/package.jsonAssertionError: expected undefined to be '>=20'.nvmrc→18AssertionError: expected 18 to be greater than or equal to 20with:block fromcross-language-integration.ymlAssertionError: cross-language-integration.yml must resolve Node from .nvmrc: expected undefined to be '.nvmrc'actions/setup-node@v6→actions/setup-nodejs@v6so the walk matches nothingAssertionError: expected 0 to be greater than 0(the anti-vacuity guard: the test cannot pass by finding no steps)Mutation 5 is not in the spec; it was added because cases 1–4 all leave the walk intact, so none of them would catch the test silently degenerating into an empty loop.
Manual End-to-End (E2E) Tests:
Verified the
enginesfield actually reaches the published artifact (npm packdepends on a builtdist/, so this stays a manual gate rather than an automated test):Full local validation on the exact pushed commit:
Two disclosures on the checks above:
npm run ts:checkis red on the base branch already — 281 pre-existing errors across 41 files (core/test/**, plus twotests/integration/**files), none of which this PR touches. I measured it both ways: 281 errors with the change, 281 errors with the change stashed — identical. My new test file does not appear in the error list. I did not attempt to fix the pre-existing 281; that is unrelated churn and belongs in its own PR.package-lock.jsonis intentionally not in this diff.enginesis manifest metadata and does not affect resolution; a fullnpm installon Node 22.22.2 / npm 10 left the lockfile byte-identical, so there is no hunk to include. (Two of the sibling PRs above do carry a lockfile hunk here; it is not required.)CI result on this PR: all checks green —
run-tests (ubuntu-latest),run-tests (macos-latest),run-tests (windows-latest)and the cross-languagerun-testsall pass.Because this PR changes the Node version CI executes on, here is the direct evidence that the pin took effect and what it changed:
.nvmrcand reportednode: v22.23.1,Found in cache @ /Users/runner/hostedtoolcache/node/22.23.1/arm64— so no download cost; the version is already in the runner tool cache.node: v24.18.0. CI was silently running Node 24 while the repo's own@types/nodeis^20.12.7. That gap is exactly whatnode-version-filecloses.One honest disclosure about the first attempt: the macOS leg initially failed with two 40s timeouts in
tests/integration/app_loader/app_loader_test.ts(a file this PR does not touch). I did not paper over it — I re-ran it on the identical commit and pin, and it passed. The numbers show why it was runner variance rather than a Node-version regression:A later commit on this same branch then passed all three legs first-try (macOS in 4m10s), which is a third data point on the same pin. The spread between two runs on the same Node 22 (164s vs 68s transform, 2.4×) is far larger than the gap between Node 22 and Node 24 (68s vs 52s), so the timeout is macOS runner variance meeting a tight 40s per-test budget, not something the pin caused. I deliberately did not change
.nvmrcto make this green, and did not touch that test's timeout — it is a pre-existing fragility in a file unrelated to this change and belongs in its own PR.The
build_setupfixture suite was run specifically because it is the one place the CI Node pin can actually bite: itsts_*_native_addonfixturesnpm installand compile a native addon in abeforeAllhook and are therefore sensitive toNODE_MODULE_VERSION.One limitation of this Testing Plan worth stating plainly:
.nvmrcpins22while the published floor is>=20, so no CI job actually executes on Node 20. The declared floor is backed by the dependency-graph evidence above and pinned by the guard test, but it is not exercised by a running job. That is a deliberate trade-off — one toolchain version rather than anode-versionmatrix, which would multiply the job count on an already-heavy three-OS workflow — and theenginesfield communicates the floor to consumers either way. Adding that matrix is worth doing in its own reviewable PR, not here.Not performed: I do not have a Node 18 toolchain available locally, so I did not empirically observe the
EBADENGINEwarning text a Node 18 consumer would see. The warning behaviour is npm's, driven entirely by the declared field, but I am flagging it rather than claiming a check I did not run.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.