@exadev/semantic-release-workspace 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -158,6 +158,7 @@ The core technique is the same one multi-semantic-release proved in production:
158
158
  - **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).
159
159
  - **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.
160
160
  - **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.
161
+ - **Private packages release without advertising themselves.** A package marked `private` takes part in the run exactly as any other, tag and version bump and dependency cascade included, but creates no GitHub Release, because a Release for a package that never reaches a registry both points at nothing installable and takes the repository's Latest label off a package that does.
161
162
  - **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.
162
163
 
163
164
  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.
@@ -225,11 +226,17 @@ Listing `@semantic-release/commit-analyzer` or `@semantic-release/release-notes-
225
226
 
226
227
  Note that the orchestrator sets `tagFormat`, `plugins`, `analyzeCommits`, and `generateNotes` explicitly on every per-package run, so those keys in any `release.config.*` found in the workspace are overridden by construction — configure the release through the orchestrator, not through a leftover single-package config.
227
228
 
228
- ### Per-package publish plugins
229
+ ### Private packages and GitHub Releases
230
+
231
+ A package whose manifest sets `"private": true` gets the workspace-wide plugin list minus `@semantic-release/github`, without being configured to. Nothing else about its release changes: it still gets its version bump, its `name@version` tag and the dependency cascade to its dependents, all of which a dependent's own release can hinge on (a private package whose build output ships inside a published one, for example). What it loses is a public GitHub Release for something nobody can install.
232
+
233
+ That release is not merely redundant. `@semantic-release/github` sets the REST API's `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. Topological order puts a package that depends on the published ones at the end of the run, which is exactly where a private package usually sits, so without this rule the repository's front page advertises an unpublishable package as its current release.
229
234
 
230
- `plugins` is one list for the whole workspace. `packagePlugins` (config file and programmatic API only, since a list keyed by package name has no natural flag form) replaces that list outright for the packages it names, and every other package keeps the workspace-wide list. The override is not merged with the workspace-wide list: it is the complete list for that package, subject to the same rules as any other (for instance `@semantic-release/git` is required under `commitStrategy: 'per-package'` and rejected under `'single'`). A name that is not a package in the workspace is rejected, so a misspelling cannot leave a package on the default list unnoticed.
235
+ To keep the Release for a private package anyway, name it in `packagePlugins` below: an explicit list is taken exactly as written.
236
+
237
+ ### Per-package publish plugins
231
238
 
232
- The case this exists for is a private package. Such a package still needs its `name@version` tag, its version bump and the dependency cascade to its dependents, because a dependent's own release can hinge on it (a private package whose build output ships inside a published one, for example). It does not need a public GitHub Release, and `@semantic-release/github` marks every release it creates from the release branch as the repository's Latest, so GitHub ends up showing whichever package the run released last, which is often a private one that depends on the rest. Leaving that plugin off the private package's list removes the Release and nothing else:
239
+ `plugins` is one list for the whole workspace. `packagePlugins` (config file and programmatic API only, since a list keyed by package name has no natural flag form) replaces that list outright for the packages it names, and every other package keeps the workspace-wide list, or the private-package variant of it described above. The override is not merged with either: it is the complete list for that package, subject to the same rules as any other (for instance `@semantic-release/git` is required under `commitStrategy: 'per-package'` and rejected under `'single'`). A name that is not a package in the workspace is rejected, so a misspelling cannot leave a package on the default list unnoticed.
233
240
 
234
241
  ```ts
235
242
  // release-workspace.config.ts
@@ -237,7 +244,10 @@ import { DEFAULT_PUBLISH_PLUGINS, type ReleaseWorkspaceOptions } from '@exadev/s
237
244
 
238
245
  const config: ReleaseWorkspaceOptions = {
239
246
  packagePlugins: {
240
- '@acme/web-console': DEFAULT_PUBLISH_PLUGINS.filter((plugin) => plugin !== '@semantic-release/github'),
247
+ // A private package that does want its GitHub Release, opting back in to the list every public package gets.
248
+ '@acme/internal-tooling': DEFAULT_PUBLISH_PLUGINS,
249
+ // A published package kept out of a step the rest need.
250
+ '@acme/docs-site': DEFAULT_PUBLISH_PLUGINS.filter((plugin) => plugin !== '@semantic-release/npm'),
241
251
  },
242
252
  };
243
253
 
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 = "2.2.0";
17
+ var version = "3.0.0";
18
18
  //#endregion
19
19
  //#region src/errors.ts
20
20
  /**
@@ -602,6 +602,10 @@ function topologicalOrder(graph) {
602
602
  if (pending.size > 0) throw new DependencyCycleError(findCycle(graph, new Set(pending.keys())));
603
603
  return ordered;
604
604
  }
605
+ /** The packages a release order names, in that order, for the stages that need each package's manifest rather than only its name. */
606
+ function orderedPackages(graph, order) {
607
+ return order.map((name) => mustGet(graph.packages, name, "package"));
608
+ }
605
609
  /**
606
610
  * Walks dependency edges between the packages Kahn's algorithm could not place, until it revisits one, so the error can name a concrete loop rather than just a set of packages. Every unplaced package is unplaced precisely because at least one of its own dependencies is too, so the walk always reaches a repeat.
607
611
  */
@@ -683,6 +687,8 @@ function matchTrailerLine(message, key) {
683
687
  }
684
688
  //#endregion
685
689
  //#region src/plugins.ts
690
+ /** The one plugin a private package is kept off by default: see `resolveWorkspacePublishPlugins` for why a package that never reaches a registry does not get a public GitHub Release either. */
691
+ const GITHUB_RELEASE_PLUGIN = "@semantic-release/github";
686
692
  /** 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. */
687
693
  function hasGitHead(lastRelease) {
688
694
  return typeof lastRelease === "object" && lastRelease !== null && "gitHead" in lastRelease && typeof lastRelease.gitHead === "string";
@@ -809,13 +815,17 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
809
815
  /**
810
816
  * Resolves the publish plugin list of every package in the workspace: the workspace-wide list for each package, except where `packagePlugins` names a package, whose own list replaces it. Every list is held to the same rules as `resolvePublishPlugins` applies to a workspace-wide one, and a failure in an override names the package it belongs to.
811
817
  *
818
+ * A package whose manifest sets `private: true` and which no `packagePlugins` entry names gets the workspace-wide list minus `@semantic-release/github`. A private package is never published, so a public GitHub Release for it advertises something nobody can install, and that release is not merely redundant: `@semantic-release/github` sets `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. A private package sitting at the end of the topological order (which is where a package that depends on the published ones necessarily sits) therefore takes the label on every run. Everything the private package actually needs from the run is untouched: its version bump, its tag, and the dependency cascade to its dependents all come from the other plugins and from the orchestrator itself.
819
+ *
820
+ * A `packagePlugins` entry naming a private package is taken exactly as written, `@semantic-release/github` included, so a workspace that does want a Release for a private package can still say so.
821
+ *
812
822
  * A `packagePlugins` key that matches no package is rejected rather than ignored: a misspelt name would otherwise leave the package it meant on the workspace-wide list with nothing to say so, which for the intended use (keeping a package out of a step such as GitHub Release creation) is a silent wrong result.
813
823
  */
814
- function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, options) {
824
+ function resolveWorkspacePublishPlugins(packages, specs, workspaceRoot, options) {
815
825
  const overrides = specs.packagePlugins === void 0 ? [] : Object.entries(specs.packagePlugins);
816
- const known = new Set(packageNames);
826
+ const known = new Set(packages.map((pkg) => pkg.name));
817
827
  const unknown = overrides.map(([name]) => name).filter((name) => !known.has(name));
818
- if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${packageNames.join(", ")}.`);
828
+ if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${[...known].join(", ")}.`);
819
829
  const workspaceWide = resolvePublishPlugins(specs.plugins, workspaceRoot, options);
820
830
  const resolvedOverrides = /* @__PURE__ */ new Map();
821
831
  for (const [name, list] of overrides) try {
@@ -824,7 +834,9 @@ function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, opti
824
834
  if (cause instanceof ReleaseConfigurationError) throw new ReleaseConfigurationError(`packagePlugins for "${name}": ${cause.message}`);
825
835
  throw cause;
826
836
  }
827
- return new Map(packageNames.map((name) => [name, resolvedOverrides.get(name) ?? workspaceWide]));
837
+ const privateSpecs = specs.plugins.filter((spec) => parsePublishPluginSpec(spec)[0] !== GITHUB_RELEASE_PLUGIN);
838
+ const forPrivatePackages = privateSpecs.length === specs.plugins.length ? workspaceWide : resolvePublishPlugins(privateSpecs, workspaceRoot, options);
839
+ return new Map(packages.map((pkg) => [pkg.name, resolvedOverrides.get(pkg.name) ?? (pkg.private ? forPrivatePackages : workspaceWide)]));
828
840
  }
829
841
  /**
830
842
  * Resolves a plugin module name to an absolute file path, first from this tool's own module context (its peer dependencies, which every workspace installing the orchestrator must provide) and then from the workspace root (a workspace's own plugin dependencies, such as a custom changelog plugin). Both bases are named in the error when neither can resolve the name.
@@ -886,7 +898,7 @@ async function releaseWorkspaceSingleCommit(options) {
886
898
  validateDependencyRanges(graph);
887
899
  const order = topologicalOrder(graph);
888
900
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
889
- const resolvedPlugins = resolveWorkspacePublishPlugins(order, {
901
+ const resolvedPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
890
902
  plugins: options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS,
891
903
  packagePlugins: options.packagePlugins
892
904
  }, workspace.root, {
@@ -1170,7 +1182,7 @@ async function releaseWorkspace(options = {}) {
1170
1182
  validateDependencyRanges(graph);
1171
1183
  const order = topologicalOrder(graph);
1172
1184
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1173
- const publishPlugins = resolveWorkspacePublishPlugins(order, {
1185
+ const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
1174
1186
  plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1175
1187
  packagePlugins: options.packagePlugins
1176
1188
  }, workspace.root, { requireGitPlugin: !dryRun });
@@ -1337,7 +1349,7 @@ async function detachWorkspaceRelease(options) {
1337
1349
  validateDependencyRanges(graph);
1338
1350
  const order = topologicalOrder(graph);
1339
1351
  log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1340
- const publishPlugins = resolveWorkspacePublishPlugins(order, {
1352
+ const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
1341
1353
  plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1342
1354
  packagePlugins: options.packagePlugins
1343
1355
  }, workspace.root, { requireGitPlugin: !dryRun });
package/dist/index.cjs CHANGED
@@ -603,6 +603,10 @@ function topologicalOrder(graph) {
603
603
  if (pending.size > 0) throw new DependencyCycleError(findCycle(graph, new Set(pending.keys())));
604
604
  return ordered;
605
605
  }
606
+ /** The packages a release order names, in that order, for the stages that need each package's manifest rather than only its name. */
607
+ function orderedPackages(graph, order) {
608
+ return order.map((name) => mustGet(graph.packages, name, "package"));
609
+ }
606
610
  /**
607
611
  * Walks dependency edges between the packages Kahn's algorithm could not place, until it revisits one, so the error can name a concrete loop rather than just a set of packages. Every unplaced package is unplaced precisely because at least one of its own dependencies is too, so the walk always reaches a repeat.
608
612
  */
@@ -681,6 +685,8 @@ function matchTrailerLine(message, key) {
681
685
  }
682
686
  //#endregion
683
687
  //#region src/plugins.ts
688
+ /** The one plugin a private package is kept off by default: see `resolveWorkspacePublishPlugins` for why a package that never reaches a registry does not get a public GitHub Release either. */
689
+ const GITHUB_RELEASE_PLUGIN = "@semantic-release/github";
684
690
  /** semantic-release's own `getLastRelease` returns `{}` for a package with no prior tag -- not `undefined`, and not a fully-populated `LastRelease` -- contradicting the `gitHead: string` its own type declares. Narrows structurally rather than trusting that declared type, so a first-release context's `lastRelease` (correctly, at runtime) never claims a `gitHead` it does not have. */
685
691
  function hasGitHead(lastRelease) {
686
692
  return typeof lastRelease === "object" && lastRelease !== null && "gitHead" in lastRelease && typeof lastRelease.gitHead === "string";
@@ -807,13 +813,17 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
807
813
  /**
808
814
  * Resolves the publish plugin list of every package in the workspace: the workspace-wide list for each package, except where `packagePlugins` names a package, whose own list replaces it. Every list is held to the same rules as `resolvePublishPlugins` applies to a workspace-wide one, and a failure in an override names the package it belongs to.
809
815
  *
816
+ * A package whose manifest sets `private: true` and which no `packagePlugins` entry names gets the workspace-wide list minus `@semantic-release/github`. A private package is never published, so a public GitHub Release for it advertises something nobody can install, and that release is not merely redundant: `@semantic-release/github` sets `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. A private package sitting at the end of the topological order (which is where a package that depends on the published ones necessarily sits) therefore takes the label on every run. Everything the private package actually needs from the run is untouched: its version bump, its tag, and the dependency cascade to its dependents all come from the other plugins and from the orchestrator itself.
817
+ *
818
+ * A `packagePlugins` entry naming a private package is taken exactly as written, `@semantic-release/github` included, so a workspace that does want a Release for a private package can still say so.
819
+ *
810
820
  * A `packagePlugins` key that matches no package is rejected rather than ignored: a misspelt name would otherwise leave the package it meant on the workspace-wide list with nothing to say so, which for the intended use (keeping a package out of a step such as GitHub Release creation) is a silent wrong result.
811
821
  */
812
- function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, options) {
822
+ function resolveWorkspacePublishPlugins(packages, specs, workspaceRoot, options) {
813
823
  const overrides = specs.packagePlugins === void 0 ? [] : Object.entries(specs.packagePlugins);
814
- const known = new Set(packageNames);
824
+ const known = new Set(packages.map((pkg) => pkg.name));
815
825
  const unknown = overrides.map(([name]) => name).filter((name) => !known.has(name));
816
- if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${packageNames.join(", ")}.`);
826
+ if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${[...known].join(", ")}.`);
817
827
  const workspaceWide = resolvePublishPlugins(specs.plugins, workspaceRoot, options);
818
828
  const resolvedOverrides = /* @__PURE__ */ new Map();
819
829
  for (const [name, list] of overrides) try {
@@ -822,7 +832,9 @@ function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, opti
822
832
  if (cause instanceof ReleaseConfigurationError) throw new ReleaseConfigurationError(`packagePlugins for "${name}": ${cause.message}`);
823
833
  throw cause;
824
834
  }
825
- return new Map(packageNames.map((name) => [name, resolvedOverrides.get(name) ?? workspaceWide]));
835
+ const privateSpecs = specs.plugins.filter((spec) => parsePublishPluginSpec(spec)[0] !== GITHUB_RELEASE_PLUGIN);
836
+ const forPrivatePackages = privateSpecs.length === specs.plugins.length ? workspaceWide : resolvePublishPlugins(privateSpecs, workspaceRoot, options);
837
+ return new Map(packages.map((pkg) => [pkg.name, resolvedOverrides.get(pkg.name) ?? (pkg.private ? forPrivatePackages : workspaceWide)]));
826
838
  }
827
839
  /**
828
840
  * Resolves a plugin module name to an absolute file path, first from this tool's own module context (its peer dependencies, which every workspace installing the orchestrator must provide) and then from the workspace root (a workspace's own plugin dependencies, such as a custom changelog plugin). Both bases are named in the error when neither can resolve the name.
@@ -899,7 +911,7 @@ async function releaseWorkspaceSingleCommit(options) {
899
911
  validateDependencyRanges(graph);
900
912
  const order = topologicalOrder(graph);
901
913
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
902
- const resolvedPlugins = resolveWorkspacePublishPlugins(order, {
914
+ const resolvedPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
903
915
  plugins: options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS,
904
916
  packagePlugins: options.packagePlugins
905
917
  }, workspace.root, {
@@ -1176,7 +1188,7 @@ async function detachWorkspaceRelease(options) {
1176
1188
  validateDependencyRanges(graph);
1177
1189
  const order = topologicalOrder(graph);
1178
1190
  log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1179
- const publishPlugins = resolveWorkspacePublishPlugins(order, {
1191
+ const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
1180
1192
  plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1181
1193
  packagePlugins: options.packagePlugins
1182
1194
  }, workspace.root, { requireGitPlugin: !dryRun });
@@ -1318,7 +1330,7 @@ async function releaseWorkspace(options = {}) {
1318
1330
  validateDependencyRanges(graph);
1319
1331
  const order = topologicalOrder(graph);
1320
1332
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1321
- const publishPlugins = resolveWorkspacePublishPlugins(order, {
1333
+ const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
1322
1334
  plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1323
1335
  packagePlugins: options.packagePlugins
1324
1336
  }, workspace.root, { requireGitPlugin: !dryRun });
@@ -1485,6 +1497,7 @@ exports.classifyDependencyRange = classifyDependencyRange;
1485
1497
  exports.createScopedPlugins = createScopedPlugins;
1486
1498
  exports.discoverWorkspace = discoverWorkspace;
1487
1499
  exports.filterCommitsToDirectory = filterCommitsToDirectory;
1500
+ exports.orderedPackages = orderedPackages;
1488
1501
  exports.packageName = packageName;
1489
1502
  exports.readManifest = readManifest;
1490
1503
  exports.releaseWorkspace = releaseWorkspace;
package/dist/index.d.cts CHANGED
@@ -86,6 +86,8 @@ export declare function buildDependencyGraph(packages: readonly WorkspacePackage
86
86
  * A cycle has no valid order at all, so it throws rather than picking one of the wrong answers. In a release context an arbitrary order is worse than a failure: it would publish a package whose sibling dependency range points at a version that does not exist yet.
87
87
  */
88
88
  export declare function topologicalOrder(graph: DependencyGraph): readonly string[];
89
+ /** The packages a release order names, in that order, for the stages that need each package's manifest rather than only its name. */
90
+ export declare function orderedPackages(graph: DependencyGraph, order: readonly string[]): readonly WorkspacePackage[];
89
91
  //#endregion
90
92
  //#region src/version-range.d.ts
91
93
  /**
@@ -149,6 +151,11 @@ interface DependencyBumpSource {
149
151
  type PublishPluginSpec = string | readonly [string] | readonly [string, Record<string, unknown>];
150
152
  /** Publish plugin lists keyed by package name. Each list replaces the workspace-wide list outright for that one package; it is not merged with it. */
151
153
  type PackagePluginSpecs = Readonly<Record<string, readonly PublishPluginSpec[]>>;
154
+ /** All resolving a package's publish plugins needs to know about it: its name, and whether its manifest marks it private. `WorkspacePackage` satisfies this structurally. */
155
+ interface PublishPluginPackage {
156
+ readonly name: string;
157
+ readonly private: boolean;
158
+ }
152
159
  /** 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. */
153
160
  export declare const DEFAULT_PUBLISH_PLUGINS: readonly PublishPluginSpec[];
154
161
  /** 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. */
@@ -193,9 +200,13 @@ export declare function resolvePublishPlugins(specs: readonly PublishPluginSpec[
193
200
  /**
194
201
  * Resolves the publish plugin list of every package in the workspace: the workspace-wide list for each package, except where `packagePlugins` names a package, whose own list replaces it. Every list is held to the same rules as `resolvePublishPlugins` applies to a workspace-wide one, and a failure in an override names the package it belongs to.
195
202
  *
203
+ * A package whose manifest sets `private: true` and which no `packagePlugins` entry names gets the workspace-wide list minus `@semantic-release/github`. A private package is never published, so a public GitHub Release for it advertises something nobody can install, and that release is not merely redundant: `@semantic-release/github` sets `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. A private package sitting at the end of the topological order (which is where a package that depends on the published ones necessarily sits) therefore takes the label on every run. Everything the private package actually needs from the run is untouched: its version bump, its tag, and the dependency cascade to its dependents all come from the other plugins and from the orchestrator itself.
204
+ *
205
+ * A `packagePlugins` entry naming a private package is taken exactly as written, `@semantic-release/github` included, so a workspace that does want a Release for a private package can still say so.
206
+ *
196
207
  * A `packagePlugins` key that matches no package is rejected rather than ignored: a misspelt name would otherwise leave the package it meant on the workspace-wide list with nothing to say so, which for the intended use (keeping a package out of a step such as GitHub Release creation) is a silent wrong result.
197
208
  */
198
- export declare function resolveWorkspacePublishPlugins(packageNames: readonly string[], specs: {
209
+ export declare function resolveWorkspacePublishPlugins(packages: readonly PublishPluginPackage[], specs: {
199
210
  readonly plugins: readonly PublishPluginSpec[];
200
211
  readonly packagePlugins: PackagePluginSpecs | undefined;
201
212
  }, workspaceRoot: string, options: {
@@ -246,7 +257,7 @@ interface ReleaseWorkspaceOptions {
246
257
  readonly branches?: readonly BranchSpec[];
247
258
  /** Publish-pipeline plugins (changelog, npm, GitHub, git), each scoped per package by semantic-release's own `cwd`. Defaults to the standard pipeline in DEFAULT_PUBLISH_PLUGINS for `commitStrategy: 'per-package'`, or SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS (the same list minus `@semantic-release/git`) for `commitStrategy: 'single'`. */
248
259
  readonly plugins?: readonly PublishPluginSpec[];
249
- /** Publish plugin lists for individual packages, keyed by package name, each replacing `plugins` (or its default) outright for that package. Every other package keeps the workspace-wide list. The usual use is keeping one package out of a step the rest need, for example leaving `@semantic-release/github` off a private package so it gets its tag, version bump and dependency cascade without a public GitHub Release. Applies under every `commitStrategy` and to `gatePublish`. A name that is not a package in the workspace is rejected. */
260
+ /** Publish plugin lists for individual packages, keyed by package name, each replacing `plugins` (or its default) outright for that package. Every other package keeps the workspace-wide list, except that a package marked `private` in its manifest drops `@semantic-release/github` from it on its own (see `resolveWorkspacePublishPlugins`), so the usual reason to reach for this is either the reverse of that default, a private package that does want a GitHub Release, or keeping a package out of some other step the rest need. Applies under every `commitStrategy` and to `gatePublish`. A name that is not a package in the workspace is rejected. */
250
261
  readonly packagePlugins?: PackagePluginSpecs;
251
262
  /** Options for the wrapped `@semantic-release/commit-analyzer`, applied per package after path filtering. */
252
263
  readonly analyzeCommits?: Record<string, unknown>;
@@ -334,4 +345,4 @@ export declare class GitCommandError extends WorkspaceReleaseError {
334
345
  /** The workspace's git state does not support the release operation -- for example a detached HEAD, which names no branch that dependency-bump commits could be pushed to. */
335
346
  export declare class WorkspaceStateError extends WorkspaceReleaseError {}
336
347
  //#endregion
337
- export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackagePluginSpecs, PackageReleaseOutcome, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
348
+ export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackagePluginSpecs, PackageReleaseOutcome, PublishPluginPackage, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
package/dist/index.d.ts CHANGED
@@ -86,6 +86,8 @@ export declare function buildDependencyGraph(packages: readonly WorkspacePackage
86
86
  * A cycle has no valid order at all, so it throws rather than picking one of the wrong answers. In a release context an arbitrary order is worse than a failure: it would publish a package whose sibling dependency range points at a version that does not exist yet.
87
87
  */
88
88
  export declare function topologicalOrder(graph: DependencyGraph): readonly string[];
89
+ /** The packages a release order names, in that order, for the stages that need each package's manifest rather than only its name. */
90
+ export declare function orderedPackages(graph: DependencyGraph, order: readonly string[]): readonly WorkspacePackage[];
89
91
  //#endregion
90
92
  //#region src/version-range.d.ts
91
93
  /**
@@ -149,6 +151,11 @@ interface DependencyBumpSource {
149
151
  type PublishPluginSpec = string | readonly [string] | readonly [string, Record<string, unknown>];
150
152
  /** Publish plugin lists keyed by package name. Each list replaces the workspace-wide list outright for that one package; it is not merged with it. */
151
153
  type PackagePluginSpecs = Readonly<Record<string, readonly PublishPluginSpec[]>>;
154
+ /** All resolving a package's publish plugins needs to know about it: its name, and whether its manifest marks it private. `WorkspacePackage` satisfies this structurally. */
155
+ interface PublishPluginPackage {
156
+ readonly name: string;
157
+ readonly private: boolean;
158
+ }
152
159
  /** 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. */
153
160
  export declare const DEFAULT_PUBLISH_PLUGINS: readonly PublishPluginSpec[];
154
161
  /** 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. */
@@ -193,9 +200,13 @@ export declare function resolvePublishPlugins(specs: readonly PublishPluginSpec[
193
200
  /**
194
201
  * Resolves the publish plugin list of every package in the workspace: the workspace-wide list for each package, except where `packagePlugins` names a package, whose own list replaces it. Every list is held to the same rules as `resolvePublishPlugins` applies to a workspace-wide one, and a failure in an override names the package it belongs to.
195
202
  *
203
+ * A package whose manifest sets `private: true` and which no `packagePlugins` entry names gets the workspace-wide list minus `@semantic-release/github`. A private package is never published, so a public GitHub Release for it advertises something nobody can install, and that release is not merely redundant: `@semantic-release/github` sets `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. A private package sitting at the end of the topological order (which is where a package that depends on the published ones necessarily sits) therefore takes the label on every run. Everything the private package actually needs from the run is untouched: its version bump, its tag, and the dependency cascade to its dependents all come from the other plugins and from the orchestrator itself.
204
+ *
205
+ * A `packagePlugins` entry naming a private package is taken exactly as written, `@semantic-release/github` included, so a workspace that does want a Release for a private package can still say so.
206
+ *
196
207
  * A `packagePlugins` key that matches no package is rejected rather than ignored: a misspelt name would otherwise leave the package it meant on the workspace-wide list with nothing to say so, which for the intended use (keeping a package out of a step such as GitHub Release creation) is a silent wrong result.
197
208
  */
198
- export declare function resolveWorkspacePublishPlugins(packageNames: readonly string[], specs: {
209
+ export declare function resolveWorkspacePublishPlugins(packages: readonly PublishPluginPackage[], specs: {
199
210
  readonly plugins: readonly PublishPluginSpec[];
200
211
  readonly packagePlugins: PackagePluginSpecs | undefined;
201
212
  }, workspaceRoot: string, options: {
@@ -246,7 +257,7 @@ interface ReleaseWorkspaceOptions {
246
257
  readonly branches?: readonly BranchSpec[];
247
258
  /** Publish-pipeline plugins (changelog, npm, GitHub, git), each scoped per package by semantic-release's own `cwd`. Defaults to the standard pipeline in DEFAULT_PUBLISH_PLUGINS for `commitStrategy: 'per-package'`, or SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS (the same list minus `@semantic-release/git`) for `commitStrategy: 'single'`. */
248
259
  readonly plugins?: readonly PublishPluginSpec[];
249
- /** Publish plugin lists for individual packages, keyed by package name, each replacing `plugins` (or its default) outright for that package. Every other package keeps the workspace-wide list. The usual use is keeping one package out of a step the rest need, for example leaving `@semantic-release/github` off a private package so it gets its tag, version bump and dependency cascade without a public GitHub Release. Applies under every `commitStrategy` and to `gatePublish`. A name that is not a package in the workspace is rejected. */
260
+ /** Publish plugin lists for individual packages, keyed by package name, each replacing `plugins` (or its default) outright for that package. Every other package keeps the workspace-wide list, except that a package marked `private` in its manifest drops `@semantic-release/github` from it on its own (see `resolveWorkspacePublishPlugins`), so the usual reason to reach for this is either the reverse of that default, a private package that does want a GitHub Release, or keeping a package out of some other step the rest need. Applies under every `commitStrategy` and to `gatePublish`. A name that is not a package in the workspace is rejected. */
250
261
  readonly packagePlugins?: PackagePluginSpecs;
251
262
  /** Options for the wrapped `@semantic-release/commit-analyzer`, applied per package after path filtering. */
252
263
  readonly analyzeCommits?: Record<string, unknown>;
@@ -334,4 +345,4 @@ export declare class GitCommandError extends WorkspaceReleaseError {
334
345
  /** The workspace's git state does not support the release operation -- for example a detached HEAD, which names no branch that dependency-bump commits could be pushed to. */
335
346
  export declare class WorkspaceStateError extends WorkspaceReleaseError {}
336
347
  //#endregion
337
- export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackagePluginSpecs, PackageReleaseOutcome, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
348
+ export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackagePluginSpecs, PackageReleaseOutcome, PublishPluginPackage, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
package/dist/index.js CHANGED
@@ -578,6 +578,10 @@ function topologicalOrder(graph) {
578
578
  if (pending.size > 0) throw new DependencyCycleError(findCycle(graph, new Set(pending.keys())));
579
579
  return ordered;
580
580
  }
581
+ /** The packages a release order names, in that order, for the stages that need each package's manifest rather than only its name. */
582
+ function orderedPackages(graph, order) {
583
+ return order.map((name) => mustGet(graph.packages, name, "package"));
584
+ }
581
585
  /**
582
586
  * Walks dependency edges between the packages Kahn's algorithm could not place, until it revisits one, so the error can name a concrete loop rather than just a set of packages. Every unplaced package is unplaced precisely because at least one of its own dependencies is too, so the walk always reaches a repeat.
583
587
  */
@@ -656,6 +660,8 @@ function matchTrailerLine(message, key) {
656
660
  }
657
661
  //#endregion
658
662
  //#region src/plugins.ts
663
+ /** The one plugin a private package is kept off by default: see `resolveWorkspacePublishPlugins` for why a package that never reaches a registry does not get a public GitHub Release either. */
664
+ const GITHUB_RELEASE_PLUGIN = "@semantic-release/github";
659
665
  /** semantic-release's own `getLastRelease` returns `{}` for a package with no prior tag -- not `undefined`, and not a fully-populated `LastRelease` -- contradicting the `gitHead: string` its own type declares. Narrows structurally rather than trusting that declared type, so a first-release context's `lastRelease` (correctly, at runtime) never claims a `gitHead` it does not have. */
660
666
  function hasGitHead(lastRelease) {
661
667
  return typeof lastRelease === "object" && lastRelease !== null && "gitHead" in lastRelease && typeof lastRelease.gitHead === "string";
@@ -782,13 +788,17 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
782
788
  /**
783
789
  * Resolves the publish plugin list of every package in the workspace: the workspace-wide list for each package, except where `packagePlugins` names a package, whose own list replaces it. Every list is held to the same rules as `resolvePublishPlugins` applies to a workspace-wide one, and a failure in an override names the package it belongs to.
784
790
  *
791
+ * A package whose manifest sets `private: true` and which no `packagePlugins` entry names gets the workspace-wide list minus `@semantic-release/github`. A private package is never published, so a public GitHub Release for it advertises something nobody can install, and that release is not merely redundant: `@semantic-release/github` sets `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. A private package sitting at the end of the topological order (which is where a package that depends on the published ones necessarily sits) therefore takes the label on every run. Everything the private package actually needs from the run is untouched: its version bump, its tag, and the dependency cascade to its dependents all come from the other plugins and from the orchestrator itself.
792
+ *
793
+ * A `packagePlugins` entry naming a private package is taken exactly as written, `@semantic-release/github` included, so a workspace that does want a Release for a private package can still say so.
794
+ *
785
795
  * A `packagePlugins` key that matches no package is rejected rather than ignored: a misspelt name would otherwise leave the package it meant on the workspace-wide list with nothing to say so, which for the intended use (keeping a package out of a step such as GitHub Release creation) is a silent wrong result.
786
796
  */
787
- function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, options) {
797
+ function resolveWorkspacePublishPlugins(packages, specs, workspaceRoot, options) {
788
798
  const overrides = specs.packagePlugins === void 0 ? [] : Object.entries(specs.packagePlugins);
789
- const known = new Set(packageNames);
799
+ const known = new Set(packages.map((pkg) => pkg.name));
790
800
  const unknown = overrides.map(([name]) => name).filter((name) => !known.has(name));
791
- if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${packageNames.join(", ")}.`);
801
+ if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${[...known].join(", ")}.`);
792
802
  const workspaceWide = resolvePublishPlugins(specs.plugins, workspaceRoot, options);
793
803
  const resolvedOverrides = /* @__PURE__ */ new Map();
794
804
  for (const [name, list] of overrides) try {
@@ -797,7 +807,9 @@ function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, opti
797
807
  if (cause instanceof ReleaseConfigurationError) throw new ReleaseConfigurationError(`packagePlugins for "${name}": ${cause.message}`);
798
808
  throw cause;
799
809
  }
800
- return new Map(packageNames.map((name) => [name, resolvedOverrides.get(name) ?? workspaceWide]));
810
+ const privateSpecs = specs.plugins.filter((spec) => parsePublishPluginSpec(spec)[0] !== GITHUB_RELEASE_PLUGIN);
811
+ const forPrivatePackages = privateSpecs.length === specs.plugins.length ? workspaceWide : resolvePublishPlugins(privateSpecs, workspaceRoot, options);
812
+ return new Map(packages.map((pkg) => [pkg.name, resolvedOverrides.get(pkg.name) ?? (pkg.private ? forPrivatePackages : workspaceWide)]));
801
813
  }
802
814
  /**
803
815
  * Resolves a plugin module name to an absolute file path, first from this tool's own module context (its peer dependencies, which every workspace installing the orchestrator must provide) and then from the workspace root (a workspace's own plugin dependencies, such as a custom changelog plugin). Both bases are named in the error when neither can resolve the name.
@@ -874,7 +886,7 @@ async function releaseWorkspaceSingleCommit(options) {
874
886
  validateDependencyRanges(graph);
875
887
  const order = topologicalOrder(graph);
876
888
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
877
- const resolvedPlugins = resolveWorkspacePublishPlugins(order, {
889
+ const resolvedPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
878
890
  plugins: options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS,
879
891
  packagePlugins: options.packagePlugins
880
892
  }, workspace.root, {
@@ -1151,7 +1163,7 @@ async function detachWorkspaceRelease(options) {
1151
1163
  validateDependencyRanges(graph);
1152
1164
  const order = topologicalOrder(graph);
1153
1165
  log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1154
- const publishPlugins = resolveWorkspacePublishPlugins(order, {
1166
+ const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
1155
1167
  plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1156
1168
  packagePlugins: options.packagePlugins
1157
1169
  }, workspace.root, { requireGitPlugin: !dryRun });
@@ -1293,7 +1305,7 @@ async function releaseWorkspace(options = {}) {
1293
1305
  validateDependencyRanges(graph);
1294
1306
  const order = topologicalOrder(graph);
1295
1307
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1296
- const publishPlugins = resolveWorkspacePublishPlugins(order, {
1308
+ const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
1297
1309
  plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1298
1310
  packagePlugins: options.packagePlugins
1299
1311
  }, workspace.root, { requireGitPlugin: !dryRun });
@@ -1446,4 +1458,4 @@ async function bumpDependents(released, version, graph, options) {
1446
1458
  return applied;
1447
1459
  }
1448
1460
  //#endregion
1449
- export { DEFAULT_PUBLISH_PLUGINS, DependencyCycleError, GitCommandError, ReleaseConfigurationError, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, UnsupportedDependencyRangeError, WorkspaceDiscoveryError, WorkspaceReleaseError, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, resolveWorkspacePublishPlugins, resumeWorkspaceRelease, topologicalOrder, updateDependencyRange, writeDependencyRange };
1461
+ export { DEFAULT_PUBLISH_PLUGINS, DependencyCycleError, GitCommandError, ReleaseConfigurationError, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, UnsupportedDependencyRangeError, WorkspaceDiscoveryError, WorkspaceReleaseError, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, orderedPackages, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, resolveWorkspacePublishPlugins, resumeWorkspaceRelease, topologicalOrder, updateDependencyRange, writeDependencyRange };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exadev/semantic-release-workspace",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.",
5
5
  "type": "module",
6
6
  "repository": {