Repository navigation
Description
Verify latest release
- I verified that the issue exists in the latest pnpm release
pnpm version
12.15.0, 11.28.7
Which area(s) of pnpm are affected? (leave empty if unsure)
Lockfile
Link to the code that reproduces this issue or a replay of the bug
No response
Reproduction steps
Save the following as repro.mjs and run:
node repro.mjs /absolute/path/to/pnpmThe script creates isolated temporary directories and configuration. It first runs an unchanged installation through a symlink, then adds a local dependency and attempts to update the lockfile. trustLockfile: true is enabled throughout, but currently does not allow this write. Finally, it repeats the update with a regular file as a control.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const pnpm = process.argv[2]; // Pass an absolute path to pnpm.
assert(pnpm, 'Usage: node repro.mjs /absolute/path/to/pnpm');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-symlink-lockfile-'));
const source = path.join(root, 'source');
const work = path.join(root, 'work');
for (const dir of [source, work]) fs.mkdirSync(dir);
const write = (dir, name, value) => fs.writeFileSync(path.join(dir, name),
typeof value === 'string' ? value : JSON.stringify(value));
const env = Object.fromEntries(Object.entries(process.env)
.filter(([key]) => !/^(npm|pnpm)_config_/i.test(key)));
Object.assign(env, {
HOME: path.join(root, 'home'), XDG_CONFIG_HOME: path.join(root, 'config'),
XDG_CACHE_HOME: path.join(root, 'cache'), XDG_DATA_HOME: path.join(root, 'data'),
XDG_STATE_HOME: path.join(root, 'state'), CI: '', NO_COLOR: '1',
npm_config_userconfig: path.join(root, 'empty.npmrc'),
});
write(root, 'empty.npmrc', '');
const args = ['install', '--lockfile-only', '--offline', '--no-frozen-lockfile', '--ignore-scripts'];
const run = (cwd) => {
const result = spawnSync(pnpm, args, { cwd, env, encoding: 'utf8', timeout: 30000 });
if (result.error) throw result.error;
console.log(`pnpm install in ${cwd}: exit ${result.status}`);
console.log(result.stdout + result.stderr);
return result;
};
write(source, 'package.json', { name: 'app', version: '1.0.0', private: true });
write(source, 'pnpm-workspace.yaml', 'packages: []\ntrustLockfile: true\n');
assert.equal(run(source).status, 0);
for (const name of ['package.json', 'pnpm-workspace.yaml']) {
fs.copyFileSync(path.join(source, name), path.join(work, name));
}
const sourceLock = path.join(source, 'pnpm-lock.yaml');
const workLock = path.join(work, 'pnpm-lock.yaml');
fs.symlinkSync('../source/pnpm-lock.yaml', workLock);
assert.equal(run(work).status, 0); // Reading an unchanged symlinked lockfile works.
fs.mkdirSync(path.join(work, 'dep'));
write(path.join(work, 'dep'), 'package.json', { name: 'dep', version: '1.0.0' });
write(work, 'package.json', {
name: 'app', version: '1.0.0', private: true, dependencies: { dep: 'file:./dep' },
});
const before = fs.readFileSync(sourceLock, 'utf8');
const linked = run(work); // Fails despite trustLockfile: true.
assert.notEqual(linked.status, 0);
assert.match(linked.stderr, /Refusing to write symlinked lockfile/);
assert(fs.lstatSync(workLock).isSymbolicLink());
assert.equal(fs.readFileSync(sourceLock, 'utf8'), before);
fs.unlinkSync(workLock);
fs.copyFileSync(sourceLock, workLock);
assert.equal(run(work).status, 0); // Regular-file control succeeds.
assert.notEqual(fs.readFileSync(workLock, 'utf8'), before);
console.log('Reproduced. Temporary project:', root);Observed on 12.15.0:
- Initial lockfile generation: exit 0.
- Unchanged install with
work/pnpm-lock.yaml -> ../source/pnpm-lock.yaml: exit 0. Here “unchanged” means that the lockfile content does not need to change: pnpm reads the existing lockfile through the symlink and skips writing because the resulting content is identical. The lockfile is therefore only read in this step; this does not demonstrate support for writing through the symlink. It also does not mean that an install cannot create or update other files, such asnode_modules. - Install after adding
dep: file:./dep, keeping that symlink: exit 1. - The identical dependency update with a regular lockfile: exit 0.
The failing command reports:
ERR_PNPM_LOCKFILE_WRITE_FILE
Failed to write lockfile content: Refusing to write symlinked lockfile at .../work/pnpm-lock.yaml
The symlink is retained, but the source lockfile is not updated.
Describe the Bug
This is a follow-up to pnpm/pnpm#13073, addressed by pnpm/pnpm#13075. That change restored reading symlinked lockfiles and allowed installs when the lockfile content does not change. Our remaining case requires writing changed content through the symlink.
We encountered this restriction while upgrading from pnpm 10.33.4, where our workflow could update the lockfile through a symlink. We also encounter the write restriction in pnpm 11.28.7. Supporting this workflow in pnpm 12 would be sufficient for our migration; we do not need the change to be backported to pnpm 11.
Our build and development system prepares a working directory for each project and runs pnpm there. In our scenario, both package.json and pnpm-lock.yaml in the working directory are symlinks to the corresponding files in source_dir. The original source manifest and lockfile remain authoritative:
source/project/package.json # tracked source manifest
source/project/pnpm-lock.yaml # tracked source lockfile
working/project/package.json -> ../../source/project/package.json
working/project/pnpm-workspace.yaml # generated configuration
working/project/pnpm-lock.yaml -> ../../source/project/pnpm-lock.yaml
When a developer changes dependencies and regenerates the lockfile, pnpm must update the source file through this link. Reading the lockfile and skipping identical writes is sufficient for an unchanged install, but does not cover this update workflow. Replacing the symlink with a regular file only updates the working copy, leaving the authoritative source lockfile stale. We need the link to remain intact and its target to receive the new content.
In pnpm 12.15.0, save_value_to_path skips identical content but calls the symlink check before a changed write. The same path still exists on the inspected main commit f0982d4f1124e66059b893fb18e75bf6bd3d7a4e. This report requests a supported opt-in for the remaining workflow; it does not claim that the read/no-change fix regressed.
Expected Behavior
Provide an explicit way to update a symlinked lockfile in a trusted working directory. Either of these alternatives would support our system:
-
Allow symlinked lockfile writes when
trustLockfile: true. Extend the existing setting to permit resolving the link and atomically updating its final target. The reproduction already enables this setting and demonstrates the currently missing behavior.trustLockfile: true
-
Add a dedicated
allowSymlinkedLockfilesetting, defaulting tofalse, so this behavior can be enabled independently oftrustLockfile.allowSymlinkedLockfile: true
This is a proposed new option, not an existing workaround.
With the chosen opt-in enabled, resolve relative or absolute symlinks (including chains), write atomically next to the final target, and preserve the symlink entries and the existing target's file permissions. Cycles should still fail, and unchanged content should still avoid a rewrite.
Which Node.js version are you using?
26.12.0
Which operating systems have you used?
- macOS
- Windows
- Linux
If your OS is a Linux based, which one it is? (Include the version if relevant)
No response
Contributing a fix
- I am working on a fix
Activity
zkochan commentedon Oct 22, 2026
A draft PR is open: #17233.
It takes your second option: a dedicated
allowSymlinkedLockfilesetting in pnpm v12 (defaultfalse), rather than wideningtrustLockfile, which is about skipping supply-chain verification. With the setting enabled, pnpm resolves the symlink chain (relative or absolute), atomically replaces the final target, keeps the links and the target's file mode, still fails on a cycle, and still skips an unchanged lockfile. It covers both the main lockfile and the env document (config dependencies / packageManager pin). It is not backported to pnpm v11.Written by an agent (Claude Code, claude-opus-5-5).
zkochan commentedon Oct 22, 2026
Update on #17233:
allowSymlinkedLockfilecan't be set inpnpm-workspace.yamlafter all. The repository commits the symlink, so if it could also allow the write, a cloned repository could overwrite any file you can write. Enable it in your build environment instead, withPNPM_CONFIG_ALLOW_SYMLINKED_LOCKFILE=trueor in the globalconfig.yaml.Written by an agent (Claude Code, claude-opus-5-5).
zkochan commentedon Oct 25, 2026
An update to #17233:
allowSymlinkedLockfileis now a list of directories, nottrue/false. pnpm writes through the symlink only if the file it finally points to is inside one of the listed directories. Atruein the global config would have applied to every project on the machine, so any repository you clone could have committed apnpm-lock.yamlthat links to a file such as~/.bashrc.For your setup, list the source tree the links point into:
or
PNPM_CONFIG_ALLOW_SYMLINKED_LOCKFILE='["/path/to/source-tree"]'in the build environment. Entries must be absolute or start with~/. As before, pnpm ignores the setting inpnpm-workspace.yaml.Written by an agent (Claude Code, claude-opus-5-5).
feat(lockfile): add allowSymlinkedLockfile to write through a symlink…
feat(lockfile): add allowSymlinkedLockfile to write through a symlink…
zkochan commentedon Oct 25, 2026
Update on #17233: the setting is renamed to
symlinkedLockfileTargetDirs, since it is a list of directories rather than a switch. It works as described in my earlier comment:or
PNPM_CONFIG_SYMLINKED_LOCKFILE_TARGET_DIRS='["/path/to/src"]'.Written by an agent (Claude Code, claude-opus-5-5).
feat: allow writing symlinked lockfiles into listed directories (pnpm…
Update pnpm to v12.16.1 (#2164)