Skip to content

chore(ci): pin DMXr to Node 24 LTS and enforce it end to end - #127

Merged
thewrz merged 4 commits into
mainfrom
chore/pin-node-24-lts
Aug 2, 2026
Merged

chore(ci): pin DMXr to Node 24 LTS and enforce it end to end#127
thewrz merged 4 commits into
mainfrom
chore/pin-node-24-lts

Conversation

@thewrz

@thewrz thewrz commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

This was written agentically; verify its assertions and edit accordingly:

#125 has merged; this PR auto-retargeted to main and CI now runs on it (it did not while stacked — ci.yml/codeql.yml/dependency-review.yml filter branches: [main], which matches the base branch).

Why

DMXr declared its Node version in four places that disagreed with each other and with reality:

Declaration Was Problem
engines.node ">=18.0.0" Node 18 EOL since 2025-04-30. Open-ended, so every newer major satisfies it.
@types/node ^25.7.0 Node 25 is EOL (2026-06-01) and never was an LTS — it is an odd major. Typings three majors ahead of CI.
CI node-version 22, hardcoded ×5 across 3 workflows Nothing related the copies; they drift independently.
.nvmrc absent Local runtimes unconstrained.
Maintainer's local runtime v26.4.0 Four majors past CI, no version manager installed.

The @types/node@25 / Node 22 gap is the costly one: tsc validates an API surface the
runtime does not have. A typecheck catches shape errors, not behavioral drift. Agent-assisted
implementation compounds it — models lag current releases and confidently emit code against
APIs they do not reliably know.

This is also a product decision, not just a CI one. build-server.yml bundles a portable
Node runtime into the release artifact, downloading node-$(node -e "console.log(process.version)")
from nodejs.org. Whatever setup-node resolves is what ships to end users.

Why Node 24

Major Phase (2026-08-01) LTS start EOL
v22 Maintenance LTS 2024-10-29 2027-04-30
v24 Active LTS 2025-10-28 2028-04-30
v25 Already EOL never (odd major) 2026-06-01
v26 Current — pre-LTS 2026-10-28 2029-04-30

Node 24 is the oldest Active LTS still reasonable, and has been LTS long enough to sit inside
the training window of every model used on this repo. Node 26 does not, and should stay
off-limits well past its LTS date — ecosystem idioms accumulate for months after.

What

Pinned and enforced, not documented:

  • .nvmrc (24) is the single source of truth. All five setup-node call sites now read
    node-version-file; no hardcoded major remains anywhere.

  • engines.node is the bounded ">=24 <25", plus engine-strict=true in server/.npmrc.
    Both halves are load-bearing — measured on npm 11.16.0 in real containers:

    engines.node server/.npmrc Runtime npm install
    ">=24 <25" (none) Node 26 exit 0 — installs silently
    ">=24" (open) engine-strict=true Node 26 exit 0 — range is satisfied
    ">=24 <25" engine-strict=true Node 26 exit 1
    ">=24 <25" engine-strict=true Node 24 exit 0

    This was re-measured rather than ported: a sibling pnpm repo found the same .npmrc
    spelling completely inert under pnpm 11, needing engineStrict: true in pnpm-workspace.yaml.
    The mechanism does not transfer between package managers.

  • @types/node tracks the runtime major (^24.13.3). tsc is clean after the downgrade —
    zero fallout.

  • New node-pin CI job runs npm run check:node-pin, catching what engine-strict cannot:
    declarations that are each individually valid but have drifted apart.

  • Automation is told the target. Renovate gets constraintsFiltering: "strict" on runtime
    deps, Node majors disabled, and node-version/@types/node bounded <25. Dependabot has no
    engines-awareness at all, so it gets an explicit @types/node major ignore with CI as backstop.

A note on where the gate lives

The comparison logic is in src/config/node-pin.ts, not scripts/tsconfig.json includes
only src/**/*.ts and vitest collects only src/**/*.test.ts, so a script there would have been
neither typechecked nor tested. scripts/check-node-pin.ts is a thin I/O shell, and the new
tsconfig.scripts.json typechecks it (and anything added to scripts/ later) under the same
strict flags.

⚠️ This will break your local install until you're on Node 24

By design — npm ci now exits 1 with expected-vs-actual instead of warning. It bites immediately
on this machine (currently Node 26.4.0, system /usr/bin/node, no version manager). A manager
that reads .nvmrc is the ergonomic fix: fnm use / nvm use / mise install.

Testing

Verified in real node:24 and node:26 containers, since local Node 26 is now correctly rejected:

  • npm ci — clean under Node 24.18.1 / npm 11.16.0
  • npm run check:node-pin — "consistent — Node 24 across 4 declarations and the running runtime"
  • npm run typecheck — exit 0 with @types/node 24 (no fallout from the downgrade)
  • npm run typecheck:scripts — exit 0
  • npm run build — exit 0
  • npm audit --audit-level=high — 0 vulnerabilities
  • 1670 tests pass, 11 skipped, 106 files (29 new, covering node-pin.ts)
  • Negative: npm ci under Node 26 → exit 1, Required: {"node":">=24 <25"} Actual: v26.5.1
  • Negative: check:node-pin under Node 26 → exit 1 with an actionable diff
  • All four workflow/Dependabot YAML files and both JSON configs parse
  • CI green — 8/8 checks, including the new node-pin job. CI resolved /opt/hostedtoolcache/node/24.18.0/x64 from .nvmrc and the gate reported "consistent — Node 24 across 4 declarations and the running runtime"
  • Manual: npm ci succeeds once you're on Node 24

Out of scope

  • tsconfig.json keeps target: "ES2022". Aligning it to a Node-24-accurate preset changes emit
    and deserves its own PR.
  • Renovate's constraintsFiltering covers direct dependencies only, so transitive engine drift
    remains a known gap.

🤖 Co-authored by Claude Opus 5 (1M context). Closes #126.

Summary by CodeRabbit

  • New Features

    • Standardized development and release environments on Node.js 24 LTS.
    • Added validation to detect mismatched Node.js runtime and package requirements.
    • Added commands for type checking and verifying runtime configuration.
  • Bug Fixes

    • Prevented incompatible Node.js and type-definition updates from being adopted automatically.
  • Documentation

    • Updated development documentation with Node.js requirements and available validation commands.

DMXr declared its Node version in four places that disagreed with each other
and with reality: engines.node was the open-ended ">=18.0.0" (Node 18 has been
EOL since 2025-04-30), @types/node was ^25.7.0 -- a major that is itself EOL
and never was an LTS -- CI hardcoded Node 22 in five separate spots, and the
maintainer's machine ran Node 26.4.0. Nothing related any of them, so nothing
could notice.

The @types/node 25 against a Node 22 runtime is the costly shape: tsc validates
an API surface the runtime does not have, and a typecheck catches shape errors,
not behavioral drift. Agent-assisted implementation compounds it, since models
lag current releases and emit code against APIs they do not reliably know.

Node 24 (LTS since 2025-10-28, EOL 2028-04-30) is the oldest Active LTS still
reasonable, and sits inside the training window of every model used here. Node
26 does not, and stays off-limits past its 2026-10-28 LTS date.

Unlike a CI-only pin, this one is also a product decision: build-server.yml
bundles a portable Node runtime into the release artifact, downloading whatever
version setup-node resolved. .nvmrc now governs what ships to users.

Enforced rather than documented:

- .nvmrc (24) is the single source of truth. All five setup-node call sites read
  node-version-file; no hardcoded majors remain.
- engines.node is the bounded ">=24 <25", plus engine-strict=true in
  server/.npmrc. Both halves are load-bearing -- measured on npm 11.16.0:

    engines          .npmrc              runtime   npm install
    ">=24 <25"       (none)              Node 26   exit 0  -- silent
    ">=24"  (open)   engine-strict=true  Node 26   exit 0  -- range satisfied
    ">=24 <25"       engine-strict=true  Node 26   exit 1  <-
    ">=24 <25"       engine-strict=true  Node 24   exit 0  <-

  This is npm-specific and was re-measured here rather than assumed: a sibling
  pnpm repo found the same .npmrc spelling completely inert under pnpm 11.
- @types/node tracks the runtime major (^24.13.3). tsc is clean after the
  downgrade -- no fallout.
- A new node-pin CI job runs check:node-pin, catching the case engine-strict
  cannot: declarations that are each valid but have drifted apart.
- Renovate gets constraintsFiltering on runtime deps, Node majors disabled, and
  node-version/@types/node bounded <25. Dependabot has no engines-awareness at
  all, so it gets an explicit @types/node major ignore with CI as the backstop.

The gate's comparison logic lives in src/config/node-pin.ts, not in scripts/,
because tsconfig includes only src/**/*.ts and vitest collects only
src/**/*.test.ts -- a script would have been neither typechecked nor tested.
tsconfig.scripts.json closes that gap for the thin I/O runner and anything else
added to scripts/ later.

Verified in real node:24 and node:26 containers, since local Node 26 is now
correctly rejected: npm ci, check:node-pin, typecheck, typecheck:scripts, build
and audit (0 vulnerabilities) all pass under Node 24; 1661 tests pass, 11
skipped. Negative test: npm ci under Node 26 exits 1 with expected-vs-actual.

Heads-up: this breaks local installs until the machine is on Node 24, by design.
A version manager reading .nvmrc is the fix (fnm use / nvm use / mise install).

Closes #126.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0feb127b-2df1-4d7d-989e-4eabf79cba4a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The repository now pins Node.js to version 24 through .nvmrc, package engines, npm settings, dependency automation, CI workflows, and a dedicated consistency checker.

Changes

Node 24 runtime enforcement

Layer / File(s) Summary
Runtime contract
.nvmrc, server/package.json, server/.npmrc
Node 24 is the required runtime. npm enforces the package engine range. Development typings target Node 24.
Pin validation
server/src/config/node-pin.ts, server/src/config/node-pin.test.ts, server/src/config/README.md
Added pure validation logic and tests for Node major parsing, semver range confinement, runtime checks, mismatch reporting, and input immutability.
CLI and type-check integration
server/scripts/check-node-pin.ts, server/tsconfig*.json, server/package.json
Added the Node pin CLI, type-check scripts, script-specific TypeScript configuration, and failure reporting for invalid declarations.
CI and dependency automation
.github/workflows/*, renovate.json, .github/dependabot.yml, CLAUDE.md
CI and release workflows read .nvmrc. CI runs type and pin checks. Renovate and Dependabot restrict Node-related updates. Documentation records the new commands and structure.

Sequence Diagram(s)

sequenceDiagram
  participant CI
  participant check-node-pin
  participant checkNodePin
  participant RepositoryDeclarations
  CI->>check-node-pin: run npm run check:node-pin
  check-node-pin->>RepositoryDeclarations: read .nvmrc and package.json
  check-node-pin->>checkNodePin: pass declarations and runtime version
  checkNodePin-->>check-node-pin: return consistency result
  check-node-pin-->>CI: report success or exit with status 1
Loading

Possibly related issues

  • SpecR issue 576: The change implements the same Node 24 pinning, bounded engine, CI validation, and dependency automation objectives.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The reviewable changes satisfy the linked issue objectives, but package-lock.json was excluded, so dependency lockfile consistency cannot be verified. Review server/package-lock.json to confirm the Node typings and semver dependency changes are synchronized.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: enforcing Node 24 LTS across DMXr.
Out of Scope Changes check ✅ Passed The changes support Node 24 pinning, validation, dependency controls, CI, release behavior, documentation, or tests.

Comment @coderabbitai help to get the list of available commands.

Adversarial review of #127 (Codex gpt-5.6-sol, xhigh) found the pin gate
accepted two range shapes that pass its check while still admitting a major we
never validated -- the exact drift it exists to prevent:

  engines.node ">=24 <26"    passed: it contains "<", so the has-an-upper-bound
                             test was satisfied. Node 25 satisfies the range.
  @types/node  ">=24"        passed: first number is 24. A fresh install can
  @types/node  "^24 || ^25"  resolve 25.x typings against a Node 24 runtime.

Both were reproduced as failing tests before the fix. Neither was live -- the
committed declarations are correct -- but a guard that only inspects the first
number and looks for a "<" cannot hold a pin against a later well-meaning edit.

Replaces the syntactic isBoundedRange with isRangeConfinedToMajor, which asks
semver whether the *whole* range is contained in the pinned major
(`subset(range, ">=24.0.0 <25.0.0")`). Range math is precisely the thing that
produced this bug, so it uses npm's own implementation rather than a second
hand-rolled attempt. Verified across nine range shapes; malformed ranges return
false rather than throwing, since an unreadable declaration is a failure.

semver is a devDependency, not a runtime one: node-pin.ts is a build-time gate,
so tsconfig.json now excludes it from emit. Confirmed on a clean build that
dist/config/ no longer contains it and nothing in dist/ imports semver -- which
matters here because build-server.yml prunes dev deps and ships dist/ as a
release artifact, where a stray import would be a latent landmine. It stays
typechecked via tsconfig.scripts.json and unit-tested via vitest, so excluding
it costs no coverage.

Verified under node:24: 1670 tests pass (11 skipped), npm ci clean, audit 0
vulnerabilities, typecheck + typecheck:scripts + build all exit 0. Negative,
under node:26: npm ci exits 1 and check:node-pin exits 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thewrz

thewrz commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

This was written agentically; verify its assertions and edit accordingly:

Adversarial cross-review — Codex gpt-5.6-sol, effort xhigh — 2 findings, both confirmed and fixed in 2fa65a6.

Both were real gaps in the gate's rigor. Neither was a live bug — the committed declarations
are correct — but a guard whose entire job is preventing range drift should not itself be
foolable by a range.

[P2] @types/node range not fully bounded (node-pin.ts) — ">=24" or "^24 || ^25"
passed, because the check read the range's first number. A fresh install could then resolve
25.x typings against a Node 24 runtime.

[P2] Engine upper bound not verified as the next major (node-pin.ts) — ">=24 <26"
passed, because the check only looked for a <. Node 25 satisfies that range, and
engine-strict would not reject it.

Both were reproduced as failing tests first, then fixed by replacing the syntactic
isBoundedRange with isRangeConfinedToMajor, which asks semver whether the whole range is
contained in the pinned major. Hand-rolled range math is what produced the bug, so this uses
npm's own implementation rather than a second attempt at it.

One consequence worth flagging for review: semver is a devDependency, and
tsconfig.json now excludes node-pin.ts from emit. build-server.yml prunes dev
dependencies and ships dist/ as a release artifact, so a semver import reaching dist/
would be a latent landmine. Verified on a clean build that dist/config/ no longer contains
the gate and nothing in dist/ imports semver. It stays typechecked and unit-tested, so the
exclusion costs no coverage.

Verification after the fix, in real containers (local Node 26 is now correctly rejected):
node:24 — 1670 tests pass / 11 skipped, npm ci clean, audit 0 vulnerabilities, typecheck +
typecheck:scripts + build all exit 0. node:26npm ci exits 1, check:node-pin exits 1.

Note this PR is stacked on #125, and ci.yml / codeql.yml / dependency-review.yml all
filter branches: [main] — which matches the base branch — so no CI runs here until
#125 merges and this retargets to main. The verification above is local-container only.

🤖 Co-authored by Claude Opus 5 (1M context).

Base automatically changed from chore/dependabot-alongside-renovate to main August 2, 2026 01:07
@thewrz
thewrz marked this pull request as ready for review August 2, 2026 05:00
@thewrz

thewrz commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
server/src/config/node-pin.test.ts (1)

59-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reference the fix commit SHA in the regression-test comments.

These regression tests document the bug that adversarial review of PR #127 found, but the comments do not cite the fix commit. Add the commit SHA 2fa65a6, as required by the coding guideline for regression tests.

As per coding guidelines, server/src/**/*.test.ts: "Add a regression test for every bug fix and reference the commit SHA."

Also applies to: 145-153

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/config/node-pin.test.ts` around lines 59 - 63, Update the
regression-test comments associated with isRangeConfinedToMajor, including the
additional tests around the referenced range cases, to cite fix commit SHA
2fa65a6. Preserve the existing test behavior and explanations while adding the
required commit reference to each applicable regression-test comment.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 29-47: Add job-level permissions to the node-pin job, granting
only contents: read. Leave all other permissions unspecified so they remain
unavailable, without changing the existing checkout, setup-node, dependency
installation, or Check Node pin steps.

In `@server/src/config/README.md`:
- Around line 17-24: Update the node-pin.ts README entry to document
isRangeConfinedToMajor(range, major) instead of isBoundedRange(range), and state
that major confinement validation applies to both engines.node and `@types/node`
declarations via rangeProblems. Keep the existing intent and file-relationship
description unchanged.

---

Nitpick comments:
In `@server/src/config/node-pin.test.ts`:
- Around line 59-63: Update the regression-test comments associated with
isRangeConfinedToMajor, including the additional tests around the referenced
range cases, to cite fix commit SHA 2fa65a6. Preserve the existing test behavior
and explanations while adding the required commit reference to each applicable
regression-test comment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c89de984-472d-4574-8fe6-b9c4dc1f196b

📥 Commits

Reviewing files that changed from the base of the PR and between 87ed65c and a2c2365.

⛔ Files ignored due to path filters (1)
  • server/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (15)
  • .github/dependabot.yml
  • .github/workflows/build-server.yml
  • .github/workflows/ci.yml
  • .github/workflows/dependency-health.yml
  • .nvmrc
  • CLAUDE.md
  • renovate.json
  • server/.npmrc
  • server/package.json
  • server/scripts/check-node-pin.ts
  • server/src/config/README.md
  • server/src/config/node-pin.test.ts
  • server/src/config/node-pin.ts
  • server/tsconfig.json
  • server/tsconfig.scripts.json

Comment thread .github/workflows/ci.yml
Comment thread server/src/config/README.md
Addresses CodeRabbit's review of #127.

ci.yml declared no permissions, so all four of its jobs inherited whatever the
repo/org default grants GITHUB_TOKEN -- potentially write. CodeRabbit flagged
only the new node-pin job, but ci.yml was the sole workflow in the repo without
a permissions block (dependency-review, dependency-health, build-server and
codeql all declare one), so the block goes at workflow level: fixing one job
would have left typecheck, test and audit inheriting the same broad token.
Every job here only checks out and runs npm, so contents: read suffices.

The config README still documented isBoundedRange(range); 2fa65a6 replaced it
with isRangeConfinedToMajor(range, major) and did not update the doc. It also
implied only engines.node is range-checked, when rangeProblems runs against
@types/node too.

Regression-test comments now cite fix commit 2fa65a6, per the repo guideline
that every bug-fix test reference its commit SHA.

Also documents the private helpers in node-pin.ts and check-node-pin.ts, which
were the gap behind the failing docstring-coverage pre-merge check.

Verified under node:24: check:node-pin consistent, typecheck and
typecheck:scripts exit 0, 1670 tests pass (11 skipped), ci.yml parses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread server/src/config/node-pin.test.ts
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.

chore(ci): pin DMXr to Node 24 LTS and enforce it end to end

1 participant