@exadev/semantic-release-workspace 1.3.6 → 1.3.8

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
@@ -14,7 +14,7 @@ import { generateNotes } from "@semantic-release/release-notes-generator";
14
14
  import semanticRelease from "semantic-release";
15
15
  import { pathToFileURL } from "node:url";
16
16
  //#region package.json
17
- var version = "1.3.6";
17
+ var version = "1.3.8";
18
18
  //#endregion
19
19
  //#region src/errors.ts
20
20
  /**
@@ -39,7 +39,7 @@ var DependencyCycleError = class extends WorkspaceReleaseError {
39
39
  this.cycle = cycle;
40
40
  }
41
41
  };
42
- /** 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. */
43
43
  var UnsupportedDependencyRangeError = class extends WorkspaceReleaseError {};
44
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. */
45
45
  var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
@@ -308,6 +308,12 @@ const DEPENDENCY_FIELDS = [
308
308
  "peerDependencies",
309
309
  "optionalDependencies"
310
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
+ ];
311
317
  async function readManifest(path) {
312
318
  const text = await readFile(path, "utf8");
313
319
  const parsed = JSON.parse(text);
@@ -327,6 +333,7 @@ async function readManifest(path) {
327
333
  return {
328
334
  name,
329
335
  version,
336
+ private: parsed.private === true,
330
337
  dependencies
331
338
  };
332
339
  }
@@ -349,7 +356,14 @@ async function writeDependencyRange(path, field, dependency, range) {
349
356
  const WORKSPACE_PROTOCOL = "workspace:";
350
357
  const CATALOG_PROTOCOL = "catalog:";
351
358
  const NPM_ALIAS_PROTOCOL = "npm:";
352
- /** 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. */
353
367
  const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
354
368
  "*",
355
369
  "^",
@@ -364,6 +378,13 @@ const WILDCARD_RANGES = [
364
378
  "latest"
365
379
  ];
366
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
+ /**
367
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.
368
389
  */
369
390
  const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
@@ -385,7 +406,7 @@ function classifyDependencyRange(current) {
385
406
  comparator: inner.comparator
386
407
  };
387
408
  }
388
- 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.`);
389
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.`);
390
411
  if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
391
412
  const match = REWRITABLE_COMPARATOR.exec(range);
@@ -409,6 +430,27 @@ function updateDependencyRange(current, version) {
409
430
  };
410
431
  }
411
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
412
454
  //#region src/workspace.ts
413
455
  /** The one filename pnpm recognises as a workspace definition. */
414
456
  const WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
@@ -445,6 +487,7 @@ async function discoverWorkspace(root) {
445
487
  packages.push({
446
488
  name: manifest.name,
447
489
  version: manifest.version,
490
+ private: manifest.private,
448
491
  directory,
449
492
  relativeDirectory,
450
493
  repoRelativeDirectory,
@@ -564,11 +607,15 @@ function firstUnplacedDependency(name, graph, unplaced) {
564
607
  return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
565
608
  }
566
609
  /**
567
- * 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).
568
614
  *
569
- * 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.
570
616
  */
571
- function validateDependencyRangeShapes(graph) {
617
+ function validateDependencyRanges(graph) {
618
+ assertPublishableDependencies([...graph.packages.values()]);
572
619
  for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
573
620
  }
574
621
  /**
@@ -785,7 +832,7 @@ async function regenerateLockfile(options) {
785
832
  * Five phases, all inside one `releaseWorkspaceSingleCommit` call:
786
833
  *
787
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.
788
- * 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.
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.
789
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.
790
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.
791
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`.
@@ -801,7 +848,7 @@ async function releaseWorkspaceSingleCommit(options) {
801
848
  const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
802
849
  await assertCleanWorkingTree({ cwd: repoRoot });
803
850
  const graph = buildDependencyGraph(workspace.packages);
804
- validateDependencyRangeShapes(graph);
851
+ validateDependencyRanges(graph);
805
852
  const order = topologicalOrder(graph);
806
853
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
807
854
  const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
@@ -1080,7 +1127,7 @@ async function releaseWorkspace(options = {}) {
1080
1127
  const env = sanitizeGitEnv(options.env ?? process.env);
1081
1128
  const workspace = await discoverWorkspace(root);
1082
1129
  const graph = buildDependencyGraph(workspace.packages);
1083
- validateDependencyRangeShapes(graph);
1130
+ validateDependencyRanges(graph);
1084
1131
  const order = topologicalOrder(graph);
1085
1132
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1086
1133
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
@@ -1243,7 +1290,7 @@ async function detachWorkspaceRelease(options) {
1243
1290
  const env = sanitizeGitEnv(options.env ?? process.env);
1244
1291
  const workspace = await discoverWorkspace(root);
1245
1292
  const graph = buildDependencyGraph(workspace.packages);
1246
- validateDependencyRangeShapes(graph);
1293
+ validateDependencyRanges(graph);
1247
1294
  const order = topologicalOrder(graph);
1248
1295
  log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1249
1296
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
@@ -1332,6 +1379,7 @@ async function resumeWorkspaceRelease(options) {
1332
1379
  const root = resolve(options.root ?? process.cwd());
1333
1380
  const log = options.log ?? console.log;
1334
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")))));
1335
1383
  const order = [];
1336
1384
  const packages = [];
1337
1385
  for (const entry of options.detached) {
package/dist/index.cjs CHANGED
@@ -61,7 +61,7 @@ var DependencyCycleError = class extends WorkspaceReleaseError {
61
61
  this.cycle = cycle;
62
62
  }
63
63
  };
64
- /** 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. */
65
65
  var UnsupportedDependencyRangeError = class extends WorkspaceReleaseError {};
66
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. */
67
67
  var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
@@ -324,6 +324,12 @@ const DEPENDENCY_FIELDS = [
324
324
  "peerDependencies",
325
325
  "optionalDependencies"
326
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
+ ];
327
333
  async function readManifest(path) {
328
334
  const text = await (0, node_fs_promises.readFile)(path, "utf8");
329
335
  const parsed = JSON.parse(text);
@@ -343,6 +349,7 @@ async function readManifest(path) {
343
349
  return {
344
350
  name,
345
351
  version,
352
+ private: parsed.private === true,
346
353
  dependencies
347
354
  };
348
355
  }
@@ -397,6 +404,7 @@ async function discoverWorkspace(root) {
397
404
  packages.push({
398
405
  name: manifest.name,
399
406
  version: manifest.version,
407
+ private: manifest.private,
400
408
  directory,
401
409
  relativeDirectory,
402
410
  repoRelativeDirectory,
@@ -445,7 +453,14 @@ function toPosix(path) {
445
453
  const WORKSPACE_PROTOCOL = "workspace:";
446
454
  const CATALOG_PROTOCOL = "catalog:";
447
455
  const NPM_ALIAS_PROTOCOL = "npm:";
448
- /** 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. */
449
464
  const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
450
465
  "*",
451
466
  "^",
@@ -460,6 +475,13 @@ const WILDCARD_RANGES = [
460
475
  "latest"
461
476
  ];
462
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
+ /**
463
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.
464
486
  */
465
487
  const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
@@ -481,7 +503,7 @@ function classifyDependencyRange(current) {
481
503
  comparator: inner.comparator
482
504
  };
483
505
  }
484
- 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.`);
485
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.`);
486
508
  if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
487
509
  const match = REWRITABLE_COMPARATOR.exec(range);
@@ -505,6 +527,27 @@ function updateDependencyRange(current, version) {
505
527
  };
506
528
  }
507
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
508
551
  //#region src/graph.ts
509
552
  /**
510
553
  * Builds the inter-package dependency graph from the manifests alone.
@@ -580,11 +623,15 @@ function firstUnplacedDependency(name, graph, unplaced) {
580
623
  return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
581
624
  }
582
625
  /**
583
- * 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).
584
630
  *
585
- * 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.
586
632
  */
587
- function validateDependencyRangeShapes(graph) {
633
+ function validateDependencyRanges(graph) {
634
+ assertPublishableDependencies([...graph.packages.values()]);
588
635
  for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
589
636
  }
590
637
  /**
@@ -798,7 +845,7 @@ async function regenerateLockfile(options) {
798
845
  * Five phases, all inside one `releaseWorkspaceSingleCommit` call:
799
846
  *
800
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.
801
- * 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.
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.
802
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.
803
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.
804
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`.
@@ -814,7 +861,7 @@ async function releaseWorkspaceSingleCommit(options) {
814
861
  const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
815
862
  await assertCleanWorkingTree({ cwd: repoRoot });
816
863
  const graph = buildDependencyGraph(workspace.packages);
817
- validateDependencyRangeShapes(graph);
864
+ validateDependencyRanges(graph);
818
865
  const order = topologicalOrder(graph);
819
866
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
820
867
  const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
@@ -1086,7 +1133,7 @@ async function detachWorkspaceRelease(options) {
1086
1133
  const env = sanitizeGitEnv(options.env ?? process.env);
1087
1134
  const workspace = await discoverWorkspace(root);
1088
1135
  const graph = buildDependencyGraph(workspace.packages);
1089
- validateDependencyRangeShapes(graph);
1136
+ validateDependencyRanges(graph);
1090
1137
  const order = topologicalOrder(graph);
1091
1138
  log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1092
1139
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
@@ -1163,6 +1210,7 @@ async function resumeWorkspaceRelease(options) {
1163
1210
  const root = (0, node_path.resolve)(options.root ?? process.cwd());
1164
1211
  const log = options.log ?? console.log;
1165
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")))));
1166
1214
  const order = [];
1167
1215
  const packages = [];
1168
1216
  for (const entry of options.detached) {
@@ -1223,7 +1271,7 @@ async function releaseWorkspace(options = {}) {
1223
1271
  const env = sanitizeGitEnv(options.env ?? process.env);
1224
1272
  const workspace = await discoverWorkspace(root);
1225
1273
  const graph = buildDependencyGraph(workspace.packages);
1226
- validateDependencyRangeShapes(graph);
1274
+ validateDependencyRanges(graph);
1227
1275
  const order = topologicalOrder(graph);
1228
1276
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1229
1277
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
package/dist/index.d.cts CHANGED
@@ -7,12 +7,14 @@ export declare const packageName = "@exadev/semantic-release-workspace";
7
7
  /**
8
8
  * The manifest fields that can name a workspace sibling.
9
9
  *
10
- * All four contribute edges to the release order: whatever field a dependency sits in, the sibling has to have released before the dependent's manifest can name its new version. All four also contribute to the decision to release a dependent (see `releaseWorkspace`), because pnpm rewrites `workspace:` ranges in every one of them at pack time -- a `devDependencies` entry is part of the published artifact even though consumers never install it.
10
+ * All four contribute edges to the release order: whatever field a dependency sits in, the sibling has to have released before the dependent's manifest can name its new version. All four also contribute to the decision to release a dependent (see `releaseWorkspace`).
11
11
  */
12
12
  type DependencyField = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';
13
13
  interface PackageManifest {
14
14
  readonly name: string;
15
15
  readonly version: string;
16
+ /** Whether the manifest sets `private` to the boolean `true`, the one value `@semantic-release/npm` treats as "never publish this package". */
17
+ readonly private: boolean;
16
18
  /** Only the fields actually present in the file, each mapping dependency name to its declared range. */
17
19
  readonly dependencies: ReadonlyMap<DependencyField, ReadonlyMap<string, string>>;
18
20
  }
@@ -28,6 +30,8 @@ export declare function writeDependencyRange(path: string, field: DependencyFiel
28
30
  interface WorkspacePackage {
29
31
  readonly name: string;
30
32
  readonly version: string;
33
+ /** Whether the package is marked `private`, which means it is never published to npm. */
34
+ readonly private: boolean;
31
35
  /** Absolute path to the package directory. */
32
36
  readonly directory: string;
33
37
  /** Path relative to the workspace root, always POSIX-separated. Used for filesystem and git-pathspec purposes scoped to the workspace itself (for example `git add` run with the workspace root as `cwd`) -- never for matching against `git log` output, which `git` always reports relative to the repository's toplevel, not to whatever `cwd` a command happened to run from. Compare `repoRelativeDirectory` for that. */
@@ -87,7 +91,7 @@ export declare function topologicalOrder(graph: DependencyGraph): readonly strin
87
91
  /**
88
92
  * What happens to one dependency range when the sibling it points at releases a new version.
89
93
  *
90
- * The distinction between `rewritten` and `resolved-at-publish` matters for the manifest, not for the release decision: both mean the dependent's *published* dependency range changes, and therefore that the dependent needs a release of its own for that change to reach consumers. Only `wildcard` leaves the published artifact genuinely identical.
94
+ * The distinction between `rewritten` and `resolved-at-publish` matters for the manifest, not for the release decision: both make the dependent release in its own right, so it always follows a sibling it depends on. Only `wildcard` leaves the dependent entirely unaffected.
91
95
  */
92
96
  type DependencyRangeUpdate =
93
97
  /** The range names a concrete version that has to be rewritten in the manifest. */
@@ -95,7 +99,7 @@ type DependencyRangeUpdate =
95
99
  readonly kind: 'rewritten';
96
100
  readonly range: string;
97
101
  } |
98
- /** A bare `workspace:*`, `workspace:^`, or `workspace:~` range: pnpm substitutes the sibling's current version at pack time, so the manifest on disk needs no edit even though the published range does change. */
102
+ /** A bare `workspace:*`, `workspace:^`, or `workspace:~` range: it names no version, so the manifest on disk needs no edit. Only a private package or a `devDependencies` entry can carry one and still be released (see `assertPublishableDependencies`), because `npm publish` ships the specifier unchanged. */
99
103
  {
100
104
  readonly kind: 'resolved-at-publish';
101
105
  } |
@@ -128,7 +132,7 @@ export declare function updateDependencyRange(current: string, version: string):
128
132
  //#endregion
129
133
  //#region src/plugins.d.ts
130
134
  /**
131
- * One workspace dependency range that changed because its package released a new version during this run. `rewritten` means the dependent's manifest was edited on disk; `resolved-at-publish` means a `workspace:^`-style range whose on-disk text is unchanged but whose published value pnpm re-resolves at pack time. Both change the dependent's published artifact, which is why both count towards its release.
135
+ * One workspace dependency range that changed because its package released a new version during this run. `rewritten` means the dependent's manifest was edited on disk; `resolved-at-publish` means a bare `workspace:^`-style range whose on-disk text is unchanged. Both count towards the dependent's release.
132
136
  */
133
137
  interface DependencyBump {
134
138
  readonly dependency: string;
@@ -294,7 +298,7 @@ export declare class DependencyCycleError extends WorkspaceReleaseError {
294
298
  readonly cycle: readonly string[];
295
299
  constructor(cycle: readonly string[]);
296
300
  }
297
- /** 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. */
301
+ /** 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. */
298
302
  export declare class UnsupportedDependencyRangeError extends WorkspaceReleaseError {}
299
303
  /** 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. */
300
304
  export declare class ReleaseConfigurationError extends WorkspaceReleaseError {}
package/dist/index.d.ts CHANGED
@@ -7,12 +7,14 @@ export declare const packageName = "@exadev/semantic-release-workspace";
7
7
  /**
8
8
  * The manifest fields that can name a workspace sibling.
9
9
  *
10
- * All four contribute edges to the release order: whatever field a dependency sits in, the sibling has to have released before the dependent's manifest can name its new version. All four also contribute to the decision to release a dependent (see `releaseWorkspace`), because pnpm rewrites `workspace:` ranges in every one of them at pack time -- a `devDependencies` entry is part of the published artifact even though consumers never install it.
10
+ * All four contribute edges to the release order: whatever field a dependency sits in, the sibling has to have released before the dependent's manifest can name its new version. All four also contribute to the decision to release a dependent (see `releaseWorkspace`).
11
11
  */
12
12
  type DependencyField = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';
13
13
  interface PackageManifest {
14
14
  readonly name: string;
15
15
  readonly version: string;
16
+ /** Whether the manifest sets `private` to the boolean `true`, the one value `@semantic-release/npm` treats as "never publish this package". */
17
+ readonly private: boolean;
16
18
  /** Only the fields actually present in the file, each mapping dependency name to its declared range. */
17
19
  readonly dependencies: ReadonlyMap<DependencyField, ReadonlyMap<string, string>>;
18
20
  }
@@ -28,6 +30,8 @@ export declare function writeDependencyRange(path: string, field: DependencyFiel
28
30
  interface WorkspacePackage {
29
31
  readonly name: string;
30
32
  readonly version: string;
33
+ /** Whether the package is marked `private`, which means it is never published to npm. */
34
+ readonly private: boolean;
31
35
  /** Absolute path to the package directory. */
32
36
  readonly directory: string;
33
37
  /** Path relative to the workspace root, always POSIX-separated. Used for filesystem and git-pathspec purposes scoped to the workspace itself (for example `git add` run with the workspace root as `cwd`) -- never for matching against `git log` output, which `git` always reports relative to the repository's toplevel, not to whatever `cwd` a command happened to run from. Compare `repoRelativeDirectory` for that. */
@@ -87,7 +91,7 @@ export declare function topologicalOrder(graph: DependencyGraph): readonly strin
87
91
  /**
88
92
  * What happens to one dependency range when the sibling it points at releases a new version.
89
93
  *
90
- * The distinction between `rewritten` and `resolved-at-publish` matters for the manifest, not for the release decision: both mean the dependent's *published* dependency range changes, and therefore that the dependent needs a release of its own for that change to reach consumers. Only `wildcard` leaves the published artifact genuinely identical.
94
+ * The distinction between `rewritten` and `resolved-at-publish` matters for the manifest, not for the release decision: both make the dependent release in its own right, so it always follows a sibling it depends on. Only `wildcard` leaves the dependent entirely unaffected.
91
95
  */
92
96
  type DependencyRangeUpdate =
93
97
  /** The range names a concrete version that has to be rewritten in the manifest. */
@@ -95,7 +99,7 @@ type DependencyRangeUpdate =
95
99
  readonly kind: 'rewritten';
96
100
  readonly range: string;
97
101
  } |
98
- /** A bare `workspace:*`, `workspace:^`, or `workspace:~` range: pnpm substitutes the sibling's current version at pack time, so the manifest on disk needs no edit even though the published range does change. */
102
+ /** A bare `workspace:*`, `workspace:^`, or `workspace:~` range: it names no version, so the manifest on disk needs no edit. Only a private package or a `devDependencies` entry can carry one and still be released (see `assertPublishableDependencies`), because `npm publish` ships the specifier unchanged. */
99
103
  {
100
104
  readonly kind: 'resolved-at-publish';
101
105
  } |
@@ -128,7 +132,7 @@ export declare function updateDependencyRange(current: string, version: string):
128
132
  //#endregion
129
133
  //#region src/plugins.d.ts
130
134
  /**
131
- * One workspace dependency range that changed because its package released a new version during this run. `rewritten` means the dependent's manifest was edited on disk; `resolved-at-publish` means a `workspace:^`-style range whose on-disk text is unchanged but whose published value pnpm re-resolves at pack time. Both change the dependent's published artifact, which is why both count towards its release.
135
+ * One workspace dependency range that changed because its package released a new version during this run. `rewritten` means the dependent's manifest was edited on disk; `resolved-at-publish` means a bare `workspace:^`-style range whose on-disk text is unchanged. Both count towards the dependent's release.
132
136
  */
133
137
  interface DependencyBump {
134
138
  readonly dependency: string;
@@ -294,7 +298,7 @@ export declare class DependencyCycleError extends WorkspaceReleaseError {
294
298
  readonly cycle: readonly string[];
295
299
  constructor(cycle: readonly string[]);
296
300
  }
297
- /** 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. */
301
+ /** 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. */
298
302
  export declare class UnsupportedDependencyRangeError extends WorkspaceReleaseError {}
299
303
  /** 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. */
300
304
  export declare class ReleaseConfigurationError extends WorkspaceReleaseError {}
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ var DependencyCycleError = class extends WorkspaceReleaseError {
36
36
  this.cycle = cycle;
37
37
  }
38
38
  };
39
- /** 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. */
40
40
  var UnsupportedDependencyRangeError = class extends WorkspaceReleaseError {};
41
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. */
42
42
  var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
@@ -299,6 +299,12 @@ const DEPENDENCY_FIELDS = [
299
299
  "peerDependencies",
300
300
  "optionalDependencies"
301
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
+ ];
302
308
  async function readManifest(path) {
303
309
  const text = await readFile(path, "utf8");
304
310
  const parsed = JSON.parse(text);
@@ -318,6 +324,7 @@ async function readManifest(path) {
318
324
  return {
319
325
  name,
320
326
  version,
327
+ private: parsed.private === true,
321
328
  dependencies
322
329
  };
323
330
  }
@@ -372,6 +379,7 @@ async function discoverWorkspace(root) {
372
379
  packages.push({
373
380
  name: manifest.name,
374
381
  version: manifest.version,
382
+ private: manifest.private,
375
383
  directory,
376
384
  relativeDirectory,
377
385
  repoRelativeDirectory,
@@ -420,7 +428,14 @@ function toPosix(path) {
420
428
  const WORKSPACE_PROTOCOL = "workspace:";
421
429
  const CATALOG_PROTOCOL = "catalog:";
422
430
  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. */
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. */
424
439
  const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
425
440
  "*",
426
441
  "^",
@@ -435,6 +450,13 @@ const WILDCARD_RANGES = [
435
450
  "latest"
436
451
  ];
437
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
+ /**
438
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.
439
461
  */
440
462
  const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
@@ -456,7 +478,7 @@ function classifyDependencyRange(current) {
456
478
  comparator: inner.comparator
457
479
  };
458
480
  }
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.`);
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.`);
460
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.`);
461
483
  if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
462
484
  const match = REWRITABLE_COMPARATOR.exec(range);
@@ -480,6 +502,27 @@ function updateDependencyRange(current, version) {
480
502
  };
481
503
  }
482
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
483
526
  //#region src/graph.ts
484
527
  /**
485
528
  * Builds the inter-package dependency graph from the manifests alone.
@@ -555,11 +598,15 @@ function firstUnplacedDependency(name, graph, unplaced) {
555
598
  return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
556
599
  }
557
600
  /**
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.
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).
559
605
  *
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.
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.
561
607
  */
562
- function validateDependencyRangeShapes(graph) {
608
+ function validateDependencyRanges(graph) {
609
+ assertPublishableDependencies([...graph.packages.values()]);
563
610
  for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
564
611
  }
565
612
  /**
@@ -773,7 +820,7 @@ async function regenerateLockfile(options) {
773
820
  * Five phases, all inside one `releaseWorkspaceSingleCommit` call:
774
821
  *
775
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.
776
- * 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.
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.
777
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.
778
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.
779
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`.
@@ -789,7 +836,7 @@ async function releaseWorkspaceSingleCommit(options) {
789
836
  const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
790
837
  await assertCleanWorkingTree({ cwd: repoRoot });
791
838
  const graph = buildDependencyGraph(workspace.packages);
792
- validateDependencyRangeShapes(graph);
839
+ validateDependencyRanges(graph);
793
840
  const order = topologicalOrder(graph);
794
841
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
795
842
  const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
@@ -1061,7 +1108,7 @@ async function detachWorkspaceRelease(options) {
1061
1108
  const env = sanitizeGitEnv(options.env ?? process.env);
1062
1109
  const workspace = await discoverWorkspace(root);
1063
1110
  const graph = buildDependencyGraph(workspace.packages);
1064
- validateDependencyRangeShapes(graph);
1111
+ validateDependencyRanges(graph);
1065
1112
  const order = topologicalOrder(graph);
1066
1113
  log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1067
1114
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
@@ -1138,6 +1185,7 @@ async function resumeWorkspaceRelease(options) {
1138
1185
  const root = resolve(options.root ?? process.cwd());
1139
1186
  const log = options.log ?? console.log;
1140
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")))));
1141
1189
  const order = [];
1142
1190
  const packages = [];
1143
1191
  for (const entry of options.detached) {
@@ -1198,7 +1246,7 @@ async function releaseWorkspace(options = {}) {
1198
1246
  const env = sanitizeGitEnv(options.env ?? process.env);
1199
1247
  const workspace = await discoverWorkspace(root);
1200
1248
  const graph = buildDependencyGraph(workspace.packages);
1201
- validateDependencyRangeShapes(graph);
1249
+ validateDependencyRanges(graph);
1202
1250
  const order = topologicalOrder(graph);
1203
1251
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1204
1252
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exadev/semantic-release-workspace",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
4
4
  "description": "Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.",
5
5
  "type": "module",
6
6
  "repository": {