@pnpm/releasing.commands 1100.5.1 → 1100.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ import { install } from '@pnpm/installing.commands';
12
12
  import { getLockfileImporterId, readWantedLockfile, writeWantedLockfile } from '@pnpm/lockfile.fs';
13
13
  import { globalWarn, logger } from '@pnpm/logger';
14
14
  import { rimraf } from '@zkochan/rimraf';
15
+ import { isSubdir } from 'is-subdir';
15
16
  import { pick } from 'ramda';
16
17
  import { renderHelp } from 'render-help';
17
18
  import { writeYamlFile } from 'write-yaml-file';
@@ -91,14 +92,35 @@ export async function handler(opts, params) {
91
92
  const selectedProject = selectedProjects[0].package;
92
93
  const deployDirParam = params[0];
93
94
  const deployDir = path.isAbsolute(deployDirParam) ? deployDirParam : path.join(opts.dir, deployDirParam);
95
+ validateDeployTarget(deployDir, {
96
+ dir: opts.dir,
97
+ force: opts.force,
98
+ projectDir: selectedProject.rootDir,
99
+ workspaceDir: opts.workspaceDir,
100
+ });
101
+ const normalizedDeployDir = path.resolve(deployDir);
102
+ const normalizedWorkspaceDir = path.resolve(opts.workspaceDir);
103
+ const workspaceChildTarget = isChildPath(normalizedDeployDir, normalizedWorkspaceDir);
104
+ if (workspaceChildTarget) {
105
+ createWorkspaceChildTargetParents(normalizedWorkspaceDir, normalizedDeployDir);
106
+ }
94
107
  if (!isEmptyDirOrNothing(deployDir)) {
95
108
  if (!opts.force) {
96
109
  throw new PnpmError('DEPLOY_DIR_NOT_EMPTY', `Deploy path ${deployDir} is not empty`);
97
110
  }
98
111
  logger.warn({ message: 'using --force, deleting deploy path', prefix: deployDir });
99
112
  }
113
+ if (workspaceChildTarget) {
114
+ validateWorkspaceChildTargetComponents(normalizedWorkspaceDir, normalizedDeployDir);
115
+ }
100
116
  await rimraf(deployDir);
101
- await fs.promises.mkdir(deployDir, { recursive: true });
117
+ if (workspaceChildTarget) {
118
+ createWorkspaceChildTargetParents(normalizedWorkspaceDir, normalizedDeployDir);
119
+ createWorkspaceChildTargetDir(normalizedWorkspaceDir, normalizedDeployDir);
120
+ }
121
+ else {
122
+ await fs.promises.mkdir(deployDir, { recursive: true });
123
+ }
102
124
  const includeOnlyPackageFiles = !opts.deployAllFiles;
103
125
  await copyProject(selectedProject.rootDir, deployDir, { includeOnlyPackageFiles });
104
126
  if (opts.sharedWorkspaceLockfile) {
@@ -181,6 +203,114 @@ async function copyProject(src, dest, opts) {
181
203
  const importPkg = createIndexedPkgImporter('clone-or-copy');
182
204
  importPkg(dest, { filesMap, force: true, resolvedFrom: 'local-dir' });
183
205
  }
206
+ function validateDeployTarget(deployDir, opts) {
207
+ const normalizedDeployDir = path.resolve(deployDir);
208
+ const workspaceDir = path.resolve(opts.workspaceDir);
209
+ const projectDir = path.resolve(opts.projectDir);
210
+ const dir = path.resolve(opts.dir);
211
+ if (samePath(normalizedDeployDir, workspaceDir)) {
212
+ throw unsafeDeployTarget(normalizedDeployDir, 'target is the workspace root');
213
+ }
214
+ if (isAncestorPath(normalizedDeployDir, workspaceDir)) {
215
+ throw unsafeDeployTarget(normalizedDeployDir, 'target contains the workspace root');
216
+ }
217
+ if (samePath(normalizedDeployDir, projectDir)) {
218
+ throw unsafeDeployTarget(normalizedDeployDir, 'target is the selected project root');
219
+ }
220
+ if (isAncestorPath(normalizedDeployDir, projectDir)) {
221
+ throw unsafeDeployTarget(normalizedDeployDir, 'target contains the selected project');
222
+ }
223
+ if (samePath(normalizedDeployDir, dir)) {
224
+ throw unsafeDeployTarget(normalizedDeployDir, 'target is the current directory');
225
+ }
226
+ if (isAncestorPath(normalizedDeployDir, dir)) {
227
+ throw unsafeDeployTarget(normalizedDeployDir, 'target contains the current directory');
228
+ }
229
+ if (opts.force && !isChildPath(normalizedDeployDir, workspaceDir)) {
230
+ throw unsafeDeployTarget(normalizedDeployDir, 'target is outside the workspace');
231
+ }
232
+ if (isChildPath(normalizedDeployDir, workspaceDir)) {
233
+ validateWorkspaceChildTargetComponents(workspaceDir, normalizedDeployDir);
234
+ }
235
+ }
236
+ function validateWorkspaceChildTargetComponents(workspaceDir, deployDir) {
237
+ const relative = path.relative(workspaceDir, deployDir);
238
+ let current = workspaceDir;
239
+ for (const component of relative.split(path.sep)) {
240
+ if (!component)
241
+ continue;
242
+ current = path.join(current, component);
243
+ let stat;
244
+ try {
245
+ stat = fs.lstatSync(current);
246
+ }
247
+ catch (error) {
248
+ if (isENOENT(error))
249
+ return;
250
+ throw error;
251
+ }
252
+ if (stat.isSymbolicLink()) {
253
+ throw unsafeDeployTarget(current, 'target path contains a symlink');
254
+ }
255
+ }
256
+ }
257
+ function createWorkspaceChildTargetParents(workspaceDir, deployDir) {
258
+ const parent = path.dirname(deployDir);
259
+ const relative = path.relative(workspaceDir, parent);
260
+ let current = workspaceDir;
261
+ for (const component of relative.split(path.sep)) {
262
+ if (!component)
263
+ continue;
264
+ current = path.join(current, component);
265
+ createWorkspaceChildTargetComponent(current);
266
+ }
267
+ }
268
+ function createWorkspaceChildTargetDir(workspaceDir, deployDir) {
269
+ try {
270
+ fs.mkdirSync(deployDir);
271
+ }
272
+ catch (error) {
273
+ if (isEEXIST(error)) {
274
+ throw unsafeDeployTarget(deployDir, 'target changed during deploy preparation');
275
+ }
276
+ throw error;
277
+ }
278
+ validateWorkspaceChildTargetComponents(workspaceDir, deployDir);
279
+ }
280
+ function createWorkspaceChildTargetComponent(component) {
281
+ try {
282
+ fs.mkdirSync(component);
283
+ }
284
+ catch (error) {
285
+ if (!isEEXIST(error))
286
+ throw error;
287
+ }
288
+ const stat = fs.lstatSync(component);
289
+ if (stat.isSymbolicLink()) {
290
+ throw unsafeDeployTarget(component, 'target path contains a symlink');
291
+ }
292
+ if (!stat.isDirectory()) {
293
+ throw unsafeDeployTarget(component, 'target path contains a non-directory');
294
+ }
295
+ }
296
+ function unsafeDeployTarget(deployDir, reason) {
297
+ return new PnpmError('INVALID_DEPLOY_TARGET', `Refusing to deploy to unsafe target ${deployDir}: ${reason}`);
298
+ }
299
+ function samePath(left, right) {
300
+ return path.normalize(left) === path.normalize(right);
301
+ }
302
+ function isAncestorPath(parent, child) {
303
+ return isChildPath(child, parent);
304
+ }
305
+ function isChildPath(child, parent) {
306
+ return !samePath(child, parent) && isSubdir(parent, child);
307
+ }
308
+ function isENOENT(error) {
309
+ return typeof error === 'object' && error != null && 'code' in error && error.code === 'ENOENT';
310
+ }
311
+ function isEEXIST(error) {
312
+ return typeof error === 'object' && error != null && 'code' in error && error.code === 'EEXIST';
313
+ }
184
314
  async function deployFromSharedLockfile(opts, selectedProject, deployDir) {
185
315
  if (!opts.injectWorkspacePackages) {
186
316
  throw new PnpmError('DEPLOY_NONINJECTED_WORKSPACE', 'By default, starting from pnpm v10, we only deploy from workspaces that have "inject-workspace-packages=true" set', {
@@ -94,6 +94,13 @@ export async function handler(opts, params) {
94
94
  if (!entryPath) {
95
95
  throw new PnpmError('PACK_APP_MISSING_ENTRY', '"pnpm pack-app" requires a CJS entry file — pass --entry <path> or set "pnpm.app.entry" in package.json.');
96
96
  }
97
+ // `entry` may come from a repo-controlled package.json, so reject absolute
98
+ // paths and `..` traversal before touching the filesystem: the entry's
99
+ // contents get embedded into the produced executable, so an escaping path
100
+ // could exfiltrate a host file (e.g. an SSH key) into a distributable binary.
101
+ if (pathEscapesProject(entryPath)) {
102
+ throw new PnpmError('PACK_APP_ENTRY_OUTSIDE_PROJECT', `The entry path "${entryPath}" resolves outside the project directory.`, { hint: 'The entry must be a relative path inside the project directory, not an absolute path or one that escapes via "..".' });
103
+ }
97
104
  const resolvedEntry = path.resolve(opts.dir, entryPath);
98
105
  let entryStat;
99
106
  try {
@@ -105,6 +112,12 @@ export async function handler(opts, params) {
105
112
  if (!entryStat.isFile()) {
106
113
  throw new PnpmError('PACK_APP_ENTRY_NOT_FILE', `Entry path must be a regular file: ${resolvedEntry}`);
107
114
  }
115
+ // Defense in depth against a same-name symlink that points out of the
116
+ // project: resolve symlinks and require the real path to stay within the
117
+ // (also symlink-resolved) project directory.
118
+ if (!isWithinDir(resolvedEntry, opts.dir)) {
119
+ throw new PnpmError('PACK_APP_ENTRY_OUTSIDE_PROJECT', `The entry path "${entryPath}" resolves outside the project directory.`, { hint: 'The entry must be a relative path inside the project directory, not an absolute path or one that escapes via "..".' });
120
+ }
108
121
  const cliTargets = opts.target == null
109
122
  ? undefined
110
123
  : Array.isArray(opts.target) ? opts.target : [opts.target];
@@ -118,9 +131,29 @@ export async function handler(opts, params) {
118
131
  // masked by later problems (missing package.json name, registry lookup, etc.).
119
132
  const runtimeSpec = opts.runtime ?? project.app?.runtime ?? `node@${process.version.slice(1)}`;
120
133
  const { version: requestedNodeSpec } = parseRuntime(runtimeSpec);
121
- const outputDir = path.resolve(opts.dir, opts.outputDir ?? project.app?.outputDir ?? 'dist-app');
134
+ // `outputDir` is likewise repo-controllable; reject absolute paths and `..`
135
+ // traversal so build artifacts cannot be written outside the project directory.
136
+ const outputDirRaw = opts.outputDir ?? project.app?.outputDir ?? 'dist-app';
137
+ if (pathEscapesProject(outputDirRaw)) {
138
+ throw new PnpmError('PACK_APP_OUTPUT_DIR_OUTSIDE_PROJECT', `The output directory "${outputDirRaw}" resolves outside the project directory.`, { hint: 'The output directory must be a relative path inside the project directory, not an absolute path or one that escapes via "..".' });
139
+ }
140
+ const outputDir = path.resolve(opts.dir, outputDirRaw);
122
141
  await mkdir(outputDir, { recursive: true });
142
+ // Defense in depth against a symlinked output directory that points out of
143
+ // the project: the lexical check above can't see through a symlink, so
144
+ // re-check containment once the real path exists.
145
+ if (!isWithinDir(outputDir, opts.dir)) {
146
+ throw new PnpmError('PACK_APP_OUTPUT_DIR_OUTSIDE_PROJECT', `The output directory "${outputDirRaw}" resolves outside the project directory.`, { hint: 'The output directory must be a relative path inside the project directory, not an absolute path or one that escapes via "..".' });
147
+ }
123
148
  const outputName = validateOutputName(opts.outputName ?? project.app?.outputName ?? deriveOutputNameFromPackage(project, opts.dir));
149
+ // Reject a pre-existing symlink (or any non-regular file) at any target's
150
+ // final output path before downloading anything: a repo could commit
151
+ // `dist-app/<target>/<name>` as a symlink pointing outside the project, and
152
+ // `node --build-sea` would follow it to overwrite an arbitrary file. The
153
+ // directory containment checks above do not cover the leaf file.
154
+ for (const target of targets) {
155
+ rejectNonRegularOutputFile(path.join(outputDir, target.raw, outputFileName(outputName, target.platform)));
156
+ }
124
157
  const fetch = createFetchFromRegistry(opts);
125
158
  const buildRoot = path.join(opts.pnpmHomeDir, 'pack-app');
126
159
  // Resolve the embedded target version first so the builder can be pinned to
@@ -147,9 +180,16 @@ export async function handler(opts, params) {
147
180
  const targetOutputDir = path.join(outputDir, target.raw);
148
181
  // eslint-disable-next-line no-await-in-loop
149
182
  await mkdir(targetOutputDir, { recursive: true });
150
- const outputFile = target.platform === 'win32'
151
- ? path.join(targetOutputDir, `${outputName}.exe`)
152
- : path.join(targetOutputDir, outputName);
183
+ // A repo could symlink `dist-app/<target>` out of the project even when
184
+ // `dist-app` itself is contained; re-check the real path before any
185
+ // binary is written into it.
186
+ if (!isWithinDir(targetOutputDir, opts.dir)) {
187
+ throw new PnpmError('PACK_APP_OUTPUT_DIR_OUTSIDE_PROJECT', `The output directory "${targetOutputDir}" resolves outside the project directory.`, { hint: 'The output directory must be a relative path inside the project directory, not an absolute path or one that escapes via "..".' });
188
+ }
189
+ const outputFile = path.join(targetOutputDir, outputFileName(outputName, target.platform));
190
+ // Re-check the leaf path right before the build in case it became a
191
+ // symlink after the upfront pass.
192
+ rejectNonRegularOutputFile(outputFile);
153
193
  const seaConfig = {
154
194
  main: resolvedEntry,
155
195
  output: outputFile,
@@ -175,7 +215,7 @@ export async function handler(opts, params) {
175
215
  await rm(tmpConfigDir, { recursive: true, force: true }).catch(() => { });
176
216
  }
177
217
  // eslint-disable-next-line no-await-in-loop
178
- await adHocSignMacBinary(target, outputFile);
218
+ await adHocSignMacBinary(target, outputFile, opts.dir);
179
219
  results.push(` ${target.raw}: ${outputFile} (Node.js ${resolvedTargetVersion})`);
180
220
  }
181
221
  return `Built ${targets.length} executable${targets.length === 1 ? '' : 's'}:\n${results.join('\n')}`;
@@ -408,6 +448,53 @@ function deriveOutputNameFromPackage(project, dir) {
408
448
  function isObject(value) {
409
449
  return value != null && typeof value === 'object' && !Array.isArray(value);
410
450
  }
451
+ // `entry` and `outputDir` can come from a repo-controlled package.json, so a
452
+ // malicious repo must not be able to read/write outside the project via an
453
+ // absolute path or `..` traversal. An absolute path replaces the base on join;
454
+ // a `..` segment climbs out. Checked lexically before any filesystem access so
455
+ // the failure is fast and side-effect-free.
456
+ function pathEscapesProject(rawPath) {
457
+ // `path.parse().root` is non-empty for any host-rooted form: a POSIX
458
+ // absolute path (`/x`), and on Windows also the drive-relative (`C:x`) and
459
+ // root-relative (`\x`) forms that `path.isAbsolute()` reports as relative
460
+ // yet still resolve outside the project. Platform-specific, matching the
461
+ // pacquet port (which rejects `Component::RootDir` / `Component::Prefix`).
462
+ if (path.parse(rawPath).root !== '')
463
+ return true;
464
+ return rawPath.split(/[/\\]/).includes('..');
465
+ }
466
+ // Defense in depth beyond `pathEscapesProject`: resolve symlinks and require
467
+ // the real target to stay inside the (also symlink-resolved) project dir, so a
468
+ // same-name symlink pointing out of the project is caught even though the
469
+ // lexical join looked contained. Fails closed on any realpath error.
470
+ function isWithinDir(target, dir) {
471
+ let realDir;
472
+ let realTarget;
473
+ try {
474
+ realDir = fs.realpathSync(dir);
475
+ realTarget = fs.realpathSync(target);
476
+ }
477
+ catch {
478
+ return false;
479
+ }
480
+ const rel = path.relative(realDir, realTarget);
481
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
482
+ }
483
+ // The on-disk file name of the produced executable for a target: a bare name
484
+ // on POSIX, suffixed with `.exe` on Windows.
485
+ function outputFileName(outputName, platform) {
486
+ return platform === 'win32' ? `${outputName}.exe` : outputName;
487
+ }
488
+ // Refuse to write to `outputFile` when it already exists and is not a regular
489
+ // file — most importantly a symlink, which `node --build-sea` would follow to
490
+ // overwrite a file outside the project. `lstatSync` does not traverse the final
491
+ // component, so a symlink reports `isFile() === false`. A missing path is fine.
492
+ function rejectNonRegularOutputFile(outputFile) {
493
+ const existing = fs.lstatSync(outputFile, { throwIfNoEntry: false });
494
+ if (existing && !existing.isFile()) {
495
+ throw new PnpmError('PACK_APP_OUTPUT_FILE_NOT_REGULAR', `The output file "${outputFile}" already exists and is not a regular file (e.g. a symlink); refusing to write through it.`, { hint: 'Remove the existing path, or choose a different --output-name or --output-dir.' });
496
+ }
497
+ }
411
498
  /**
412
499
  * SEA injection invalidates the existing code signature on macOS binaries, so
413
500
  * the output must be re-signed. Native macOS hosts use `codesign`; Linux hosts
@@ -415,16 +502,20 @@ function isObject(value) {
415
502
  * available ad-hoc signer, so we refuse to produce an unsigned output silently
416
503
  * and tell the user to re-sign on macOS or Linux.
417
504
  */
418
- async function adHocSignMacBinary(target, outputFile) {
505
+ async function adHocSignMacBinary(target, outputFile, dir) {
419
506
  if (target.platform !== 'darwin')
420
507
  return;
421
508
  if (process.platform === 'darwin') {
422
- await execa('codesign', ['--sign', '-', outputFile], { stdio: 'inherit' });
509
+ // `codesign` is a macOS system tool; spawn it by absolute path so a
510
+ // repo-controlled `node_modules/.bin/codesign` on PATH can't be run in its
511
+ // place.
512
+ await execa('/usr/bin/codesign', ['--sign', '-', outputFile], { stdio: 'inherit' });
423
513
  return;
424
514
  }
425
515
  if (process.platform === 'linux') {
516
+ const ldid = resolveTrustedSigner('ldid', dir, outputFile);
426
517
  try {
427
- await execa('ldid', ['-S', outputFile], { stdio: 'inherit' });
518
+ await execa(ldid, ['-S', outputFile], { stdio: 'inherit' });
428
519
  }
429
520
  catch {
430
521
  throw new PnpmError('PACK_APP_MACOS_SIGN_FAILED', `Cross-compiled macOS binary at ${outputFile} could not be ad-hoc signed with "ldid".`, { hint: 'Install ldid (https://github.com/ProcursusTeam/ldid) or re-sign the binary on macOS with "codesign --sign - <file>".' });
@@ -433,4 +524,35 @@ async function adHocSignMacBinary(target, outputFile) {
433
524
  }
434
525
  throw new PnpmError('PACK_APP_MACOS_SIGN_UNSUPPORTED_HOST', `Cannot ad-hoc sign the macOS binary at ${outputFile} on a ${process.platform} host.`, { hint: 'Build macOS targets on a macOS or Linux host, or re-sign the produced binary yourself with "codesign --sign -" on macOS.' });
435
526
  }
527
+ // Resolve an external signer (`ldid`) to an absolute path via PATH, skipping
528
+ // any match that resolves inside the project directory — a repo could ship
529
+ // `node_modules/.bin/ldid` and, if that directory is on the developer's PATH,
530
+ // get an attacker-controlled binary executed when packaging a darwin target.
531
+ // Returns the first match outside the project, or throws PACK_APP_MACOS_SIGN_FAILED.
532
+ function resolveTrustedSigner(name, dir, outputFile) {
533
+ for (const entry of (process.env.PATH ?? '').split(path.delimiter)) {
534
+ if (entry === '')
535
+ continue;
536
+ const candidate = path.join(entry, name);
537
+ let stat;
538
+ try {
539
+ stat = fs.statSync(candidate);
540
+ }
541
+ catch {
542
+ continue;
543
+ }
544
+ if (!stat.isFile())
545
+ continue;
546
+ if (isWithinDir(candidate, dir))
547
+ continue;
548
+ try {
549
+ fs.accessSync(candidate, fs.constants.X_OK);
550
+ }
551
+ catch {
552
+ continue;
553
+ }
554
+ return candidate;
555
+ }
556
+ throw new PnpmError('PACK_APP_MACOS_SIGN_FAILED', `Cross-compiled macOS binary at ${outputFile} could not be ad-hoc signed with "ldid".`, { hint: 'Install ldid (https://github.com/ProcursusTeam/ldid) or re-sign the binary on macOS with "codesign --sign - <file>".' });
557
+ }
436
558
  //# sourceMappingURL=packApp.js.map
@@ -4,7 +4,7 @@ export declare function rcOptionsTypes(): Record<string, unknown>;
4
4
  export declare function cliOptionsTypes(): Record<string, unknown>;
5
5
  export declare const commandNames: string[];
6
6
  export declare function help(): string;
7
- export type PackOptions = Pick<UniversalOptions, 'dir'> & Pick<Config, 'catalogs' | 'ignoreScripts' | 'embedReadme' | 'packGzipLevel' | 'nodeLinker' | 'skipManifestObfuscation' | 'userAgent'> & Partial<Pick<Config, 'extraBinPaths' | 'extraEnv' | 'recursive' | 'workspaceConcurrency' | 'workspaceDir'>> & Partial<Pick<ConfigContext, 'hooks' | 'selectedProjectsGraph'>> & {
7
+ export type PackOptions = Pick<UniversalOptions, 'dir'> & Pick<Config, 'catalogs' | 'ignoreScripts' | 'embedReadme' | 'packGzipLevel' | 'nodeLinker' | 'skipManifestObfuscation' | 'userAgent'> & Partial<Pick<Config, 'extraBinPaths' | 'extraEnv' | 'recursive' | 'workspaceConcurrency' | 'workspaceDir'>> & Partial<Pick<ConfigContext, 'hooks' | 'selectedProjectsGraph' | 'allProjectsGraph' | 'prodAllProjectsGraph' | 'prodOnlySelectedProjectDirs'>> & {
8
8
  argv: {
9
9
  original: string[];
10
10
  };
@@ -9,7 +9,7 @@ import { PnpmError } from '@pnpm/error';
9
9
  import { packlist } from '@pnpm/fs.packlist';
10
10
  import { logger } from '@pnpm/logger';
11
11
  import { createExportableManifest } from '@pnpm/releasing.exportable-manifest';
12
- import { sortProjects } from '@pnpm/workspace.projects-sorter';
12
+ import { sortFilteredProjects } from '@pnpm/workspace.projects-sorter';
13
13
  import chalk from 'chalk';
14
14
  import pLimit from 'p-limit';
15
15
  import { pick } from 'ramda';
@@ -104,7 +104,12 @@ export async function handler(opts) {
104
104
  prefix: opts.dir,
105
105
  });
106
106
  }
107
- const chunks = sortProjects(selectedProjectsGraph);
107
+ const chunks = sortFilteredProjects({
108
+ selectedProjectsGraph,
109
+ allProjectsGraph: opts.allProjectsGraph,
110
+ prodAllProjectsGraph: opts.prodAllProjectsGraph,
111
+ prodOnlySelectedProjectDirs: opts.prodOnlySelectedProjectDirs,
112
+ });
108
113
  const limitPack = pLimit(getWorkspaceConcurrency(opts.workspaceConcurrency));
109
114
  const resolvedOpts = { ...opts };
110
115
  if (opts.out) {
@@ -215,16 +220,44 @@ export async function api(opts) {
215
220
  const filesMap = Object.fromEntries(files.map((file) => [`package/${file}`, path.join(dir, file)]));
216
221
  // cspell:disable-next-line
217
222
  if (opts.workspaceDir != null && dir !== opts.workspaceDir && !files.some((file) => /LICEN[CS]E(?:\..+)?/i.test(file))) {
218
- const licenses = await glob([LICENSE_GLOB], { cwd: opts.workspaceDir, expandDirectories: false });
219
- for (const license of licenses) {
220
- filesMap[`package/${license}`] = path.join(opts.workspaceDir, license);
221
- }
223
+ const { workspaceDir } = opts;
224
+ const licenses = await glob([LICENSE_GLOB], { cwd: workspaceDir, expandDirectories: false });
225
+ await Promise.all(licenses.map(async (license) => {
226
+ const licensePath = path.join(workspaceDir, license);
227
+ // Only inject a regular file. A symlink could point outside the workspace and leak its
228
+ // target's bytes into the published tarball, so `lstat()` (which does not follow symlinks)
229
+ // rejects it — matching pacquet's inject_workspace_license.
230
+ const stats = await fs.promises.lstat(licensePath);
231
+ if (stats.isFile()) {
232
+ filesMap[`package/${license}`] = licensePath;
233
+ }
234
+ }));
222
235
  }
223
236
  const destDir = packDestination
224
237
  ? (path.isAbsolute(packDestination) ? packDestination : path.join(dir, packDestination ?? '.'))
225
238
  : dir;
226
239
  if (!opts.dryRun) {
227
240
  await fs.promises.mkdir(destDir, { recursive: true });
241
+ }
242
+ // Derive `contents` and `unpackedSize` from `filesMap` (the full set of tar entries) rather than
243
+ // from `files` (the packlist subset) so that:
244
+ // - workspace LICENSE files appended to `filesMap` after the packlist call are included; and
245
+ // - `package.yaml` / `package.json5` entries are reported under the name they actually have in
246
+ // the tar (`package.json`), since `packPkg()` rewrites them.
247
+ // The `stat()` pass must run before `postpack`, which may delete prepack-generated files that
248
+ // were packed. See https://github.com/pnpm/pnpm/issues/12775.
249
+ const sizes = await Promise.all(Object.entries(filesMap).map(async ([name, source]) => {
250
+ if (isManifestEntry(name)) {
251
+ return Buffer.byteLength(JSON.stringify(publishManifest, null, 2));
252
+ }
253
+ const stat = await fs.promises.stat(source);
254
+ return stat.size;
255
+ }));
256
+ const unpackedSize = sizes.reduce((acc, size) => acc + size, 0);
257
+ const packedContents = Array.from(new Set(Object.keys(filesMap).map((name) => isManifestEntry(name)
258
+ ? 'package.json'
259
+ : name.replace(/^package\//, '')))).sort((a, b) => a.localeCompare(b, 'en'));
260
+ if (!opts.dryRun) {
228
261
  await packPkg({
229
262
  destFile: path.join(destDir, tarballName),
230
263
  filesMap,
@@ -248,22 +281,6 @@ export async function api(opts) {
248
281
  else {
249
282
  packedTarballPath = path.relative(opts.dir, path.join(dir, tarballName));
250
283
  }
251
- // Derive `contents` and `unpackedSize` from `filesMap` (the full set of tar entries) rather than
252
- // from `files` (the packlist subset) so that:
253
- // - workspace LICENSE files appended to `filesMap` after the packlist call are included; and
254
- // - `package.yaml` / `package.json5` entries are reported under the name they actually have in
255
- // the tar (`package.json`), since `packPkg()` rewrites them.
256
- const sizes = await Promise.all(Object.entries(filesMap).map(async ([name, source]) => {
257
- if (/^package\/package\.(?:json|json5|yaml)$/.test(name)) {
258
- return Buffer.byteLength(JSON.stringify(publishManifest, null, 2));
259
- }
260
- const stat = await fs.promises.stat(source);
261
- return stat.size;
262
- }));
263
- const unpackedSize = sizes.reduce((acc, size) => acc + size, 0);
264
- const packedContents = Array.from(new Set(Object.keys(filesMap).map((name) => /^package\/package\.(?:json|json5|yaml)$/.test(name)
265
- ? 'package.json'
266
- : name.replace(/^package\//, '')))).sort((a, b) => a.localeCompare(b, 'en'));
267
284
  return {
268
285
  publishedManifest: publishManifest,
269
286
  contents: packedContents,
@@ -271,6 +288,12 @@ export async function api(opts) {
271
288
  unpackedSize,
272
289
  };
273
290
  }
291
+ // True when a `package/<path>` tar key names the package manifest, which is
292
+ // packed as a single serialized `package/package.json` entry and reported as
293
+ // `package.json` in the contents listing regardless of the source file name.
294
+ function isManifestEntry(name) {
295
+ return name === 'package/package.json' || name === 'package/package.json5' || name === 'package/package.yaml';
296
+ }
274
297
  function stripBuildMetadata(version) {
275
298
  const plusIndex = version.indexOf('+');
276
299
  return plusIndex === -1 ? version : version.slice(0, plusIndex);
@@ -294,7 +317,7 @@ async function packPkg(opts) {
294
317
  await Promise.all(Object.entries(filesMap).map(async ([name, source]) => {
295
318
  const isExecutable = bins.some((bin) => path.relative(bin, source) === '');
296
319
  const mode = isExecutable ? 0o755 : 0o644;
297
- if (/^package\/package\.(?:json|json5|yaml)$/.test(name)) {
320
+ if (isManifestEntry(name)) {
298
321
  pack.entry({ mode, mtime, name: 'package/package.json' }, JSON.stringify(manifest, null, 2));
299
322
  return;
300
323
  }
@@ -1,6 +1,6 @@
1
1
  import type { Config, ConfigContext } from '@pnpm/config.reader';
2
2
  import type { PublishPackedPkgOptions, PublishSummary } from './publishPackedPkg.js';
3
- export type PublishRecursiveOpts = Required<Pick<Config, 'bin' | 'cacheDir' | 'dir' | 'pnpmHomeDir' | 'configByUri' | 'registries' | 'workspaceDir'>> & Required<Pick<ConfigContext, 'cliOptions'>> & Partial<Pick<Config, 'tag' | 'ca' | 'catalogs' | 'cert' | 'fetchTimeout' | 'force' | 'dryRun' | 'extraBinPaths' | 'extraEnv' | 'fetchRetries' | 'fetchRetryFactor' | 'fetchRetryMaxtimeout' | 'fetchRetryMintimeout' | 'key' | 'httpProxy' | 'httpsProxy' | 'localAddress' | 'lockfileDir' | 'noProxy' | 'npmPath' | 'offline' | 'strictSsl' | 'unsafePerm' | 'userAgent' | 'verifyStoreIntegrity'>> & Partial<Pick<ConfigContext, 'selectedProjectsGraph'>> & {
3
+ export type PublishRecursiveOpts = Required<Pick<Config, 'bin' | 'cacheDir' | 'dir' | 'pnpmHomeDir' | 'configByUri' | 'registries' | 'workspaceDir'>> & Required<Pick<ConfigContext, 'cliOptions'>> & Partial<Pick<Config, 'tag' | 'ca' | 'catalogs' | 'cert' | 'fetchTimeout' | 'force' | 'dryRun' | 'extraBinPaths' | 'extraEnv' | 'fetchRetries' | 'fetchRetryFactor' | 'fetchRetryMaxtimeout' | 'fetchRetryMintimeout' | 'key' | 'httpProxy' | 'httpsProxy' | 'localAddress' | 'lockfileDir' | 'noProxy' | 'npmPath' | 'offline' | 'strictSsl' | 'unsafePerm' | 'userAgent' | 'verifyStoreIntegrity'>> & Partial<Pick<ConfigContext, 'selectedProjectsGraph' | 'allProjectsGraph' | 'prodAllProjectsGraph' | 'prodOnlySelectedProjectDirs'>> & {
4
4
  access?: 'public' | 'restricted';
5
5
  argv: {
6
6
  original: string[];
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package';
3
3
  import { createResolver } from '@pnpm/installing.client';
4
4
  import { logger } from '@pnpm/logger';
5
- import { sortProjects } from '@pnpm/workspace.projects-sorter';
5
+ import { sortFilteredProjects } from '@pnpm/workspace.projects-sorter';
6
6
  import pFilter from 'p-filter';
7
7
  import { pick } from 'ramda';
8
8
  import { writeJsonFile } from 'write-json-file';
@@ -55,7 +55,7 @@ export async function recursivePublish(opts) {
55
55
  if (opts.cliOptions['otp']) {
56
56
  appendedArgs.push(`--otp=${opts.cliOptions['otp']}`);
57
57
  }
58
- const chunks = sortProjects(opts.selectedProjectsGraph);
58
+ const chunks = sortFilteredProjects(opts);
59
59
  const tag = opts.tag ?? 'latest';
60
60
  if (opts.batch) {
61
61
  const sortedPkgs = chunks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/releasing.commands",
3
- "version": "1100.5.1",
3
+ "version": "1100.5.3",
4
4
  "description": "Commands for deploy, pack, and publish",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -10,9 +10,9 @@
10
10
  "funding": "https://opencollective.com/pnpm",
11
11
  "repository": {
12
12
  "type": "git",
13
- "url": "https://github.com/pnpm/pnpm/tree/main/releasing/commands"
13
+ "url": "https://github.com/pnpm/pnpm/tree/main/pnpm11/releasing/commands"
14
14
  },
15
- "homepage": "https://github.com/pnpm/pnpm/tree/main/releasing/commands#readme",
15
+ "homepage": "https://github.com/pnpm/pnpm/tree/main/pnpm11/releasing/commands#readme",
16
16
  "bugs": {
17
17
  "url": "https://github.com/pnpm/pnpm/issues"
18
18
  },
@@ -35,6 +35,7 @@
35
35
  "ci-info": "^4.4.0",
36
36
  "detect-libc": "^2.1.2",
37
37
  "execa": "npm:safe-execa@0.3.0",
38
+ "is-subdir": "^2.0.0",
38
39
  "libnpmpublish": "^11.2.0",
39
40
  "normalize-path": "^3.0.0",
40
41
  "normalize-registry-url": "2.0.1",
@@ -51,35 +52,35 @@
51
52
  "validate-npm-package-name": "7.0.2",
52
53
  "write-json-file": "^7.0.0",
53
54
  "write-yaml-file": "^6.0.0",
54
- "@pnpm/cli.utils": "1101.0.12",
55
- "@pnpm/cli.common-cli-options-help": "1100.0.2",
56
- "@pnpm/config.reader": "1101.10.0",
57
- "@pnpm/catalogs.types": "1100.0.0",
58
- "@pnpm/constants": "1100.0.0",
55
+ "@pnpm/bins.resolver": "1100.0.8",
59
56
  "@pnpm/config.pick-registry-for-package": "1100.0.9",
57
+ "@pnpm/config.reader": "1101.11.0",
58
+ "@pnpm/constants": "1100.0.0",
59
+ "@pnpm/cli.common-cli-options-help": "1100.0.2",
60
60
  "@pnpm/deps.path": "1100.0.8",
61
- "@pnpm/engine.runtime.commands": "1100.1.6",
62
- "@pnpm/engine.runtime.node-resolver": "1101.1.8",
63
- "@pnpm/fetching.directory-fetcher": "1100.0.17",
64
- "@pnpm/error": "1100.0.0",
65
- "@pnpm/exec.lifecycle": "1100.1.0",
61
+ "@pnpm/catalogs.types": "1100.0.0",
62
+ "@pnpm/engine.runtime.commands": "1100.1.8",
63
+ "@pnpm/error": "1100.0.1",
64
+ "@pnpm/engine.runtime.node-resolver": "1101.1.10",
65
+ "@pnpm/exec.lifecycle": "1100.1.2",
66
+ "@pnpm/fetching.directory-fetcher": "1100.0.19",
67
+ "@pnpm/cli.utils": "1101.0.13",
66
68
  "@pnpm/exec.pnpm-cli-runner": "1100.0.1",
67
- "@pnpm/fs.is-empty-dir-or-nothing": "1100.0.0",
68
- "@pnpm/fs.indexed-pkg-importer": "1100.0.15",
69
- "@pnpm/installing.commands": "1100.10.0",
70
- "@pnpm/lockfile.fs": "1100.1.6",
71
- "@pnpm/installing.client": "1100.2.9",
69
+ "@pnpm/fs.indexed-pkg-importer": "1100.0.17",
72
70
  "@pnpm/fs.packlist": "1100.0.1",
73
- "@pnpm/lockfile.types": "1100.0.11",
74
- "@pnpm/network.fetch": "1100.1.3",
71
+ "@pnpm/installing.client": "1100.2.11",
72
+ "@pnpm/installing.commands": "1100.10.2",
73
+ "@pnpm/fs.is-empty-dir-or-nothing": "1100.0.0",
74
+ "@pnpm/lockfile.fs": "1100.1.8",
75
+ "@pnpm/network.fetch": "1100.1.4",
76
+ "@pnpm/lockfile.types": "1100.0.13",
77
+ "@pnpm/network.auth-header": "1101.1.3",
75
78
  "@pnpm/network.git-utils": "1100.0.2",
76
- "@pnpm/network.auth-header": "1101.1.2",
77
- "@pnpm/network.web-auth": "1101.1.1",
78
- "@pnpm/releasing.exportable-manifest": "1100.1.7",
79
+ "@pnpm/releasing.exportable-manifest": "1100.1.9",
80
+ "@pnpm/resolving.resolver-base": "1100.5.1",
81
+ "@pnpm/network.web-auth": "1101.2.0",
79
82
  "@pnpm/types": "1101.3.2",
80
- "@pnpm/workspace.projects-sorter": "1100.0.7",
81
- "@pnpm/resolving.resolver-base": "1100.4.2",
82
- "@pnpm/bins.resolver": "1100.0.8"
83
+ "@pnpm/workspace.projects-sorter": "1100.0.8"
83
84
  },
84
85
  "peerDependencies": {
85
86
  "@pnpm/logger": "^1100.0.0"
@@ -102,17 +103,17 @@
102
103
  "tar": "^7.5.15",
103
104
  "undici": "^7.27.2",
104
105
  "write-yaml-file": "^6.0.0",
105
- "@pnpm/assert-project": "1100.0.16",
106
- "@pnpm/hooks.pnpmfile": "1100.0.15",
107
- "@pnpm/releasing.commands": "1100.5.1",
106
+ "@pnpm/assert-project": "1100.0.18",
107
+ "@pnpm/catalogs.config": "1100.0.2",
108
+ "@pnpm/hooks.pnpmfile": "1100.0.17",
109
+ "@pnpm/prepare": "1100.0.18",
110
+ "@pnpm/releasing.commands": "1100.5.3",
111
+ "@pnpm/logger": "1100.0.0",
108
112
  "@pnpm/test-fixtures": "1100.0.0",
109
113
  "@pnpm/test-ipc-server": "1100.0.0",
110
- "@pnpm/prepare": "1100.0.16",
111
- "@pnpm/catalogs.config": "1100.0.1",
112
- "@pnpm/testing.command-defaults": "1100.0.6",
113
- "@pnpm/logger": "1100.0.0",
114
- "@pnpm/testing.registry-mock": "1100.0.6",
115
- "@pnpm/workspace.projects-filter": "1100.0.22"
114
+ "@pnpm/testing.command-defaults": "1100.0.8",
115
+ "@pnpm/testing.registry-mock": "1100.0.8",
116
+ "@pnpm/workspace.projects-filter": "1100.0.24"
116
117
  },
117
118
  "engines": {
118
119
  "node": ">=22.13"