Visitar URL original
deploy silently drops project symlinks to dependencies installed in its output · Issue #17288 · pnpm/pnpm · GitHub
Skip to content

deploy silently drops project symlinks to dependencies installed in its output #17288

Closed

Description

@zaverden

Verify latest release

  • I verified that the issue exists in the latest pnpm release

pnpm version

12.15.0

Which area(s) of pnpm are affected? (leave empty if unsure)

No response

Link to the code that reproduces this issue or a replay of the bug

No response

Reproduction steps

  1. Save the script below as repro.mjs.
  2. Run node repro.mjs /absolute/path/to/pnpm-12.15.0. Alternatively, if that version is on PATH, run node repro.mjs pnpm.
  3. Inspect the two JSON result rows: both deploy commands succeed and install the dependency, but neither output contains the generated alias symlink.

The script creates a tiny local dependency, installs it through pnpm, and then creates a working project symlink to that installed dependency. It tests both default and legacy production deploy. Everything is created in a fresh temporary directory with isolated HOME/config/cache/store; all commands use --offline. No registry, downloads, Next.js installation, tar utility, or pre-existing fixtures are needed.

The file: dependency is intentional: it makes the example fully self-contained. The script verifies that the dependency is resolvable in the deployed project and that its actual installed files are inside the output, rather than linked back to the source project. A separate symlink whose target is included in .next serves as a positive 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';
import { createRequire } from 'node:module';

const pnpm = process.argv[2] || 'pnpm';
const command = /\.[cm]js$/.test(pnpm) ? [process.execPath, pnpm] : [pnpm];
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-deploy-dependency-alias-')));
const project = path.join(base, 'app');
const home = path.join(base, 'home');
fs.mkdirSync(home);
const npmrc = path.join(home, 'empty.npmrc');
fs.writeFileSync(npmrc, '');
const env = Object.fromEntries(Object.entries(process.env).filter(
  ([key]) => !/^(npm|pnpm)_config_/i.test(key)
));
Object.assign(env, {
  HOME: home, USERPROFILE: home, CI: '', NO_COLOR: '1',
  XDG_CONFIG_HOME: home, XDG_CACHE_HOME: path.join(home, 'cache'),
  XDG_DATA_HOME: path.join(home, 'data'), XDG_STATE_HOME: path.join(home, 'state'),
  npm_config_userconfig: npmrc,
});
function write(relative, value) {
  const file = path.join(project, relative);
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, typeof value === 'string' ? value : JSON.stringify(value, null, 2));
}
function run(args) {
  const result = spawnSync(command[0], [...command.slice(1), ...args], {
    cwd: project, env, encoding: 'utf8', timeout: 60000,
  });
  if (result.error) throw result.error;
  assert.equal(result.status, 0, result.stdout + result.stderr);
  return result;
}
function isSymlink(file) {
  try { return fs.lstatSync(file).isSymbolicLink(); } catch (error) {
    if (error.code !== 'ENOENT') throw error;
    return false;
  }
}
write('package.json', {
  name: 'deploy-alias-repro', version: '1.0.0', private: true,
  files: ['.next'],
  dependencies: { 'repro-dependency': 'file:vendor/repro-dependency' },
});
write('pnpm-workspace.yaml', 'packages: [.]\ninjectWorkspacePackages: true\nignoreScripts: true\n');
write('vendor/repro-dependency/package.json', {
  name: 'repro-dependency', version: '1.0.0', main: 'index.cjs',
});
write('vendor/repro-dependency/index.cjs', 'module.exports = "dependency works";\n');
const store = '--store-dir=' + path.join(base, 'store');
console.log(`pnpm=${run(['--version']).stdout.trim()} node=${process.version} platform=${process.platform}/${process.arch}`);
console.log(`Temporary project: ${project}`);
run(['install', '--offline', '--ignore-scripts', store]);

// The dependency has genuinely been installed by pnpm before creating the alias.
const aliasPath = '.next/node_modules/repro-dependency-alias';
const linkText = '../../node_modules/repro-dependency';
fs.mkdirSync(path.dirname(path.join(project, aliasPath)), { recursive: true });
fs.symlinkSync(linkText, path.join(project, aliasPath));
const sourceRequire = createRequire(path.join(project, 'package.json'));
assert.equal(sourceRequire('./' + aliasPath), 'dependency works');
assert.ok(fs.existsSync(path.join(project, aliasPath, 'index.cjs')));
write('.next/control.txt', 'control\n');
fs.symlinkSync('control.txt', path.join(project, '.next/control-link'));

for (const mode of ['default', 'legacy']) {
  const destination = path.join(base, 'deployed-' + mode);
  run(['--filter=.', 'deploy', '--prod', '--offline', '--ignore-scripts',
    ...(mode === 'legacy' ? ['--legacy'] : []), store, destination]);
  const deployedRequire = createRequire(path.join(destination, 'package.json'));
  const dependencyWorks = deployedRequire('repro-dependency') === 'dependency works';
  assert.ok(dependencyWorks, 'The dependency must be installed in the deploy output');
  assert.ok(fs.realpathSync(deployedRequire.resolve('repro-dependency')).startsWith(destination + path.sep),
    'The installed dependency files must be inside the output, not linked to the source project');
  const control = path.join(destination, '.next/control-link');
  assert.ok(isSymlink(control), 'The included-target control symlink must be preserved');
  assert.equal(fs.readlinkSync(control), 'control.txt');
  assert.equal(fs.readFileSync(control, 'utf8'), 'control\n');
  const alias = path.join(destination, aliasPath);
  const aliasIsSymlink = isSymlink(alias);
  const row = {
    mode, deployExitCode: 0, sourceAliasResolves: true,
    dependencyInstalledInOutput: dependencyWorks,
    intendedAliasTargetExistsInOutput: fs.existsSync(path.resolve(path.dirname(alias), linkText)),
    controlSymlinkPreserved: true,
    aliasEntryExists: fs.existsSync(alias) || aliasIsSymlink,
    aliasIsSymlink,
    aliasResolves: fs.existsSync(path.join(alias, 'index.cjs')),
    sameReadlink: aliasIsSymlink && fs.readlinkSync(alias) === linkText,
  };
  assert.ok(row.intendedAliasTargetExistsInOutput);
  console.log(JSON.stringify(row));
}

Observed result rows on 12.15.0:

{"mode":"default","deployExitCode":0,"sourceAliasResolves":true,"dependencyInstalledInOutput":true,"intendedAliasTargetExistsInOutput":true,"controlSymlinkPreserved":true,"aliasEntryExists":false,"aliasIsSymlink":false,"aliasResolves":false,"sameReadlink":false}
{"mode":"legacy","deployExitCode":0,"sourceAliasResolves":true,"dependencyInstalledInOutput":true,"intendedAliasTargetExistsInOutput":true,"controlSymlinkPreserved":true,"aliasEntryExists":false,"aliasIsSymlink":false,"aliasResolves":false,"sameReadlink":false}

The assertions verify that installation and both deploys return exit code 0, the source alias works before deploy, the dependency works from inside each deploy output, the intended target exists there, and the included-target control symlink is preserved. The script prints the problematic alias state rather than asserting that it must be missing, so it can also be run against a fixed version.

Describe the Bug

A generated project symlink can point to an ordinary installed dependency. For example, Next.js can emit aliases under .next/node_modules that point into the project's root node_modules.

In this reproduction, files: [".next"] includes .next/node_modules/repro-dependency-alias, whose original relative target is ../../node_modules/repro-dependency. The link exists and resolves before deploy. Both pnpm deploy --prod and pnpm deploy --legacy --prod install repro-dependency into their output, but silently omit the alias entry. The dependency and the exact intended target path exist in the completed output; only the generated alias is missing. Both commands return exit code 0.

This is also confirmed with full production deploys of real Next.js build output and its original dependency manifest/lockfile: native 12.15.0 preserves 64 of 67 project symlinks, omitting three aliases to dependency directories that are present in the final output. Our pnpm 11.28.7 build with a local preserve-symlinks patch retains all 67. Multiple aliases whose targets are included in the project-copy set are preserved by both versions.

Cause in the 12.15.0 source

The following links are pinned to the released commit 91774e72a04d52ce61db06339696a433d383c764.

  1. Deploy copies selected project files before installing the deployed dependencies. copy_project explicitly enables preserve_symlinks. The project packlist excludes root node_modules, so the alias is selected but its dependency target is not part of this copy operation.
  2. imported_paths builds a set of the current import's file paths and parent directories. imports_link_target checks whether the link target belongs to that set. It does not check whether the target exists in the source or will be installed as a dependency.
  3. For an existing directory target outside that set, place_symlink_entry returns Ok(is_dir), which is Ok(true), without creating the link. The caller treats true as handled and returns success. This explains the silent omission and successful exit.
  4. Dependency installation subsequently provides the target directory, but does not recreate the generated project alias discarded during copying. The two phases therefore produce an incomplete project payload even though the dependency installation succeeds.

The defect is the requirement that a preserved project's symlink target must be included in the same copy operation. Here the final target is supplied by the following dependency-installation phase. This reproduction uses existing source targets and installed output targets throughout; it does not rely on dangling source links.

Expected Behavior

Both deploy modes should preserve .next/node_modules/repro-dependency-alias as a symlink with the original readlink value ../../node_modules/repro-dependency.

The dependency should still be installed normally. Once deploy completes, both the package's normal dependency name and the generated alias should resolve to the installed package inside the output.

For both result rows, aliasEntryExists, aliasIsSymlink, aliasResolves, and sameReadlink should all be true; the other successful checks should remain true, and deployExitCode should remain 0. The project-copy phase should preserve the selected symlink independently of whether the dependency target is copied by that phase.

Which Node.js version are you using?

v26.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

  1. added theissue type on Oct 23, 2026
  2. zaverden commented on Oct 23, 2026

    @zaverden
    ContributorAuthor

    For pnpm@10 and pnpm@11 we made a patch. Our patch makes deploy preserve selected symlinks as-is, keeping their original link targets even when those targets are outside the copied file set, or even when symlink is dangling at the moment of deploy. By the idea if users includes broken link into output, they'll provide a file for it later.

  3. added a commit that references this issue on Oct 24, 2026
    9d361bf
  4. added a commit that references this issue on Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: monorepoEverything related to the pnpm workspace feature

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions