@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/README.md CHANGED
@@ -37,20 +37,22 @@ Why commit immediately, rather than the alternatives:
37
37
 
38
38
  Because every dependent sits downstream in topological order, its own run always sees the bump commit: the commit touches only the dependent's directory, so it passes that dependent's path filter and participates in its analysis.
39
39
 
40
- **A package whose only change is dependency bumps still gets a patch release — deliberately.** Whether the range was rewritten on disk (`^1.0.0` → `^1.1.0`) or is a `workspace:^`-style range that pnpm re-resolves at pack time, the dependent's *published* dependency range changes, so the dependent must be republished for the change to reach consumers. This is not left to chance: the wrapped `analyzeCommits` returns `patch` whenever the standard analyzer found nothing but this run bumped one of the package's dependency ranges, so the behaviour does not depend on how the workspace's own analyzer config happens to classify `chore(deps)` commits (many presets release nothing for `chore`). Release notes gain a `### Dependencies` section listing the bumps, so the release is self-explaining rather than empty.
40
+ **A package whose only change is dependency bumps still gets a patch release — deliberately.** A range rewritten on disk (`^1.0.0` → `^1.1.0`) changes the dependent's *published* dependency range, so the dependent must be republished for the change to reach consumers. A range that names no version, such as a bare `workspace:^` in a private package, has nothing to rewrite, but the dependent still releases, so a package always follows a sibling it depends on. This is not left to chance: the wrapped `analyzeCommits` returns `patch` whenever the standard analyzer found nothing but this run bumped one of the package's dependency ranges, so the behaviour does not depend on how the workspace's own analyzer config happens to classify `chore(deps)` commits (many presets release nothing for `chore`). Release notes gain a `### Dependencies` section listing the bumps, so the release is self-explaining rather than empty.
41
41
 
42
42
  Dependency-range handling, in full:
43
43
 
44
44
  | Range in the dependent's manifest | What happens |
45
45
  | --- | --- |
46
- | `^1.0.0`, `~1.0.0`, `>=1.0.0`, `=1.0.0`, `1.0.0`, `workspace:^1.0.0` | Rewritten in place, preserving the comparator (`^1.0.0` → `^1.1.0`); `pnpm-lock.yaml` is regenerated to match, and both are committed and pushed together; dependent gets at least a patch release |
47
- | `workspace:*`, `workspace:^`, `workspace:~` | No manifest edit (pnpm resolves these at pack time), but the published range still changes, so the dependent still gets a patch release |
46
+ | `^1.0.0`, `~1.0.0`, `>=1.0.0`, `=1.0.0`, `1.0.0` | Rewritten in place, preserving the comparator (`^1.0.0` → `^1.1.0`); `pnpm-lock.yaml` is regenerated to match, and both are committed and pushed together; dependent gets at least a patch release |
47
+ | `workspace:^1.0.0` and the other anchored `workspace:` forms, in `devDependencies` or a private package | Rewritten in place with the `workspace:` prefix kept (`workspace:^1.0.0` → `workspace:^1.1.0`), committed and pushed as above; dependent gets at least a patch release |
48
+ | `workspace:*`, `workspace:^`, `workspace:~`, in `devDependencies` or a private package | No manifest edit, since the range names no version; the dependent still gets a patch release |
49
+ | Any `workspace:`, `catalog:`, `link:`, or `file:` specifier in the `dependencies`, `peerDependencies`, or `optionalDependencies` of a package that is not private, whichever package it names | The run stops with `UnsupportedDependencyRangeError`: `npm publish` ships the specifier unchanged, so the published package could not be installed |
48
50
  | `*`, `x`, `latest` | Nothing to update and the published range is unaffected — no bump, no forced release |
49
51
  | Compound ranges (`>=1.0.0 <2.0.0`), unions (`1.x \|\| 2.x`), `<`/`<=` bounds, `catalog:`, `npm:` aliases, git/tarball URLs | The run stops with `UnsupportedDependencyRangeError` — rewriting any of these wrongly, or leaving them silently stale, both produce a published manifest that disagrees with the repository, so neither is attempted |
50
52
 
51
- Every workspace dependency edge's range is validated against this table before the release loop starts, not just when the dependency it names happens to release: an unsupported range is a static property of the manifests, knowable at discovery time, so `UnsupportedDependencyRangeError` stops the run before the first package publishes, rather than after some upstream sibling has already been published, tagged, committed, and pushed.
53
+ Every range above is validated before the release loop starts, not just when the dependency it names happens to release: both the shape of each workspace dependency edge and the specifiers in each publishable package's installed dependency fields are static properties of the manifests, knowable at discovery time, so `UnsupportedDependencyRangeError` stops the run before the first package publishes, rather than after some upstream sibling has already been published, tagged, committed, and pushed. Every offending entry is listed in one error. `resume` checks the manifests again before it publishes anything.
52
54
 
53
- For that reason concrete ranges (which the orchestrator maintains for you) are the recommended mode. Publishing `workspace:` ranges correctly additionally requires a pack step that substitutes them, as `pnpm publish` does.
55
+ Publishing goes through `@semantic-release/npm`, which is plain `npm publish`: it ships a manifest's specifiers unchanged and never substitutes pnpm's protocols the way `pnpm publish` does. A publishable package therefore declares a concrete version range for each workspace sibling, which the orchestrator keeps up to date for you, and keeps `linkWorkspacePackages: true` in `pnpm-workspace.yaml` so pnpm still links the sibling locally instead of fetching it from the registry. `workspace:` ranges remain usable in private packages, which never publish, and in `devDependencies`, which no consumer installs.
54
56
 
55
57
  ## Commit strategies
56
58
 
@@ -149,7 +151,7 @@ The core technique is the same one multi-semantic-release proved in production:
149
151
  - **In-process delegation, not CLI wrapping.** semantic-release is invoked through its programmatic API with inline plugin functions, so the wrappers delegate to the real @semantic-release/commit-analyzer and @semantic-release/release-notes-generator running in the same process. (The analysed plugins are ESM named exports here, resolved as peers of this package — no plugin re-implementation anywhere.)
150
152
  - **Bump-only dependents always release.** multi-semantic-release rewrites dependency ranges in the working tree without committing them, so a dependent whose only change is a dependency update can go unreleased until some other commit triggers it. Here the bump is committed before the dependent's turn and a patch release is forced deterministically (see the timing section above).
151
153
  - **Loud failures by design.** A dependency cycle, an unsupported dependency range, a publish pipeline without @semantic-release/git (which would leave released manifests uncommitted), an unresolvable plugin, a duplicate package name — each stops the run with a specific error rather than degrading silently. There is deliberately no "skip this package and carry on" path: a partially-consistent set of publishes is worse than none.
152
- - **pnpm-native range semantics.** `workspace:*`/`workspace:^`/`workspace:~` are understood as publish-resolved (bump the release, not the manifest text); `catalog:` and `npm:` aliases are rejected with an explanation instead of being mangled.
154
+ - **Only installable specifiers reach the registry.** `workspace:`, `catalog:`, `link:`, and `file:` specifiers in a publishable package's installed dependency fields are rejected instead of being published as written. In private packages and `devDependencies`, `workspace:*`/`workspace:^`/`workspace:~` are understood as naming no version (bump the release, not the manifest text), and `catalog:` and `npm:` aliases on a workspace sibling are rejected with an explanation instead of being mangled.
153
155
  - **Workspace-agnostic discovery.** Everything comes from `pnpm-workspace.yaml` and the manifests its globs match; pointing the orchestrator at any pnpm workspace is the entire configuration.
154
156
 
155
157
  Out of scope, on purpose: parallelising independent branches of the dependency graph (packages release sequentially in topological order for correctness first — a real future optimisation, not attempted here), and any Changesets-style explicit-changeset mode, which is a different paradigm rather than a missing feature.
package/dist/cli.js CHANGED
@@ -6,7 +6,6 @@ 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
8
  import { execFile } from "node:child_process";
9
- import { promisify } from "node:util";
10
9
  import validateNpmPackageName from "validate-npm-package-name";
11
10
  import { glob } from "tinyglobby";
12
11
  import { parse } from "yaml";
@@ -15,7 +14,7 @@ import { generateNotes } from "@semantic-release/release-notes-generator";
15
14
  import semanticRelease from "semantic-release";
16
15
  import { pathToFileURL } from "node:url";
17
16
  //#region package.json
18
- var version = "1.3.5";
17
+ var version = "1.3.7";
19
18
  //#endregion
20
19
  //#region src/errors.ts
21
20
  /**
@@ -40,7 +39,7 @@ var DependencyCycleError = class extends WorkspaceReleaseError {
40
39
  this.cycle = cycle;
41
40
  }
42
41
  };
43
- /** 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. */
42
+ /** 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. */
44
43
  var UnsupportedDependencyRangeError = class extends WorkspaceReleaseError {};
45
44
  /** 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. */
46
45
  var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
@@ -48,7 +47,7 @@ var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
48
47
  var GitCommandError = class extends WorkspaceReleaseError {
49
48
  exitCode;
50
49
  constructor(args, cwd, exitCode, detail) {
51
- super(`git ${args.join(" ")} failed in ${cwd}${exitCode === void 0 ? "" : ` (exit ${exitCode})`}: ${detail}`);
50
+ super(`git ${args.join(" ")} failed in ${cwd}${exitCode === void 0 ? "" : ` (exit ${String(exitCode)})`}: ${detail}`);
52
51
  this.exitCode = exitCode;
53
52
  }
54
53
  };
@@ -61,10 +60,30 @@ var PnpmCommandError = class extends WorkspaceReleaseError {
61
60
  }
62
61
  };
63
62
  //#endregion
63
+ //#region src/exec-file.ts
64
+ /**
65
+ * 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.
66
+ */
67
+ async function execFile$1(command, args, options) {
68
+ return new Promise((resolve, reject) => {
69
+ execFile(command, [...args], {
70
+ cwd: options.cwd,
71
+ env: options.env,
72
+ maxBuffer: options.maxBuffer
73
+ }, (error, stdout, stderr) => {
74
+ if (error) {
75
+ reject(error instanceof Error ? error : new Error(error.message));
76
+ return;
77
+ }
78
+ resolve({
79
+ stdout,
80
+ stderr
81
+ });
82
+ });
83
+ });
84
+ }
85
+ //#endregion
64
86
  //#region src/git.ts
65
- const execFileAsync$1 = promisify(execFile);
66
- /** `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. */
67
- const GIT_MAX_BUFFER_BYTES = 104857600;
68
87
  /** 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. */
69
88
  const COMMIT_RECORD_SEPARATOR = "";
70
89
  /** 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. */
@@ -92,18 +111,22 @@ const GIT_REPOSITORY_DISCOVERY_ENV_KEYS = [
92
111
  */
93
112
  function sanitizeGitEnv(env) {
94
113
  const sanitized = { ...env };
95
- for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) delete sanitized[key];
114
+ for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) Reflect.deleteProperty(sanitized, key);
96
115
  return sanitized;
97
116
  }
98
117
  const SANITIZED_PROCESS_GIT_ENV = sanitizeGitEnv(process.env);
118
+ /** `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. */
119
+ async function execGit(args, cwd, maxBuffer = 104857600) {
120
+ const { stdout } = await execFile$1("git", args, {
121
+ cwd,
122
+ maxBuffer,
123
+ env: SANITIZED_PROCESS_GIT_ENV
124
+ });
125
+ return stdout;
126
+ }
99
127
  async function git(args, options) {
100
128
  try {
101
- const { stdout } = await execFileAsync$1("git", [...args], {
102
- cwd: options.cwd,
103
- maxBuffer: GIT_MAX_BUFFER_BYTES,
104
- env: SANITIZED_PROCESS_GIT_ENV
105
- });
106
- return stdout;
129
+ return await execGit(args, options.cwd);
107
130
  } catch (cause) {
108
131
  throw toGitCommandError(args, options.cwd, cause);
109
132
  }
@@ -217,9 +240,11 @@ async function workingTreeChanges(options) {
217
240
  const paths = [];
218
241
  for (let index = 0; index < tokens.length; index += 1) {
219
242
  const entry = tokens[index];
220
- if (entry === void 0 || entry.length < 4) continue;
243
+ if (entry === void 0) continue;
221
244
  const statusCode = entry.slice(0, 2);
222
- paths.push(entry.slice(3));
245
+ const path = entry.slice(3);
246
+ if (path === "") continue;
247
+ paths.push(path);
223
248
  if (statusCode.includes("R") || statusCode.includes("C")) index += 1;
224
249
  }
225
250
  return paths;
@@ -283,6 +308,12 @@ const DEPENDENCY_FIELDS = [
283
308
  "peerDependencies",
284
309
  "optionalDependencies"
285
310
  ];
311
+ /** 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. */
312
+ const INSTALLED_DEPENDENCY_FIELDS = [
313
+ "dependencies",
314
+ "peerDependencies",
315
+ "optionalDependencies"
316
+ ];
286
317
  async function readManifest(path) {
287
318
  const text = await readFile(path, "utf8");
288
319
  const parsed = JSON.parse(text);
@@ -290,7 +321,7 @@ async function readManifest(path) {
290
321
  const { name, version } = parsed;
291
322
  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.`);
292
323
  const validity = validateNpmPackageName(name);
293
- if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${(validity.errors ?? []).join("; ")}`);
324
+ if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${validity.errors.join("; ")}`);
294
325
  if (typeof version !== "string" || version.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "version".`);
295
326
  const dependencies = /* @__PURE__ */ new Map();
296
327
  for (const field of DEPENDENCY_FIELDS) {
@@ -302,6 +333,7 @@ async function readManifest(path) {
302
333
  return {
303
334
  name,
304
335
  version,
336
+ private: parsed.private === true,
305
337
  dependencies
306
338
  };
307
339
  }
@@ -324,7 +356,14 @@ async function writeDependencyRange(path, field, dependency, range) {
324
356
  const WORKSPACE_PROTOCOL = "workspace:";
325
357
  const CATALOG_PROTOCOL = "catalog:";
326
358
  const NPM_ALIAS_PROTOCOL = "npm:";
327
- /** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
359
+ /** 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. */
360
+ const UNPUBLISHABLE_SPECIFIER_PROTOCOLS = [
361
+ WORKSPACE_PROTOCOL,
362
+ CATALOG_PROTOCOL,
363
+ "link:",
364
+ "file:"
365
+ ];
366
+ /** The `workspace:` suffixes that name no version, and so leave nothing in the manifest to rewrite. */
328
367
  const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
329
368
  "*",
330
369
  "^",
@@ -339,6 +378,13 @@ const WILDCARD_RANGES = [
339
378
  "latest"
340
379
  ];
341
380
  /**
381
+ * 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.
382
+ */
383
+ function unpublishableSpecifierProtocol(specifier) {
384
+ const trimmed = specifier.trim();
385
+ return UNPUBLISHABLE_SPECIFIER_PROTOCOLS.find((protocol) => trimmed.startsWith(protocol));
386
+ }
387
+ /**
342
388
  * 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.
343
389
  */
344
390
  const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
@@ -360,7 +406,7 @@ function classifyDependencyRange(current) {
360
406
  comparator: inner.comparator
361
407
  };
362
408
  }
363
- 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.`);
409
+ 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.`);
364
410
  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.`);
365
411
  if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
366
412
  const match = REWRITABLE_COMPARATOR.exec(range);
@@ -384,6 +430,27 @@ function updateDependencyRange(current, version) {
384
430
  };
385
431
  }
386
432
  //#endregion
433
+ //#region src/publishable-dependencies.ts
434
+ /**
435
+ * Throws `UnsupportedDependencyRangeError` unless every publishable package's installed dependencies (`dependencies`, `peerDependencies`, `optionalDependencies`) are ranges a consumer's package manager can resolve.
436
+ *
437
+ * 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.
438
+ *
439
+ * 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.
440
+ */
441
+ function assertPublishableDependencies(packages) {
442
+ const offences = [];
443
+ for (const pkg of packages) {
444
+ if (pkg.private) continue;
445
+ for (const field of INSTALLED_DEPENDENCY_FIELDS) {
446
+ const declared = pkg.dependencies.get(field);
447
+ if (declared === void 0) continue;
448
+ for (const [dependency, specifier] of declared) if (unpublishableSpecifierProtocol(specifier) !== void 0) offences.push(` ${pkg.name}: "${dependency}" in ${field} is declared as "${specifier}"`);
449
+ }
450
+ }
451
+ 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.`);
452
+ }
453
+ //#endregion
387
454
  //#region src/workspace.ts
388
455
  /** The one filename pnpm recognises as a workspace definition. */
389
456
  const WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
@@ -420,6 +487,7 @@ async function discoverWorkspace(root) {
420
487
  packages.push({
421
488
  name: manifest.name,
422
489
  version: manifest.version,
490
+ private: manifest.private,
423
491
  directory,
424
492
  relativeDirectory,
425
493
  repoRelativeDirectory,
@@ -539,11 +607,15 @@ function firstUnplacedDependency(name, graph, unplaced) {
539
607
  return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
540
608
  }
541
609
  /**
542
- * 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.
610
+ * 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:
611
+ *
612
+ * - Every publishable package's installed dependencies must be specifiers a consumer can install (see `assertPublishableDependencies`), whichever package they name.
613
+ * - 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).
543
614
  *
544
- * 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.
615
+ * 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.
545
616
  */
546
- function validateDependencyRangeShapes(graph) {
617
+ function validateDependencyRanges(graph) {
618
+ assertPublishableDependencies([...graph.packages.values()]);
547
619
  for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
548
620
  }
549
621
  /**
@@ -596,6 +668,10 @@ function matchTrailerLine(message, key) {
596
668
  }
597
669
  //#endregion
598
670
  //#region src/plugins.ts
671
+ /** 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. */
672
+ function hasGitHead(lastRelease) {
673
+ return typeof lastRelease === "object" && lastRelease !== null && "gitHead" in lastRelease && typeof lastRelease.gitHead === "string";
674
+ }
599
675
  /** 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. */
600
676
  const DEFAULT_PUBLISH_PLUGINS = [
601
677
  "@semantic-release/changelog",
@@ -606,7 +682,7 @@ const DEFAULT_PUBLISH_PLUGINS = [
606
682
  message: "chore(release): ${nextRelease.gitTag} [skip ci]"
607
683
  }]
608
684
  ];
609
- /** 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. */
685
+ /** 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. */
610
686
  const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
611
687
  "@semantic-release/changelog",
612
688
  "@semantic-release/npm",
@@ -616,14 +692,14 @@ const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-r
616
692
  /**
617
693
  * Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
618
694
  *
619
- * 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.
695
+ * 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.
620
696
  *
621
697
  * 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.
622
698
  */
623
699
  function createScopedPlugins(scope) {
624
700
  let cached;
625
701
  async function commitsForPackage(context) {
626
- const from = context.lastRelease?.gitHead ?? void 0;
702
+ const from = hasGitHead(context.lastRelease) ? context.lastRelease.gitHead : void 0;
627
703
  if (cached === void 0) cached = {
628
704
  from,
629
705
  paths: changedPathsSince(from, { cwd: context.cwd })
@@ -643,10 +719,10 @@ function createScopedPlugins(scope) {
643
719
  ...context,
644
720
  commits
645
721
  });
646
- if (type) return type;
722
+ if (typeof type === "string") return type;
647
723
  const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
648
724
  if (bumps.length === 0) return false;
649
- 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.`);
725
+ 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.`);
650
726
  return "patch";
651
727
  },
652
728
  async generateNotes(_pluginConfig, context) {
@@ -662,7 +738,7 @@ function createScopedPlugins(scope) {
662
738
  "",
663
739
  ...bumps.map((bump) => describeDependencyBump(bump))
664
740
  ].join("\n");
665
- return notes ? `${notes}\n\n${section}` : section;
741
+ return typeof notes === "string" ? `${notes}\n\n${section}` : section;
666
742
  }
667
743
  };
668
744
  }
@@ -734,7 +810,6 @@ function parsePublishPluginSpec(spec) {
734
810
  }
735
811
  //#endregion
736
812
  //#region src/pnpm.ts
737
- const execFileAsync = promisify(execFile);
738
813
  /**
739
814
  * 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.
740
815
  *
@@ -742,7 +817,7 @@ const execFileAsync = promisify(execFile);
742
817
  */
743
818
  async function regenerateLockfile(options) {
744
819
  try {
745
- await execFileAsync("pnpm", ["install", "--lockfile-only"], { cwd: options.cwd });
820
+ await execFile$1("pnpm", ["install", "--lockfile-only"], { cwd: options.cwd });
746
821
  } catch (cause) {
747
822
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
748
823
  const detail = stderr !== "" ? stderr : cause instanceof Error ? cause.message : String(cause);
@@ -757,8 +832,8 @@ async function regenerateLockfile(options) {
757
832
  * Five phases, all inside one `releaseWorkspaceSingleCommit` call:
758
833
  *
759
834
  * 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.
760
- * 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.
761
- * 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.
835
+ * 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.
836
+ * 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.
762
837
  * 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.
763
838
  * 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`.
764
839
  *
@@ -773,9 +848,9 @@ async function releaseWorkspaceSingleCommit(options) {
773
848
  const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
774
849
  await assertCleanWorkingTree({ cwd: repoRoot });
775
850
  const graph = buildDependencyGraph(workspace.packages);
776
- validateDependencyRangeShapes(graph);
851
+ validateDependencyRanges(graph);
777
852
  const order = topologicalOrder(graph);
778
- log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
853
+ log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
779
854
  const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
780
855
  requireGitPlugin: false,
781
856
  forbidGitPlugin: true
@@ -801,19 +876,26 @@ async function releaseWorkspaceSingleCommit(options) {
801
876
  bumpsForThisPackage,
802
877
  env,
803
878
  branches: options.branches,
804
- onCommitsResolved: (commits) => capturedCommits.set(name, commits),
879
+ onCommitsResolved: (commits) => {
880
+ capturedCommits.set(name, commits);
881
+ },
805
882
  onContextCaptured: (context) => {
806
883
  captured.branch = context.branch;
807
884
  captured.repositoryUrl = context.repositoryUrl;
808
885
  }
809
886
  });
810
- outcomes.push({
887
+ outcomes.push(nextRelease === void 0 ? {
888
+ name,
889
+ directory: pkg.directory,
890
+ released: false,
891
+ dependencyBumps: bumpsForThisPackage
892
+ } : {
811
893
  name,
812
894
  directory: pkg.directory,
813
- released: nextRelease !== void 0,
814
- version: nextRelease?.version,
815
- gitTag: nextRelease?.gitTag,
816
- type: nextRelease?.type,
895
+ released: true,
896
+ version: nextRelease.version,
897
+ gitTag: nextRelease.gitTag,
898
+ type: nextRelease.type,
817
899
  dependencyBumps: bumpsForThisPackage
818
900
  });
819
901
  if (nextRelease === void 0) {
@@ -841,7 +923,7 @@ async function releaseWorkspaceSingleCommit(options) {
841
923
  };
842
924
  const branch = captured.branch;
843
925
  const repositoryUrl = captured.repositoryUrl;
844
- 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.`);
926
+ 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.`);
845
927
  const shared = {
846
928
  env,
847
929
  branch,
@@ -867,7 +949,7 @@ async function releaseWorkspaceSingleCommit(options) {
867
949
  }
868
950
  if (anyRangeRewritten) await regenerateLockfile({ cwd: workspace.root });
869
951
  const touchedPaths = await workingTreeChanges({ cwd: repoRoot });
870
- 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.`);
952
+ 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.`);
871
953
  const identity = await resolveCommitIdentity({ cwd: repoRoot });
872
954
  await commitFiles(touchedPaths, describeCombinedCommit(planned), {
873
955
  cwd: repoRoot,
@@ -877,7 +959,7 @@ async function releaseWorkspaceSingleCommit(options) {
877
959
  const tagNames = planned.map((release) => release.gitTag);
878
960
  for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
879
961
  await pushHeadAndTags(tagNames, { cwd: repoRoot });
880
- log(`${packageName}: committed ${commitSha} and pushed ${tagNames.length} tag(s): ${tagNames.join(", ")}`);
962
+ log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
881
963
  for (const release of planned) {
882
964
  const releases = [];
883
965
  for (const [modulePath, pluginConfig] of resolvedPlugins) {
@@ -969,10 +1051,18 @@ function describeCombinedCommit(planned) {
969
1051
  }
970
1052
  function buildPluginContext(release, shared, capturedCommits, releases) {
971
1053
  const logger = {
972
- log: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`),
973
- warn: (...args) => shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`),
974
- error: (...args) => shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`),
975
- success: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`)
1054
+ log: (...args) => {
1055
+ shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`);
1056
+ },
1057
+ warn: (...args) => {
1058
+ shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`);
1059
+ },
1060
+ error: (...args) => {
1061
+ shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`);
1062
+ },
1063
+ success: (...args) => {
1064
+ shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`);
1065
+ }
976
1066
  };
977
1067
  return {
978
1068
  cwd: release.pkg.directory,
@@ -1037,9 +1127,9 @@ async function releaseWorkspace(options = {}) {
1037
1127
  const env = sanitizeGitEnv(options.env ?? process.env);
1038
1128
  const workspace = await discoverWorkspace(root);
1039
1129
  const graph = buildDependencyGraph(workspace.packages);
1040
- validateDependencyRangeShapes(graph);
1130
+ validateDependencyRanges(graph);
1041
1131
  const order = topologicalOrder(graph);
1042
- log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")}`);
1132
+ log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1043
1133
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1044
1134
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1045
1135
  const generateNotesConfig = options.generateNotes ?? {};
@@ -1063,13 +1153,18 @@ async function releaseWorkspace(options = {}) {
1063
1153
  };
1064
1154
  })).map((entry) => {
1065
1155
  const nextRelease = entry.result === false ? void 0 : entry.result.nextRelease;
1066
- return {
1156
+ return nextRelease === void 0 ? {
1067
1157
  name: entry.name,
1068
1158
  directory: entry.directory,
1069
- released: nextRelease !== void 0,
1070
- version: nextRelease?.version,
1071
- gitTag: nextRelease?.gitTag,
1072
- type: nextRelease?.type,
1159
+ released: false,
1160
+ dependencyBumps: entry.dependencyBumps
1161
+ } : {
1162
+ name: entry.name,
1163
+ directory: entry.directory,
1164
+ released: true,
1165
+ version: nextRelease.version,
1166
+ gitTag: nextRelease.gitTag,
1167
+ type: nextRelease.type,
1073
1168
  dependencyBumps: entry.dependencyBumps
1074
1169
  };
1075
1170
  })
@@ -1195,9 +1290,9 @@ async function detachWorkspaceRelease(options) {
1195
1290
  const env = sanitizeGitEnv(options.env ?? process.env);
1196
1291
  const workspace = await discoverWorkspace(root);
1197
1292
  const graph = buildDependencyGraph(workspace.packages);
1198
- validateDependencyRangeShapes(graph);
1293
+ validateDependencyRanges(graph);
1199
1294
  const order = topologicalOrder(graph);
1200
- log(`${packageName}: ${order.length} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1295
+ log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1201
1296
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1202
1297
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1203
1298
  const generateNotesConfig = options.generateNotes ?? {};
@@ -1219,15 +1314,20 @@ async function detachWorkspaceRelease(options) {
1219
1314
  });
1220
1315
  return {
1221
1316
  order,
1222
- packages: entries.map((entry) => ({
1317
+ packages: entries.map((entry) => entry.result === null ? {
1318
+ name: entry.name,
1319
+ directory: entry.directory,
1320
+ released: false,
1321
+ dependencyBumps: entry.dependencyBumps
1322
+ } : {
1223
1323
  name: entry.name,
1224
1324
  directory: entry.directory,
1225
- released: entry.result !== null,
1226
- version: entry.result?.nextRelease.version,
1227
- gitTag: entry.result?.nextRelease.gitTag,
1228
- type: entry.result?.nextRelease.type,
1325
+ released: true,
1326
+ version: entry.result.nextRelease.version,
1327
+ gitTag: entry.result.nextRelease.gitTag,
1328
+ type: entry.result.nextRelease.type,
1229
1329
  dependencyBumps: entry.dependencyBumps
1230
- })),
1330
+ }),
1231
1331
  detached: entries.map((entry) => ({
1232
1332
  name: entry.name,
1233
1333
  relativeDirectory: entry.relativeDirectory,
@@ -1279,6 +1379,7 @@ async function resumeWorkspaceRelease(options) {
1279
1379
  const root = resolve(options.root ?? process.cwd());
1280
1380
  const log = options.log ?? console.log;
1281
1381
  const env = sanitizeGitEnv(options.env ?? process.env);
1382
+ assertPublishableDependencies(await Promise.all(options.detached.filter((entry) => entry.state !== null).map(async (entry) => readManifest(resolve(root, entry.relativeDirectory, "package.json")))));
1282
1383
  const order = [];
1283
1384
  const packages = [];
1284
1385
  for (const entry of options.detached) {
@@ -1289,9 +1390,6 @@ async function resumeWorkspaceRelease(options) {
1289
1390
  name: entry.name,
1290
1391
  directory: resolve(root, entry.relativeDirectory),
1291
1392
  released: false,
1292
- version: void 0,
1293
- gitTag: void 0,
1294
- type: void 0,
1295
1393
  dependencyBumps: entry.dependencyBumps
1296
1394
  });
1297
1395
  continue;
@@ -1307,7 +1405,7 @@ async function resumeWorkspaceRelease(options) {
1307
1405
  } catch (cause) {
1308
1406
  throw new WorkspaceReleaseError(`Resuming ${entry.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1309
1407
  }
1310
- log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${releases.length} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
1408
+ log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${String(releases.length)} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
1311
1409
  packages.push({
1312
1410
  name: entry.name,
1313
1411
  directory,
@@ -1396,7 +1494,7 @@ async function runRelease(flags) {
1396
1494
  if (gatePublish) {
1397
1495
  if (flags.gateStateFile === void 0) throw new WorkspaceReleaseError("gatePublish was true but no --gate-state-file was resolved -- this should be unreachable.");
1398
1496
  await writeFile(flags.gateStateFile, JSON.stringify(outcome.detached ?? [], null, 2));
1399
- console.log(`${packageName}: wrote gate state for ${(outcome.detached ?? []).length} package(s) to ${flags.gateStateFile}`);
1497
+ console.log(`${packageName}: wrote gate state for ${String((outcome.detached ?? []).length)} package(s) to ${flags.gateStateFile}`);
1400
1498
  }
1401
1499
  }
1402
1500
  async function runResume(flags) {