@exadev/semantic-release-workspace 1.3.5 → 1.3.7

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/index.cjs CHANGED
@@ -26,7 +26,6 @@ let node_path = require("node:path");
26
26
  let tinyglobby = require("tinyglobby");
27
27
  let yaml = require("yaml");
28
28
  let node_child_process = require("node:child_process");
29
- let node_util = require("node:util");
30
29
  let validate_npm_package_name = require("validate-npm-package-name");
31
30
  validate_npm_package_name = __toESM(validate_npm_package_name, 1);
32
31
  let node_module = require("node:module");
@@ -62,7 +61,7 @@ var DependencyCycleError = class extends WorkspaceReleaseError {
62
61
  this.cycle = cycle;
63
62
  }
64
63
  };
65
- /** A dependency on a workspace sibling uses a range this tool cannot rewrite with confidence. Rewriting it wrongly, or leaving it silently stale, both produce a published manifest that disagrees with the repository, so the run stops instead. */
64
+ /** A dependency range this tool cannot release correctly: either a range on a workspace sibling that it cannot rewrite with confidence (rewriting it wrongly, or leaving it silently stale, produces a published manifest that disagrees with the repository), or a `workspace:`, `catalog:`, `link:`, or `file:` specifier in a publishable package's published dependencies, which `npm publish` ships verbatim and no consumer can install. The run stops instead. */
66
65
  var UnsupportedDependencyRangeError = class extends WorkspaceReleaseError {};
67
66
  /** The semantic-release options handed to the orchestrator cannot be scoped to a single package -- typically a publish plugin list that would leave a release commit or a cross-package manifest bump uncommitted. */
68
67
  var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
@@ -70,7 +69,7 @@ var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
70
69
  var GitCommandError = class extends WorkspaceReleaseError {
71
70
  exitCode;
72
71
  constructor(args, cwd, exitCode, detail) {
73
- super(`git ${args.join(" ")} failed in ${cwd}${exitCode === void 0 ? "" : ` (exit ${exitCode})`}: ${detail}`);
72
+ super(`git ${args.join(" ")} failed in ${cwd}${exitCode === void 0 ? "" : ` (exit ${String(exitCode)})`}: ${detail}`);
74
73
  this.exitCode = exitCode;
75
74
  }
76
75
  };
@@ -83,10 +82,30 @@ var PnpmCommandError = class extends WorkspaceReleaseError {
83
82
  }
84
83
  };
85
84
  //#endregion
85
+ //#region src/exec-file.ts
86
+ /**
87
+ * 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.
88
+ */
89
+ async function execFile(command, args, options) {
90
+ return new Promise((resolve, reject) => {
91
+ (0, node_child_process.execFile)(command, [...args], {
92
+ cwd: options.cwd,
93
+ env: options.env,
94
+ maxBuffer: options.maxBuffer
95
+ }, (error, stdout, stderr) => {
96
+ if (error) {
97
+ reject(error instanceof Error ? error : new Error(error.message));
98
+ return;
99
+ }
100
+ resolve({
101
+ stdout,
102
+ stderr
103
+ });
104
+ });
105
+ });
106
+ }
107
+ //#endregion
86
108
  //#region src/git.ts
87
- const execFileAsync$1 = (0, node_util.promisify)(node_child_process.execFile);
88
- /** `git log --name-only` over everything since a package's last release tag can legitimately produce tens of megabytes of path output on a long-lived monorepo, well past execFile's default buffer, failing on exactly the big workspaces this tool exists for. */
89
- const GIT_MAX_BUFFER_BYTES = 104857600;
90
109
  /** Separates one commit's record in `git log --format` output. Chosen from the C0 control range so it can never appear in a hash or a file path. */
91
110
  const COMMIT_RECORD_SEPARATOR = "";
92
111
  /** The identity semantic-release's own core writes release commits under in CI when nothing else is configured (its COMMIT_NAME/COMMIT_EMAIL constants); dependency-bump commits use the same fallback so every commit a release run produces has a consistent author when the repository declares none. */
@@ -114,18 +133,22 @@ const GIT_REPOSITORY_DISCOVERY_ENV_KEYS = [
114
133
  */
115
134
  function sanitizeGitEnv(env) {
116
135
  const sanitized = { ...env };
117
- for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) delete sanitized[key];
136
+ for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) Reflect.deleteProperty(sanitized, key);
118
137
  return sanitized;
119
138
  }
120
139
  const SANITIZED_PROCESS_GIT_ENV = sanitizeGitEnv(process.env);
140
+ /** `git log --name-only` over everything since a package's last release tag can legitimately produce tens of megabytes of path output on a long-lived monorepo, well past execFile's default buffer, failing on exactly the big workspaces this tool exists for -- hence the generous 100 MiB default, rather than execFile's own. */
141
+ async function execGit(args, cwd, maxBuffer = 104857600) {
142
+ const { stdout } = await execFile("git", args, {
143
+ cwd,
144
+ maxBuffer,
145
+ env: SANITIZED_PROCESS_GIT_ENV
146
+ });
147
+ return stdout;
148
+ }
121
149
  async function git(args, options) {
122
150
  try {
123
- const { stdout } = await execFileAsync$1("git", [...args], {
124
- cwd: options.cwd,
125
- maxBuffer: GIT_MAX_BUFFER_BYTES,
126
- env: SANITIZED_PROCESS_GIT_ENV
127
- });
128
- return stdout;
151
+ return await execGit(args, options.cwd);
129
152
  } catch (cause) {
130
153
  throw toGitCommandError(args, options.cwd, cause);
131
154
  }
@@ -239,9 +262,11 @@ async function workingTreeChanges(options) {
239
262
  const paths = [];
240
263
  for (let index = 0; index < tokens.length; index += 1) {
241
264
  const entry = tokens[index];
242
- if (entry === void 0 || entry.length < 4) continue;
265
+ if (entry === void 0) continue;
243
266
  const statusCode = entry.slice(0, 2);
244
- paths.push(entry.slice(3));
267
+ const path = entry.slice(3);
268
+ if (path === "") continue;
269
+ paths.push(path);
245
270
  if (statusCode.includes("R") || statusCode.includes("C")) index += 1;
246
271
  }
247
272
  return paths;
@@ -299,6 +324,12 @@ const DEPENDENCY_FIELDS = [
299
324
  "peerDependencies",
300
325
  "optionalDependencies"
301
326
  ];
327
+ /** 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. */
328
+ const INSTALLED_DEPENDENCY_FIELDS = [
329
+ "dependencies",
330
+ "peerDependencies",
331
+ "optionalDependencies"
332
+ ];
302
333
  async function readManifest(path) {
303
334
  const text = await (0, node_fs_promises.readFile)(path, "utf8");
304
335
  const parsed = JSON.parse(text);
@@ -306,7 +337,7 @@ async function readManifest(path) {
306
337
  const { name, version } = parsed;
307
338
  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.`);
308
339
  const validity = (0, validate_npm_package_name.default)(name);
309
- if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${(validity.errors ?? []).join("; ")}`);
340
+ if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${validity.errors.join("; ")}`);
310
341
  if (typeof version !== "string" || version.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "version".`);
311
342
  const dependencies = /* @__PURE__ */ new Map();
312
343
  for (const field of DEPENDENCY_FIELDS) {
@@ -318,6 +349,7 @@ async function readManifest(path) {
318
349
  return {
319
350
  name,
320
351
  version,
352
+ private: parsed.private === true,
321
353
  dependencies
322
354
  };
323
355
  }
@@ -372,6 +404,7 @@ async function discoverWorkspace(root) {
372
404
  packages.push({
373
405
  name: manifest.name,
374
406
  version: manifest.version,
407
+ private: manifest.private,
375
408
  directory,
376
409
  relativeDirectory,
377
410
  repoRelativeDirectory,
@@ -420,7 +453,14 @@ function toPosix(path) {
420
453
  const WORKSPACE_PROTOCOL = "workspace:";
421
454
  const CATALOG_PROTOCOL = "catalog:";
422
455
  const NPM_ALIAS_PROTOCOL = "npm:";
423
- /** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
456
+ /** 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. */
457
+ const UNPUBLISHABLE_SPECIFIER_PROTOCOLS = [
458
+ WORKSPACE_PROTOCOL,
459
+ CATALOG_PROTOCOL,
460
+ "link:",
461
+ "file:"
462
+ ];
463
+ /** The `workspace:` suffixes that name no version, and so leave nothing in the manifest to rewrite. */
424
464
  const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
425
465
  "*",
426
466
  "^",
@@ -435,6 +475,13 @@ const WILDCARD_RANGES = [
435
475
  "latest"
436
476
  ];
437
477
  /**
478
+ * 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.
479
+ */
480
+ function unpublishableSpecifierProtocol(specifier) {
481
+ const trimmed = specifier.trim();
482
+ return UNPUBLISHABLE_SPECIFIER_PROTOCOLS.find((protocol) => trimmed.startsWith(protocol));
483
+ }
484
+ /**
438
485
  * 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.
439
486
  */
440
487
  const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
@@ -456,7 +503,7 @@ function classifyDependencyRange(current) {
456
503
  comparator: inner.comparator
457
504
  };
458
505
  }
459
- 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. Depend on the sibling directly (for example "workspace:^") instead.`);
506
+ 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.`);
460
507
  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.`);
461
508
  if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
462
509
  const match = REWRITABLE_COMPARATOR.exec(range);
@@ -480,6 +527,27 @@ function updateDependencyRange(current, version) {
480
527
  };
481
528
  }
482
529
  //#endregion
530
+ //#region src/publishable-dependencies.ts
531
+ /**
532
+ * Throws `UnsupportedDependencyRangeError` unless every publishable package's installed dependencies (`dependencies`, `peerDependencies`, `optionalDependencies`) are ranges a consumer's package manager can resolve.
533
+ *
534
+ * 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.
535
+ *
536
+ * 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.
537
+ */
538
+ function assertPublishableDependencies(packages) {
539
+ const offences = [];
540
+ for (const pkg of packages) {
541
+ if (pkg.private) continue;
542
+ for (const field of INSTALLED_DEPENDENCY_FIELDS) {
543
+ const declared = pkg.dependencies.get(field);
544
+ if (declared === void 0) continue;
545
+ for (const [dependency, specifier] of declared) if (unpublishableSpecifierProtocol(specifier) !== void 0) offences.push(` ${pkg.name}: "${dependency}" in ${field} is declared as "${specifier}"`);
546
+ }
547
+ }
548
+ 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.`);
549
+ }
550
+ //#endregion
483
551
  //#region src/graph.ts
484
552
  /**
485
553
  * Builds the inter-package dependency graph from the manifests alone.
@@ -555,11 +623,15 @@ function firstUnplacedDependency(name, graph, unplaced) {
555
623
  return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
556
624
  }
557
625
  /**
558
- * Checks every workspace dependency edge's range shape before anything releases, so an `UnsupportedDependencyRangeError` stops a run before the first publish rather than after some sibling has already been published, tagged, committed, and pushed. The shape a range supports depends only on the range text itself (see `classifyDependencyRange`), never on which version a sibling ends up releasing, so this can run once up front for the whole graph instead of only being discovered edge by edge as each dependency happens to release.
626
+ * Validates every dependency range in the workspace before anything releases, so a run stops before the first publish rather than after some sibling has already been published, tagged, committed, and pushed. Two independent checks, both static properties of the manifests:
627
+ *
628
+ * - Every publishable package's installed dependencies must be specifiers a consumer can install (see `assertPublishableDependencies`), whichever package they name.
629
+ * - Every workspace dependency edge's range must have a shape this tool can maintain (see `classifyDependencyRange`, which depends only on the range text and never on which version a sibling ends up releasing).
559
630
  *
560
- * Shared by both commit strategies (`release.ts`'s per-package loop and `single-commit-release.ts`'s analysis phase), which is why it lives alongside the graph it validates rather than inside either strategy's own module.
631
+ * Both throw `UnsupportedDependencyRangeError`. Shared by every release path (`release.ts`'s per-package loop, `single-commit-release.ts`'s analysis phase, and `gate-publish.ts`'s detach), which is why it lives alongside the graph it validates rather than inside any one of them.
561
632
  */
562
- function validateDependencyRangeShapes(graph) {
633
+ function validateDependencyRanges(graph) {
634
+ assertPublishableDependencies([...graph.packages.values()]);
563
635
  for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
564
636
  }
565
637
  /**
@@ -609,6 +681,10 @@ function matchTrailerLine(message, key) {
609
681
  }
610
682
  //#endregion
611
683
  //#region src/plugins.ts
684
+ /** semantic-release's own `getLastRelease` returns `{}` for a package with no prior tag -- not `undefined`, and not a fully-populated `LastRelease` -- contradicting the `gitHead: string` its own type declares. Narrows structurally rather than trusting that declared type, so a first-release context's `lastRelease` (correctly, at runtime) never claims a `gitHead` it does not have. */
685
+ function hasGitHead(lastRelease) {
686
+ return typeof lastRelease === "object" && lastRelease !== null && "gitHead" in lastRelease && typeof lastRelease.gitHead === "string";
687
+ }
612
688
  /** The standard publish pipeline this orchestrator coordinates when a workspace configures none of its own. Every entry reuses the corresponding official plugin -- the orchestrator scopes and sequences them per package, it does not reimplement npm publishing, GitHub release creation, or changelog writing. */
613
689
  const DEFAULT_PUBLISH_PLUGINS = [
614
690
  "@semantic-release/changelog",
@@ -619,7 +695,7 @@ const DEFAULT_PUBLISH_PLUGINS = [
619
695
  message: "chore(release): ${nextRelease.gitTag} [skip ci]"
620
696
  }]
621
697
  ];
622
- /** The standard publish pipeline for `commitStrategy: 'single'`: the same as `DEFAULT_PUBLISH_PLUGINS` minus @semantic-release/git, which that mode never runs -- see `resolvePublishPlugins`'s `forbidGitPlugin` option for why it is rejected outright rather than merely unused. Single-commit mode does its own committing (one combined commit for every released package), so a `prepare`-step git plugin here would create the very per-package commits that mode exists to avoid. */
698
+ /** The standard publish pipeline for `commitStrategy: 'single'`: the same as `DEFAULT_PUBLISH_PLUGINS` minus `@semantic-release/git`, which that mode never runs -- see `resolvePublishPlugins`'s `forbidGitPlugin` option for why it is rejected outright rather than merely unused. Single-commit mode does its own committing (one combined commit for every released package), so a `prepare`-step git plugin here would create the very per-package commits that mode exists to avoid. */
623
699
  const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
624
700
  "@semantic-release/changelog",
625
701
  "@semantic-release/npm",
@@ -629,14 +705,14 @@ const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-r
629
705
  /**
630
706
  * Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
631
707
  *
632
- * 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.
708
+ * 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.
633
709
  *
634
710
  * 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.
635
711
  */
636
712
  function createScopedPlugins(scope) {
637
713
  let cached;
638
714
  async function commitsForPackage(context) {
639
- const from = context.lastRelease?.gitHead ?? void 0;
715
+ const from = hasGitHead(context.lastRelease) ? context.lastRelease.gitHead : void 0;
640
716
  if (cached === void 0) cached = {
641
717
  from,
642
718
  paths: changedPathsSince(from, { cwd: context.cwd })
@@ -656,10 +732,10 @@ function createScopedPlugins(scope) {
656
732
  ...context,
657
733
  commits
658
734
  });
659
- if (type) return type;
735
+ if (typeof type === "string") return type;
660
736
  const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
661
737
  if (bumps.length === 0) return false;
662
- context.logger.log(`No releasable commits under ${scope.pkg.relativeDirectory}, but ${bumps.length === 1 ? "a workspace dependency range changed" : `${bumps.length} workspace dependency ranges changed`}; forcing a patch release.`);
738
+ 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.`);
663
739
  return "patch";
664
740
  },
665
741
  async generateNotes(_pluginConfig, context) {
@@ -675,7 +751,7 @@ function createScopedPlugins(scope) {
675
751
  "",
676
752
  ...bumps.map((bump) => describeDependencyBump(bump))
677
753
  ].join("\n");
678
- return notes ? `${notes}\n\n${section}` : section;
754
+ return typeof notes === "string" ? `${notes}\n\n${section}` : section;
679
755
  }
680
756
  };
681
757
  }
@@ -747,7 +823,6 @@ function parsePublishPluginSpec(spec) {
747
823
  }
748
824
  //#endregion
749
825
  //#region src/pnpm.ts
750
- const execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
751
826
  /**
752
827
  * Regenerates `pnpm-lock.yaml` for the whole workspace from the manifests currently on disk, without touching `node_modules` or installing anything -- the same lockfile-refresh step a contributor runs by hand after editing a `package.json` dependency range.
753
828
  *
@@ -755,7 +830,7 @@ const execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
755
830
  */
756
831
  async function regenerateLockfile(options) {
757
832
  try {
758
- await execFileAsync("pnpm", ["install", "--lockfile-only"], { cwd: options.cwd });
833
+ await execFile("pnpm", ["install", "--lockfile-only"], { cwd: options.cwd });
759
834
  } catch (cause) {
760
835
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
761
836
  const detail = stderr !== "" ? stderr : cause instanceof Error ? cause.message : String(cause);
@@ -770,8 +845,8 @@ async function regenerateLockfile(options) {
770
845
  * Five phases, all inside one `releaseWorkspaceSingleCommit` call:
771
846
  *
772
847
  * 1. **Analyse** (this file's `analysePackage`): for every package, in topological order, run semantic-release with `dryRun: true` forced (regardless of the caller's own `dryRun` option) using the same path-scoped `analyzeCommits`/`generateNotes` wrapper `commitStrategy: 'per-package'` uses -- computing each package's next version and notes without writing, committing, tagging, or publishing anything. Cross-package dependency bumps are tracked purely in memory during this phase (`pendingBumps`), exactly as the per-package strategy tracks them for the span of one run; nothing is committed yet for a later run to recover from, because this strategy never leaves a partial commit for a crash to recover from in the first place -- either the whole combined commit lands, or nothing does.
773
- * 2. **Verify** every released package's configured publish plugins' `verifyConditions` step (npm registry auth, GitHub token/repo access), before any file is written -- the same fail-fast-before-anything-releases discipline `validateDependencyRangeShapes` already applies to dependency ranges.
774
- * 3. **Prepare**: for every released package, in topological order, apply any dependency-range bump its own manifest received (writing `package.json` directly, the same `writeDependencyRange` the per-package strategy uses), then run every configured publish plugin's own `prepare` step generically (whichever it defines -- @semantic-release/npm bumps `package.json`'s version, @semantic-release/changelog writes `CHANGELOG.md`). @semantic-release/git is rejected outright from this mode's plugin list (see `resolvePublishPlugins`'s `forbidGitPlugin`), since its own `prepare` step would create exactly the per-package commit this mode exists to avoid. The lockfile is regenerated once at the end, not once per bump, since `pnpm install --lockfile-only` recomputes it from whatever is on disk regardless of how many manifests changed.
848
+ * 2. **Verify** every released package's configured publish plugins' `verifyConditions` step (npm registry auth, GitHub token/repo access), before any file is written -- the same fail-fast-before-anything-releases discipline `validateDependencyRanges` already applies to dependency ranges.
849
+ * 3. **Prepare**: for every released package, in topological order, apply any dependency-range bump its own manifest received (writing `package.json` directly, the same `writeDependencyRange` the per-package strategy uses), then run every configured publish plugin's own `prepare` step generically (whichever it defines -- `@semantic-release/npm` bumps `package.json`'s version, `@semantic-release/changelog` writes `CHANGELOG.md`). `@semantic-release/git` is rejected outright from this mode's plugin list (see `resolvePublishPlugins`'s `forbidGitPlugin`), since its own `prepare` step would create exactly the per-package commit this mode exists to avoid. The lockfile is regenerated once at the end, not once per bump, since `pnpm install --lockfile-only` recomputes it from whatever is on disk regardless of how many manifests changed.
775
850
  * 4. **Commit**: discover every file phase 3 touched via `git status` (rather than predicting filenames per plugin), make one commit, tag it once per released package (`name@version`, lightweight, matching semantic-release's own tag form), and push the commit and every tag together.
776
851
  * 5. **Publish**: for every released package, in topological order, call each configured plugin's own `publish` step directly (not through semantic-release's top-level orchestrator -- see the note below), then `success`.
777
852
  *
@@ -786,9 +861,9 @@ async function releaseWorkspaceSingleCommit(options) {
786
861
  const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
787
862
  await assertCleanWorkingTree({ cwd: repoRoot });
788
863
  const graph = buildDependencyGraph(workspace.packages);
789
- validateDependencyRangeShapes(graph);
864
+ validateDependencyRanges(graph);
790
865
  const order = topologicalOrder(graph);
791
- log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
866
+ log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
792
867
  const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
793
868
  requireGitPlugin: false,
794
869
  forbidGitPlugin: true
@@ -814,19 +889,26 @@ async function releaseWorkspaceSingleCommit(options) {
814
889
  bumpsForThisPackage,
815
890
  env,
816
891
  branches: options.branches,
817
- onCommitsResolved: (commits) => capturedCommits.set(name, commits),
892
+ onCommitsResolved: (commits) => {
893
+ capturedCommits.set(name, commits);
894
+ },
818
895
  onContextCaptured: (context) => {
819
896
  captured.branch = context.branch;
820
897
  captured.repositoryUrl = context.repositoryUrl;
821
898
  }
822
899
  });
823
- outcomes.push({
900
+ outcomes.push(nextRelease === void 0 ? {
901
+ name,
902
+ directory: pkg.directory,
903
+ released: false,
904
+ dependencyBumps: bumpsForThisPackage
905
+ } : {
824
906
  name,
825
907
  directory: pkg.directory,
826
- released: nextRelease !== void 0,
827
- version: nextRelease?.version,
828
- gitTag: nextRelease?.gitTag,
829
- type: nextRelease?.type,
908
+ released: true,
909
+ version: nextRelease.version,
910
+ gitTag: nextRelease.gitTag,
911
+ type: nextRelease.type,
830
912
  dependencyBumps: bumpsForThisPackage
831
913
  });
832
914
  if (nextRelease === void 0) {
@@ -854,7 +936,7 @@ async function releaseWorkspaceSingleCommit(options) {
854
936
  };
855
937
  const branch = captured.branch;
856
938
  const repositoryUrl = captured.repositoryUrl;
857
- if (branch === void 0 || repositoryUrl === void 0) throw new ReleaseConfigurationError(`Internal error: ${packageName} analysed ${planned.length} package release(s) but never captured a branch/repositoryUrl from semantic-release's own context. This should be impossible when at least one package releases.`);
939
+ if (branch === void 0 || repositoryUrl === void 0) throw new ReleaseConfigurationError(`Internal error: ${packageName} analysed ${String(planned.length)} package release(s) but never captured a branch/repositoryUrl from semantic-release's own context. This should be impossible when at least one package releases.`);
858
940
  const shared = {
859
941
  env,
860
942
  branch,
@@ -880,7 +962,7 @@ async function releaseWorkspaceSingleCommit(options) {
880
962
  }
881
963
  if (anyRangeRewritten) await regenerateLockfile({ cwd: workspace.root });
882
964
  const touchedPaths = await workingTreeChanges({ cwd: repoRoot });
883
- if (touchedPaths.length === 0) throw new ReleaseConfigurationError(`${packageName}: analysis planned ${planned.length} release(s), but no files changed while preparing them. Every configured publish plugin's own "prepare" step (bumping package.json, writing CHANGELOG.md) produced nothing to commit -- check the plugin list includes something that writes the version, e.g. @semantic-release/npm.`);
965
+ if (touchedPaths.length === 0) throw new ReleaseConfigurationError(`${packageName}: analysis planned ${String(planned.length)} release(s), but no files changed while preparing them. Every configured publish plugin's own "prepare" step (bumping package.json, writing CHANGELOG.md) produced nothing to commit -- check the plugin list includes something that writes the version, e.g. @semantic-release/npm.`);
884
966
  const identity = await resolveCommitIdentity({ cwd: repoRoot });
885
967
  await commitFiles(touchedPaths, describeCombinedCommit(planned), {
886
968
  cwd: repoRoot,
@@ -890,7 +972,7 @@ async function releaseWorkspaceSingleCommit(options) {
890
972
  const tagNames = planned.map((release) => release.gitTag);
891
973
  for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
892
974
  await pushHeadAndTags(tagNames, { cwd: repoRoot });
893
- log(`${packageName}: committed ${commitSha} and pushed ${tagNames.length} tag(s): ${tagNames.join(", ")}`);
975
+ log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
894
976
  for (const release of planned) {
895
977
  const releases = [];
896
978
  for (const [modulePath, pluginConfig] of resolvedPlugins) {
@@ -982,10 +1064,18 @@ function describeCombinedCommit(planned) {
982
1064
  }
983
1065
  function buildPluginContext(release, shared, capturedCommits, releases) {
984
1066
  const logger = {
985
- log: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`),
986
- warn: (...args) => shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`),
987
- error: (...args) => shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`),
988
- success: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`)
1067
+ log: (...args) => {
1068
+ shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`);
1069
+ },
1070
+ warn: (...args) => {
1071
+ shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`);
1072
+ },
1073
+ error: (...args) => {
1074
+ shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`);
1075
+ },
1076
+ success: (...args) => {
1077
+ shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`);
1078
+ }
989
1079
  };
990
1080
  return {
991
1081
  cwd: release.pkg.directory,
@@ -1043,9 +1133,9 @@ async function detachWorkspaceRelease(options) {
1043
1133
  const env = sanitizeGitEnv(options.env ?? process.env);
1044
1134
  const workspace = await discoverWorkspace(root);
1045
1135
  const graph = buildDependencyGraph(workspace.packages);
1046
- validateDependencyRangeShapes(graph);
1136
+ validateDependencyRanges(graph);
1047
1137
  const order = topologicalOrder(graph);
1048
- log(`${packageName}: ${order.length} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1138
+ log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1049
1139
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1050
1140
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1051
1141
  const generateNotesConfig = options.generateNotes ?? {};
@@ -1067,15 +1157,20 @@ async function detachWorkspaceRelease(options) {
1067
1157
  });
1068
1158
  return {
1069
1159
  order,
1070
- packages: entries.map((entry) => ({
1160
+ packages: entries.map((entry) => entry.result === null ? {
1071
1161
  name: entry.name,
1072
1162
  directory: entry.directory,
1073
- released: entry.result !== null,
1074
- version: entry.result?.nextRelease.version,
1075
- gitTag: entry.result?.nextRelease.gitTag,
1076
- type: entry.result?.nextRelease.type,
1163
+ released: false,
1077
1164
  dependencyBumps: entry.dependencyBumps
1078
- })),
1165
+ } : {
1166
+ name: entry.name,
1167
+ directory: entry.directory,
1168
+ released: true,
1169
+ version: entry.result.nextRelease.version,
1170
+ gitTag: entry.result.nextRelease.gitTag,
1171
+ type: entry.result.nextRelease.type,
1172
+ dependencyBumps: entry.dependencyBumps
1173
+ }),
1079
1174
  detached: entries.map((entry) => ({
1080
1175
  name: entry.name,
1081
1176
  relativeDirectory: entry.relativeDirectory,
@@ -1115,6 +1210,7 @@ async function resumeWorkspaceRelease(options) {
1115
1210
  const root = (0, node_path.resolve)(options.root ?? process.cwd());
1116
1211
  const log = options.log ?? console.log;
1117
1212
  const env = sanitizeGitEnv(options.env ?? process.env);
1213
+ assertPublishableDependencies(await Promise.all(options.detached.filter((entry) => entry.state !== null).map(async (entry) => readManifest((0, node_path.resolve)(root, entry.relativeDirectory, "package.json")))));
1118
1214
  const order = [];
1119
1215
  const packages = [];
1120
1216
  for (const entry of options.detached) {
@@ -1125,9 +1221,6 @@ async function resumeWorkspaceRelease(options) {
1125
1221
  name: entry.name,
1126
1222
  directory: (0, node_path.resolve)(root, entry.relativeDirectory),
1127
1223
  released: false,
1128
- version: void 0,
1129
- gitTag: void 0,
1130
- type: void 0,
1131
1224
  dependencyBumps: entry.dependencyBumps
1132
1225
  });
1133
1226
  continue;
@@ -1143,7 +1236,7 @@ async function resumeWorkspaceRelease(options) {
1143
1236
  } catch (cause) {
1144
1237
  throw new WorkspaceReleaseError(`Resuming ${entry.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1145
1238
  }
1146
- log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${releases.length} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
1239
+ log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${String(releases.length)} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
1147
1240
  packages.push({
1148
1241
  name: entry.name,
1149
1242
  directory,
@@ -1178,9 +1271,9 @@ async function releaseWorkspace(options = {}) {
1178
1271
  const env = sanitizeGitEnv(options.env ?? process.env);
1179
1272
  const workspace = await discoverWorkspace(root);
1180
1273
  const graph = buildDependencyGraph(workspace.packages);
1181
- validateDependencyRangeShapes(graph);
1274
+ validateDependencyRanges(graph);
1182
1275
  const order = topologicalOrder(graph);
1183
- log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")}`);
1276
+ log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1184
1277
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1185
1278
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1186
1279
  const generateNotesConfig = options.generateNotes ?? {};
@@ -1204,13 +1297,18 @@ async function releaseWorkspace(options = {}) {
1204
1297
  };
1205
1298
  })).map((entry) => {
1206
1299
  const nextRelease = entry.result === false ? void 0 : entry.result.nextRelease;
1207
- return {
1300
+ return nextRelease === void 0 ? {
1208
1301
  name: entry.name,
1209
1302
  directory: entry.directory,
1210
- released: nextRelease !== void 0,
1211
- version: nextRelease?.version,
1212
- gitTag: nextRelease?.gitTag,
1213
- type: nextRelease?.type,
1303
+ released: false,
1304
+ dependencyBumps: entry.dependencyBumps
1305
+ } : {
1306
+ name: entry.name,
1307
+ directory: entry.directory,
1308
+ released: true,
1309
+ version: nextRelease.version,
1310
+ gitTag: nextRelease.gitTag,
1311
+ type: nextRelease.type,
1214
1312
  dependencyBumps: entry.dependencyBumps
1215
1313
  };
1216
1314
  })