feat: add --no-runtime to skip installing runtime entries (#11557)

Adds a `--no-runtime` flag (config: `runtime: boolean`, default `true`) that suppresses install of runtime entries declared via `devEngines.runtime` (the `runtime:` protocol) **without modifying the lockfile**.

The lockfile keeps the runtime entry, so frozen-lockfile validation still passes; only the runtime fetch and `.bin` linking are skipped. Useful in CI matrices where the runtime is provisioned externally (e.g. via `pnpm runtime -g set node <version>`) before `pnpm install` runs.

The existing `--runtime-on-fail=ignore` is unsuitable for this case: it mutates the manifest and regenerates the lockfile to drop the runtime entry, which trips frozen-lockfile validation. The two flags are orthogonal and serve different purposes.

### Implementation

The hook lives in the lockfile filter stage:

- `lockfile/filtering/src/filterImporter.ts` — strips `runtime:` refs from the importer's deps maps when `skipRuntimes` is set.
- `lockfile/filtering/src/filterLockfileByImportersAndEngine.ts` — new `skipRuntimes?: boolean` option; runtime-protocol direct deps are dropped before they enter `pickedPackages`, so they never reach the dep graph or bin-linker. Applies to all runtimes (`node`, `deno`, `bun`) since they share the `runtime:` protocol prefix.

The option is plumbed through `installing/deps-restorer`, `installing/deps-installer`, and `installing/commands` to the user-facing `pnpm install --no-runtime` flag.

### Example

```json
// package.json
{
  "devEngines": {
    "runtime": {
      "name": "node",
      "version": "22.13.0",
      "onFail": "download"
    }
  }
}
```

Local dev: `pnpm install` — installs node 22.13.0 as before.

CI matrix entry:
```yaml
- run: pn runtime -g set node ${{ matrix.node }}
- run: pn install --no-runtime
```

The lockfile is unchanged; the matrix's externally-provisioned node is used.
This commit is contained in:
Zoltan Kochan
2026-05-09 19:16:27 +02:00
committed by GitHub
parent f2b28f85ff
commit e1e29c1520
18 changed files with 251 additions and 13 deletions

View File

@@ -0,0 +1,11 @@
---
"@pnpm/lockfile.filtering": minor
"@pnpm/installing.linking.modules-cleaner": minor
"@pnpm/installing.deps-restorer": minor
"@pnpm/installing.deps-installer": minor
"@pnpm/installing.commands": minor
"@pnpm/config.reader": minor
"pnpm": minor
---
Add `--no-runtime` flag (config: `runtime=false`) to skip installing runtime entries (e.g. Node.js downloaded via `devEngines.runtime`) without modifying the lockfile. The lockfile keeps the runtime entry so frozen-lockfile validation still passes; only the runtime fetch and `.bin` linking are skipped. Useful in CI matrices where the runtime is provisioned externally (e.g. via `pnpm runtime -g set node <version>`) before `pnpm install` runs.

View File

@@ -249,6 +249,7 @@ export interface Config extends OptionsFromRootManifest {
dedupeInjectedDeps?: boolean
nodeOptions?: string
pmOnFail?: 'download' | 'error' | 'warn' | 'ignore'
runtime?: boolean
runtimeOnFail?: 'download' | 'error' | 'warn' | 'ignore'
virtualStoreDirMaxLength: number
peersSuffixMaxLength?: number

View File

@@ -129,6 +129,7 @@ export const excludedPnpmKeys = [
'publish-branch',
'recursive-install',
'resolve-peers-from-workspace-root',
'runtime',
'runtime-on-fail',
'aggregate-output',
'reporter-hide-prefix',

View File

@@ -98,6 +98,7 @@ export const pnpmTypes = {
reporter: String,
'resolution-mode': ['highest', 'time-based', 'lowest-direct'],
'resolve-peers-from-workspace-root': Boolean,
runtime: Boolean,
'runtime-on-fail': ['ignore', 'warn', 'error', 'download'],
'aggregate-output': Boolean,
'reporter-hide-prefix': Boolean,

View File

@@ -56,6 +56,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
'public-hoist-pattern',
'registry',
'reporter',
'runtime',
'save-workspace-protocol',
'scripts-prepend-node-path',
'shamefully-hoist',
@@ -132,6 +133,10 @@ For options that may be used with `-r`, see "pnpm help recursive"',
description: '`optionalDependencies` are not installed',
name: '--no-optional',
},
{
description: 'Skip installing runtime entries (e.g. Node.js downloaded via `devEngines.runtime`). The lockfile is left untouched, so frozen installs still validate; only the runtime fetch and bin-linking are skipped. Useful in CI matrices where the runtime is provisioned externally.',
name: '--no-runtime',
},
{
description: `Don't read or generate a \`${WANTED_LOCKFILE}\` file`,
name: '--no-lockfile',

View File

@@ -84,6 +84,7 @@ export type InstallDepsOptions = Pick<Config,
| 'production'
| 'preferWorkspacePackages'
| 'registries'
| 'runtime'
| 'runtimeOnFail'
| 'save'
| 'saveDev'
@@ -275,6 +276,7 @@ export async function installDeps (
linkWorkspacePackagesDepth: opts.linkWorkspacePackages === 'deep' ? Infinity : opts.linkWorkspacePackages ? 0 : -1,
sideEffectsCacheRead: opts.sideEffectsCache ?? opts.sideEffectsCacheReadonly,
sideEffectsCacheWrite: opts.sideEffectsCache,
skipRuntimes: opts.runtime === false,
storeController: store.ctrl,
storeDir: store.dir,
workspacePackages,

View File

@@ -73,6 +73,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
| 'agent'
| 'allowBuilds'
| 'registries'
| 'runtime'
| 'save'
| 'saveCatalogName'
| 'saveDev'
@@ -160,6 +161,7 @@ export async function recursive (
(((opts.ignoredPackages == null) || opts.ignoredPackages.size === 0) &&
pkgs.length === allProjects.length),
saveCatalogName: opts.saveCatalogName,
skipRuntimes: opts.runtime === false,
storeController: store.ctrl,
storeDir: store.dir,
targetDependenciesField,

View File

@@ -165,6 +165,7 @@ export interface StrictInstallOptions {
*/
disableRelinkLocalDirDeps: boolean
skipRuntimes: boolean
supportedArchitectures?: SupportedArchitectures
hoistWorkspacePackages?: boolean
virtualStoreDirMaxLength: number
@@ -279,6 +280,7 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => {
ignoreWorkspaceCycles: false,
disallowWorkspaceCycles: false,
excludeLinksFromLockfile: false,
skipRuntimes: false,
virtualStoreDirMaxLength: 120,
peersSuffixMaxLength: 1000,
blockExoticSubdeps: false,

View File

@@ -1371,6 +1371,20 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
}
}
}
if (opts.skipRuntimes) {
// The lockfile filter (filterImporter) handles wantedLockfile-driven linking,
// but the direct bin-linking path at the end of _installInContext iterates
// dependenciesByProjectId and only filters by ctx.skipped. Add runtime
// depPaths there so that path skips them too.
for (const id of Object.keys(dependenciesByProjectId) as ProjectId[]) {
for (const [alias, depPath] of dependenciesByProjectId[id].entries()) {
if (depPath.includes('@runtime:')) {
ctx.skipped.add(depPath)
dependenciesByProjectId[id].delete(alias)
}
}
}
}
stageLogger.debug({
prefix: ctx.lockfileDir,
@@ -1423,6 +1437,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
sideEffectsCacheRead: opts.sideEffectsCacheRead,
symlink: opts.symlink,
skipped: ctx.skipped,
skipRuntimes: opts.skipRuntimes,
storeController: opts.storeController,
virtualStoreDir: ctx.virtualStoreDir,
virtualStoreDirMaxLength: ctx.virtualStoreDirMaxLength,

View File

@@ -68,6 +68,7 @@ export interface LinkPackagesOptions {
sideEffectsCacheRead: boolean
symlink: boolean
skipped: Set<DepPath>
skipRuntimes?: boolean
storeController: StoreController
virtualStoreDir: string
virtualStoreDirMaxLength: number
@@ -120,6 +121,7 @@ export async function linkPackages (projects: ImporterToUpdate[], depGraph: Depe
pruneVirtualStore: opts.pruneVirtualStore,
publicHoistedModulesDir: (opts.publicHoistPattern != null) ? opts.rootModulesDir : undefined,
skipped: opts.skipped,
skipRuntimes: opts.skipRuntimes,
storeController: opts.storeController,
virtualStoreDir: opts.virtualStoreDir,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
@@ -136,6 +138,7 @@ export async function linkPackages (projects: ImporterToUpdate[], depGraph: Depe
include: opts.include,
registries: opts.registries,
skipped: opts.skipped,
skipRuntimes: opts.skipRuntimes,
}
const newCurrentLockfile = filterLockfileByImporters(opts.wantedLockfile, projectIds, {
...filterOpts,

View File

@@ -176,6 +176,7 @@ export interface HeadlessOptions {
pendingBuilds: string[]
resolveSymlinksInInjectedDirs?: boolean
skipped: Set<DepPath>
skipRuntimes?: boolean
enableModulesDir?: boolean
virtualStoreOnly?: boolean
nodeLinker?: 'isolated' | 'hoisted' | 'pnp'
@@ -259,6 +260,7 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
include: opts.include,
registries: opts.registries,
skipped,
skipRuntimes: opts.skipRuntimes,
currentEngine: opts.currentEngine,
engineStrict: opts.engineStrict,
failOnMissingDependencies: true,

View File

@@ -50,6 +50,7 @@ export async function prune (
pruneStore?: boolean
pruneVirtualStore?: boolean
skipped: Set<DepPath>
skipRuntimes?: boolean
virtualStoreDir: string
virtualStoreDirMaxLength: number
lockfileDir: string
@@ -59,6 +60,7 @@ export async function prune (
const wantedLockfile = filterLockfile(opts.wantedLockfile, {
include: opts.include,
skipped: opts.skipped,
skipRuntimes: opts.skipRuntimes,
})
const rootImporter = wantedLockfile.importers['.' as ProjectId] ?? {} as ProjectSnapshot
const wantedRootPkgs = mergeDependencies(rootImporter)

View File

@@ -1,14 +1,28 @@
import type { ProjectSnapshot } from '@pnpm/lockfile.types'
import type { ProjectSnapshot, ResolvedDependencies } from '@pnpm/lockfile.types'
import type { DependenciesField } from '@pnpm/types'
export function filterImporter (
importer: ProjectSnapshot,
include: { [dependenciesField in DependenciesField]: boolean }
include: { [dependenciesField in DependenciesField]: boolean },
opts?: { skipRuntimes?: boolean }
): ProjectSnapshot {
const skipRuntimes = opts?.skipRuntimes === true
return {
dependencies: !include.dependencies ? {} : importer.dependencies ?? {},
devDependencies: !include.devDependencies ? {} : importer.devDependencies ?? {},
optionalDependencies: !include.optionalDependencies ? {} : importer.optionalDependencies ?? {},
specifiers: importer.specifiers,
dependencies: !include.dependencies ? {} : pickNonRuntime(importer.dependencies, skipRuntimes),
devDependencies: !include.devDependencies ? {} : pickNonRuntime(importer.devDependencies, skipRuntimes),
optionalDependencies: !include.optionalDependencies ? {} : pickNonRuntime(importer.optionalDependencies, skipRuntimes),
specifiers: pickNonRuntime(importer.specifiers, skipRuntimes),
}
}
function pickNonRuntime (deps: ResolvedDependencies | undefined, skipRuntimes: boolean): ResolvedDependencies {
if (!deps) return {}
if (!skipRuntimes) return deps
const result: ResolvedDependencies = {}
for (const [name, ref] of Object.entries(deps)) {
if (!ref.startsWith('runtime:')) {
result[name] = ref
}
}
return result
}

View File

@@ -8,6 +8,7 @@ export function filterLockfile (
opts: {
include: { [dependenciesField in DependenciesField]: boolean }
skipped: Set<DepPath>
skipRuntimes?: boolean
}
): LockfileObject {
return filterLockfileByImporters(lockfile, Object.keys(lockfile.importers) as ProjectId[], {

View File

@@ -18,14 +18,20 @@ export function filterLockfileByImporters (
opts: {
include: { [dependenciesField in DependenciesField]: boolean }
skipped: Set<DepPath>
skipRuntimes?: boolean
failOnMissingDependencies: boolean
}
): LockfileObject {
const importers = { ...lockfile.importers }
for (const importerId of importerIds) {
importers[importerId] = filterImporter(lockfile.importers[importerId], opts.include, { skipRuntimes: opts.skipRuntimes })
}
const packages = {} as PackageSnapshots
if (lockfile.packages != null) {
pkgAllDeps(
lockfileWalker(
lockfile,
{ ...lockfile, importers },
importerIds,
{ include: opts.include, skipped: opts.skipped }
).step,
@@ -36,11 +42,6 @@ export function filterLockfileByImporters (
)
}
const importers = { ...lockfile.importers }
for (const importerId of importerIds) {
importers[importerId] = filterImporter(lockfile.importers[importerId], opts.include)
}
return {
...lockfile,
importers,

View File

@@ -39,6 +39,7 @@ export interface FilterLockfileOptions {
failOnMissingDependencies: boolean
lockfileDir: string
skipped: Set<string>
skipRuntimes?: boolean
supportedArchitectures?: SupportedArchitectures
}
@@ -52,6 +53,7 @@ export function filterLockfileByImportersAndEngine (
const directDepPaths = toImporterDepPaths(lockfile, importerIds, {
include: opts.include,
importerIdSet,
skipRuntimes: opts.skipRuntimes,
})
const packages =
@@ -65,12 +67,13 @@ export function filterLockfileByImportersAndEngine (
opts.includeIncompatiblePackages === true,
lockfileDir: opts.lockfileDir,
skipped: opts.skipped,
skipRuntimes: opts.skipRuntimes,
supportedArchitectures: opts.supportedArchitectures,
})
: {}
const importers = mapValues((importer) => {
const newImporter = filterImporter(importer, opts.include)
const newImporter = filterImporter(importer, opts.include, { skipRuntimes: opts.skipRuntimes })
if (newImporter.optionalDependencies != null) {
newImporter.optionalDependencies = pickBy((ref, depName) => {
const depPath = dp.refToRelative(ref, depName)
@@ -105,6 +108,7 @@ function pickPkgsWithAllDeps (
includeIncompatiblePackages: boolean
lockfileDir: string
skipped: Set<string>
skipRuntimes?: boolean
supportedArchitectures?: SupportedArchitectures
}
): PackageSnapshots {
@@ -132,6 +136,7 @@ function pkgAllDeps (
includeIncompatiblePackages: boolean
lockfileDir: string
skipped: Set<string>
skipRuntimes?: boolean
supportedArchitectures?: SupportedArchitectures
}
) {
@@ -189,6 +194,7 @@ function pkgAllDeps (
...toImporterDepPaths(ctx.lockfile, additionalImporterIds, {
include: opts.include,
importerIdSet: ctx.importerIdSet,
skipRuntimes: opts.skipRuntimes,
})
)
pkgAllDeps(ctx, nextRelDepPaths, installable, opts)
@@ -201,6 +207,7 @@ function toImporterDepPaths (
opts: {
include: { [dependenciesField in DependenciesField]: boolean }
importerIdSet: Set<ProjectId>
skipRuntimes?: boolean
}
): DepPath[] {
const importerDeps = importerIds
@@ -213,6 +220,7 @@ function toImporterDepPaths (
: {}),
}))
.map(Object.entries)
.map(entries => opts.skipRuntimes ? entries.filter(([, ref]) => !ref.startsWith('runtime:')) : entries)
let { depPaths, importerIds: nextImporterIds } = parseDepRefs(unnest(importerDeps), lockfile)

View File

@@ -672,3 +672,80 @@ test('filterByImportersAndEngine(): includes linked packages', () => {
'project-3',
])
})
test('filterByImportersAndEngine(): skipRuntimes drops runtime: entries from importers and packages', () => {
const filteredLockfile = filterLockfileByImportersAndEngine(
{
importers: {
['.' as ProjectId]: {
dependencies: {
'regular-dep': '1.0.0',
},
devDependencies: {
node: 'runtime:22.13.0',
'dev-dep': '1.0.0',
},
optionalDependencies: {
bun: 'runtime:1.1.0',
},
specifiers: {
'regular-dep': '^1.0.0',
node: 'runtime:22.13.0',
'dev-dep': '^1.0.0',
bun: 'runtime:1.1.0',
},
},
},
lockfileVersion: LOCKFILE_VERSION,
packages: {
['regular-dep@1.0.0' as DepPath]: {
resolution: { integrity: '' },
},
['dev-dep@1.0.0' as DepPath]: {
resolution: { integrity: '' },
},
['node@runtime:22.13.0' as DepPath]: {
resolution: { integrity: '' },
},
['bun@runtime:1.1.0' as DepPath]: {
resolution: { integrity: '' },
},
},
},
['.' as ProjectId],
{
currentEngine: {
nodeVersion: '22.0.0',
pnpmVersion: '11.0.0',
},
engineStrict: false,
failOnMissingDependencies: true,
include: {
dependencies: true,
devDependencies: true,
optionalDependencies: true,
},
lockfileDir: process.cwd(),
skipped: new Set<string>(),
skipRuntimes: true,
}
)
expect(filteredLockfile.lockfile.importers['.' as ProjectId]).toStrictEqual({
dependencies: {
'regular-dep': '1.0.0',
},
devDependencies: {
'dev-dep': '1.0.0',
},
optionalDependencies: {},
specifiers: {
'regular-dep': '^1.0.0',
'dev-dep': '^1.0.0',
},
})
expect(Object.keys(filteredLockfile.lockfile.packages ?? {}).sort()).toStrictEqual([
'dev-dep@1.0.0',
'regular-dep@1.0.0',
])
})

View File

@@ -1,7 +1,9 @@
import fs from 'node:fs'
import path from 'node:path'
import { expect, test } from '@jest/globals'
import { prepare } from '@pnpm/prepare'
import { writeYamlFileSync } from 'write-yaml-file'
import { execPnpm } from '../utils/index.js'
@@ -45,3 +47,91 @@ test('runtimeOnFail=ignore prevents Node.js download even when manifest sets onF
const lockfile = project.readLockfile()
expect(lockfile.importers['.'].devDependencies).toBeUndefined()
})
test('--no-runtime keeps the runtime entry in the lockfile but skips installing the binary', async () => {
const project = prepare({
devEngines: {
runtime: {
name: 'node',
version: '24.0.0',
onFail: 'download',
},
},
})
await execPnpm(['install'])
project.isExecutable('.bin/node')
const lockfileBefore = project.readLockfile()
expect(lockfileBefore.importers['.'].devDependencies).toStrictEqual({
node: { specifier: 'runtime:24.0.0', version: 'runtime:24.0.0' },
})
fs.rmSync('node_modules', { recursive: true, force: true })
await execPnpm(['install', '--frozen-lockfile', '--no-runtime'])
const lockfileAfter = project.readLockfile()
expect(lockfileAfter.importers['.'].devDependencies).toStrictEqual({
node: { specifier: 'runtime:24.0.0', version: 'runtime:24.0.0' },
})
expectNoNodeBin()
})
test('--no-runtime works on a fresh checkout with no lockfile (non-frozen path)', async () => {
const project = prepare({
devEngines: {
runtime: {
name: 'node',
version: '24.0.0',
onFail: 'download',
},
},
})
await execPnpm(['install', '--no-runtime'])
const lockfile = project.readLockfile()
expect(lockfile.importers['.'].devDependencies).toStrictEqual({
node: { specifier: 'runtime:24.0.0', version: 'runtime:24.0.0' },
})
expectNoNodeBin()
})
test('--no-runtime works with enableGlobalVirtualStore=true', async () => {
const project = prepare({
devEngines: {
runtime: {
name: 'node',
version: '24.0.0',
onFail: 'download',
},
},
})
writeYamlFileSync(path.resolve('pnpm-workspace.yaml'), {
enableGlobalVirtualStore: true,
storeDir: path.resolve('store'),
})
await execPnpm(['install', '--no-runtime'])
const lockfile = project.readLockfile()
expect(lockfile.importers['.'].devDependencies).toStrictEqual({
node: { specifier: 'runtime:24.0.0', version: 'runtime:24.0.0' },
})
expectNoNodeBin()
})
function expectNoNodeBin (): void {
const binDir = path.join('node_modules', '.bin')
for (const name of ['node', 'node.exe', 'node.cmd', 'node.ps1']) {
const p = path.join(binDir, name)
// lstatSync (vs existsSync) catches dangling symlinks too — existsSync
// follows symlinks and would return false for a symlink whose target was
// never created, hiding a real bug.
let exists = false
try {
fs.lstatSync(p)
exists = true
} catch {}
expect(exists).toBe(false)
}
}