@pnpm/releasing.commands 1100.5.5 → 1100.6.0

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.
@@ -0,0 +1,157 @@
1
+ import { PnpmError } from '@pnpm/error';
2
+ import { indexProjectRefs, toProjectDir } from '@pnpm/releasing.versioning';
3
+ import { updateWorkspaceManifest } from '@pnpm/workspace.workspace-manifest-writer';
4
+ import { renderHelp } from 'render-help';
5
+ import { getReleasableProjects } from '../change/index.js';
6
+ export function rcOptionsTypes() {
7
+ return {};
8
+ }
9
+ export function cliOptionsTypes() {
10
+ return {
11
+ recursive: Boolean,
12
+ };
13
+ }
14
+ export const commandNames = ['lane'];
15
+ /**
16
+ * The reserved name of the default lane: every package is on it unless
17
+ * assigned elsewhere, packages on it release stable versions, and no
18
+ * prerelease lane can take the name.
19
+ */
20
+ export const MAIN_LANE = 'main';
21
+ export function help() {
22
+ return renderHelp({
23
+ description: 'Manages per-package release lanes. A lane is a parallel release track: while a package is on one, the bare "pnpm version -r" releases it as X.Y.Z-<lane>.N prereleases while the rest of the workspace keeps releasing stable versions. Moving a package back to the main lane releases its accumulated stable version on the next run. Membership lives under the versioning.lanes key of pnpm-workspace.yaml; this command is a convenience editor for that key.',
24
+ usages: [
25
+ 'pnpm lane',
26
+ 'pnpm lane <name> --filter <pattern>',
27
+ 'pnpm lane main --filter <pattern>',
28
+ ],
29
+ descriptionLists: [
30
+ {
31
+ title: 'Options',
32
+ list: [
33
+ {
34
+ description: 'Select the packages to move between lanes',
35
+ name: '--filter <pattern>',
36
+ },
37
+ ],
38
+ },
39
+ ],
40
+ });
41
+ }
42
+ export async function handler(opts, params) {
43
+ const workspaceDir = opts.workspaceDir;
44
+ if (!workspaceDir) {
45
+ throw new PnpmError('WORKSPACE_ONLY', 'pnpm lane is only supported in a workspace');
46
+ }
47
+ if (params.length === 0) {
48
+ return renderLanes(opts.versioning?.lanes ?? {});
49
+ }
50
+ const laneName = params[0];
51
+ if ((opts.filter ?? []).length === 0) {
52
+ throw new PnpmError('VERSIONING_LANE_FILTER_REQUIRED', 'Select the packages to move with --filter, e.g. "pnpm lane alpha --filter <pkg>..."');
53
+ }
54
+ const refs = indexProjectRefs(opts.allProjects ?? [], workspaceDir);
55
+ const releasableDirs = new Set(getReleasableProjects(opts.allProjects ?? [], workspaceDir, opts.versioning).map((project) => project.dir));
56
+ const selected = Object.values(opts.selectedProjectsGraph ?? {})
57
+ .map((node) => ({
58
+ name: node.package.manifest.name,
59
+ dir: toProjectDir(workspaceDir, node.package.rootDir),
60
+ }))
61
+ .filter((project) => project.name != null && releasableDirs.has(project.dir));
62
+ if (selected.length === 0) {
63
+ throw new PnpmError('VERSIONING_NO_PACKAGES', 'The filter selected no releasable packages');
64
+ }
65
+ // Existing entries may reference projects by name or by directory; resolve
66
+ // them so assignments and removals key on the project, not the spelling.
67
+ const lanes = { ...opts.versioning?.lanes };
68
+ const laneByDir = new Map();
69
+ for (const [key, lane] of Object.entries(lanes)) {
70
+ for (const dir of refs.refToDirs(key)) {
71
+ laneByDir.set(dir, { key, lane });
72
+ }
73
+ }
74
+ let output;
75
+ if (laneName === MAIN_LANE) {
76
+ for (const project of selected) {
77
+ const existing = laneByDir.get(project.dir);
78
+ if (existing != null) {
79
+ delete lanes[existing.key];
80
+ }
81
+ }
82
+ output = `Moved to the main lane:\n${selected.map((project) => ` ${refFor(project, refs)}\n`).join('')}` +
83
+ 'The accumulated stable versions release on the next "pnpm version -r" run.';
84
+ }
85
+ else {
86
+ if (laneName.toLowerCase() === MAIN_LANE) {
87
+ throw new PnpmError('VERSIONING_INVALID_LANE_NAME', `Invalid lane name: ${laneName}. "main" is the reserved default lane; spell it in lowercase to move packages back onto it.`);
88
+ }
89
+ // A purely numeric lane name is rejected because semver parses an
90
+ // all-digit prerelease identifier as a number, which changes sorting
91
+ // semantics.
92
+ if (!/^[0-9A-Z-]+$/i.test(laneName) || /^\d+$/.test(laneName)) {
93
+ throw new PnpmError('VERSIONING_INVALID_LANE_NAME', `Invalid lane name: ${laneName}. Lane names may contain only alphanumerics and hyphens, and cannot be purely numeric.`);
94
+ }
95
+ for (const project of selected) {
96
+ const existing = laneByDir.get(project.dir);
97
+ if (existing != null && existing.lane !== laneName) {
98
+ throw new PnpmError('VERSIONING_ALREADY_ON_LANE', `${refFor(project, refs)} is already on the "${existing.lane}" lane. Move it back with "pnpm lane main" first.`);
99
+ }
100
+ if (existing == null) {
101
+ lanes[refFor(project, refs)] = laneName;
102
+ }
103
+ }
104
+ output = `Moved to the "${laneName}" lane:\n${selected.map((project) => ` ${refFor(project, refs)}\n`).join('')}`;
105
+ }
106
+ const versioning = { ...opts.versioning };
107
+ if (Object.keys(lanes).length > 0) {
108
+ versioning.lanes = lanes;
109
+ }
110
+ else {
111
+ delete versioning.lanes;
112
+ }
113
+ await updateWorkspaceManifest(workspaceDir, {
114
+ updatedFields: {
115
+ versioning: Object.keys(versioning).length > 0 ? versioning : undefined,
116
+ },
117
+ });
118
+ return output;
119
+ }
120
+ /**
121
+ * How the project is referenced in versioning.lanes and in output: the bare
122
+ * name, or the directory path when the name is shared by several projects.
123
+ */
124
+ function refFor(project, refs) {
125
+ return refs.nameToDirs(project.name).length > 1 ? `./${project.dir}` : project.name;
126
+ }
127
+ function renderLanes(lanes) {
128
+ const laneEntries = Object.entries(lanes);
129
+ if (laneEntries.length === 0) {
130
+ return 'All packages are on the main lane.';
131
+ }
132
+ const byLane = new Map();
133
+ for (const [ref, laneName] of laneEntries) {
134
+ let members = byLane.get(laneName);
135
+ if (members == null) {
136
+ members = [];
137
+ byLane.set(laneName, members);
138
+ }
139
+ members.push(ref);
140
+ }
141
+ let output = 'Lanes:\n';
142
+ for (const [laneName, members] of Array.from(byLane.entries()).sort(([left], [right]) => left.localeCompare(right))) {
143
+ output += ` ${laneName}:\n`;
144
+ for (const ref of members.sort()) {
145
+ output += ` ${ref}\n`;
146
+ }
147
+ }
148
+ return output;
149
+ }
150
+ export const lane = {
151
+ handler,
152
+ help,
153
+ commandNames,
154
+ cliOptionsTypes,
155
+ rcOptionsTypes,
156
+ };
157
+ //# sourceMappingURL=index.js.map
@@ -135,7 +135,7 @@ async function multiPublishToRegistry(registry, group, opts) {
135
135
  operation: async (otp) => npmFetch(BATCH_PUBLISH_ENDPOINT, {
136
136
  ...publishOptions,
137
137
  access: publishOptions.access ?? undefined,
138
- otp,
138
+ otp: otp ?? publishOptions.otp,
139
139
  method: 'PUT',
140
140
  body,
141
141
  ignoreBody: true,
@@ -5,6 +5,13 @@ export type TarballSuffix = typeof TARBALL_SUFFIXES[number];
5
5
  export type TarballPath = `${string}${TarballSuffix}`;
6
6
  export declare const isTarballPath: (path: string) => path is TarballPath;
7
7
  export declare function extractManifestFromPacked<Output = ExportedManifest>(tarballPath: TarballPath): Promise<Output>;
8
+ /**
9
+ * Read the publish manifest from a pre-built tarball, filling in its `readme` from the tarball's
10
+ * root README file when the manifest doesn't already declare one. This mirrors the npm CLI, which
11
+ * reads the readme out of the tarball (via pacote's `fullReadJson`) so the registry gets it as
12
+ * metadata even though it isn't stored in the packed `package.json`.
13
+ */
14
+ export declare function extractPublishManifestFromPacked(tarballPath: TarballPath): Promise<ExportedManifest>;
8
15
  export declare class PublishArchiveMissingManifestError extends PnpmError {
9
16
  readonly tarballPath: string;
10
17
  constructor(tarballPath: string);
@@ -6,6 +6,30 @@ import tar from 'tar-stream';
6
6
  const TARBALL_SUFFIXES = ['.tar.gz', '.tgz'];
7
7
  export const isTarballPath = (path) => TARBALL_SUFFIXES.some(suffix => path.endsWith(suffix));
8
8
  export async function extractManifestFromPacked(tarballPath) {
9
+ const { manifest } = await extractEntriesFromPacked(tarballPath, false);
10
+ return JSON.parse(manifest);
11
+ }
12
+ /**
13
+ * Read the publish manifest from a pre-built tarball, filling in its `readme` from the tarball's
14
+ * root README file when the manifest doesn't already declare one. This mirrors the npm CLI, which
15
+ * reads the readme out of the tarball (via pacote's `fullReadJson`) so the registry gets it as
16
+ * metadata even though it isn't stored in the packed `package.json`.
17
+ */
18
+ export async function extractPublishManifestFromPacked(tarballPath) {
19
+ const { manifest, readme } = await extractEntriesFromPacked(tarballPath, true);
20
+ const parsed = JSON.parse(manifest);
21
+ if (parsed.readme == null && readme != null) {
22
+ parsed.readme = readme;
23
+ }
24
+ return parsed;
25
+ }
26
+ /**
27
+ * Scan the tarball for `package/package.json` and, when `wantReadme` is set, the root
28
+ * `README.md`. The manifest-only path (`wantReadme` false) resolves as soon as the manifest
29
+ * entry is read and stops decompressing the rest of the archive; the publish path scans on
30
+ * because a README can appear after the manifest.
31
+ */
32
+ async function extractEntriesFromPacked(tarballPath, wantReadme) {
9
33
  const extract = tar.extract();
10
34
  const gunzip = createGunzip();
11
35
  const tarballStream = fs.createReadStream(tarballPath);
@@ -19,47 +43,62 @@ export async function extractManifestFromPacked(tarballPath) {
19
43
  tarballStream.destroy();
20
44
  }
21
45
  const promise = new Promise((resolve, reject) => {
46
+ let settled = false;
47
+ let manifest;
48
+ let readme;
22
49
  function handleError(error) {
23
50
  cleanup();
24
51
  reject(error);
25
52
  }
53
+ function settle() {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ cleanup();
58
+ if (manifest == null) {
59
+ reject(new PublishArchiveMissingManifestError(tarballPath));
60
+ return;
61
+ }
62
+ resolve({ manifest, readme });
63
+ }
26
64
  tarballStream.once('error', handleError);
27
65
  gunzip.once('error', handleError);
28
- let manifestFound = false;
29
66
  extract.on('entry', (header, stream, next) => {
30
67
  const normalizedPath = path.normalize(header.name).replaceAll('\\', '/');
31
- if (normalizedPath !== 'package/package.json') {
68
+ const isManifest = normalizedPath === 'package/package.json';
69
+ const isReadme = wantReadme && /^package\/readme\.md$/i.test(normalizedPath);
70
+ if (!isManifest && !isReadme) {
32
71
  stream.once('end', next);
33
72
  stream.resume();
34
73
  return;
35
74
  }
36
- manifestFound = true;
37
75
  const chunks = [];
38
76
  stream.on('data', (chunk) => {
39
77
  chunks.push(chunk);
40
78
  });
41
79
  stream.once('end', () => {
42
- try {
43
- const text = Buffer.concat(chunks).toString();
44
- cleanup();
45
- resolve(text);
80
+ const text = Buffer.concat(chunks).toString();
81
+ if (isManifest) {
82
+ manifest = text;
46
83
  }
47
- catch (error) {
48
- handleError(error);
84
+ else {
85
+ readme = text;
49
86
  }
87
+ // Stop early once every wanted entry has been captured, so the manifest-only
88
+ // path doesn't stream and decompress the remainder of the tarball.
89
+ if (manifest != null && (!wantReadme || readme != null)) {
90
+ settle();
91
+ return;
92
+ }
93
+ next();
50
94
  });
51
95
  stream.once('error', handleError);
52
96
  });
53
- extract.once('finish', () => {
54
- cleanup();
55
- if (!manifestFound) {
56
- reject(new PublishArchiveMissingManifestError(tarballPath));
57
- }
58
- });
97
+ extract.once('finish', settle);
59
98
  extract.once('error', handleError);
60
99
  });
61
100
  tarballStream.pipe(gunzip).pipe(extract);
62
- return JSON.parse(await promise);
101
+ return promise;
63
102
  }
64
103
  export class PublishArchiveMissingManifestError extends PnpmError {
65
104
  tarballPath;
@@ -28,11 +28,14 @@ export async function publishWithOtpHandling({ context = SHARED_CONTEXT, manifes
28
28
  return withOtpHandling({
29
29
  context,
30
30
  fetchOptions,
31
- // When otp is undefined (first attempt), { ...publishOptions, otp } adds
32
- // otp: undefined to the options. This is safe because libnpmpublish treats
33
- // undefined the same as absent (unlike HTTP headers, where undefined gets
34
- // coerced to the string "undefined").
35
- operation: otp => publish(manifest, tarballData, { ...publishOptions, otp }),
31
+ // withOtpHandling first calls this with no otp, then retries with the otp
32
+ // it obtained from an OTP challenge. On the first attempt we fall back to
33
+ // the configured --otp (publishOptions.otp) so it is actually sent; a retry
34
+ // otp takes precedence over it. When both are undefined, passing
35
+ // otp: undefined is safe because libnpmpublish treats undefined the same as
36
+ // absent (unlike HTTP headers, where undefined gets coerced to the string
37
+ // "undefined").
38
+ operation: otp => publish(manifest, tarballData, { ...publishOptions, otp: otp ?? publishOptions.otp }),
36
39
  });
37
40
  }
38
41
  //# sourceMappingURL=otp.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' | 'allProjectsGraph' | 'prodAllProjectsGraph' | 'prodOnlySelectedProjectDirs'>> & {
7
+ export type PackOptions = Pick<UniversalOptions, 'dir'> & Pick<Config, 'catalogs' | 'ignoreScripts' | 'embedReadme' | 'packGzipLevel' | 'nodeLinker' | 'skipManifestObfuscation' | 'userAgent'> & Partial<Pick<Config, 'extraBinPaths' | 'extraEnv' | 'recursive' | 'workspaceConcurrency' | 'workspaceDir' | 'versioning' | 'registries' | 'configByUri' | 'fetchRetries' | 'fetchRetryFactor' | 'fetchRetryMaxtimeout' | 'fetchRetryMintimeout' | 'fetchTimeout' | 'ca' | 'cert' | 'key' | 'strictSsl' | 'httpProxy' | 'httpsProxy' | 'noProxy' | 'localAddress'>> & Partial<Pick<ConfigContext, 'hooks' | 'selectedProjectsGraph' | 'allProjectsGraph' | 'prodAllProjectsGraph' | 'prodOnlySelectedProjectDirs'>> & {
8
8
  argv: {
9
9
  original: string[];
10
10
  };
@@ -8,7 +8,8 @@ import { getDefaultWorkspaceConcurrency, getWorkspaceConcurrency, types as allTy
8
8
  import { PnpmError } from '@pnpm/error';
9
9
  import { packlist } from '@pnpm/fs.packlist';
10
10
  import { logger } from '@pnpm/logger';
11
- import { createExportableManifest } from '@pnpm/releasing.exportable-manifest';
11
+ import { createExportableManifest, readReadmeFile } from '@pnpm/releasing.exportable-manifest';
12
+ import { changelogStorage, readPendingChangelog, renderChangelog } from '@pnpm/releasing.versioning';
12
13
  import { sortFilteredProjects } from '@pnpm/workspace.projects-sorter';
13
14
  import chalk from 'chalk';
14
15
  import pLimit from 'p-limit';
@@ -18,6 +19,7 @@ import { renderHelp } from 'render-help';
18
19
  import tar from 'tar-stream';
19
20
  import { glob } from 'tinyglobby';
20
21
  import validateNpmPackageName from 'validate-npm-package-name';
22
+ import { fetchPreviousChangelog } from './previousChangelog.js';
21
23
  import { runScriptsIfPresent } from './publish.js';
22
24
  const LICENSE_GLOB = 'LICEN{S,C}E{,.*}'; // cspell:disable-line
23
25
  export function rcOptionsTypes() {
@@ -216,6 +218,7 @@ export async function api(opts) {
216
218
  }
217
219
  const files = await packlist(dir, {
218
220
  manifest: publishManifest,
221
+ workspaceDir: opts.workspaceDir,
219
222
  });
220
223
  const filesMap = Object.fromEntries(files.map((file) => [`package/${file}`, path.join(dir, file)]));
221
224
  // cspell:disable-next-line
@@ -233,6 +236,16 @@ export async function api(opts) {
233
236
  }
234
237
  }));
235
238
  }
239
+ // In `registry` changelog storage the package carries no committed
240
+ // CHANGELOG.md; its section was parked at `pnpm version -r` time and is
241
+ // composed here on top of the previously published version's changelog and
242
+ // packed in. A composed entry supersedes any stale committed CHANGELOG.md.
243
+ const injectedEntries = {};
244
+ const composedChangelog = await composeRegistryChangelog(opts, manifest.name, manifest.version);
245
+ if (composedChangelog != null) {
246
+ delete filesMap['package/CHANGELOG.md'];
247
+ injectedEntries['package/CHANGELOG.md'] = composedChangelog;
248
+ }
236
249
  const destDir = packDestination
237
250
  ? (path.isAbsolute(packDestination) ? packDestination : path.join(dir, packDestination ?? '.'))
238
251
  : dir;
@@ -253,14 +266,19 @@ export async function api(opts) {
253
266
  const stat = await fs.promises.stat(source);
254
267
  return stat.size;
255
268
  }));
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'));
269
+ const injectedSize = Object.values(injectedEntries).reduce((acc, content) => acc + Buffer.byteLength(content), 0);
270
+ const unpackedSize = sizes.reduce((acc, size) => acc + size, 0) + injectedSize;
271
+ const packedContents = Array.from(new Set([
272
+ ...Object.keys(filesMap).map((name) => isManifestEntry(name)
273
+ ? 'package.json'
274
+ : name.replace(/^package\//, '')),
275
+ ...Object.keys(injectedEntries).map((name) => name.replace(/^package\//, '')),
276
+ ])).sort((a, b) => a.localeCompare(b, 'en'));
260
277
  if (!opts.dryRun) {
261
278
  await packPkg({
262
279
  destFile: path.join(destDir, tarballName),
263
280
  filesMap,
281
+ injectedEntries,
264
282
  modulesDir: path.join(opts.dir, 'node_modules'),
265
283
  packGzipLevel: opts.packGzipLevel,
266
284
  manifest: publishManifest,
@@ -282,18 +300,51 @@ export async function api(opts) {
282
300
  packedTarballPath = path.relative(opts.dir, path.join(dir, tarballName));
283
301
  }
284
302
  return {
285
- publishedManifest: publishManifest,
303
+ publishedManifest: await withRegistryReadme(publishManifest, dir),
286
304
  contents: packedContents,
287
305
  tarballPath: packedTarballPath,
288
306
  unpackedSize,
289
307
  };
290
308
  }
309
+ /**
310
+ * The readme is always sent to the registry as package metadata, matching the npm CLI, so that
311
+ * registries can render it on the package page. The `embed-readme` setting only controls whether
312
+ * the readme is additionally written into the `package.json` inside the tarball (via
313
+ * `createExportableManifest`), which is why it is added to the returned manifest here rather than
314
+ * to the packed one.
315
+ */
316
+ async function withRegistryReadme(manifest, projectDir) {
317
+ if (manifest.readme != null)
318
+ return manifest;
319
+ const readme = await readReadmeFile(projectDir);
320
+ if (readme == null)
321
+ return manifest;
322
+ return { ...manifest, readme };
323
+ }
291
324
  // True when a `package/<path>` tar key names the package manifest, which is
292
325
  // packed as a single serialized `package/package.json` entry and reported as
293
326
  // `package.json` in the contents listing regardless of the source file name.
294
327
  function isManifestEntry(name) {
295
328
  return name === 'package/package.json' || name === 'package/package.json5' || name === 'package/package.yaml';
296
329
  }
330
+ /**
331
+ * The CHANGELOG.md to pack for a `registry`-storage release: its parked
332
+ * section (written at `pnpm version -r` time) rendered on top of the
333
+ * previously published version's changelog. `undefined` when storage is
334
+ * `repository`, there is no workspace, or the release has no parked section
335
+ * (an ordinary `pnpm pack` of a package that is not mid-release).
336
+ */
337
+ async function composeRegistryChangelog(opts, pkgName, version) {
338
+ if (changelogStorage(opts.versioning) !== 'registry' || opts.workspaceDir == null)
339
+ return undefined;
340
+ const section = await readPendingChangelog(opts.workspaceDir, pkgName, version);
341
+ if (section == null)
342
+ return undefined;
343
+ const previous = opts.registries != null
344
+ ? await fetchPreviousChangelog(opts, pkgName, version)
345
+ : undefined;
346
+ return renderChangelog(previous ?? null, pkgName, section);
347
+ }
297
348
  function stripBuildMetadata(version) {
298
349
  const plusIndex = version.indexOf('+');
299
350
  return plusIndex === -1 ? version : version.slice(0, plusIndex);
@@ -311,7 +362,7 @@ function preventBundledDependenciesWithoutHoistedNodeLinker(nodeLinker, manifest
311
362
  }
312
363
  }
313
364
  async function packPkg(opts) {
314
- const { destFile, filesMap, bins, manifest, } = opts;
365
+ const { destFile, filesMap, injectedEntries, bins, manifest, } = opts;
315
366
  const mtime = new Date('1985-10-26T08:15:00.000Z');
316
367
  const pack = tar.pack();
317
368
  await Promise.all(Object.entries(filesMap).map(async ([name, source]) => {
@@ -323,6 +374,9 @@ async function packPkg(opts) {
323
374
  }
324
375
  pack.entry({ mode, mtime, name }, fs.readFileSync(source));
325
376
  }));
377
+ for (const [name, content] of Object.entries(injectedEntries ?? {})) {
378
+ pack.entry({ mode: 0o644, mtime, name }, content);
379
+ }
326
380
  const tarball = fs.createWriteStream(destFile);
327
381
  pack.pipe(createGzip({ level: opts.packGzipLevel })).pipe(tarball);
328
382
  pack.finalize();
@@ -0,0 +1,19 @@
1
+ import type { Config } from '@pnpm/config.reader';
2
+ import { type CreateFetchFromRegistryOptions } from '@pnpm/network.fetch';
3
+ export type PreviousChangelogOptions = CreateFetchFromRegistryOptions & Pick<Config, 'registries' | 'fetchRetries' | 'fetchRetryFactor' | 'fetchRetryMaxtimeout' | 'fetchRetryMintimeout' | 'fetchTimeout'>;
4
+ /**
5
+ * The `CHANGELOG.md` packed into the highest published version of `pkgName`
6
+ * that is semver-lower than `version` — the changelog the section for
7
+ * `version` is prepended onto in `registry` storage. `undefined` when the
8
+ * package is new, has no earlier published version, or that version's tarball
9
+ * carried no changelog.
10
+ */
11
+ export declare function fetchPreviousChangelog(opts: PreviousChangelogOptions, pkgName: string, version: string): Promise<string | undefined>;
12
+ /**
13
+ * The `CHANGELOG.md` packed into the published `pkgName@version` exactly, or
14
+ * `undefined` when that version is not published (or carried no changelog).
15
+ * Used to confirm a release actually carries its composed section before the
16
+ * intents behind it are garbage-collected.
17
+ */
18
+ export declare function fetchPublishedChangelog(opts: PreviousChangelogOptions, pkgName: string, version: string): Promise<string | undefined>;
19
+ export declare function changelogHasSection(changelog: string, section: string): boolean;
@@ -0,0 +1,182 @@
1
+ import { gunzipSync } from 'node:zlib';
2
+ import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package';
3
+ import { PnpmError } from '@pnpm/error';
4
+ import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header';
5
+ import { createFetchFromRegistry } from '@pnpm/network.fetch';
6
+ import { fetchMetadataFromFromRegistry } from '@pnpm/resolving.npm-resolver';
7
+ import { lt, rsort, valid } from 'semver';
8
+ import tar from 'tar-stream';
9
+ const CHANGELOG_ENTRY = 'package/CHANGELOG.md';
10
+ /**
11
+ * Caps the previous tarball we buffer and decompress to compose the changelog.
12
+ * The bytes come from a registry/proxy, so an unbounded read or a highly
13
+ * compressible ("gzip bomb") tarball could OOM release automation. A composed
14
+ * changelog is best-effort — exceeding the cap just skips the history prepend —
15
+ * so this bound can be generous while still defeating the amplification attack.
16
+ */
17
+ const MAX_TARBALL_BYTES = 256 * 1024 * 1024;
18
+ function createRegistryClient(opts) {
19
+ return {
20
+ fetch: createFetchFromRegistry(opts),
21
+ getAuthHeader: createGetAuthHeaderByURI(opts.configByUri ?? {}),
22
+ };
23
+ }
24
+ /**
25
+ * The `CHANGELOG.md` packed into the highest published version of `pkgName`
26
+ * that is semver-lower than `version` — the changelog the section for
27
+ * `version` is prepended onto in `registry` storage. `undefined` when the
28
+ * package is new, has no earlier published version, or that version's tarball
29
+ * carried no changelog.
30
+ */
31
+ export async function fetchPreviousChangelog(opts, pkgName, version) {
32
+ const client = createRegistryClient(opts);
33
+ const meta = await fetchPackument(client, opts, pkgName);
34
+ if (meta == null)
35
+ return undefined;
36
+ const previousVersion = pickPreviousVersion(meta, version);
37
+ if (previousVersion == null)
38
+ return undefined;
39
+ return downloadTarballChangelog(client, pkgName, meta, previousVersion);
40
+ }
41
+ /**
42
+ * The `CHANGELOG.md` packed into the published `pkgName@version` exactly, or
43
+ * `undefined` when that version is not published (or carried no changelog).
44
+ * Used to confirm a release actually carries its composed section before the
45
+ * intents behind it are garbage-collected.
46
+ */
47
+ export async function fetchPublishedChangelog(opts, pkgName, version) {
48
+ const client = createRegistryClient(opts);
49
+ const meta = await fetchPackument(client, opts, pkgName);
50
+ if (meta?.versions[version] == null)
51
+ return undefined;
52
+ return downloadTarballChangelog(client, pkgName, meta, version);
53
+ }
54
+ export function changelogHasSection(changelog, section) {
55
+ return changelog.includes(section.trim());
56
+ }
57
+ async function fetchPackument(client, opts, pkgName) {
58
+ const registry = pickRegistryForPackage(opts.registries, pkgName);
59
+ let fetchResult;
60
+ try {
61
+ fetchResult = await fetchMetadataFromFromRegistry({
62
+ fetch: client.fetch,
63
+ retry: {
64
+ factor: opts.fetchRetryFactor,
65
+ maxTimeout: opts.fetchRetryMaxtimeout,
66
+ minTimeout: opts.fetchRetryMintimeout,
67
+ retries: opts.fetchRetries,
68
+ },
69
+ timeout: opts.fetchTimeout ?? 60000,
70
+ fetchWarnTimeoutMs: 10000,
71
+ }, pkgName, {
72
+ registry,
73
+ authHeaderValue: client.getAuthHeader(registry, { pkgName }),
74
+ fullMetadata: false,
75
+ });
76
+ }
77
+ catch (err) {
78
+ // A package with no published version yet has no changelog to build on.
79
+ if (err != null && typeof err === 'object' && 'code' in err && err.code === 'ERR_PNPM_FETCH_404') {
80
+ return undefined;
81
+ }
82
+ throw err;
83
+ }
84
+ return fetchResult.notModified ? undefined : fetchResult.meta;
85
+ }
86
+ /** Highest published version of the package that is semver-lower than `version`. */
87
+ function pickPreviousVersion(meta, version) {
88
+ const candidates = Object.keys(meta.versions).filter((candidate) => valid(candidate) != null && lt(candidate, version));
89
+ return candidates.length > 0 ? rsort(candidates)[0] : undefined;
90
+ }
91
+ async function downloadTarballChangelog(client, pkgName, meta, version) {
92
+ const tarballUrl = meta.versions[version]?.dist?.tarball;
93
+ if (tarballUrl == null)
94
+ return undefined;
95
+ const response = await client.fetch(tarballUrl, { authHeaderValue: client.getAuthHeader(tarballUrl, { pkgName }) });
96
+ if (!response.ok) {
97
+ throw new PnpmError('CHANGELOG_TARBALL_FETCH_FAILED', `Failed to download ${pkgName}@${version} tarball (${response.status}) to compose the changelog: ${tarballUrl}`);
98
+ }
99
+ const tarballData = await readCapped(response, MAX_TARBALL_BYTES);
100
+ if (tarballData == null)
101
+ return undefined;
102
+ return extractTarballEntry(tarballData, CHANGELOG_ENTRY);
103
+ }
104
+ /**
105
+ * Reads a response body into a buffer, stopping (and returning `undefined`) as
106
+ * soon as it exceeds `maxBytes` — bounding the actual download rather than
107
+ * trusting a `content-length` header, which may be absent or lie.
108
+ */
109
+ async function readCapped(response, maxBytes) {
110
+ const reader = response.body?.getReader();
111
+ if (reader == null) {
112
+ const buffer = Buffer.from(await response.arrayBuffer());
113
+ return buffer.byteLength > maxBytes ? undefined : buffer;
114
+ }
115
+ const chunks = [];
116
+ let total = 0;
117
+ for (;;) {
118
+ // Streaming a body is inherently sequential — each read must await the
119
+ // previous chunk — so the successive awaits here are intentional.
120
+ // eslint-disable-next-line no-await-in-loop
121
+ const { done, value } = await reader.read();
122
+ if (done)
123
+ break;
124
+ total += value.byteLength;
125
+ if (total > maxBytes) {
126
+ // Best-effort cleanup: a cancel failure must not turn the over-cap path
127
+ // into a hard error — the caller just gets no changelog either way.
128
+ // eslint-disable-next-line no-await-in-loop
129
+ await reader.cancel().catch(() => { });
130
+ return undefined;
131
+ }
132
+ chunks.push(Buffer.from(value));
133
+ }
134
+ return Buffer.concat(chunks);
135
+ }
136
+ /**
137
+ * Reads one entry's contents out of a (optionally gzipped) tarball buffer.
138
+ * Composing the changelog is best-effort, so any decode/parse failure —
139
+ * including a gzip bomb tripping the inflate cap — resolves to `undefined`
140
+ * (no history prepend) rather than throwing.
141
+ */
142
+ async function extractTarballEntry(tarballData, entryName) {
143
+ const tarData = gunzipCapped(tarballData);
144
+ if (tarData == null)
145
+ return undefined;
146
+ const extract = tar.extract();
147
+ return new Promise((resolve) => {
148
+ let contents;
149
+ extract.on('entry', (header, stream, next) => {
150
+ if (header.name !== entryName) {
151
+ stream.resume();
152
+ stream.on('end', next);
153
+ stream.on('error', () => resolve(undefined));
154
+ return;
155
+ }
156
+ const chunks = [];
157
+ stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
158
+ stream.on('error', () => resolve(undefined));
159
+ stream.on('end', () => {
160
+ contents = Buffer.concat(chunks).toString('utf8');
161
+ next();
162
+ });
163
+ });
164
+ extract.on('error', () => resolve(undefined));
165
+ extract.on('finish', () => resolve(contents));
166
+ extract.end(tarData);
167
+ });
168
+ }
169
+ /**
170
+ * Inflates a published `.tgz`, capped at `MAX_TARBALL_BYTES` of output so a
171
+ * gzip bomb throws rather than ballooning memory. Returns `undefined` on that
172
+ * cap or any other decode failure — the caller treats it as no changelog.
173
+ */
174
+ function gunzipCapped(tarballData) {
175
+ try {
176
+ return gunzipSync(tarballData, { maxOutputLength: MAX_TARBALL_BYTES });
177
+ }
178
+ catch {
179
+ return undefined;
180
+ }
181
+ }
182
+ //# sourceMappingURL=previousChangelog.js.map