Skip to content

chore(toolchain): pnpm 11 + devEngines (blocked on upstream pnpm bug) - #144

Open
lollipop-onl wants to merge 11 commits into
mainfrom
claude/toolchain-pnpm-11-devengines
Open

lollipop-onl wants to merge 11 commits into
mainfrom
claude/toolchain-pnpm-11-devengines

Conversation

@lollipop-onl

Copy link
Copy Markdown
Collaborator

Follow-up to #143, which resolved all 24 Dependabot alerts in the least invasive way available and explicitly deferred the toolchain work. This does that work and deletes the stopgaps #143 introduced.

Based on claude/dependabot-alerts-resolution-b4bc9a, so this targets #143 rather than main. Retarget to main once #143 merges.

Result

Every override from #143 is gone — all twelve — and the 24 alerts stay resolved.

before after
pnpm 10.30.3 11.23.0
typescript 6.0.3 (pinned) 7.0.2
overrides entries 12 0
Node declaration sites 4 2 (by design)

1. pnpm 11

Two behaviour changes had to be absorbed, both of which fail quietly:

  • onlyBuiltDependencies (list) is renamed allowBuilds (map). pnpm 11 does not read the old key, so esbuild/lefthook/sharp/turbo would have silently stopped running install scripts.
  • packageManager is now enforced, not just read — a mismatched pnpm refuses to run. Nested invocations must come from Corepack, which mise.toml's postinstall hook already arranges.

2. typescript 7 — the load-bearing pin is gone

rollup-plugin-dts 6.5.0 added typescript 7 to its peer range and a runtime fallback: it probes the installed typescript for createProgram and loads @typescript/typescript6 when it is missing. The optional peer arrives automatically via autoInstallPeers.

No override is needed to reach it — unbuild asks for rollup-plugin-dts: ^6.2.1 and 6.5.1 satisfies that on its own. So both the override and the catalog hold were deleted rather than retargeted.

Confirmed by forcing declaration: true, which is the code path that actually loads rollup-plugin-dts. The CLI leaves declaration unset and emits no .d.ts, so a passing default build would not have exercised it — worth knowing, since this is the failure #143 hit.

3. All ten security overrides removed

Upstream ranges have widened (postcss ^8.5.13, esbuild ^0.27.0 || ^0.28.0, picomatch ^3 || ^4, vite ^6 || ^7 || ^8), so ordinary caret resolution now reaches the patched releases. Removing all ten and re-resolving from scratch gives identical versions: postcss 8.5.26, nanoid 3.3.18, js-yaml 4.3.1, svgo 4.0.2, vite 8.2.2, qs 6.15.3, defu 6.1.7, picomatch 2.3.2 + 4.0.5, esbuild 0.25.12 + 0.28.2, smol-toml 1.8.0 — the last already ahead of the ^1.6.1 the override pinned.

Keeping them would be worse than redundant: a stale override caps a dependency at whatever was current when it was written, eventually holding the tree back from a later patch while hiding that behind a green audit.

4. devEngines

devEngines.runtime states the range the development toolchain needs, and pnpm enforces it per-install:

[ERROR] This project requires Node.js >=26. Your current Node.js is v24.18.0

Two of the four drift sites collapse into it. release.yml's node-version: 24 is deleted — its setup-node exists only for registry-url, and mise-action runs right after and shadows the pin anyway. mise.toml keeps node = "24" (mise cannot read devEngines) but now documents devEngines as the source of truth.

Two are deliberately not collapsed. engines.node in apps/cli is a published constraint on consumers of @nulab/bee; devEngines constrains the machine building this repo. They hold the same >=20.18 today, but merging them would mean either publishing a development requirement to users or letting a toolchain change silently retarget the released package.

I also did not add devEngines.packageManager: it injects 199 lockfile lines vendoring pnpm binaries for 7 platforms, duplicating what Corepack already does. Note that declaring it with a version differing from packageManager silently ignores packageManager — and Corepack rejects ranges (expected a semver version) while pnpm accepts them.

⚠️ CI restructured: pnpm 11 cannot run on Node 20

pnpm@11 declares engines.node: ">=22.13" and dies on Node 20:

Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite

The product is unaffected — the built CLI and all 767 tests pass on Node 20. Only pnpm can't execute there. Per maintainer decision, the matrix stays [20, 22, 24] and the job was restructured instead: dependencies install on the mise.toml Node, and only the test run moves to the matrix version.

Two non-obvious details, both commented in the workflow:

  • The matrix Node must be selected explicitly with mise exec node@<version>. mise.toml pins node = "24", so a bare node resolves to 24 and all three legs would have tested the same runtime while appearing to pass.
  • Vitest is invoked as node node_modules/vitest/vitest.mjs, not pnpm exec vitest, which would put pnpm back onto the Node under test.

Trade-off: Node 20 coverage is now runtime-only — installing under Node 20 is no longer exercised.

Recommendation (not actioned — needs a release decision)

commander and undici stay one major back. commander@15 needs Node >=22.12 and undici@8 needs >=22.19, both above the published >=20.18, so raising either breaks npm install for Node 20 users.

Node 20 reached EOL on 2026-04-30. Dropping it would mean: engines.node → >=22.12, matrix → [22, 24], both majors taken, and the CI workaround above becomes unnecessary. That is a breaking change for CLI users and needs a version/release-notes decision, so I have left it — the catalog comments now name the exact unblocking condition.

Verification

Every check run on a clean --frozen-lockfile install:

  • pnpm install — clean; the only peer warning (unbuild@3.6.1 wanting typescript ^5.9.2) predates this PR: the pnpm 10 lockfile resolved the same unbuild@3.6.1(typescript@6.0.3) pairing
  • pnpm test — 767 passed, 116 files (unchanged)
  • pnpm typecheck — 5/5
  • pnpm build --force — CLI + docs, 105 pages, all internal links valid, 0 cached
  • pnpm format:check — 333 files clean
  • pnpm audit — no known vulnerabilities
  • CLI smoke — --version / --help correct
  • All 24 advisories re-checked individually against resolved lockfile versions, not pnpm audit alone: 0 vulnerable
  • Matrix verified locally on v20.20.0, v22.21.1, v24.18.0 using the exact CI command: 767 pass on each

esbuild@0.25.12 remains via unbuild, outside the vulnerable >=0.27.3 <0.28.1 window, as before.

🤖 Generated with Claude Code

lollipop-onl and others added 6 commits August 24, 2026 02:34
Every open alert pointed at a transitive dependency: all direct
dependencies are already on their latest release, so no manifest could
be bumped to fix them. The vulnerable copies were pinned by upstream
packages (astro/starlight, unbuild, backlog-js) that have not yet
released updated ranges, which left `overrides` as the only way to
reach the patched versions without waiting on those projects.

Overrides live in pnpm-workspace.yaml because pnpm 10 no longer reads
the `pnpm` field from package.json; placing them there is silently
ignored apart from a warning.

vite is unified on 8.x rather than held at ^7.3.5: vitest 4.1.11 and
vitefu both require vite@8, so pinning the 7.x line would trade the
alerts for unmet peer ranges.

The rollup-plugin-dts>typescript pin is required, not incidental.
Raising the other ranges shifted that subtree onto typescript 7, which
dropped the JS compiler API it relies on, and `pnpm build` died in
rollup-plugin-dts with `Cannot read properties of undefined (reading
'useCaseSensitiveFileNames')`. The baseline only avoided this by
happening to resolve 6.x.

esbuild 0.25.12 (via unbuild) stays as-is; it sits outside the
vulnerable >=0.27.3 <0.28.1 range.

Verified: 767 tests, typecheck, oxfmt, CLI + 105 docs pages built, CLI
smoke-tested, and `pnpm audit` reports no known vulnerabilities.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pnpm 10 is a major behind, and staying on it defers the resolver change
rather than avoiding it. Doing the bump on its own keeps the wholesale
lockfile regeneration separable from the dependency decisions that follow,
so a resolver regression can be bisected to this commit alone.

Two pnpm 11 behaviour changes had to be absorbed:

`onlyBuiltDependencies` is renamed to `allowBuilds` and takes a map rather
than a list. pnpm 11 does not read the old key, so leaving it would have
silently stopped esbuild, lefthook, sharp and turbo from running their
install scripts; pnpm rewrote the file with `set this to true or false`
placeholders on first install, which is how it surfaced.

pnpm 11 also enforces the `packageManager` field instead of merely reading
it: a pnpm whose version disagrees now refuses to run rather than warning.
Nested invocations therefore have to come from Corepack, which is already
what `mise.toml`'s `postinstall = "npx corepack enable"` hook arranges.

The lockfile is regenerated wholesale, as expected across a resolver major.
The one peer warning pnpm 11 reports -- unbuild@3.6.1 wanting
typescript ^5.9.2 -- predates this change; the pnpm 10 lockfile resolved the
same `unbuild@3.6.1(typescript@6.0.3)` pairing, and pnpm 11 only reports it
more prominently.

Verified with install, test (767 tests / 116 files), typecheck, build
(CLI + 105 docs pages, internal links valid), format:check and audit
(no known vulnerabilities).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `rollup-plugin-dts>typescript: ^6.0.3` override was the load-bearing
stopgap from the previous change: typescript 7 (tsgo) ships no JS compiler
API, so `rollup-plugin-dts` crashed with `ts.sys` undefined and `pnpm build`
failed while tests and typecheck stayed green.

Upstream has since solved this properly. rollup-plugin-dts 6.5.0 added
typescript 7 to its peer range and gained a runtime fallback: it probes the
installed `typescript` for `createProgram`, and when that is missing it loads
the `@typescript/typescript6` compatibility package, which provides the
TypeScript 6.0 API. The optional peer is pulled in automatically because
`autoInstallPeers` is on by default, so no manifest entry is needed.

No override is required to reach it either. unbuild 3.6.1 asks for
`rollup-plugin-dts: ^6.2.1`, and 6.5.1 satisfies that range on its own, so
the fix arrives through normal resolution rather than another pin. Both
workarounds are therefore deleted rather than retargeted, and the catalog
comment explaining the typescript hold goes with them.

Confirmed by building with `declaration: true` forced on, which is the code
path that actually loads rollup-plugin-dts -- the CLI leaves `declaration`
unset and emits no .d.ts files, so a passing default build alone would not
have exercised it.

Verified with test (767 tests / 116 files), typecheck, build --force
(CLI + 105 docs pages, internal links valid) and audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e caught up

Every one of these overrides existed only because some upstream package still
pinned a vulnerable range, and each was documented as a stopgap to revisit
once those ranges widened. They have widened.

The dependency tree now asks for open ranges that reach the patched releases
by ordinary caret resolution -- postcss `^8.5.13`, esbuild
`^0.27.0 || ^0.28.0`, picomatch `^3 || ^4`, vite `^6 || ^7 || ^8` -- so
forcing the same versions by hand no longer changes the outcome. Removing all
eleven entries and re-resolving from scratch produces byte-identical
resolutions for every overridden package: postcss 8.5.26, nanoid 3.3.18,
js-yaml 4.3.1, svgo 4.0.2, vite 8.2.2, qs 6.15.3, defu 6.1.7, picomatch
2.3.2 + 4.0.5, esbuild 0.25.12 + 0.28.2, and smol-toml 1.8.0 -- the last one
already ahead of the `^1.6.1` the override pinned.

Keeping them would be worse than redundant. A stale override silently caps a
dependency at whatever was current when it was written, so it eventually
holds the tree *back* from a later patch instead of pulling it forward, and
it hides that fact behind a green audit.

Each of the 24 advisories was re-checked individually against the resolved
versions in the regenerated lockfile rather than trusting `pnpm audit` alone;
none of the installed versions falls inside its vulnerable range. esbuild
0.25.12 remains in the tree via unbuild and sits outside the affected
`>=0.27.3 <0.28.1` window, as before.

Verified with test (767 tests / 116 files), typecheck, build --force
(CLI + 105 docs pages, internal links valid), format:check, audit
(no known vulnerabilities) and a CLI smoke test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Node version was asserted in four places that could drift apart, and
nothing checked that a contributor's Node actually satisfied any of them --
mise.toml silently provisioned 24, CI tested 20/22/24, release.yml pinned 24,
and apps/cli declared >=20.18 for consumers.

`devEngines.runtime` in the root package.json now states the range the
development toolchain requires, and pnpm enforces it on every install:

  [ERROR] This project requires Node.js >=26. Your current Node.js is v24.18.0

Two of the four sites collapse into that. release.yml's `node-version: 24` is
gone: its `actions/setup-node` step exists only for `registry-url`, which
writes the .npmrc that `pnpm publish` needs, and mise-action runs immediately
after and puts the mise.toml Node on PATH -- so the pin was shadowed and
purely decorative. mise.toml keeps `node = "24"` because mise cannot read
devEngines, but it now documents devEngines as the source of truth.

Two are deliberately left alone.

`engines.node` in apps/cli is a *published* constraint: it tells consumers of
the @nulab/bee package which Node they can install it on. devEngines
constrains the machine building this repo. They currently hold the same
`>=20.18` value, but collapsing them would mean either publishing a
development requirement to users or letting a toolchain change silently
retarget the released package. They are kept as separate contracts.

The CI matrix stays [20, 22, 24], but the jobs had to be restructured,
because pnpm 11 declares `engines.node: ">=22.13"` and dies outright on
Node 20:

  Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite

Dependency installation therefore runs on the mise.toml Node, and only the
test run is moved onto the matrix version. This required selecting that
version explicitly with `mise exec node@<version>`: mise.toml pins node = 24,
so a bare `node` resolves to 24 and all three legs would have tested the same
runtime while appearing to pass. Vitest is also invoked as
`node node_modules/vitest/vitest.mjs` rather than `pnpm exec vitest`, since
`pnpm exec` would put pnpm back onto the Node under test.

Node 20 coverage is consequently runtime-only -- installing under Node 20 is
no longer exercised -- which is the honest trade for keeping a Node 20
guarantee while running a pnpm that cannot execute there. Verified locally on
v20.20.0, v22.21.1 and v24.18.0: 767 tests pass on each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous comment said only that these two are held back because the CLI
supports Node >=20.18. It did not say what would let the next reader raise
them, so the pin looked like a preference rather than a blocked decision.

Both majors are still genuinely out of reach: commander 15 requires Node
>=22.12 and undici 8 requires >=22.19, while @nulab/bee publishes
`engines.node: ">=20.18"`. Raising either would break `npm install` for Node
20 users, which makes this a release decision rather than a dependency bump.

The comment now names the exact condition that unblocks it -- dropping Node 20
from the published engines and the CI matrix -- and records that Node 20
reached EOL on 2026-04-30, so the reader has the argument for doing so rather
than having to rediscover it. undici's entry gets a back-reference, since it
sits far enough from commander's in the catalog to be edited on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lollipop-onl
lollipop-onl changed the base branch from claude/dependabot-alerts-resolution-b4bc9a to main August 25, 2026 09:20
lollipop-onl and others added 5 commits August 25, 2026 18:22
The PR was opened against the #143 branch, and ci.yml only triggers on pull
requests targeting main, so the restructured Node matrix -- the part of this
change most worth verifying -- had never run in CI. Retargeting the base does
not re-fire workflows on its own.

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

Copy link
Copy Markdown
Collaborator Author

Parking this. The pnpm 11 bump is blocked by an upstream bug, and the parts worth keeping do not depend on it.

The blocker: pnpm 11 (verified on both 11.23.0 and 11.24.0) does not install optional native bindings whose engines field uses a disjunction. Four packages here declare engines: {node: ^20.19.0 || >=22.12.0} and are silently skipped:

  • @oxlint/binding-linux-x64-gnu → lint fails with Cannot find native binding
  • @rolldown/binding-linux-x64-gnu → vitest cannot start, on every Node version
  • @oxfmt/binding-linux-x64-gnu, @astrojs/compiler-binding-linux-x64-gnu

Measured on the runner: pnpm 10 installs 574 packages, pnpm 11 installs 564. Packages with simple ranges (sharp >=20.9.0, lightningcss >= 12.0.0) or no engines install fine, so this is specific to the || form, not to glibc gating or to allowBuilds.

This is unrelated to Node 20 — the CLI and all 767 tests run fine on Node 20. It is pnpm 11's installer.

Superseded by #149, which does the override removal on pnpm 10. That was the part that actually mattered, and it needs nothing from this branch.

Not carried over, all gated behind pnpm 11:

  • typescript 7 — the fallback in rollup-plugin-dts 6.5.0 is only reachable when unbuild's ^6.2.1 resolves to 6.5.1, which pnpm 10 does not do (it picks 6.3.0). Verified: forcing typescript 7 on pnpm 10 still fails with ts.sys undefined.
  • devEngines — worth doing on its own later; it was bundled here only because the pnpm 11 bump made packageManager enforcement relevant.
  • The CI matrix restructuring — only needed because pnpm 11 cannot run on Node 20. On pnpm 10 the existing matrix is fine as-is.

Leaving this open as the record of the investigation. Worth retrying once pnpm fixes the engines disjunction handling.

@lollipop-onl lollipop-onl changed the title chore(toolchain): pnpm 11, devEngines, and retiring the #143 stopgaps chore(toolchain): pnpm 11 + devEngines (blocked on upstream pnpm bug) Aug 25, 2026
@lollipop-onl
lollipop-onl changed the base branch from main to claude/dependabot-alerts-resolution-b4bc9a August 25, 2026 09:46
Base automatically changed from claude/dependabot-alerts-resolution-b4bc9a to main August 25, 2026 09:54

This branch has not been deployed

No deployments
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