Feat: declare a supported Node.js range (>=20.6.0) on the published packages and enforce it against the lockfile - #544
Open
AmaadMartin wants to merge 3 commits into
Open
Conversation
added 2 commits
August 2, 2026 14:35
Consumers of @google/adk, @google/adk-devtools and @google/adk-integrations had no machine-readable statement of which Node.js versions are supported. The only support statement was prose in the README -- "a current Node.js LTS release" -- which no tool can check and whose meaning drifts with the calendar. A user on an unsupported runtime got silence at install time and an opaque failure later, from a transitive dependency. The floor is derived from the runtime dependency closure recorded in package-lock.json rather than chosen as a policy: across the 426 third-party entries a consumer inherits, 27 @opentelemetry/* packages require '^18.19.0 || >=20.6.0' and 13 more (including @google/genai) require '>=20.0.0', so Node 20.5.0 is rejected and 20.6.0 satisfies every range. The two ranges in the lockfile that would force a higher floor -- eslint-visitor-keys and vite -- are devDependency-only and are never installed by a consumer, so they must not raise the published floor. The root manifest deliberately gets no engines field: it is the npm workspace container, release-please marks it skip-changelog, and it has no files array, so a declaration there would reach nobody. Not a breaking change. npm treats an unsatisfied engines range as an advisory EBADENGINE warning, so installs that succeed today still succeed; only consumers who have opted into engine-strict themselves see a hard failure, and only on runtimes 13 of core's dependencies already reject.
Declaring a floor is only half the job: nothing checked that the floor is
actually deliverable. A floor that is too low promises support the packages
cannot honour, which is strictly worse than declaring nothing -- the install
looks clean and the failure surfaces later, from a transitive dependency, on a
runtime the metadata said was fine.
This walks every third-party entry in the committed package-lock.json that a
consumer inherits (dev entries dropped, devOptional kept, workspace entries
excluded since their range is the value under test) and asserts the lowest
Node version each published manifest admits satisfies all of them, naming the
offending package and range when it does not. A vacuity guard fails the run if
the lockfile scan ever returns nothing, so the check cannot rot into a no-op.
The check is deliberately one-sided: it pins the floor from below only, so a
dependency loosening its own range never forces a manifest edit, and a
maintainer stays free to tighten the floor later as a policy decision. The
literal range assertion pins it from above, so the published value cannot move
without an explicit, reviewed edit.
semver and @types/semver become root devDependencies: the ranges involved
('^18.19.0 || >=20.6.0', '18 || 20 || >=22') need a real range parser, and
semver@7.8.4 was already in the tree only as a transitive runtime dependency,
so importing it without declaring it would be a phantom dependency.
AmaadMartin
force-pushed
the
feat/declare-node-engines-published-packages
branch
from
August 2, 2026 21:37
730ccb3 to
3abdf46
Compare
AmaadMartin
changed the base branch from
feat/declare-engines-node-range
to
main
August 2, 2026 21:37
The comment justified the assertion with two claims the repository contradicts: release-please-config.json does register the root as a tracked package (component "main"; only skip-changelog is set), and a missing files array widens what npm publish includes rather than preventing publication. Replaced with the reason that actually holds. The assertion itself is unchanged.
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: none of the three published manifests declares an
enginesfield,so
npm install @google/adkon an unsupported Node prints nothing and thefailure surfaces later and opaquely, from a transitive dependency. The only
support statement in the repo was prose at
README.md:55— "requires a currentNode.js LTS release" — which no tool can check and whose meaning drifts with the
calendar (at time of writing that could mean 20.x, 22.x or 24.x).
Solution: three changes.
"engines": {"node": ">=20.6.0"}oncore,devandintegrations— thethree release-please-managed, publishable workspaces.
dependency in the committed
package-lock.json, so the declaration cannotquietly become a lie.
Why
>=20.6.0, and why it is derived rather than chosen. The value is theexact intersection of the
engines.noderanges in the runtime dependencyclosure, computed from the committed lockfile:
@opentelemetry/*,^18.19.0 || >=20.6.0)>=20.0.0(13 packages including@google/genai) eliminates the^18.19.0branch of the OpenTelemetry range, leaving
>=20.6.0as the tight bound.The two ranges in the lockfile that would force a higher floor are
devDependency-only and must not be allowed to raise a consumer-facing
declaration:
^20.19.0 || ^22.13.0 || >=24(five copies ofeslint-visitor-keysundertypescript-eslint) and^20.19.0 || >=22.12.0(
vite, viavitest). A consumer installing@google/adknever receiveseither, so tracking them would lock out runtimes that work fine.
That point is load-bearing, because three sibling PRs on this fork each picked a
different value and none is the derived one. Running this check against each:
>=20>=22.0.0>=22.12.0vite— a devDependency no consumer installsThe root manifest deliberately gets no
enginesfield: it is the npmworkspace container,
release-please-config.jsonmarks itskip-changelog, andit has no
filesarray, so a declaration there reaches nobody. A test pins thatdistinction so it is not "helpfully" re-added.
The check is deliberately one-sided, and the test says so in a comment. It
asserts the floor is not too low — the direction that breaks users — and does
not assert it is tight. Asserting tightness would force a manifest edit every
time an unrelated dependency loosens its own range, and would forbid a
maintainer from later tightening the floor as a support-policy decision (e.g.
when Node 20 ages out). The floor is pinned from above by the literal
EXPECTED_NODE_RANGEassertion, so the published value still cannot movewithout an explicit, reviewed edit.
Dependency added:
semver+@types/semveras rootdevDependencies. Theclosure contains ranges like
^18.19.0 || >=20.6.0,18 || 20 || >=22and6.* || 8.* || >= 10.*; hand-rolling range comparison would be a bug farm.semver@7.8.4was already in the tree as a transitive runtime dependency, soimporting it without declaring it would be a phantom dependency, and semver 7
ships no types of its own. Nothing new is downloaded — the lockfile diff is two
lines in
packages[""].devDependenciesplus the threeenginesblocks npmwrites for the workspace packages.
Relationship to the other open PRs (collision check). Open PRs on the fork
were enumerated before any code was written:
Three live PRs add
enginesto these three manifests: #445 (>=22.12.0+a manifest drift guard), #508 (
>=20+.nvmrcand CI pins) and #133(
>=22.0.0+.nvmrc, CI pins, rootengines). None of the three checks thedeclaration against the lockfile, which is how the unsound
>=20in #508survived review. This PR supersedes all three for the manifest half and adds
the enforcement none of them has; whichever lands first, the others should be
closed or narrowed to their CI/
.nvmrchalves rather than merged alongside.Adjacent PRs cleared by file list: #443 (
README.mdonly — overlaps item 3here), #507/#467/#345/#415/#338 (lockfile freshness under
npm ci, adifferent invariant), #428/#416/#406/#509/#510 (CI Node pinning only).
Deliberately out of scope, so the omissions are not read as oversights: a root
.npmrcwithengine-strict=true(never published, so it does nothing forconsumers; it would make devDependency ranges hard install errors —
including
eslint-visitor-keys's rejection of Node 22.0–22.12 — and must landtogether with a CI Node pin), pinning
node-versionin the workflows, and"private": trueon the root manifest.Not a breaking change. npm treats an unsatisfied
enginesrange as anadvisory
EBADENGINEwarning, so installs that succeed today still succeed.Only consumers who have opted into
engine-strictthemselves get a hardfailure, and only on runtimes that 13 of
core's runtime dependencies —@google/genaiamong them — already reject. The declaration reports apre-existing incompatibility rather than creating one.
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.
One new file,
tests/integration/packaging/engines_test.ts. It lives undertests/integration/because that is the onlytests/project wired into bothnpm testandnpm run test:coverage(theunit:integrationsproject definedin
vitest.config.tsis in neither), and becausetests/integration/build_setup/is the existing precedent for repo-level packaging checks. Paths are built with
path.joinso it behaves on the Windows runner. No existing test was modified,skipped, weakened or deleted.
Eight cases across three concerns:
%s declares the shared engines.node range(×3) — a single equality againstthe literal
>=20.6.0per workspace, which simultaneously asserts the field ispresent, that all three agree, and that the floor is exactly the derived value.
it.eachnames the failing workspace, so a drift still reports which one moved.leaves engines off the workspace root— pins the published-vs-containerdistinction.
reads runtime engine constraints out of package-lock.json— the vacuityguard.
%s declares a floor every runtime dependency accepts(×3) — the core check.Proving the tests can fail. Every assertion was run against a mutated tree
and observed to fail; the tree was restored and re-verified green (8/8) after
each. The offender counts are exact — a wrong filter or a wrong
satisfiescallchanges them.
corefloor →">=20.5.0"engines.node >=20.5.0 admits Node 20.5.0, which the listed runtime dependencies reject...: expected [ …(27) ] to deeply equal []corefloor →">=18.0.0"corefloor →">=22.0.0"expected '>=22.0.0' to be '>=20.6.0'. This is exactly why the literal case exists.enginesdeleted fromintegrations/package.jsonexpected undefined to be '>=20.6.0', andintegrations/package.json declares engines.node undefined, which is not a satisfiable semver rangecorefloor →"<0.0.0"(valid range, unsatisfiable →minVersionreturns null)core/package.json declares engines.node "<0.0.0", which is not a satisfiable semver rangepackage.jsongiven anenginesblockexpected { node: '>=20.6.0' } to be undefinednode_modules/→no_such_dir/(empties the closure)expected [] to not have a length of +0The last one is the vacuity guard: without it the whole check passes trivially
if the lockfile scan ever silently returns nothing, which is how a check like
this rots — green forever while testing an empty set. The two
expect.failrowscover both halves of the null-floor guard (field absent, and
present-but-unsatisfiable) rather than dereferencing a possibly-null
SemVer.Coverage: this change adds no lines under
core/src,dev/srcorintegrations/src, the only paths in the vitestcoverage.includelist, so thethresholds are untouched and were not edited. Every branch of the new code is
exercised above: both outcomes of each of the three filter conditions, both
expect.failpaths, and both the empty and non-empty offender lists.No
any, noas any, no@ts-expect-error, noeslint-disable, no coveragesuppression anywhere in this diff.
npx tsc --noEmit --pretty falsereports281 errors before this change and 281 after (pre-existing
core/test/**dual-identity errors between
core/dist/typesandcore/src); 0 mentionthe new file.
Manual End-to-End (E2E) Tests:
Machine:
node -v→v22.22.2,npm -v→9.2.0.The lockfile is a faithful stand-in for what a consumer installs. The
check reads engine ranges out of
package-lock.json; if that metadata werestale the whole thing would be theatre. Every collected constraint was
cross-checked against the real
package.jsonon disk innode_modulesaftera clean
npm install:Zero mismatches. (The 25 are hoisted/deduped paths npm records but does not
materialise at that exact location.)
The declared value is verified, not asserted — the probe behind the table
at the top:
devOptionalsensitivity, measured rather than assumed: switching thepredicate from
dev !== trueto!devselects the same 426 entries andyields the same 0 offenders, because npm writes
devOptionalinstead ofdevfor dual-reachable packages. The stricter predicate is kept anyway —devOptionalpackages are runtime-reachable — and is called out here so afuture reader does not "simplify" it into a behaviour change.
The published manifests carry the field, and the values agree:
Build, lint, format, license:
Regression: the
build_setupintegration suite, which performs realnpm install/npm run buildruns against these manifests and this lockfile,still passes:
CI on this PR is green:
run-testspassed on ubuntu-latest, windows-latestand macos-latest (they run
npm run test:coverage, which includes theintegrationproject and therefore this new test), plus the cross-languagerun-testsjob andcheck-license. No workflow file needed to change.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.