Skip to content

Ci: pin an explicit Node.js version in CI and declare the supported floor - #509

Open
AmaadMartin wants to merge 2 commits into
mainfrom
fix/pin-explicit-node-version-in-ci
Open

Ci: pin an explicit Node.js version in CI and declare the supported floor#509
AmaadMartin wants to merge 2 commits into
mainfrom
fix/pin-explicit-node-version-in-ci

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    Closes: #issue_number
    Related: #issue_number
  2. Or, if no issue exists, describe the change:
    Problem: Both CI workflows call actions/setup-node@v6 with no node-version input, so the Node.js version under test is whatever the current GitHub runner image happens to ship — undeclared, unreviewed, and free to change when the image is rebuilt.

Per the actions/setup-node README the input is optional and, when omitted, the action installs no toolchain at all; it just leaves the runner's preinstalled Node on PATH. Three consequences:

  • The 3-OS matrix does not hold Node constant. ubuntu-latest, windows-latest and macos-latest are independently built images. They agree today, but nothing enforces it, and they skew during staged image rollouts. A matrix leg that differs in two variables cannot attribute a failure to either one.
  • The tested runtime drifts silently. When GitHub bumps the image's Node, the tested version changes with no commit and no record. A version-specific regression can land green, and a version-specific breakage can turn CI red on an unrelated PR.
  • Nothing was written down. The root package.json had no engines field, and README.md promised only "a current Node.js LTS release" — a claim no file in the repo asserted.

This is a regression rather than an original omission: commit 4365b12 bumped actions/setup-node@v3@v6 and deleted the node-version: '20' input in the same hunk.

Node is also the only unpinned toolchain in these files — setup-python is pinned to '3.11' and setup-go to '1.25'. This makes the three consistent.

Solution: Four edits, 11 insertions and 2 deletions across four files.

  1. .github/workflows/validation.yaml — add a node-version: ['22', '24'] matrix dimension, wire it into the step with node-version: ${{ matrix.node-version }}, and rename the step to Use Node.js ${{ matrix.node-version }} so the version appears in the job log header.
  2. .github/workflows/cross-language-integration.yml — pin node-version: '24'. A matrix here was rejected deliberately: it is a single-OS A2A wire-protocol interop smoke test whose failure modes are serialization and transport rather than Node runtime semantics, and it provisions a Go toolchain per job. The Node-version signal comes from validation.yaml.
  3. package.json (root) — add "engines": { "node": ">=22.13.0" }.
  4. README.md — replace the vague prerequisite with the concrete floor and the tested lines.

Why >=22.13.0, and why both 22 and 24. The floor is derived from the installed dependency graph, not chosen. I scanned all 739 packages in node_modules that declare engines.node and counted how many each candidate floor leaves unsatisfied:

Candidate floor Unsatisfied deps
22.0.0 6
22.12.0 5
22.13.0 0
24.0.0 0

The binding constraint is eslint-visitor-keys@5.0.1, which declares ^20.19.0 || ^22.13.0 || >=24. Its ^20.19.0 branch is excluded because Node 20 reached end of life on 2026-04-30, leaving 22.13.0 as the lowest version that satisfies the whole tree. Node 22 (maintenance LTS, EOL 2027-04-30) and Node 24 (active LTS, EOL 2028-04-30) are exactly the supported LTS lines today, which is why the matrix tests both: testing only one would make the declared floor an untested claim. Node 26 is deliberately excluded — it is Current, not LTS until 2026-10-28.

Because the matrix and engines and the README now all name the same numbers, a cross-check asserts they cannot drift apart (see Testing Plan).

Scope deliberately held narrow. No npm ci, no cache: 'npm', no cache-dependency-path, no package-manager-cache — all separately tracked. In particular packageManager and devEngines are not added to package.json: under setup-node@v6 either field set to npm auto-enables npm dependency caching in every job using the action, which would smuggle the caching change into this diff. engines is the only new manifest key. No .nvmrc / .node-version / node-version-file either — a version file cannot express a two-version matrix, and node-version-file: 'package.json' would resolve the >=22.13.0 range to the newest satisfying release, which today is Node 26 (non-LTS). Workspace manifests and package-lock.json are untouched; declaring engines on the three published packages is a separate concern.

Collision check (required, and it found a lot). Before writing anything I ran gh pr list --repo AmaadMartin/adk-js --state open --limit 1000 (409 open PRs) and diffed every plausibly adjacent one. Seven open PRs touch these same two setup-node steps: #133 and #508 (both .nvmrc + node-version-file, plus engines on the workspace manifests), #428 ('24' in both workflows), #416 (.nvmrc in both), #406 ('22' in both), #467 ('24' in validation.yaml, bundled with npm ci), plus #443 (README floor only) and #445 (engines on published packages only).

I did not stack on any of them: they mutually conflict on the same lines, so at most one can land, and the two closest (#133, #508) use the .nvmrc + node-version-file mechanism this change deliberately avoids for the reason above. Note also that #508 declares >=20, which is EOL, and #133 declares >=22.0.0, which the table above shows leaves six packages unsatisfied. Maintainers should expect to close the duplicates in favour of whichever approach they prefer.

Two operational notes for the merger:

  • Required status check names change. run-tests currently has no name:, so its checks are run-tests (ubuntu-latest) etc. Adding the matrix dimension renames them to run-tests (<os>, <node>). If branch protection on main lists the old names as required checks, an admin must update them or PRs will block on checks that no longer exist. This cannot be fixed from a branch.
  • Runner cost doubles, 3 legs → 6. This is a public repo so GitHub-hosted runners are free; the real cost is runner-pool concurrency (two macOS legs instead of one — macOS runners are scarcest). Wall clock is unchanged when all six legs start together. Accepted because it is the only way to make the declared floor a tested claim. fail-fast is left at its default, so one red leg cancels its siblings — re-run to see the full picture.

One honest gap: >=22.13.0 as a range also admits Node 23.x, which eslint-visitor-keys excludes. In practice this is academic — 23 is an odd-numbered Current line that is already EOL and no one should be running it — and expressing it exactly (^22.13.0 || >=24) would state a range where the task asked for a floor.

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.

No unit test file was added, and that is deliberate — it must not be faked. This change adds zero lines of executable TypeScript; it is three declarative config lines plus one manifest key. There is no new code to cover and no test that could meaningfully assert on it at runtime, so adding a test file purely to manufacture a coverage number would be dishonest. The equivalent rigour is the mechanical assertions below, run against the committed tree.

A1 OK .github/workflows/validation.yaml -> "${{ matrix.node-version }}"
A1 OK .github/workflows/cross-language-integration.yml -> "24"
A2 OK 6 legs: ubuntu-latest, 22 | ubuntu-latest, 24 | windows-latest, 22 |
      windows-latest, 24 | macos-latest, 22 | macos-latest, 24
A3 OK engines: {"node":">=22.13.0"}   (and no packageManager / devEngines)
A4 OK floor satisfies strictest transitive constraint
A5 OK README <-> engines <-> matrix consistent

A1 parses both workflows with js-yaml (already in the tree; no dependency added) and asserts each has exactly one setup-node step carrying a non-empty node-version. This is the assertion that catches the silent failure mode — a typo'd expression yields an empty input and falls back to system Node, i.e. the exact bug being fixed, with no YAML parse error to warn you. A5 re-derives the floor from engines.node and the tested lines from the matrix and greps both out of README.md, so the three can never drift.

Proof the assertions can fail. Each was run against the unfixed tree (git stash of all four files), and each failed with a specific message:

Assertion Failure against unfixed tree
A1 Error: .github/workflows/validation.yaml: setup-node has no node-version input
A2 Error: expected 6 matrix legs, got 0
A3 Error: bad engines: undefined
A5 Error: no engines.node to compare README against

Two further targeted line mutations against the fixed tree, to prove the assertions pin the exact values rather than merely their presence:

Mutation Resulting failure
node-version: ['22', '24'][22, 24] (unquoted) Error: node-version values must be quoted strings
engines.node">=22.0.0" Error: floor 22.0.0 does not satisfy eslint-visitor-keys engines and Error: README floor does not match engines.node (22.0.0)

The quoting matters: unquoted integers are harmless today but become a footgun the moment someone writes 22.13, which YAML parses as a float and truncates.

Guards run on the committed tree (unchanged by this diff, run to prove no collateral damage): npm run lint clean, npm run format:check clean, npx prettier --check package.json README.md clean, git diff fork/main --name-only lists exactly the four intended files, and package-lock.json is byte-identical after a full npm install.

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

This PR is its own end-to-end test — both edited workflows trigger on pull_request: branches: [main], so opening it exercises the change for real. On the Checks tab, confirm:

  1. Six run-tests (<os>, <node>) checks appear instead of three.
  2. Each leg's step is titled "Use Node.js 22" or "Use Node.js 24", and the log shows the action resolving/installing that exact version rather than reporting a preinstalled system Node.
  3. Cross-Language Tests / run-tests is green and its "Use Node.js" log shows a 24.x version.
  4. No EBADENGINE warning in any leg's npm install (both 22 and 24 satisfy >=22.13.0).

To verify the engines declaration is advisory and cannot break a contributor:

nvm use 22   # or any >= 22.13.0
npm install  # completes with no EBADENGINE naming the root `adk` package

nvm use 20
npm install  # prints `npm WARN EBADENGINE` citing adk and
             # required: {"node":">=22.13.0"} -- and still completes

There is no .npmrc in the repo, so engine-strict is off; engines warns and never fails an install.

If a Node 24 leg fails, that is a real finding — Node 24 has never been exercised by this repo. It is reported here rather than masked; the pin is not silently downgraded to hide it.

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.


CI results on this PR (the end-to-end test above, actually run)

All 9 checks pass. The six matrix legs materialized exactly as designed:

run-tests (ubuntu-latest, 22)    pass  5m34s
run-tests (ubuntu-latest, 24)    pass  5m01s
run-tests (windows-latest, 22)   pass  7m39s
run-tests (windows-latest, 24)   pass  7m35s
run-tests (macos-latest, 22)     pass  4m34s
run-tests (macos-latest, 24)     pass  6m06s

Each leg's step header renders the interpolated version (Use Node.js 22 /
Use Node.js 24), and the action now resolves a real toolchain instead of
falling back to the runner's system Node:

ubuntu-latest, 22 ->  node-version: 22   Found in cache @ .../node/22.23.1/x64
ubuntu-latest, 24 ->  node-version: 24   Found in cache @ .../node/24.18.0/x64

22.23.1 is the version the runner images preinstall, so the '22' leg
preserves exactly what CI was already doing by accident; 24.18.0 is the
net-new coverage this PR buys. No EBADENGINE warning appeared in any leg.

One flake, reported rather than hidden. On the first run,
run-tests (windows-latest, 24) failed — 2679 tests passed, one test file
did not:

FAIL  integration  tests/integration/adk_web/webui_test.ts > WebUI Integration Test > 'Run from ADK CLI'
Error: CLI exited prematurely with code 1
 ❯ ChildProcess.<anonymous> tests/integration/test_case_utils.ts:341:13
Test Files  1 failed | 223 passed | 20 skipped (244)

I re-ran that single job against the same commit with no code change and it
passed (7m35s), which makes the failure non-deterministic rather than a Node 24
incompatibility. The test spawns the ADK CLI as a child process and waits for a
startup message, which is the same shape as the Windows/macOS child-process
timing flakes already tracked elsewhere in this repo; it touches no code this PR
changes (this PR changes no TypeScript at all). I have not downgraded the
Windows pin to '22' to make it disappear — the pin stands, and the flake is
filed as separate work.

Worth noting for reviewers: this is arguably the matrix earning its keep on day
one. A pre-existing Windows flake that was previously a one-in-three-legs event
is now a one-in-six-legs event, so fail-fast will trip more often until the
underlying test is stabilized.

Amaad Martin added 2 commits August 2, 2026 01:54
Both workflows called actions/setup-node with no node-version input, so the
tested runtime was whatever Node happened to be baked into the current GitHub
runner image -- undeclared, unreviewed, and free to drift when the image is
rebuilt. The three-OS matrix did not hold Node constant either, since the
ubuntu, windows and macos images are built independently.

validation.yaml gains a node-version matrix dimension covering both supported
LTS lines (22 and 24), expanding run-tests from 3 legs to 6 and making a
Node-version-specific regression attributable to a version rather than an OS.
The step is renamed to include the version so it is visible in the job log.

cross-language-integration.yml pins a single version: it is a single-OS A2A
wire-protocol interop test whose failure modes are serialization and transport,
not Node runtime semantics, so one declared version is enough there.

This restores a pin that was dropped when setup-node was bumped v3 -> v6 and
the node-version: '20' input was deleted in the same hunk. It does not come
back as '20', which is end-of-life.
The root manifest declared no engines field, so contributors got no signal
about the supported runtime, and README.md promised only "a current Node.js
LTS release" -- a claim nothing in the repository asserted or enforced.

engines.node is set to >=22.13.0. That floor is derived from the installed
dependency graph rather than picked: eslint-visitor-keys@5.0.1 declares
^20.19.0 || ^22.13.0 || >=24, which is the strictest constraint in the tree,
and the v20 branch is excluded because Node 20 reached end-of-life. 22.12.0
leaves five packages unsatisfied; 22.13.0 leaves none.

There is no .npmrc, so engine-strict is off and this is advisory: an
unsupported runtime gets an npm WARN EBADENGINE and the install still
succeeds. The README prerequisite now names that same number and the two LTS
lines CI actually tests, so the documented floor, the declared floor and the
tested versions cannot drift apart silently.

This is the dev/CI toolchain floor on the workspace root, which is not a
published artifact. Declaring engines on the three published packages is a
separate concern and is deliberately not done here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant