@exadev/semantic-release-workspace 3.1.28 → 3.2.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.
package/dist/cli.js CHANGED
@@ -5,16 +5,16 @@ import { cosmiconfig } from "cosmiconfig";
5
5
  import { Command, InvalidArgumentError } from "commander";
6
6
  import { dirname, relative, resolve, sep } from "node:path";
7
7
  import { detachRelease, resumeRelease } from "@exadev/release-gate";
8
- import { execFile } from "node:child_process";
9
8
  import validateNpmPackageName from "validate-npm-package-name";
10
9
  import { glob } from "tinyglobby";
11
10
  import { parse } from "yaml";
11
+ import { execFile } from "node:child_process";
12
12
  import { analyzeCommits } from "@semantic-release/commit-analyzer";
13
13
  import { generateNotes } from "@semantic-release/release-notes-generator";
14
14
  import semanticRelease from "semantic-release";
15
15
  import { pathToFileURL } from "node:url";
16
16
  //#region package.json
17
- var version = "3.1.28";
17
+ var version = "3.2.0";
18
18
  //#endregion
19
19
  //#region src/errors.ts
20
20
  /**
@@ -75,6 +75,187 @@ function formatTagForPackage(tagFormat, name) {
75
75
  return tagFormat.replaceAll("${name}", name);
76
76
  }
77
77
  //#endregion
78
+ //#region src/json.ts
79
+ function isJsonObject(value) {
80
+ return typeof value === "object" && value !== null && !Array.isArray(value);
81
+ }
82
+ function isStringRecord(value) {
83
+ return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
84
+ }
85
+ function isStringArray(value) {
86
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
87
+ }
88
+ /**
89
+ * Narrows `unknown` to `unknown[]`. `Array.isArray` alone narrows to `any[]`, whose elements flow as `any` into any later destructuring; going through this guard keeps the elements `unknown` so they must still be narrowed by hand.
90
+ */
91
+ function isUnknownArray(value) {
92
+ return Array.isArray(value);
93
+ }
94
+ /**
95
+ * The indentation of the first indented line in a JSON document, so a rewritten manifest keeps the formatting the repository already uses instead of being reflowed to whatever `JSON.stringify` defaults to. A file with no indented line at all (`{}` on one line) has no evidence either way, in which case two spaces -- npm's own default when it writes a `package.json` -- is the closest thing to a neutral choice.
96
+ */
97
+ const FIRST_INDENTED_LINE = /^[ \t]+(?=")/m;
98
+ const NPM_DEFAULT_INDENT = " ";
99
+ function detectIndent(text) {
100
+ const match = FIRST_INDENTED_LINE.exec(text);
101
+ return match === null ? NPM_DEFAULT_INDENT : match[0];
102
+ }
103
+ /** Serialises a JSON document back to text with the indentation and trailing-newline convention of the text it was read from, so rewriting one dependency range produces a one-line diff rather than a whole-file reformat. */
104
+ function stringifyJsonLike(value, originalText) {
105
+ const serialised = JSON.stringify(value, null, detectIndent(originalText));
106
+ return originalText.endsWith("\n") ? `${serialised}\n` : serialised;
107
+ }
108
+ //#endregion
109
+ //#region src/manifest.ts
110
+ const DEPENDENCY_FIELDS = [
111
+ "dependencies",
112
+ "devDependencies",
113
+ "peerDependencies",
114
+ "optionalDependencies"
115
+ ];
116
+ /** The fields whose entries a consumer's package manager resolves when it installs the published package. `devDependencies` is absent: it is published, but nothing ever installs it for a consumer. */
117
+ const INSTALLED_DEPENDENCY_FIELDS = [
118
+ "dependencies",
119
+ "peerDependencies",
120
+ "optionalDependencies"
121
+ ];
122
+ async function readManifest(path) {
123
+ const text = await readFile(path, "utf8");
124
+ const parsed = JSON.parse(text);
125
+ if (!isJsonObject(parsed)) throw new WorkspaceDiscoveryError(`${path} does not contain a JSON object.`);
126
+ const { name, version } = parsed;
127
+ if (typeof name !== "string" || name.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "name". Every workspace package needs a name: releases are ordered, tagged, and matched to dependents by it.`);
128
+ const validity = validateNpmPackageName(name);
129
+ if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${validity.errors.join("; ")}`);
130
+ if (typeof version !== "string" || version.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "version".`);
131
+ const dependencies = /* @__PURE__ */ new Map();
132
+ for (const field of DEPENDENCY_FIELDS) {
133
+ const declared = parsed[field];
134
+ if (declared === void 0) continue;
135
+ if (!isStringRecord(declared)) throw new WorkspaceDiscoveryError(`${path} has a "${field}" field that is not an object of name-to-range strings.`);
136
+ dependencies.set(field, new Map(Object.entries(declared)));
137
+ }
138
+ return {
139
+ name,
140
+ version,
141
+ private: parsed.private === true,
142
+ dependencies
143
+ };
144
+ }
145
+ /**
146
+ * Rewrites one dependency range in a manifest on disk.
147
+ *
148
+ * Deliberately re-reads the file rather than editing a copy held from discovery time: by the time a cross-package bump is applied, semantic-release's own `@semantic-release/npm` prepare step may already have rewritten `version` in this same file for an earlier package in the run. Writing back a manifest parsed before that would silently revert it.
149
+ */
150
+ async function writeDependencyRange(path, field, dependency, range) {
151
+ const text = await readFile(path, "utf8");
152
+ const parsed = JSON.parse(text);
153
+ if (!isJsonObject(parsed)) throw new WorkspaceDiscoveryError(`${path} does not contain a JSON object.`);
154
+ const declared = parsed[field];
155
+ if (!isStringRecord(declared)) throw new WorkspaceDiscoveryError(`${path} no longer has a "${field}" object holding "${dependency}".`);
156
+ declared[dependency] = range;
157
+ await writeFile(path, stringifyJsonLike(parsed, text), "utf8");
158
+ }
159
+ //#endregion
160
+ //#region src/version-range.ts
161
+ const WORKSPACE_PROTOCOL = "workspace:";
162
+ const CATALOG_PROTOCOL = "catalog:";
163
+ const NPM_ALIAS_PROTOCOL = "npm:";
164
+ /** Specifier protocols that only a workspace-aware package manager resolves locally. `npm publish` copies them into the registry as written, so a published package declaring one in an installed dependency field cannot be installed. */
165
+ const UNPUBLISHABLE_SPECIFIER_PROTOCOLS = [
166
+ WORKSPACE_PROTOCOL,
167
+ CATALOG_PROTOCOL,
168
+ "link:",
169
+ "file:"
170
+ ];
171
+ /** The `workspace:` suffixes that name no version, and so leave nothing in the manifest to rewrite. */
172
+ const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
173
+ "*",
174
+ "^",
175
+ "~"
176
+ ];
177
+ /** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
178
+ const WILDCARD_RANGES = [
179
+ "",
180
+ "*",
181
+ "x",
182
+ "X",
183
+ "latest"
184
+ ];
185
+ /**
186
+ * The protocol of a specifier that `npm publish` would ship verbatim while no consumer's package manager can resolve it (`workspace:`, `catalog:`, `link:`, `file:`), or `undefined` for a specifier a consumer can install.
187
+ */
188
+ function unpublishableSpecifierProtocol(specifier) {
189
+ const trimmed = specifier.trim();
190
+ return UNPUBLISHABLE_SPECIFIER_PROTOCOLS.find((protocol) => trimmed.startsWith(protocol));
191
+ }
192
+ /**
193
+ * A single comparator whose version can be replaced in place without changing the comparator's intent. `<` and `<=` are deliberately absent: rewriting `<2.0.0` to `<1.4.0` narrows an upper bound to the very version being released, which is never what the author meant, so such a range is rejected rather than mangled.
194
+ */
195
+ const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
196
+ /**
197
+ * Classifies a dependency range's shape, throwing `UnsupportedDependencyRangeError` for anything this tool cannot rewrite with confidence: a compound range (`>=1.0.0 <2.0.0`), a union (`1.x || 2.x`), a `catalog:` reference whose real version lives in `pnpm-workspace.yaml`, an `npm:` alias, a git or tarball URL. Guessing at those would either corrupt the range or silently leave it pointing at a version that no longer exists in the workspace, and a stale published range is exactly the divergence this tool exists to prevent.
198
+ *
199
+ * This never needs the version a sibling is releasing: every case above depends only on the shape of `current` itself, which is what lets `releaseWorkspace` validate every workspace dependency edge up front, before any package has published anything, rather than discovering an unsupported range only when the first dependency it names happens to release.
200
+ */
201
+ function classifyDependencyRange(current) {
202
+ const range = current.trim();
203
+ if (range.startsWith(WORKSPACE_PROTOCOL)) {
204
+ const suffix = range.slice(10);
205
+ if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
206
+ const inner = classifyDependencyRange(suffix);
207
+ if (inner.kind !== "rewritable") throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only "workspace:*", "workspace:^", "workspace:~", and "workspace:" followed by a single concrete version range are supported.`);
208
+ return {
209
+ kind: "rewritable",
210
+ workspacePrefixed: true,
211
+ comparator: inner.comparator
212
+ };
213
+ }
214
+ if (range.startsWith(CATALOG_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": the version of a "catalog:" dependency lives in pnpm-workspace.yaml, not in the package manifest, so bumping it here would leave the catalog entry stale. Declare the sibling's version range directly (for example "^1.0.0") instead.`);
215
+ if (range.startsWith(NPM_ALIAS_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": an "npm:" alias points at a differently-named package, so the version released in this workspace is not necessarily the version this range refers to.`);
216
+ if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
217
+ const match = REWRITABLE_COMPARATOR.exec(range);
218
+ if (match === null) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only a single "^", "~", ">=", "=", or bare version comparator can be rewritten in place.`);
219
+ return {
220
+ kind: "rewritable",
221
+ workspacePrefixed: false,
222
+ comparator: match[1] ?? ""
223
+ };
224
+ }
225
+ /**
226
+ * Computes what a dependency range on a workspace sibling becomes once that sibling releases `version`, by classifying the range's shape and then, for a rewritable shape, substituting `version` in place of the version it currently names.
227
+ */
228
+ function updateDependencyRange(current, version) {
229
+ const shape = classifyDependencyRange(current);
230
+ if (shape.kind !== "rewritable") return shape;
231
+ const rewritten = `${shape.comparator}${version}`;
232
+ return {
233
+ kind: "rewritten",
234
+ range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
235
+ };
236
+ }
237
+ //#endregion
238
+ //#region src/publishable-dependencies.ts
239
+ /**
240
+ * Throws `UnsupportedDependencyRangeError` unless every publishable package's installed dependencies (`dependencies`, `peerDependencies`, `optionalDependencies`) are ranges a consumer's package manager can resolve.
241
+ *
242
+ * Publishing goes through `@semantic-release/npm`, that is plain `npm publish`, which copies the manifest's specifiers into the registry unchanged. A `workspace:`, `catalog:`, `link:`, or `file:` specifier therefore reaches consumers as written and makes the package uninstallable, whichever sibling or external package it names and whether or not the tool would have rewritten it. `pnpm publish` would substitute some of them at pack time, but this tool never packs with pnpm, so accepting them would mean publishing a broken package with no warning.
243
+ *
244
+ * A `private` package never publishes and is exempt, and so is `devDependencies`, which no consumer installs. Every offending entry across every package is reported in one error, so a workspace with several of them is fixed in one pass rather than one failed run per entry.
245
+ */
246
+ function assertPublishableDependencies(packages) {
247
+ const offences = [];
248
+ for (const pkg of packages) {
249
+ if (pkg.private) continue;
250
+ for (const field of INSTALLED_DEPENDENCY_FIELDS) {
251
+ const declared = pkg.dependencies.get(field);
252
+ if (declared === void 0) continue;
253
+ for (const [dependency, specifier] of declared) if (unpublishableSpecifierProtocol(specifier) !== void 0) offences.push(` ${pkg.name}: "${dependency}" in ${field} is declared as "${specifier}"`);
254
+ }
255
+ }
256
+ if (offences.length > 0) throw new UnsupportedDependencyRangeError(`Refusing to release: npm publish ships these dependency specifiers unchanged, so the published package could not be installed.\n${offences.join("\n")}\nDeclare a plain version range instead (for example "^1.2.3"). For a sibling in this workspace, keep "linkWorkspacePackages: true" in pnpm-workspace.yaml so pnpm still links it locally; this tool rewrites the range whenever the sibling releases. Packages marked "private" are never published and are exempt.`);
257
+ }
258
+ //#endregion
78
259
  //#region src/exec-file.ts
79
260
  /**
80
261
  * A hand-written promise wrapper around `child_process.execFile`, in place of `util.promisify(execFile)`: `execFile` synchronously returns a `ChildProcess` in addition to invoking its callback, which trips `@typescript-eslint/strict-void-return` when the whole function is handed to `promisify` (a value-returning function used where a void-returning one is contextually expected there) -- exactly the void-return contravariance leniency that rule exists to catch, even though `tsc` itself accepts the pattern. Calling `execFile` directly with our own callback, whose own return type really is `void`, sidesteps the mismatch instead of suppressing it.
@@ -340,187 +521,6 @@ function toGitCommandError(args, cwd, cause) {
340
521
  return new GitCommandError(args, cwd, exitCode, stderr !== "" ? stderr : cause instanceof Error ? cause.message : String(cause));
341
522
  }
342
523
  //#endregion
343
- //#region src/json.ts
344
- function isJsonObject(value) {
345
- return typeof value === "object" && value !== null && !Array.isArray(value);
346
- }
347
- function isStringRecord(value) {
348
- return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
349
- }
350
- function isStringArray(value) {
351
- return Array.isArray(value) && value.every((entry) => typeof entry === "string");
352
- }
353
- /**
354
- * Narrows `unknown` to `unknown[]`. `Array.isArray` alone narrows to `any[]`, whose elements flow as `any` into any later destructuring; going through this guard keeps the elements `unknown` so they must still be narrowed by hand.
355
- */
356
- function isUnknownArray(value) {
357
- return Array.isArray(value);
358
- }
359
- /**
360
- * The indentation of the first indented line in a JSON document, so a rewritten manifest keeps the formatting the repository already uses instead of being reflowed to whatever `JSON.stringify` defaults to. A file with no indented line at all (`{}` on one line) has no evidence either way, in which case two spaces -- npm's own default when it writes a `package.json` -- is the closest thing to a neutral choice.
361
- */
362
- const FIRST_INDENTED_LINE = /^[ \t]+(?=")/m;
363
- const NPM_DEFAULT_INDENT = " ";
364
- function detectIndent(text) {
365
- const match = FIRST_INDENTED_LINE.exec(text);
366
- return match === null ? NPM_DEFAULT_INDENT : match[0];
367
- }
368
- /** Serialises a JSON document back to text with the indentation and trailing-newline convention of the text it was read from, so rewriting one dependency range produces a one-line diff rather than a whole-file reformat. */
369
- function stringifyJsonLike(value, originalText) {
370
- const serialised = JSON.stringify(value, null, detectIndent(originalText));
371
- return originalText.endsWith("\n") ? `${serialised}\n` : serialised;
372
- }
373
- //#endregion
374
- //#region src/manifest.ts
375
- const DEPENDENCY_FIELDS = [
376
- "dependencies",
377
- "devDependencies",
378
- "peerDependencies",
379
- "optionalDependencies"
380
- ];
381
- /** The fields whose entries a consumer's package manager resolves when it installs the published package. `devDependencies` is absent: it is published, but nothing ever installs it for a consumer. */
382
- const INSTALLED_DEPENDENCY_FIELDS = [
383
- "dependencies",
384
- "peerDependencies",
385
- "optionalDependencies"
386
- ];
387
- async function readManifest(path) {
388
- const text = await readFile(path, "utf8");
389
- const parsed = JSON.parse(text);
390
- if (!isJsonObject(parsed)) throw new WorkspaceDiscoveryError(`${path} does not contain a JSON object.`);
391
- const { name, version } = parsed;
392
- if (typeof name !== "string" || name.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "name". Every workspace package needs a name: releases are ordered, tagged, and matched to dependents by it.`);
393
- const validity = validateNpmPackageName(name);
394
- if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${validity.errors.join("; ")}`);
395
- if (typeof version !== "string" || version.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "version".`);
396
- const dependencies = /* @__PURE__ */ new Map();
397
- for (const field of DEPENDENCY_FIELDS) {
398
- const declared = parsed[field];
399
- if (declared === void 0) continue;
400
- if (!isStringRecord(declared)) throw new WorkspaceDiscoveryError(`${path} has a "${field}" field that is not an object of name-to-range strings.`);
401
- dependencies.set(field, new Map(Object.entries(declared)));
402
- }
403
- return {
404
- name,
405
- version,
406
- private: parsed.private === true,
407
- dependencies
408
- };
409
- }
410
- /**
411
- * Rewrites one dependency range in a manifest on disk.
412
- *
413
- * Deliberately re-reads the file rather than editing a copy held from discovery time: by the time a cross-package bump is applied, semantic-release's own `@semantic-release/npm` prepare step may already have rewritten `version` in this same file for an earlier package in the run. Writing back a manifest parsed before that would silently revert it.
414
- */
415
- async function writeDependencyRange(path, field, dependency, range) {
416
- const text = await readFile(path, "utf8");
417
- const parsed = JSON.parse(text);
418
- if (!isJsonObject(parsed)) throw new WorkspaceDiscoveryError(`${path} does not contain a JSON object.`);
419
- const declared = parsed[field];
420
- if (!isStringRecord(declared)) throw new WorkspaceDiscoveryError(`${path} no longer has a "${field}" object holding "${dependency}".`);
421
- declared[dependency] = range;
422
- await writeFile(path, stringifyJsonLike(parsed, text), "utf8");
423
- }
424
- //#endregion
425
- //#region src/version-range.ts
426
- const WORKSPACE_PROTOCOL = "workspace:";
427
- const CATALOG_PROTOCOL = "catalog:";
428
- const NPM_ALIAS_PROTOCOL = "npm:";
429
- /** Specifier protocols that only a workspace-aware package manager resolves locally. `npm publish` copies them into the registry as written, so a published package declaring one in an installed dependency field cannot be installed. */
430
- const UNPUBLISHABLE_SPECIFIER_PROTOCOLS = [
431
- WORKSPACE_PROTOCOL,
432
- CATALOG_PROTOCOL,
433
- "link:",
434
- "file:"
435
- ];
436
- /** The `workspace:` suffixes that name no version, and so leave nothing in the manifest to rewrite. */
437
- const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
438
- "*",
439
- "^",
440
- "~"
441
- ];
442
- /** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
443
- const WILDCARD_RANGES = [
444
- "",
445
- "*",
446
- "x",
447
- "X",
448
- "latest"
449
- ];
450
- /**
451
- * The protocol of a specifier that `npm publish` would ship verbatim while no consumer's package manager can resolve it (`workspace:`, `catalog:`, `link:`, `file:`), or `undefined` for a specifier a consumer can install.
452
- */
453
- function unpublishableSpecifierProtocol(specifier) {
454
- const trimmed = specifier.trim();
455
- return UNPUBLISHABLE_SPECIFIER_PROTOCOLS.find((protocol) => trimmed.startsWith(protocol));
456
- }
457
- /**
458
- * A single comparator whose version can be replaced in place without changing the comparator's intent. `<` and `<=` are deliberately absent: rewriting `<2.0.0` to `<1.4.0` narrows an upper bound to the very version being released, which is never what the author meant, so such a range is rejected rather than mangled.
459
- */
460
- const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
461
- /**
462
- * Classifies a dependency range's shape, throwing `UnsupportedDependencyRangeError` for anything this tool cannot rewrite with confidence: a compound range (`>=1.0.0 <2.0.0`), a union (`1.x || 2.x`), a `catalog:` reference whose real version lives in `pnpm-workspace.yaml`, an `npm:` alias, a git or tarball URL. Guessing at those would either corrupt the range or silently leave it pointing at a version that no longer exists in the workspace, and a stale published range is exactly the divergence this tool exists to prevent.
463
- *
464
- * This never needs the version a sibling is releasing: every case above depends only on the shape of `current` itself, which is what lets `releaseWorkspace` validate every workspace dependency edge up front, before any package has published anything, rather than discovering an unsupported range only when the first dependency it names happens to release.
465
- */
466
- function classifyDependencyRange(current) {
467
- const range = current.trim();
468
- if (range.startsWith(WORKSPACE_PROTOCOL)) {
469
- const suffix = range.slice(10);
470
- if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
471
- const inner = classifyDependencyRange(suffix);
472
- if (inner.kind !== "rewritable") throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only "workspace:*", "workspace:^", "workspace:~", and "workspace:" followed by a single concrete version range are supported.`);
473
- return {
474
- kind: "rewritable",
475
- workspacePrefixed: true,
476
- comparator: inner.comparator
477
- };
478
- }
479
- if (range.startsWith(CATALOG_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": the version of a "catalog:" dependency lives in pnpm-workspace.yaml, not in the package manifest, so bumping it here would leave the catalog entry stale. Declare the sibling's version range directly (for example "^1.0.0") instead.`);
480
- if (range.startsWith(NPM_ALIAS_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": an "npm:" alias points at a differently-named package, so the version released in this workspace is not necessarily the version this range refers to.`);
481
- if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
482
- const match = REWRITABLE_COMPARATOR.exec(range);
483
- if (match === null) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only a single "^", "~", ">=", "=", or bare version comparator can be rewritten in place.`);
484
- return {
485
- kind: "rewritable",
486
- workspacePrefixed: false,
487
- comparator: match[1] ?? ""
488
- };
489
- }
490
- /**
491
- * Computes what a dependency range on a workspace sibling becomes once that sibling releases `version`, by classifying the range's shape and then, for a rewritable shape, substituting `version` in place of the version it currently names.
492
- */
493
- function updateDependencyRange(current, version) {
494
- const shape = classifyDependencyRange(current);
495
- if (shape.kind !== "rewritable") return shape;
496
- const rewritten = `${shape.comparator}${version}`;
497
- return {
498
- kind: "rewritten",
499
- range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
500
- };
501
- }
502
- //#endregion
503
- //#region src/publishable-dependencies.ts
504
- /**
505
- * Throws `UnsupportedDependencyRangeError` unless every publishable package's installed dependencies (`dependencies`, `peerDependencies`, `optionalDependencies`) are ranges a consumer's package manager can resolve.
506
- *
507
- * Publishing goes through `@semantic-release/npm`, that is plain `npm publish`, which copies the manifest's specifiers into the registry unchanged. A `workspace:`, `catalog:`, `link:`, or `file:` specifier therefore reaches consumers as written and makes the package uninstallable, whichever sibling or external package it names and whether or not the tool would have rewritten it. `pnpm publish` would substitute some of them at pack time, but this tool never packs with pnpm, so accepting them would mean publishing a broken package with no warning.
508
- *
509
- * A `private` package never publishes and is exempt, and so is `devDependencies`, which no consumer installs. Every offending entry across every package is reported in one error, so a workspace with several of them is fixed in one pass rather than one failed run per entry.
510
- */
511
- function assertPublishableDependencies(packages) {
512
- const offences = [];
513
- for (const pkg of packages) {
514
- if (pkg.private) continue;
515
- for (const field of INSTALLED_DEPENDENCY_FIELDS) {
516
- const declared = pkg.dependencies.get(field);
517
- if (declared === void 0) continue;
518
- for (const [dependency, specifier] of declared) if (unpublishableSpecifierProtocol(specifier) !== void 0) offences.push(` ${pkg.name}: "${dependency}" in ${field} is declared as "${specifier}"`);
519
- }
520
- }
521
- if (offences.length > 0) throw new UnsupportedDependencyRangeError(`Refusing to release: npm publish ships these dependency specifiers unchanged, so the published package could not be installed.\n${offences.join("\n")}\nDeclare a plain version range instead (for example "^1.2.3"). For a sibling in this workspace, keep "linkWorkspacePackages: true" in pnpm-workspace.yaml so pnpm still links it locally; this tool rewrites the range whenever the sibling releases. Packages marked "private" are never published and are exempt.`);
522
- }
523
- //#endregion
524
524
  //#region src/workspace.ts
525
525
  /** The one filename pnpm recognises as a workspace definition. */
526
526
  const WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
@@ -701,9 +701,68 @@ function mustGet(map, key, what) {
701
701
  return value;
702
702
  }
703
703
  //#endregion
704
+ //#region src/bundling.ts
705
+ /**
706
+ * Validates `bundlingPackages` against the workspace and works out, for every package, which workspace dependencies it bundles.
707
+ *
708
+ * A package named in `bundlingPackages` declares that its build inlines every workspace sibling it lists in `devDependencies`, so consumers never install those siblings from the registry and their code reaches consumers only through this package. When such a sibling releases, this package's release type is at least the sibling's shipped release type (a `feat` in the sibling releases this package as a minor), instead of the patch an ordinary dependency bump forces. Edges in `dependencies`, `peerDependencies` and `optionalDependencies` are installed by consumers, so they keep the forced patch.
709
+ *
710
+ * A private package's own release type never changes: it keeps the patch a dependency bump forces, so its versions and tags are exactly what they would be without `bundlingPackages`. What it gains is a carried release type (see `BundledDependencies.carried`), which lets a bundling package that lists only the private package still see the release type of a private sibling the private package depends on.
711
+ *
712
+ * `bundlingPackages` names public workspace packages whose builds bundle their workspace `devDependencies`; `undefined` or an empty list leaves every package's release type as it was. Returns one entry per package in the graph. Throws `ReleaseConfigurationError` when a name is not a package in the workspace, or names a private package, which never publishes and so ships nothing to consumers.
713
+ */
714
+ function resolveBundledDependencies(graph, bundlingPackages) {
715
+ const bundling = new Set(bundlingPackages);
716
+ const unknown = [...bundling].filter((name) => !graph.packages.has(name));
717
+ if (unknown.length > 0) throw new ReleaseConfigurationError(`bundlingPackages names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${[...graph.packages.keys()].join(", ")}.`);
718
+ const privateNames = [...bundling].filter((name) => mustGet(graph.packages, name, "package").private);
719
+ if (privateNames.length > 0) throw new ReleaseConfigurationError(`bundlingPackages names ${privateNames.map((name) => `"${name}"`).join(", ")}, which ${privateNames.length === 1 ? "is" : "are"} private. A private package is never published, so nothing it bundles reaches a consumer through it; list the public package whose build inlines it instead.`);
720
+ const resolved = /* @__PURE__ */ new Map();
721
+ for (const [name, pkg] of graph.packages) {
722
+ const edges = mustGet(graph.dependencies, name, "dependency edges");
723
+ const releaseTypeFrom = new Set(edges.filter((edge) => bundling.has(name) && edge.field === "devDependencies").map((edge) => edge.dependency));
724
+ const carried = /* @__PURE__ */ new Set([...releaseTypeFrom, ...edges.filter((edge) => pkg.private && isPrivateRuntimeEdge(edge, graph)).map((edge) => edge.dependency)]);
725
+ resolved.set(name, {
726
+ releaseTypeFrom,
727
+ carried
728
+ });
729
+ }
730
+ return resolved;
731
+ }
732
+ function isPrivateRuntimeEdge(edge, graph) {
733
+ return edge.field !== "devDependencies" && mustGet(graph.packages, edge.dependency, "package").private;
734
+ }
735
+ //#endregion
704
736
  //#region src/package-name.ts
705
737
  const packageName = "@exadev/semantic-release-workspace";
706
738
  //#endregion
739
+ //#region src/release-type.ts
740
+ /** Every release type semantic-release knows, highest first: the order `@semantic-release/commit-analyzer` itself ranks them in (its `lib/default-release-types.js`), so comparing two types here agrees with how the analyzer picks the highest type among a package's own commits. */
741
+ const RELEASE_TYPES_HIGHEST_FIRST = [
742
+ "major",
743
+ "premajor",
744
+ "minor",
745
+ "preminor",
746
+ "patch",
747
+ "prepatch",
748
+ "prerelease"
749
+ ];
750
+ const KNOWN_RELEASE_TYPES = new Set(RELEASE_TYPES_HIGHEST_FIRST);
751
+ /** Whether `value` is one of semantic-release's release types. */
752
+ function isReleaseType(value) {
753
+ return typeof value === "string" && KNOWN_RELEASE_TYPES.has(value);
754
+ }
755
+ /** The highest of the given release types, ranked as `@semantic-release/commit-analyzer` ranks them, or `undefined` when `types` is empty. */
756
+ function highestReleaseType(types) {
757
+ let highest;
758
+ for (const type of types) if (highest === void 0 || RELEASE_TYPES_HIGHEST_FIRST.indexOf(type) < RELEASE_TYPES_HIGHEST_FIRST.indexOf(highest)) highest = type;
759
+ return highest;
760
+ }
761
+ /** `base`, raised to `other` when `other` is the higher release type; an `undefined` `other` leaves `base` as it is. */
762
+ function raiseReleaseType(base, other) {
763
+ return other === void 0 || RELEASE_TYPES_HIGHEST_FIRST.indexOf(base) <= RELEASE_TYPES_HIGHEST_FIRST.indexOf(other) ? base : other;
764
+ }
765
+ //#endregion
707
766
  //#region src/dependency-bump-commit.ts
708
767
  /**
709
768
  * The commit message format for a cross-package dependency-range bump, shared between the code that writes it (`bumpDependents` in `release.ts`) and the code that reads it back (`createScopedPlugins` in `plugins.ts`).
@@ -713,26 +772,30 @@ const packageName = "@exadev/semantic-release-workspace";
713
772
  const TRAILER_DEPENDENCY = "Bumped-Workspace-Dependency";
714
773
  const TRAILER_VERSION = "Bumped-Workspace-Dependency-Version";
715
774
  const TRAILER_RANGE = "Bumped-Workspace-Dependency-Range";
775
+ const TRAILER_RELEASE_TYPE = "Bumped-Workspace-Dependency-Release-Type";
716
776
  /** Builds the full commit message (subject and trailer) for one dependency-range bump. */
717
777
  function formatDependencyBumpMessage(info) {
718
778
  return `${`chore(deps): bump ${info.dependency} to ${info.range} in ${info.dependent} [skip ci]`}\n\n${[
719
779
  `${TRAILER_DEPENDENCY}: ${info.dependency}`,
720
780
  `${TRAILER_VERSION}: ${info.version}`,
721
- `${TRAILER_RANGE}: ${info.range}`
781
+ `${TRAILER_RANGE}: ${info.range}`,
782
+ `${TRAILER_RELEASE_TYPE}: ${info.releaseType}`
722
783
  ].join("\n")}`;
723
784
  }
724
785
  /**
725
- * Recovers the dependency bump a `formatDependencyBumpMessage` commit recorded, from its full git message (subject and body), or `undefined` if the message carries no such trailer. All three lines must be present for the commit to be treated as a bump commit at all -- a message missing even one is left alone rather than partially trusted.
786
+ * Recovers the dependency bump a `formatDependencyBumpMessage` commit recorded, from its full git message (subject and body), or `undefined` if the message carries no such trailer. The dependency, version and range lines must all be present for the commit to be treated as a bump commit at all -- a message missing even one is left alone rather than partially trusted. The release type line is optional, since bump commits written before it existed lack it; when it is absent or names no release type, `releaseType` is `undefined`.
726
787
  */
727
788
  function parseDependencyBumpTrailer(message) {
728
789
  const dependency = matchTrailerLine(message, TRAILER_DEPENDENCY);
729
790
  const version = matchTrailerLine(message, TRAILER_VERSION);
730
791
  const range = matchTrailerLine(message, TRAILER_RANGE);
731
792
  if (dependency === void 0 || version === void 0 || range === void 0) return;
793
+ const releaseType = matchTrailerLine(message, TRAILER_RELEASE_TYPE);
732
794
  return {
733
795
  dependency,
734
796
  version,
735
- range
797
+ range,
798
+ releaseType: isReleaseType(releaseType) ? releaseType : void 0
736
799
  };
737
800
  }
738
801
  function matchTrailerLine(message, key) {
@@ -770,10 +833,11 @@ const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-r
770
833
  *
771
834
  * Both apply the same path scoping before delegating to the real `@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator`: the commit list semantic-release already fetched for the release range is filtered down to commits whose `git log --name-only` file list intersects the package's own directory, and only the filtered list reaches the standard plugin. Conventional-commit parsing and changelog formatting stay entirely inside the standard plugins.
772
835
  *
773
- * The `analyzeCommits` wrapper carries one addition beyond filtering: when the standard analyzer finds no releasable commits but a workspace dependency range of the package's has changed, it returns 'patch' anyway. A dependent whose only change is a dependency bump still needs a release for that range to reach the registry. "Has changed" is read from two sources, merged: bumps recorded in memory earlier in the current run (`scope.bumps`), and bumps recorded in the package's own filtered commit history via the trailer `dependency-bump-commit.ts` writes and reads -- the latter is what lets a run that starts after a previous run already committed and pushed the bump (a crash recovery, or simply a later run) reach the same decision, rather than depending on state that existed only inside the process that made the commit.
836
+ * The `analyzeCommits` wrapper carries two additions beyond filtering. When the standard analyzer finds no releasable commits but a workspace dependency range of the package's has changed, it returns 'patch' anyway: a dependent whose only change is a dependency bump still needs a release for that range to reach the registry. And when a bumped dependency is one the package bundles (`scope.bundled.releaseTypeFrom`), the package's release type is raised to at least that dependency's shipped release type, whether or not the package has releasable commits of its own, because the dependency's change reaches consumers only inside this package. "Has changed" is read from two sources, merged: bumps recorded in memory earlier in the current run (`scope.bumps`), and bumps recorded in the package's own filtered commit history via the trailer `dependency-bump-commit.ts` writes and reads -- the latter is what lets a run that starts after a previous run already committed and pushed the bump (a crash recovery, or simply a later run) reach the same decision, rather than depending on state that existed only inside the process that made the commit.
774
837
  */
775
838
  function createScopedPlugins(scope) {
776
839
  let cached;
840
+ let carriedReleaseType;
777
841
  async function commitsForPackage(context) {
778
842
  const from = hasGitHead(context.lastRelease) ? context.lastRelease.gitHead : void 0;
779
843
  if (cached === void 0) cached = {
@@ -791,13 +855,26 @@ function createScopedPlugins(scope) {
791
855
  return {
792
856
  async analyzeCommits(_pluginConfig, context) {
793
857
  const commits = await commitsForPackage(context);
794
- const type = await analyzeCommits(scope.analyzeCommitsConfig, {
858
+ const analysed = await analyzeCommits(scope.analyzeCommitsConfig, {
795
859
  ...context,
796
860
  commits
797
861
  });
798
- if (typeof type === "string") return type;
862
+ if (typeof analysed === "string" && !isReleaseType(analysed)) throw new ReleaseConfigurationError(`@semantic-release/commit-analyzer returned "${analysed}" for ${scope.pkg.name}, which is not a semantic-release release type.`);
863
+ const own = typeof analysed === "string" ? analysed : void 0;
799
864
  const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
865
+ carriedReleaseType = releaseTypeOfBumps(bumps.filter((bump) => scope.bundled?.carried.has(bump.dependency) === true));
866
+ const bundledBumps = bumps.filter((bump) => scope.bundled?.releaseTypeFrom.has(bump.dependency) === true);
867
+ const fromBundled = releaseTypeOfBumps(bundledBumps);
868
+ if (own !== void 0) {
869
+ const type = raiseReleaseType(own, fromBundled);
870
+ if (type !== own) context.logger.log(`${scope.pkg.name} bundles ${describeBundledBumps(bundledBumps)}; raising its ${own} release to ${type}.`);
871
+ return type;
872
+ }
800
873
  if (bumps.length === 0) return false;
874
+ if (fromBundled !== void 0) {
875
+ context.logger.log(`No releasable commits under ${scope.pkg.relativeDirectory}, but it bundles ${describeBundledBumps(bundledBumps)}; releasing it as ${fromBundled}.`);
876
+ return fromBundled;
877
+ }
801
878
  context.logger.log(`No releasable commits under ${scope.pkg.relativeDirectory}, but ${bumps.length === 1 ? "a workspace dependency range changed" : `${String(bumps.length)} workspace dependency ranges changed`}; forcing a patch release.`);
802
879
  return "patch";
803
880
  },
@@ -815,9 +892,19 @@ function createScopedPlugins(scope) {
815
892
  ...bumps.map((bump) => describeDependencyBump(bump))
816
893
  ].join("\n");
817
894
  return typeof notes === "string" ? `${notes}\n\n${section}` : section;
895
+ },
896
+ shippedReleaseType(releaseType) {
897
+ return raiseReleaseType(releaseType, carriedReleaseType);
818
898
  }
819
899
  };
820
900
  }
901
+ /** The highest release type among `bumps`, or `undefined` when there are none. A bump with no recorded type counts as the patch it always forced. */
902
+ function releaseTypeOfBumps(bumps) {
903
+ return highestReleaseType(bumps.map((bump) => bump.releaseType ?? "patch"));
904
+ }
905
+ function describeBundledBumps(bumps) {
906
+ return bumps.map((bump) => `${bump.dependency} (released ${bump.version}, ${bump.releaseType ?? "patch"})`).join(", ");
907
+ }
821
908
  /**
822
909
  * Combines the bumps recorded in memory earlier in the current run with bumps recovered from the package's own filtered commit history (a bump commit from this run, already visible because it touches the package's own directory, or one left over from a previous run), de-duplicated by dependency name. The in-memory entry wins on overlap: it carries the manifest field and dependent name a `resolved-at-publish` bump has no commit to recover from at all.
823
910
  */
@@ -1001,6 +1088,7 @@ async function attemptSingleCommitRelease(options) {
1001
1088
  requireGitPlugin: false,
1002
1089
  forbidGitPlugin: true
1003
1090
  });
1091
+ const bundled = resolveBundledDependencies(graph, options.bundlingPackages);
1004
1092
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1005
1093
  const generateNotesConfig = options.generateNotes ?? {};
1006
1094
  const tagFormat = validateTagFormat(options.tagFormat ?? "${name}@${version}");
@@ -1022,6 +1110,7 @@ async function attemptSingleCommitRelease(options) {
1022
1110
  analyzeCommitsConfig,
1023
1111
  generateNotesConfig,
1024
1112
  bumpsForThisPackage,
1113
+ bundled: mustGet(bundled, name, "bundled dependencies"),
1025
1114
  env,
1026
1115
  branches: options.branches,
1027
1116
  onCommitsResolved: (commits) => {
@@ -1059,7 +1148,10 @@ async function attemptSingleCommitRelease(options) {
1059
1148
  notes: nextRelease.notes,
1060
1149
  bumps: bumpsForThisPackage
1061
1150
  });
1062
- for (const bump of planDependentBumps(pkg, nextRelease.version, graph)) {
1151
+ for (const bump of planDependentBumps(pkg, {
1152
+ version: nextRelease.version,
1153
+ releaseType: nextRelease.shippedReleaseType
1154
+ }, graph)) {
1063
1155
  const forDependent = pendingBumps.get(bump.dependent) ?? [];
1064
1156
  forDependent.push(bump);
1065
1157
  pendingBumps.set(bump.dependent, forDependent);
@@ -1165,6 +1257,7 @@ async function analysePackage(pkg, options) {
1165
1257
  analyzeCommitsConfig: options.analyzeCommitsConfig,
1166
1258
  generateNotesConfig: options.generateNotesConfig,
1167
1259
  bumps: { bumpsFor: () => options.bumpsForThisPackage },
1260
+ bundled: options.bundled,
1168
1261
  onCommitsResolved: options.onCommitsResolved
1169
1262
  });
1170
1263
  const semanticReleaseOptions = {
@@ -1190,13 +1283,15 @@ async function analysePackage(pkg, options) {
1190
1283
  if (result.nextRelease.notes === void 0) throw new ReleaseConfigurationError(`Internal error: ${pkg.name} released with no notes computed.`);
1191
1284
  return {
1192
1285
  type: result.nextRelease.type,
1286
+ shippedReleaseType: scoped.shippedReleaseType(result.nextRelease.type),
1193
1287
  version: result.nextRelease.version,
1194
1288
  gitTag: result.nextRelease.gitTag,
1195
1289
  notes: result.nextRelease.notes
1196
1290
  };
1197
1291
  }
1198
1292
  /** Pure classification of what a released package's version does to each dependent's declared range -- the same logic `bumpDependents` in `release.ts` applies, minus the file write/commit/push: `commitStrategy: 'single'` defers every write to phase 3, applying the same classification once analysis has finished for the whole run. */
1199
- function planDependentBumps(released, version, graph) {
1293
+ function planDependentBumps(released, release, graph) {
1294
+ const { version, releaseType } = release;
1200
1295
  const applied = [];
1201
1296
  const dependents = graph.dependents.get(released.name);
1202
1297
  if (dependents === void 0) return applied;
@@ -1209,7 +1304,8 @@ function planDependentBumps(released, version, graph) {
1209
1304
  field: edge.field,
1210
1305
  version,
1211
1306
  range: update.kind === "rewritten" ? update.range : edge.range,
1212
- kind: update.kind
1307
+ kind: update.kind,
1308
+ releaseType
1213
1309
  });
1214
1310
  }
1215
1311
  return applied;
@@ -1310,17 +1406,19 @@ async function releaseWorkspace(options = {}) {
1310
1406
  plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1311
1407
  packagePlugins: options.packagePlugins
1312
1408
  }, workspace.root, { requireGitPlugin: !dryRun });
1409
+ const bundled = resolveBundledDependencies(graph, options.bundlingPackages);
1313
1410
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1314
1411
  const generateNotesConfig = options.generateNotes ?? {};
1315
1412
  return {
1316
1413
  order,
1317
1414
  packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1318
- const result = await runPackageRelease(pkg, {
1415
+ const { result, releaseType } = await runPackageRelease(pkg, {
1319
1416
  tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1320
1417
  publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1321
1418
  analyzeCommitsConfig,
1322
1419
  generateNotesConfig,
1323
1420
  bumpsForThisPackage,
1421
+ bundled: mustGet(bundled, pkg.name, "bundled dependencies"),
1324
1422
  dryRun,
1325
1423
  env,
1326
1424
  branches: options.branches
@@ -1329,6 +1427,7 @@ async function releaseWorkspace(options = {}) {
1329
1427
  return {
1330
1428
  released: nextRelease !== void 0,
1331
1429
  version: nextRelease?.version,
1430
+ releaseType,
1332
1431
  result
1333
1432
  };
1334
1433
  })).map((entry) => {
@@ -1351,7 +1450,7 @@ async function releaseWorkspace(options = {}) {
1351
1450
  };
1352
1451
  }
1353
1452
  /**
1354
- * The per-package release loop shared by `releaseWorkspace` (normal per-package releases) and `detachWorkspaceRelease` (gated tag-only releases): iterate the topological `order`, call the caller's own `releaseOne` for each package, record which dependency-range bumps were already applied to it, and -- when it released -- bump every workspace dependent's manifest before that dependent's own turn (see `bumpDependents`'s doc comment for why this happens immediately rather than being staged). The two call sites differ only in how they call semantic-release (`semanticRelease(...)` vs `detachRelease(...)`) and how they read "did it release, and what version" off the result, both captured by `releaseOne`.
1453
+ * The per-package release loop shared by `releaseWorkspace` (normal per-package releases) and `detachWorkspaceRelease` (gated tag-only releases): iterate the topological `order`, call the caller's own `releaseOne` for each package, record which dependency-range bumps were already applied to it, and -- when it released -- bump every workspace dependent's manifest before that dependent's own turn (see `bumpDependents`'s doc comment for why this happens immediately rather than being staged). The two call sites differ only in how they call semantic-release (`semanticRelease(...)` vs `detachRelease(...)`) and how they read "did it release, at what version and with which shipped release type" off the result, all captured by `releaseOne`.
1355
1454
  */
1356
1455
  async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne) {
1357
1456
  const pendingBumps = /* @__PURE__ */ new Map();
@@ -1361,7 +1460,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1361
1460
  const pkg = mustGet(graph.packages, name, "package");
1362
1461
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1363
1462
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1364
- const { released, version, result } = dryRun ? await releaseOne(pkg, bumpsForThisPackage) : await releasePackageAcrossBranchMoves(name, branchTracker(workspace, log), async () => await releaseOne(pkg, bumpsForThisPackage), log);
1463
+ const { released, version, releaseType, result } = dryRun ? await releaseOne(pkg, bumpsForThisPackage) : await releasePackageAcrossBranchMoves(name, branchTracker(workspace, log), async () => await releaseOne(pkg, bumpsForThisPackage), log);
1365
1464
  entries.push({
1366
1465
  name,
1367
1466
  directory: pkg.directory,
@@ -1370,13 +1469,16 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1370
1469
  dependencyBumps: bumpsForThisPackage
1371
1470
  });
1372
1471
  pendingBumps.delete(name);
1373
- if (!released || version === void 0) {
1472
+ if (!released || version === void 0 || releaseType === void 0) {
1374
1473
  log(`${name}: no release`);
1375
1474
  continue;
1376
1475
  }
1377
1476
  log(`${name}: released ${version}`);
1378
1477
  identity ??= await resolveCommitIdentity({ cwd: workspace.root });
1379
- const bumps = await bumpDependents(pkg, version, graph, {
1478
+ const bumps = await bumpDependents(pkg, {
1479
+ version,
1480
+ releaseType
1481
+ }, graph, {
1380
1482
  workspace,
1381
1483
  dryRun,
1382
1484
  identity,
@@ -1395,7 +1497,8 @@ async function runPackageRelease(pkg, options) {
1395
1497
  pkg,
1396
1498
  analyzeCommitsConfig: options.analyzeCommitsConfig,
1397
1499
  generateNotesConfig: options.generateNotesConfig,
1398
- bumps: { bumpsFor: () => options.bumpsForThisPackage }
1500
+ bumps: { bumpsFor: () => options.bumpsForThisPackage },
1501
+ bundled: options.bundled
1399
1502
  });
1400
1503
  const semanticReleaseOptions = {
1401
1504
  tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
@@ -1406,10 +1509,14 @@ async function runPackageRelease(pkg, options) {
1406
1509
  if (options.dryRun) semanticReleaseOptions.dryRun = true;
1407
1510
  if (options.branches !== void 0) semanticReleaseOptions.branches = options.branches;
1408
1511
  try {
1409
- return await semanticRelease(semanticReleaseOptions, {
1512
+ const result = await semanticRelease(semanticReleaseOptions, {
1410
1513
  cwd: pkg.directory,
1411
1514
  env: { ...options.env }
1412
1515
  });
1516
+ return {
1517
+ result,
1518
+ releaseType: result === false ? void 0 : scoped.shippedReleaseType(result.nextRelease.type)
1519
+ };
1413
1520
  } catch (cause) {
1414
1521
  throw new WorkspaceReleaseError(`Release of ${pkg.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1415
1522
  }
@@ -1421,10 +1528,11 @@ const LOCKFILE_FILENAME = "pnpm-lock.yaml";
1421
1528
  *
1422
1529
  * The bump is committed (and pushed) right away rather than staged, because a dependent's semantic-release run analyses git history, not the working tree: an uncommitted bump would be invisible to its commit analysis, and would be stranded uncommitted if the dependent then released nothing -- a manifest that names versions the registry has never seen. Committing immediately means the dependent's own run sees the bump commit (it touches only the dependent's directory, so it passes that dependent's path filter), the forced-patch logic in the scoped analyzer covers the case where that bump is the dependent's only change, and a run interrupted partway leaves the remote describing exactly what was published. Pushing immediately mirrors what semantic-release itself does with release commits. The lockfile is regenerated (`pnpm install --lockfile-only`) and committed alongside the manifest for the same reason: a manifest bump committed without it leaves `pnpm-lock.yaml` naming the old range, which `pnpm install --frozen-lockfile` (what CI runs) then rejects.
1423
1530
  */
1424
- async function bumpDependents(released, version, graph, options) {
1531
+ async function bumpDependents(released, release, graph, options) {
1425
1532
  const applied = [];
1426
1533
  const dependents = graph.dependents.get(released.name);
1427
1534
  if (dependents === void 0) return applied;
1535
+ const { version, releaseType } = release;
1428
1536
  for (const edge of dependents) {
1429
1537
  const update = updateDependencyRange(edge.range, version);
1430
1538
  if (update.kind === "wildcard") continue;
@@ -1437,6 +1545,7 @@ async function bumpDependents(released, version, graph, options) {
1437
1545
  dependency: released.name,
1438
1546
  version,
1439
1547
  range: update.range,
1548
+ releaseType,
1440
1549
  dependent: edge.dependent
1441
1550
  });
1442
1551
  await commitFiles([`${dependent.relativeDirectory}/package.json`, LOCKFILE_FILENAME], message, {
@@ -1453,7 +1562,8 @@ async function bumpDependents(released, version, graph, options) {
1453
1562
  field: edge.field,
1454
1563
  version,
1455
1564
  range: update.kind === "rewritten" ? update.range : edge.range,
1456
- kind: update.kind
1565
+ kind: update.kind,
1566
+ releaseType
1457
1567
  });
1458
1568
  }
1459
1569
  return applied;
@@ -1499,14 +1609,16 @@ async function detachWorkspaceRelease(options) {
1499
1609
  plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1500
1610
  packagePlugins: options.packagePlugins
1501
1611
  }, workspace.root, { requireGitPlugin: !dryRun });
1612
+ const bundled = resolveBundledDependencies(graph, options.bundlingPackages);
1502
1613
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1503
1614
  const generateNotesConfig = options.generateNotes ?? {};
1504
1615
  const entries = await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1505
- const state = await runPackageDetach(pkg, {
1616
+ const { state, releaseType } = await runPackageDetach(pkg, {
1506
1617
  publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1507
1618
  analyzeCommitsConfig,
1508
1619
  generateNotesConfig,
1509
1620
  bumpsForThisPackage,
1621
+ bundled: mustGet(bundled, pkg.name, "bundled dependencies"),
1510
1622
  tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1511
1623
  dryRun,
1512
1624
  env,
@@ -1515,6 +1627,7 @@ async function detachWorkspaceRelease(options) {
1515
1627
  return {
1516
1628
  released: state !== null,
1517
1629
  version: state?.nextRelease.version,
1630
+ releaseType,
1518
1631
  result: state
1519
1632
  };
1520
1633
  });
@@ -1547,7 +1660,8 @@ async function runPackageDetach(pkg, options) {
1547
1660
  pkg,
1548
1661
  analyzeCommitsConfig: options.analyzeCommitsConfig,
1549
1662
  generateNotesConfig: options.generateNotesConfig,
1550
- bumps: { bumpsFor: () => options.bumpsForThisPackage }
1663
+ bumps: { bumpsFor: () => options.bumpsForThisPackage },
1664
+ bundled: options.bundled
1551
1665
  });
1552
1666
  const cliOptions = {
1553
1667
  tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
@@ -1558,10 +1672,14 @@ async function runPackageDetach(pkg, options) {
1558
1672
  if (options.dryRun) cliOptions.dryRun = true;
1559
1673
  if (options.branches !== void 0) cliOptions.branches = options.branches;
1560
1674
  try {
1561
- return await detachRelease(cliOptions, {
1675
+ const state = await detachRelease(cliOptions, {
1562
1676
  cwd: pkg.directory,
1563
1677
  env: { ...options.env }
1564
1678
  });
1679
+ return {
1680
+ state,
1681
+ releaseType: state === null ? void 0 : scoped.shippedReleaseType(state.nextRelease.type)
1682
+ };
1565
1683
  } catch (cause) {
1566
1684
  throw new WorkspaceReleaseError(`Detaching ${pkg.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1567
1685
  }
@@ -1638,7 +1756,8 @@ const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
1638
1756
  "generateNotes",
1639
1757
  "commitStrategy",
1640
1758
  "gatePublish",
1641
- "tagFormat"
1759
+ "tagFormat",
1760
+ "bundlingPackages"
1642
1761
  ]);
1643
1762
  const COMMIT_STRATEGIES = /* @__PURE__ */ new Set(["per-package", "single"]);
1644
1763
  function isCommitStrategy(value) {
@@ -1663,7 +1782,8 @@ function createProgram() {
1663
1782
  release.option("--tag-format <template>", "template for each package's release tag, with ${name} and ${version} placeholders; must contain ${version} (default: ${name}@${version})", parseTagFormat);
1664
1783
  release.option("--gate-publish", "tag and push each due package via @exadev/release-gate, but defer publishing -- requires --gate-state-file, and cannot be combined with --commit-strategy single");
1665
1784
  release.option("--gate-state-file <path>", "with --gate-publish: where to write the state a later \"resume\" run needs to finish publishing");
1666
- release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, tagFormat, gatePublish); explicit flags win");
1785
+ release.option("--bundling-package <name>", "a public package whose build bundles its workspace devDependencies, so it releases as at least their release type rather than a forced patch; repeat for several packages", collectRepeated, []);
1786
+ release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, tagFormat, gatePublish, bundlingPackages); explicit flags win");
1667
1787
  release.action(runRelease);
1668
1788
  const resume = program.command("resume");
1669
1789
  resume.description("Finish publishing every package a --gate-publish release tagged and pushed but did not publish.");
@@ -1699,7 +1819,8 @@ const NO_CONFIG_FILE = {
1699
1819
  generateNotes: void 0,
1700
1820
  commitStrategy: void 0,
1701
1821
  tagFormat: void 0,
1702
- gatePublish: void 0
1822
+ gatePublish: void 0,
1823
+ bundlingPackages: void 0
1703
1824
  };
1704
1825
  async function runRelease(flags) {
1705
1826
  const file = flags.config === void 0 ? NO_CONFIG_FILE : await readReleaseConfigFile(flags.config);
@@ -1715,7 +1836,8 @@ async function runRelease(flags) {
1715
1836
  generateNotes: flags.generateNotes === void 0 ? file.generateNotes : parseJsonObjectFlag(flags.generateNotes, "--generate-notes"),
1716
1837
  commitStrategy: flags.commitStrategy ?? file.commitStrategy,
1717
1838
  tagFormat: flags.tagFormat ?? file.tagFormat,
1718
- gatePublish
1839
+ gatePublish,
1840
+ bundlingPackages: flags.bundlingPackage.length > 0 ? flags.bundlingPackage : file.bundlingPackages
1719
1841
  });
1720
1842
  for (const pkg of outcome.packages) console.log(describeOutcome(pkg));
1721
1843
  if (gatePublish) {
@@ -1776,7 +1898,7 @@ async function readReleaseConfigFile(path) {
1776
1898
  const parsed = await readConfigFile(path);
1777
1899
  if (!isJsonObject(parsed)) throw new InvalidArgumentError(`--config file ${path} must contain a JSON object`);
1778
1900
  for (const key of Object.keys(parsed)) if (!CONFIG_OPTION_KEYS.has(key)) throw new InvalidArgumentError(`--config file ${path} has an unknown option "${key}"; recognised options: ${[...CONFIG_OPTION_KEYS].join(", ")}`);
1779
- const { dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, tagFormat, gatePublish } = parsed;
1901
+ const { dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, tagFormat, gatePublish, bundlingPackages } = parsed;
1780
1902
  if (dryRun !== void 0 && typeof dryRun !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "dryRun" must be a boolean`);
1781
1903
  if (branches !== void 0 && !isStringArray(branches)) throw new InvalidArgumentError(`--config file ${path}: "branches" must be an array of branch name strings`);
1782
1904
  if (plugins !== void 0 && !Array.isArray(plugins)) throw new InvalidArgumentError(`--config file ${path}: "plugins" must be an array`);
@@ -1786,6 +1908,7 @@ async function readReleaseConfigFile(path) {
1786
1908
  if (commitStrategy !== void 0 && (typeof commitStrategy !== "string" || !isCommitStrategy(commitStrategy))) throw new InvalidArgumentError(`--config file ${path}: "commitStrategy" must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
1787
1909
  if (tagFormat !== void 0 && typeof tagFormat !== "string") throw new InvalidArgumentError(`--config file ${path}: "tagFormat" must be a string`);
1788
1910
  if (gatePublish !== void 0 && typeof gatePublish !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "gatePublish" must be a boolean`);
1911
+ if (bundlingPackages !== void 0 && !isStringArray(bundlingPackages)) throw new InvalidArgumentError(`--config file ${path}: "bundlingPackages" must be an array of package name strings`);
1789
1912
  return {
1790
1913
  dryRun,
1791
1914
  branches,
@@ -1795,7 +1918,8 @@ async function readReleaseConfigFile(path) {
1795
1918
  generateNotes,
1796
1919
  commitStrategy,
1797
1920
  tagFormat: tagFormat === void 0 ? void 0 : validateTagFormatOption(tagFormat, (message) => `--config file ${path}: ${message}`),
1798
- gatePublish
1921
+ gatePublish,
1922
+ bundlingPackages
1799
1923
  };
1800
1924
  }
1801
1925
  function parseConfigFilePackagePlugins(packagePlugins, path) {