@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.js CHANGED
@@ -4,7 +4,6 @@ import { dirname, relative, resolve, sep } from "node:path";
4
4
  import { glob } from "tinyglobby";
5
5
  import { parse } from "yaml";
6
6
  import { execFile } from "node:child_process";
7
- import { promisify } from "node:util";
8
7
  import validateNpmPackageName from "validate-npm-package-name";
9
8
  import { analyzeCommits } from "@semantic-release/commit-analyzer";
10
9
  import { generateNotes } from "@semantic-release/release-notes-generator";
@@ -37,7 +36,7 @@ var DependencyCycleError = class extends WorkspaceReleaseError {
37
36
  this.cycle = cycle;
38
37
  }
39
38
  };
40
- /** 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. */
39
+ /** 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. */
41
40
  var UnsupportedDependencyRangeError = class extends WorkspaceReleaseError {};
42
41
  /** 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. */
43
42
  var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
@@ -45,7 +44,7 @@ var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
45
44
  var GitCommandError = class extends WorkspaceReleaseError {
46
45
  exitCode;
47
46
  constructor(args, cwd, exitCode, detail) {
48
- super(`git ${args.join(" ")} failed in ${cwd}${exitCode === void 0 ? "" : ` (exit ${exitCode})`}: ${detail}`);
47
+ super(`git ${args.join(" ")} failed in ${cwd}${exitCode === void 0 ? "" : ` (exit ${String(exitCode)})`}: ${detail}`);
49
48
  this.exitCode = exitCode;
50
49
  }
51
50
  };
@@ -58,10 +57,30 @@ var PnpmCommandError = class extends WorkspaceReleaseError {
58
57
  }
59
58
  };
60
59
  //#endregion
60
+ //#region src/exec-file.ts
61
+ /**
62
+ * 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.
63
+ */
64
+ async function execFile$1(command, args, options) {
65
+ return new Promise((resolve, reject) => {
66
+ execFile(command, [...args], {
67
+ cwd: options.cwd,
68
+ env: options.env,
69
+ maxBuffer: options.maxBuffer
70
+ }, (error, stdout, stderr) => {
71
+ if (error) {
72
+ reject(error instanceof Error ? error : new Error(error.message));
73
+ return;
74
+ }
75
+ resolve({
76
+ stdout,
77
+ stderr
78
+ });
79
+ });
80
+ });
81
+ }
82
+ //#endregion
61
83
  //#region src/git.ts
62
- const execFileAsync$1 = promisify(execFile);
63
- /** `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. */
64
- const GIT_MAX_BUFFER_BYTES = 104857600;
65
84
  /** 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. */
66
85
  const COMMIT_RECORD_SEPARATOR = "";
67
86
  /** 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. */
@@ -89,18 +108,22 @@ const GIT_REPOSITORY_DISCOVERY_ENV_KEYS = [
89
108
  */
90
109
  function sanitizeGitEnv(env) {
91
110
  const sanitized = { ...env };
92
- for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) delete sanitized[key];
111
+ for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) Reflect.deleteProperty(sanitized, key);
93
112
  return sanitized;
94
113
  }
95
114
  const SANITIZED_PROCESS_GIT_ENV = sanitizeGitEnv(process.env);
115
+ /** `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. */
116
+ async function execGit(args, cwd, maxBuffer = 104857600) {
117
+ const { stdout } = await execFile$1("git", args, {
118
+ cwd,
119
+ maxBuffer,
120
+ env: SANITIZED_PROCESS_GIT_ENV
121
+ });
122
+ return stdout;
123
+ }
96
124
  async function git(args, options) {
97
125
  try {
98
- const { stdout } = await execFileAsync$1("git", [...args], {
99
- cwd: options.cwd,
100
- maxBuffer: GIT_MAX_BUFFER_BYTES,
101
- env: SANITIZED_PROCESS_GIT_ENV
102
- });
103
- return stdout;
126
+ return await execGit(args, options.cwd);
104
127
  } catch (cause) {
105
128
  throw toGitCommandError(args, options.cwd, cause);
106
129
  }
@@ -214,9 +237,11 @@ async function workingTreeChanges(options) {
214
237
  const paths = [];
215
238
  for (let index = 0; index < tokens.length; index += 1) {
216
239
  const entry = tokens[index];
217
- if (entry === void 0 || entry.length < 4) continue;
240
+ if (entry === void 0) continue;
218
241
  const statusCode = entry.slice(0, 2);
219
- paths.push(entry.slice(3));
242
+ const path = entry.slice(3);
243
+ if (path === "") continue;
244
+ paths.push(path);
220
245
  if (statusCode.includes("R") || statusCode.includes("C")) index += 1;
221
246
  }
222
247
  return paths;
@@ -274,6 +299,12 @@ const DEPENDENCY_FIELDS = [
274
299
  "peerDependencies",
275
300
  "optionalDependencies"
276
301
  ];
302
+ /** 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. */
303
+ const INSTALLED_DEPENDENCY_FIELDS = [
304
+ "dependencies",
305
+ "peerDependencies",
306
+ "optionalDependencies"
307
+ ];
277
308
  async function readManifest(path) {
278
309
  const text = await readFile(path, "utf8");
279
310
  const parsed = JSON.parse(text);
@@ -281,7 +312,7 @@ async function readManifest(path) {
281
312
  const { name, version } = parsed;
282
313
  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.`);
283
314
  const validity = validateNpmPackageName(name);
284
- if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${(validity.errors ?? []).join("; ")}`);
315
+ if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${validity.errors.join("; ")}`);
285
316
  if (typeof version !== "string" || version.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "version".`);
286
317
  const dependencies = /* @__PURE__ */ new Map();
287
318
  for (const field of DEPENDENCY_FIELDS) {
@@ -293,6 +324,7 @@ async function readManifest(path) {
293
324
  return {
294
325
  name,
295
326
  version,
327
+ private: parsed.private === true,
296
328
  dependencies
297
329
  };
298
330
  }
@@ -347,6 +379,7 @@ async function discoverWorkspace(root) {
347
379
  packages.push({
348
380
  name: manifest.name,
349
381
  version: manifest.version,
382
+ private: manifest.private,
350
383
  directory,
351
384
  relativeDirectory,
352
385
  repoRelativeDirectory,
@@ -395,7 +428,14 @@ function toPosix(path) {
395
428
  const WORKSPACE_PROTOCOL = "workspace:";
396
429
  const CATALOG_PROTOCOL = "catalog:";
397
430
  const NPM_ALIAS_PROTOCOL = "npm:";
398
- /** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
431
+ /** 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. */
432
+ const UNPUBLISHABLE_SPECIFIER_PROTOCOLS = [
433
+ WORKSPACE_PROTOCOL,
434
+ CATALOG_PROTOCOL,
435
+ "link:",
436
+ "file:"
437
+ ];
438
+ /** The `workspace:` suffixes that name no version, and so leave nothing in the manifest to rewrite. */
399
439
  const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
400
440
  "*",
401
441
  "^",
@@ -410,6 +450,13 @@ const WILDCARD_RANGES = [
410
450
  "latest"
411
451
  ];
412
452
  /**
453
+ * 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.
454
+ */
455
+ function unpublishableSpecifierProtocol(specifier) {
456
+ const trimmed = specifier.trim();
457
+ return UNPUBLISHABLE_SPECIFIER_PROTOCOLS.find((protocol) => trimmed.startsWith(protocol));
458
+ }
459
+ /**
413
460
  * 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.
414
461
  */
415
462
  const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
@@ -431,7 +478,7 @@ function classifyDependencyRange(current) {
431
478
  comparator: inner.comparator
432
479
  };
433
480
  }
434
- 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.`);
481
+ 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.`);
435
482
  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.`);
436
483
  if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
437
484
  const match = REWRITABLE_COMPARATOR.exec(range);
@@ -455,6 +502,27 @@ function updateDependencyRange(current, version) {
455
502
  };
456
503
  }
457
504
  //#endregion
505
+ //#region src/publishable-dependencies.ts
506
+ /**
507
+ * Throws `UnsupportedDependencyRangeError` unless every publishable package's installed dependencies (`dependencies`, `peerDependencies`, `optionalDependencies`) are ranges a consumer's package manager can resolve.
508
+ *
509
+ * 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.
510
+ *
511
+ * 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.
512
+ */
513
+ function assertPublishableDependencies(packages) {
514
+ const offences = [];
515
+ for (const pkg of packages) {
516
+ if (pkg.private) continue;
517
+ for (const field of INSTALLED_DEPENDENCY_FIELDS) {
518
+ const declared = pkg.dependencies.get(field);
519
+ if (declared === void 0) continue;
520
+ for (const [dependency, specifier] of declared) if (unpublishableSpecifierProtocol(specifier) !== void 0) offences.push(` ${pkg.name}: "${dependency}" in ${field} is declared as "${specifier}"`);
521
+ }
522
+ }
523
+ 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.`);
524
+ }
525
+ //#endregion
458
526
  //#region src/graph.ts
459
527
  /**
460
528
  * Builds the inter-package dependency graph from the manifests alone.
@@ -530,11 +598,15 @@ function firstUnplacedDependency(name, graph, unplaced) {
530
598
  return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
531
599
  }
532
600
  /**
533
- * 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.
601
+ * 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:
602
+ *
603
+ * - Every publishable package's installed dependencies must be specifiers a consumer can install (see `assertPublishableDependencies`), whichever package they name.
604
+ * - 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).
534
605
  *
535
- * 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.
606
+ * 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.
536
607
  */
537
- function validateDependencyRangeShapes(graph) {
608
+ function validateDependencyRanges(graph) {
609
+ assertPublishableDependencies([...graph.packages.values()]);
538
610
  for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
539
611
  }
540
612
  /**
@@ -584,6 +656,10 @@ function matchTrailerLine(message, key) {
584
656
  }
585
657
  //#endregion
586
658
  //#region src/plugins.ts
659
+ /** 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. */
660
+ function hasGitHead(lastRelease) {
661
+ return typeof lastRelease === "object" && lastRelease !== null && "gitHead" in lastRelease && typeof lastRelease.gitHead === "string";
662
+ }
587
663
  /** 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. */
588
664
  const DEFAULT_PUBLISH_PLUGINS = [
589
665
  "@semantic-release/changelog",
@@ -594,7 +670,7 @@ const DEFAULT_PUBLISH_PLUGINS = [
594
670
  message: "chore(release): ${nextRelease.gitTag} [skip ci]"
595
671
  }]
596
672
  ];
597
- /** 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. */
673
+ /** 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. */
598
674
  const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
599
675
  "@semantic-release/changelog",
600
676
  "@semantic-release/npm",
@@ -604,14 +680,14 @@ const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-r
604
680
  /**
605
681
  * Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
606
682
  *
607
- * 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.
683
+ * 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.
608
684
  *
609
685
  * 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.
610
686
  */
611
687
  function createScopedPlugins(scope) {
612
688
  let cached;
613
689
  async function commitsForPackage(context) {
614
- const from = context.lastRelease?.gitHead ?? void 0;
690
+ const from = hasGitHead(context.lastRelease) ? context.lastRelease.gitHead : void 0;
615
691
  if (cached === void 0) cached = {
616
692
  from,
617
693
  paths: changedPathsSince(from, { cwd: context.cwd })
@@ -631,10 +707,10 @@ function createScopedPlugins(scope) {
631
707
  ...context,
632
708
  commits
633
709
  });
634
- if (type) return type;
710
+ if (typeof type === "string") return type;
635
711
  const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
636
712
  if (bumps.length === 0) return false;
637
- 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.`);
713
+ 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.`);
638
714
  return "patch";
639
715
  },
640
716
  async generateNotes(_pluginConfig, context) {
@@ -650,7 +726,7 @@ function createScopedPlugins(scope) {
650
726
  "",
651
727
  ...bumps.map((bump) => describeDependencyBump(bump))
652
728
  ].join("\n");
653
- return notes ? `${notes}\n\n${section}` : section;
729
+ return typeof notes === "string" ? `${notes}\n\n${section}` : section;
654
730
  }
655
731
  };
656
732
  }
@@ -722,7 +798,6 @@ function parsePublishPluginSpec(spec) {
722
798
  }
723
799
  //#endregion
724
800
  //#region src/pnpm.ts
725
- const execFileAsync = promisify(execFile);
726
801
  /**
727
802
  * 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.
728
803
  *
@@ -730,7 +805,7 @@ const execFileAsync = promisify(execFile);
730
805
  */
731
806
  async function regenerateLockfile(options) {
732
807
  try {
733
- await execFileAsync("pnpm", ["install", "--lockfile-only"], { cwd: options.cwd });
808
+ await execFile$1("pnpm", ["install", "--lockfile-only"], { cwd: options.cwd });
734
809
  } catch (cause) {
735
810
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
736
811
  const detail = stderr !== "" ? stderr : cause instanceof Error ? cause.message : String(cause);
@@ -745,8 +820,8 @@ async function regenerateLockfile(options) {
745
820
  * Five phases, all inside one `releaseWorkspaceSingleCommit` call:
746
821
  *
747
822
  * 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.
748
- * 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.
749
- * 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.
823
+ * 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.
824
+ * 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.
750
825
  * 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.
751
826
  * 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`.
752
827
  *
@@ -761,9 +836,9 @@ async function releaseWorkspaceSingleCommit(options) {
761
836
  const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
762
837
  await assertCleanWorkingTree({ cwd: repoRoot });
763
838
  const graph = buildDependencyGraph(workspace.packages);
764
- validateDependencyRangeShapes(graph);
839
+ validateDependencyRanges(graph);
765
840
  const order = topologicalOrder(graph);
766
- log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
841
+ log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
767
842
  const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
768
843
  requireGitPlugin: false,
769
844
  forbidGitPlugin: true
@@ -789,19 +864,26 @@ async function releaseWorkspaceSingleCommit(options) {
789
864
  bumpsForThisPackage,
790
865
  env,
791
866
  branches: options.branches,
792
- onCommitsResolved: (commits) => capturedCommits.set(name, commits),
867
+ onCommitsResolved: (commits) => {
868
+ capturedCommits.set(name, commits);
869
+ },
793
870
  onContextCaptured: (context) => {
794
871
  captured.branch = context.branch;
795
872
  captured.repositoryUrl = context.repositoryUrl;
796
873
  }
797
874
  });
798
- outcomes.push({
875
+ outcomes.push(nextRelease === void 0 ? {
876
+ name,
877
+ directory: pkg.directory,
878
+ released: false,
879
+ dependencyBumps: bumpsForThisPackage
880
+ } : {
799
881
  name,
800
882
  directory: pkg.directory,
801
- released: nextRelease !== void 0,
802
- version: nextRelease?.version,
803
- gitTag: nextRelease?.gitTag,
804
- type: nextRelease?.type,
883
+ released: true,
884
+ version: nextRelease.version,
885
+ gitTag: nextRelease.gitTag,
886
+ type: nextRelease.type,
805
887
  dependencyBumps: bumpsForThisPackage
806
888
  });
807
889
  if (nextRelease === void 0) {
@@ -829,7 +911,7 @@ async function releaseWorkspaceSingleCommit(options) {
829
911
  };
830
912
  const branch = captured.branch;
831
913
  const repositoryUrl = captured.repositoryUrl;
832
- 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.`);
914
+ 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.`);
833
915
  const shared = {
834
916
  env,
835
917
  branch,
@@ -855,7 +937,7 @@ async function releaseWorkspaceSingleCommit(options) {
855
937
  }
856
938
  if (anyRangeRewritten) await regenerateLockfile({ cwd: workspace.root });
857
939
  const touchedPaths = await workingTreeChanges({ cwd: repoRoot });
858
- 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.`);
940
+ 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.`);
859
941
  const identity = await resolveCommitIdentity({ cwd: repoRoot });
860
942
  await commitFiles(touchedPaths, describeCombinedCommit(planned), {
861
943
  cwd: repoRoot,
@@ -865,7 +947,7 @@ async function releaseWorkspaceSingleCommit(options) {
865
947
  const tagNames = planned.map((release) => release.gitTag);
866
948
  for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
867
949
  await pushHeadAndTags(tagNames, { cwd: repoRoot });
868
- log(`${packageName}: committed ${commitSha} and pushed ${tagNames.length} tag(s): ${tagNames.join(", ")}`);
950
+ log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
869
951
  for (const release of planned) {
870
952
  const releases = [];
871
953
  for (const [modulePath, pluginConfig] of resolvedPlugins) {
@@ -957,10 +1039,18 @@ function describeCombinedCommit(planned) {
957
1039
  }
958
1040
  function buildPluginContext(release, shared, capturedCommits, releases) {
959
1041
  const logger = {
960
- log: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`),
961
- warn: (...args) => shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`),
962
- error: (...args) => shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`),
963
- success: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`)
1042
+ log: (...args) => {
1043
+ shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`);
1044
+ },
1045
+ warn: (...args) => {
1046
+ shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`);
1047
+ },
1048
+ error: (...args) => {
1049
+ shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`);
1050
+ },
1051
+ success: (...args) => {
1052
+ shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`);
1053
+ }
964
1054
  };
965
1055
  return {
966
1056
  cwd: release.pkg.directory,
@@ -1018,9 +1108,9 @@ async function detachWorkspaceRelease(options) {
1018
1108
  const env = sanitizeGitEnv(options.env ?? process.env);
1019
1109
  const workspace = await discoverWorkspace(root);
1020
1110
  const graph = buildDependencyGraph(workspace.packages);
1021
- validateDependencyRangeShapes(graph);
1111
+ validateDependencyRanges(graph);
1022
1112
  const order = topologicalOrder(graph);
1023
- log(`${packageName}: ${order.length} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1113
+ log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1024
1114
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1025
1115
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1026
1116
  const generateNotesConfig = options.generateNotes ?? {};
@@ -1042,15 +1132,20 @@ async function detachWorkspaceRelease(options) {
1042
1132
  });
1043
1133
  return {
1044
1134
  order,
1045
- packages: entries.map((entry) => ({
1135
+ packages: entries.map((entry) => entry.result === null ? {
1046
1136
  name: entry.name,
1047
1137
  directory: entry.directory,
1048
- released: entry.result !== null,
1049
- version: entry.result?.nextRelease.version,
1050
- gitTag: entry.result?.nextRelease.gitTag,
1051
- type: entry.result?.nextRelease.type,
1138
+ released: false,
1052
1139
  dependencyBumps: entry.dependencyBumps
1053
- })),
1140
+ } : {
1141
+ name: entry.name,
1142
+ directory: entry.directory,
1143
+ released: true,
1144
+ version: entry.result.nextRelease.version,
1145
+ gitTag: entry.result.nextRelease.gitTag,
1146
+ type: entry.result.nextRelease.type,
1147
+ dependencyBumps: entry.dependencyBumps
1148
+ }),
1054
1149
  detached: entries.map((entry) => ({
1055
1150
  name: entry.name,
1056
1151
  relativeDirectory: entry.relativeDirectory,
@@ -1090,6 +1185,7 @@ async function resumeWorkspaceRelease(options) {
1090
1185
  const root = resolve(options.root ?? process.cwd());
1091
1186
  const log = options.log ?? console.log;
1092
1187
  const env = sanitizeGitEnv(options.env ?? process.env);
1188
+ assertPublishableDependencies(await Promise.all(options.detached.filter((entry) => entry.state !== null).map(async (entry) => readManifest(resolve(root, entry.relativeDirectory, "package.json")))));
1093
1189
  const order = [];
1094
1190
  const packages = [];
1095
1191
  for (const entry of options.detached) {
@@ -1100,9 +1196,6 @@ async function resumeWorkspaceRelease(options) {
1100
1196
  name: entry.name,
1101
1197
  directory: resolve(root, entry.relativeDirectory),
1102
1198
  released: false,
1103
- version: void 0,
1104
- gitTag: void 0,
1105
- type: void 0,
1106
1199
  dependencyBumps: entry.dependencyBumps
1107
1200
  });
1108
1201
  continue;
@@ -1118,7 +1211,7 @@ async function resumeWorkspaceRelease(options) {
1118
1211
  } catch (cause) {
1119
1212
  throw new WorkspaceReleaseError(`Resuming ${entry.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1120
1213
  }
1121
- log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${releases.length} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
1214
+ log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${String(releases.length)} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
1122
1215
  packages.push({
1123
1216
  name: entry.name,
1124
1217
  directory,
@@ -1153,9 +1246,9 @@ async function releaseWorkspace(options = {}) {
1153
1246
  const env = sanitizeGitEnv(options.env ?? process.env);
1154
1247
  const workspace = await discoverWorkspace(root);
1155
1248
  const graph = buildDependencyGraph(workspace.packages);
1156
- validateDependencyRangeShapes(graph);
1249
+ validateDependencyRanges(graph);
1157
1250
  const order = topologicalOrder(graph);
1158
- log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")}`);
1251
+ log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1159
1252
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1160
1253
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1161
1254
  const generateNotesConfig = options.generateNotes ?? {};
@@ -1179,13 +1272,18 @@ async function releaseWorkspace(options = {}) {
1179
1272
  };
1180
1273
  })).map((entry) => {
1181
1274
  const nextRelease = entry.result === false ? void 0 : entry.result.nextRelease;
1182
- return {
1275
+ return nextRelease === void 0 ? {
1183
1276
  name: entry.name,
1184
1277
  directory: entry.directory,
1185
- released: nextRelease !== void 0,
1186
- version: nextRelease?.version,
1187
- gitTag: nextRelease?.gitTag,
1188
- type: nextRelease?.type,
1278
+ released: false,
1279
+ dependencyBumps: entry.dependencyBumps
1280
+ } : {
1281
+ name: entry.name,
1282
+ directory: entry.directory,
1283
+ released: true,
1284
+ version: nextRelease.version,
1285
+ gitTag: nextRelease.gitTag,
1286
+ type: nextRelease.type,
1189
1287
  dependencyBumps: entry.dependencyBumps
1190
1288
  };
1191
1289
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exadev/semantic-release-workspace",
3
- "version": "1.3.5",
3
+ "version": "1.3.7",
4
4
  "description": "Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -75,7 +75,7 @@
75
75
  "ci"
76
76
  ],
77
77
  "license": "MIT",
78
- "packageManager": "pnpm@11.6.0",
78
+ "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
79
79
  "dependencies": {
80
80
  "@exadev/release-gate": "^1.0.0",
81
81
  "commander": "^15.0.0",
@@ -89,7 +89,7 @@
89
89
  "@commitlint/cli": "^21.2.2",
90
90
  "@commitlint/config-conventional": "^21.2.2",
91
91
  "@eslint/js": "^10.0.1",
92
- "@exadev/eslint-config": "^2.1.1",
92
+ "@exadev/eslint-config": "2.12.1",
93
93
  "@semantic-release/changelog": "^7.0.0",
94
94
  "@semantic-release/commit-analyzer": "^13.0.1",
95
95
  "@semantic-release/git": "^11.0.1",