@exadev/semantic-release-workspace 2.0.0 → 2.2.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
@@ -14,7 +14,7 @@ One orchestrator run, five stages:
14
14
 
15
15
  1. **Workspace discovery.** Reads the `packages` globs from `pnpm-workspace.yaml` and every matched package's `package.json` (`dependencies`, `devDependencies`, `peerDependencies`, `optionalDependencies`), building the inter-package dependency graph keyed by package name. Packages must live in subdirectories (a package at the workspace root touches every commit and cannot be path-scoped) and names must be unique. This stage also resolves the workspace root's own path relative to the git repository's toplevel (`git rev-parse --show-prefix`), because `git log --name-only` always reports changed paths relative to that toplevel, not to `pnpm-workspace.yaml`'s own directory -- the two differ whenever the workspace is nested inside a larger repository, and every package's commit filtering is scoped against the repository-relative path, not the workspace-relative one, so nested workspaces are path-scoped correctly rather than silently matching nothing.
16
16
  2. **Topological release ordering.** Kahn's algorithm over the discovered graph, so a package only releases after every workspace sibling it depends on has already released in this run. Ties are broken alphabetically within each dependency layer, so the same workspace always produces the same order. A dependency cycle has no valid order at all — the run fails loudly, naming the loop (`x -> y -> x`), rather than picking one of the wrong answers and publishing a package whose sibling dependency points at a version that does not exist yet.
17
- 3. **Per-package scoped release.** For each package in order, the orchestrator calls semantic-release's programmatic API (`require('semantic-release')`, not the CLI) with `cwd` set to the package's directory, `tagFormat` set to `name@version` so each package's tags stay distinct in the one shared tag namespace, and inline `analyzeCommits`/`generateNotes` plugins. Each wrapper runs one `git log --name-only --no-renames` pass over the same release range semantic-release already analysed (from the package's last matching tag to `HEAD`, or the whole history for a first release), maps every commit to the paths it changed, and filters the commit list down to commits touching the package's own directory **before delegating to the real @semantic-release/commit-analyzer and @semantic-release/release-notes-generator**. Conventional-commit parsing and changelog formatting stay entirely inside the standard plugins — the orchestrator only scopes what they see.
17
+ 3. **Per-package scoped release.** For each package in order, the orchestrator calls semantic-release's programmatic API (`require('semantic-release')`, not the CLI) with `cwd` set to the package's directory, `tagFormat` resolved from the `tagFormat` option (default `'${name}@${version}'`) so each package's tags stay distinct in the one shared tag namespace, and inline `analyzeCommits`/`generateNotes` plugins. Each wrapper runs one `git log --name-only --no-renames` pass over the same release range semantic-release already analysed (from the package's last matching tag to `HEAD`, or the whole history for a first release), maps every commit to the paths it changed, and filters the commit list down to commits touching the package's own directory **before delegating to the real @semantic-release/commit-analyzer and @semantic-release/release-notes-generator**. Conventional-commit parsing and changelog formatting stay entirely inside the standard plugins — the orchestrator only scopes what they see.
18
18
  4. **Cross-package manifest bumping** — the heart of the design, covered in its own section below.
19
19
  5. **Standard plugins do the publishing.** @semantic-release/npm, @semantic-release/github, @semantic-release/changelog, and @semantic-release/git run per package exactly as in a single-package repository, scoped by `cwd`. The orchestrator coordinates and sequences them; it does not reimplement npm publishing, GitHub release creation, or changelog file writing.
20
20
 
@@ -66,7 +66,13 @@ Everything above describes `commitStrategy: 'per-package'`, the default: unchang
66
66
  | Default publish plugins | `DEFAULT_PUBLISH_PLUGINS` (changelog, npm, github, git) | `SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS` — the same list minus git |
67
67
  | Crash recovery | A bump commit already sitting in git history (from this run or an earlier one) is recognised via a machine-parseable trailer, so a run that resumes after a partial push still forces the right dependent releases | Not needed: either the whole combined commit lands and pushes, or the run fails before touching git at all, so there is never a partial push to recover from |
68
68
 
69
- Everything about *what* releases (topological order, forced-patch dependents, dependency-range classification, path-scoped commit analysis, unsupported-range rejection) is identical between the two modes — `commitStrategy` only changes how the result is committed, tagged, and pushed. Set it via the `--commit-strategy <mode>` CLI flag, the `commitStrategy` field in a `--config` file, or the `commitStrategy` option to `releaseWorkspace()`; omit it and nothing changes.
69
+ Everything about *what* releases (topological order, forced-patch dependents, dependency-range classification, path-scoped commit analysis, unsupported-range rejection) is identical between the two modes — `commitStrategy` only changes how the result is committed, tagged, and pushed. ## Tag format
70
+
71
+ Each package's release tag comes from the `tagFormat` option, a lodash template with `${name}` and `${version}` placeholders, defaulting to `'${name}@${version}'`. Set it via the `tagFormat` field in a `--config` file or the `tagFormat` option to `releaseWorkspace()`. The template must contain `${version}` and may contain `${name}`; anything else is rejected before any package releases.
72
+
73
+ Override it when the tags are consumed as refs outside git: GitHub Actions pins composite actions as `owner/repo/path@ref`, and GitHub's workflow parser rejects a ref containing `@` -- so a repository of actions sets `'${name}-v${version}'` and every workflow pins `...@<action>-v<major>`. Note that switching an existing repository's format starts a fresh tag namespace: semantic-release will not see prior releases recorded under the old format, so rename existing tags to the new template as part of the switch.
74
+
75
+ Set it via the `--commit-strategy <mode>` CLI flag, the `commitStrategy` field in a `--config` file, or the `commitStrategy` option to `releaseWorkspace()`; omit it and nothing changes.
70
76
 
71
77
  `'single'` mode still runs each package's own configured publish plugins afterward (npm publish with provenance, GitHub releases), scoped per package exactly as `'per-package'` mode does — it just runs their `verifyConditions`/`publish`/`success` steps directly against the already-committed-and-tagged repository state, since by that point semantic-release's own top-level orchestrator would misread the tag this mode already created as an existing release. `addChannel` and `fail` are not called in this mode (pre-release channel promotion and posting an automated failure comment/issue, respectively) — a deliberate scope boundary, not a silent gap: raise an issue if your workflow needs them.
72
78
 
@@ -206,7 +212,7 @@ Run it from the workspace root (or pass `--root <directory>`). A dry run analyse
206
212
  | `--commit-strategy <mode>` | `per-package` (default) or `single` — see [Commit strategies](#commit-strategies) |
207
213
  | `--gate-publish` | Tag and push each due package, but defer publishing — see [Gating publish](#gating-publish). Requires `--gate-state-file`; rejected with `--commit-strategy single` |
208
214
  | `--gate-state-file <path>` | With `--gate-publish`: where to write the state a later `resume` run needs |
209
- | `--config <file>` | A config file (`.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.cts`, or `.mts`, loaded via [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig)) providing any of the above through its default export; explicit flags win. TypeScript files are run by Node's own type stripping, so they may use only erasable type syntax (annotations and `import type`, not `enum` or `namespace`) |
215
+ | `--config <file>` | A config file (`.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.cts`, or `.mts`, loaded via [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig)) providing any of the above, plus `packagePlugins` (see [Per-package publish plugins](#per-package-publish-plugins)), through its default export; explicit flags win. TypeScript files are run by Node's own type stripping, so they may use only erasable type syntax (annotations and `import type`, not `enum` or `namespace`) |
210
216
 
211
217
  `resume` (a separate subcommand, not a `release` flag) finishes publishing what a `--gate-publish` run tagged and pushed:
212
218
 
@@ -219,6 +225,27 @@ Listing `@semantic-release/commit-analyzer` or `@semantic-release/release-notes-
219
225
 
220
226
  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.
221
227
 
228
+ ### Per-package publish plugins
229
+
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.
231
+
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:
233
+
234
+ ```ts
235
+ // release-workspace.config.ts
236
+ import { DEFAULT_PUBLISH_PLUGINS, type ReleaseWorkspaceOptions } from '@exadev/semantic-release-workspace';
237
+
238
+ const config: ReleaseWorkspaceOptions = {
239
+ packagePlugins: {
240
+ '@acme/web-console': DEFAULT_PUBLISH_PLUGINS.filter((plugin) => plugin !== '@semantic-release/github'),
241
+ },
242
+ };
243
+
244
+ export default config;
245
+ ```
246
+
247
+ The override behaves the same under both commit strategies and with `gatePublish`. A gated run persists each package's pipeline in its state file, so the override reaches `resume` without being repeated there. Under `commitStrategy: 'single'` start from `SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS` instead of `DEFAULT_PUBLISH_PLUGINS`.
248
+
222
249
  ### Programmatic API
223
250
 
224
251
  ```ts
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.0.0";
17
+ var version = "2.2.0";
18
18
  //#endregion
19
19
  //#region src/errors.ts
20
20
  /**
@@ -59,6 +59,21 @@ var PnpmCommandError = class extends WorkspaceReleaseError {
59
59
  super(`pnpm install --lockfile-only failed in ${cwd}: ${detail}`);
60
60
  }
61
61
  };
62
+ /**
63
+ * Validate a caller-supplied tagFormat once per run, before any package releases. The template must contain `${version}` (semantic-release interpolates it; without it every package would compute the same tag and overwrite its predecessor) and may contain `${name}`, which the orchestrator substitutes per package. Any other `${...}` token is a typo or a placeholder semantic-release does not support in tagFormat, so it is rejected here rather than released as a literal into a tag name.
64
+ */
65
+ function validateTagFormat(tagFormat) {
66
+ if (!tagFormat.includes("${version}")) throw new ReleaseConfigurationError(`tagFormat must contain '\${version}' so each package's release tag carries its version; got: ${tagFormat}`);
67
+ const unknown = [...tagFormat.matchAll(/\$\{([^}]*)\}/g)].map((match) => match[1] ?? "").filter((token) => token !== "name" && token !== "version").filter((token) => token !== "");
68
+ if (unknown.length > 0) throw new ReleaseConfigurationError(`tagFormat supports only the '\${name}' and '\${version}' placeholders; unknown token(s): ${unknown.map((token) => `\${${token}}`).join(", ")}`);
69
+ return tagFormat;
70
+ }
71
+ /**
72
+ * Substitute `${name}` for one package. Called per package at release time; the returned template still carries the literal `${version}` for semantic-release to interpolate, exactly as the default `name@version` format always has.
73
+ */
74
+ function formatTagForPackage(tagFormat, name) {
75
+ return tagFormat.replaceAll("${name}", name);
76
+ }
62
77
  //#endregion
63
78
  //#region src/exec-file.ts
64
79
  /**
@@ -792,6 +807,26 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
792
807
  return resolved;
793
808
  }
794
809
  /**
810
+ * 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
+ *
812
+ * 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
+ */
814
+ function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, options) {
815
+ const overrides = specs.packagePlugins === void 0 ? [] : Object.entries(specs.packagePlugins);
816
+ const known = new Set(packageNames);
817
+ 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(", ")}.`);
819
+ const workspaceWide = resolvePublishPlugins(specs.plugins, workspaceRoot, options);
820
+ const resolvedOverrides = /* @__PURE__ */ new Map();
821
+ for (const [name, list] of overrides) try {
822
+ resolvedOverrides.set(name, resolvePublishPlugins(list, workspaceRoot, options));
823
+ } catch (cause) {
824
+ if (cause instanceof ReleaseConfigurationError) throw new ReleaseConfigurationError(`packagePlugins for "${name}": ${cause.message}`);
825
+ throw cause;
826
+ }
827
+ return new Map(packageNames.map((name) => [name, resolvedOverrides.get(name) ?? workspaceWide]));
828
+ }
829
+ /**
795
830
  * 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.
796
831
  */
797
832
  function resolvePluginModule(name, requireFromTool, requireFromWorkspace) {
@@ -851,12 +886,16 @@ async function releaseWorkspaceSingleCommit(options) {
851
886
  validateDependencyRanges(graph);
852
887
  const order = topologicalOrder(graph);
853
888
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
854
- const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
889
+ const resolvedPlugins = resolveWorkspacePublishPlugins(order, {
890
+ plugins: options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS,
891
+ packagePlugins: options.packagePlugins
892
+ }, workspace.root, {
855
893
  requireGitPlugin: false,
856
894
  forbidGitPlugin: true
857
895
  });
858
896
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
859
897
  const generateNotesConfig = options.generateNotes ?? {};
898
+ const tagFormat = validateTagFormat(options.tagFormat ?? "${name}@${version}");
860
899
  const capturedCommits = /* @__PURE__ */ new Map();
861
900
  const captured = {
862
901
  branch: void 0,
@@ -870,7 +909,8 @@ async function releaseWorkspaceSingleCommit(options) {
870
909
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
871
910
  pendingBumps.delete(name);
872
911
  const nextRelease = await analysePackage(pkg, {
873
- resolvedPlugins,
912
+ tagFormat,
913
+ resolvedPlugins: mustGet(resolvedPlugins, name, "publish plugins"),
874
914
  analyzeCommitsConfig,
875
915
  generateNotesConfig,
876
916
  bumpsForThisPackage,
@@ -931,7 +971,7 @@ async function releaseWorkspaceSingleCommit(options) {
931
971
  log
932
972
  };
933
973
  const moduleCache = /* @__PURE__ */ new Map();
934
- for (const release of planned) for (const [modulePath, pluginConfig] of resolvedPlugins) {
974
+ for (const release of planned) for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
935
975
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
936
976
  if (plugin.verifyConditions) await plugin.verifyConditions(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
937
977
  }
@@ -942,7 +982,7 @@ async function releaseWorkspaceSingleCommit(options) {
942
982
  await writeDependencyRange(release.pkg.manifestPath, bump.field, bump.dependency, bump.range);
943
983
  anyRangeRewritten = true;
944
984
  }
945
- for (const [modulePath, pluginConfig] of resolvedPlugins) {
985
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
946
986
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
947
987
  if (plugin.prepare) await plugin.prepare(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
948
988
  }
@@ -962,14 +1002,14 @@ async function releaseWorkspaceSingleCommit(options) {
962
1002
  log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
963
1003
  for (const release of planned) {
964
1004
  const releases = [];
965
- for (const [modulePath, pluginConfig] of resolvedPlugins) {
1005
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
966
1006
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
967
1007
  if (plugin.publish) {
968
1008
  const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
969
1009
  if (result !== false && result !== void 0) releases.push(result);
970
1010
  }
971
1011
  }
972
- for (const [modulePath, pluginConfig] of resolvedPlugins) {
1012
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
973
1013
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
974
1014
  if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
975
1015
  }
@@ -992,7 +1032,7 @@ async function analysePackage(pkg, options) {
992
1032
  onCommitsResolved: options.onCommitsResolved
993
1033
  });
994
1034
  const semanticReleaseOptions = {
995
- tagFormat: `${pkg.name}@\${version}`,
1035
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
996
1036
  plugins: options.resolvedPlugins,
997
1037
  dryRun: true,
998
1038
  async analyzeCommits(pluginConfig, context) {
@@ -1130,14 +1170,18 @@ async function releaseWorkspace(options = {}) {
1130
1170
  validateDependencyRanges(graph);
1131
1171
  const order = topologicalOrder(graph);
1132
1172
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1133
- const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1173
+ const publishPlugins = resolveWorkspacePublishPlugins(order, {
1174
+ plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1175
+ packagePlugins: options.packagePlugins
1176
+ }, workspace.root, { requireGitPlugin: !dryRun });
1134
1177
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1135
1178
  const generateNotesConfig = options.generateNotes ?? {};
1136
1179
  return {
1137
1180
  order,
1138
1181
  packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1139
1182
  const result = await runPackageRelease(pkg, {
1140
- publishPlugins,
1183
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1184
+ publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1141
1185
  analyzeCommitsConfig,
1142
1186
  generateNotesConfig,
1143
1187
  bumpsForThisPackage,
@@ -1218,7 +1262,7 @@ async function runPackageRelease(pkg, options) {
1218
1262
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1219
1263
  });
1220
1264
  const semanticReleaseOptions = {
1221
- tagFormat: `${pkg.name}@\${version}`,
1265
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1222
1266
  plugins: options.publishPlugins,
1223
1267
  analyzeCommits: scoped.analyzeCommits,
1224
1268
  generateNotes: scoped.generateNotes
@@ -1293,15 +1337,19 @@ async function detachWorkspaceRelease(options) {
1293
1337
  validateDependencyRanges(graph);
1294
1338
  const order = topologicalOrder(graph);
1295
1339
  log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1296
- const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1340
+ const publishPlugins = resolveWorkspacePublishPlugins(order, {
1341
+ plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1342
+ packagePlugins: options.packagePlugins
1343
+ }, workspace.root, { requireGitPlugin: !dryRun });
1297
1344
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1298
1345
  const generateNotesConfig = options.generateNotes ?? {};
1299
1346
  const entries = await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1300
1347
  const state = await runPackageDetach(pkg, {
1301
- publishPlugins,
1348
+ publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1302
1349
  analyzeCommitsConfig,
1303
1350
  generateNotesConfig,
1304
1351
  bumpsForThisPackage,
1352
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1305
1353
  dryRun,
1306
1354
  env,
1307
1355
  branches: options.branches
@@ -1344,7 +1392,7 @@ async function runPackageDetach(pkg, options) {
1344
1392
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1345
1393
  });
1346
1394
  const cliOptions = {
1347
- tagFormat: `${pkg.name}@\${version}`,
1395
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1348
1396
  plugins: options.publishPlugins,
1349
1397
  analyzeCommits: scoped.analyzeCommits,
1350
1398
  generateNotes: scoped.generateNotes
@@ -1427,6 +1475,7 @@ const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
1427
1475
  "dryRun",
1428
1476
  "branches",
1429
1477
  "plugins",
1478
+ "packagePlugins",
1430
1479
  "analyzeCommits",
1431
1480
  "generateNotes",
1432
1481
  "commitStrategy",
@@ -1454,7 +1503,7 @@ function createProgram() {
1454
1503
  release.option("--commit-strategy <mode>", "how the run commits its released changes: \"per-package\" (default; today's behaviour, one commit per release plus one per dependency bump) or \"single\" (one combined commit for the whole run, tagged once per released package)", parseCommitStrategy);
1455
1504
  release.option("--gate-publish", "tag and push each due package via @exadev/release-gate, but defer publishing -- requires --gate-state-file, and cannot be combined with --commit-strategy single");
1456
1505
  release.option("--gate-state-file <path>", "with --gate-publish: where to write the state a later \"resume\" run needs to finish publishing");
1457
- release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy, gatePublish); explicit flags win");
1506
+ release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, gatePublish); explicit flags win");
1458
1507
  release.action(runRelease);
1459
1508
  const resume = program.command("resume");
1460
1509
  resume.description("Finish publishing every package a --gate-publish release tagged and pushed but did not publish.");
@@ -1471,6 +1520,7 @@ const NO_CONFIG_FILE = {
1471
1520
  dryRun: void 0,
1472
1521
  branches: void 0,
1473
1522
  plugins: void 0,
1523
+ packagePlugins: void 0,
1474
1524
  analyzeCommits: void 0,
1475
1525
  generateNotes: void 0,
1476
1526
  commitStrategy: void 0,
@@ -1485,6 +1535,7 @@ async function runRelease(flags) {
1485
1535
  dryRun: flags.dryRun ?? (file.dryRun === true ? true : void 0),
1486
1536
  branches: flags.branches.length > 0 ? flags.branches : file.branches,
1487
1537
  plugins: flags.plugin.length > 0 ? flags.plugin.map((spec) => parsePluginSpec(spec)) : file.plugins,
1538
+ packagePlugins: file.packagePlugins,
1488
1539
  analyzeCommits: flags.analyzeCommits === void 0 ? file.analyzeCommits : parseJsonObjectFlag(flags.analyzeCommits, "--analyze-commits"),
1489
1540
  generateNotes: flags.generateNotes === void 0 ? file.generateNotes : parseJsonObjectFlag(flags.generateNotes, "--generate-notes"),
1490
1541
  commitStrategy: flags.commitStrategy ?? file.commitStrategy,
@@ -1549,10 +1600,11 @@ async function readReleaseConfigFile(path) {
1549
1600
  const parsed = await readConfigFile(path);
1550
1601
  if (!isJsonObject(parsed)) throw new InvalidArgumentError(`--config file ${path} must contain a JSON object`);
1551
1602
  for (const key of Object.keys(parsed)) if (!CONFIG_OPTION_KEYS.has(key)) throw new InvalidArgumentError(`--config file ${path} has an unknown option "${key}"; recognised options: ${[...CONFIG_OPTION_KEYS].join(", ")}`);
1552
- const { dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy, gatePublish } = parsed;
1603
+ const { dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, gatePublish } = parsed;
1553
1604
  if (dryRun !== void 0 && typeof dryRun !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "dryRun" must be a boolean`);
1554
1605
  if (branches !== void 0 && !isStringArray(branches)) throw new InvalidArgumentError(`--config file ${path}: "branches" must be an array of branch name strings`);
1555
1606
  if (plugins !== void 0 && !Array.isArray(plugins)) throw new InvalidArgumentError(`--config file ${path}: "plugins" must be an array`);
1607
+ if (packagePlugins !== void 0 && !isJsonObject(packagePlugins)) throw new InvalidArgumentError(`--config file ${path}: "packagePlugins" must be an object mapping package names to plugin arrays`);
1556
1608
  if (analyzeCommits !== void 0 && !isJsonObject(analyzeCommits)) throw new InvalidArgumentError(`--config file ${path}: "analyzeCommits" must be an object`);
1557
1609
  if (generateNotes !== void 0 && !isJsonObject(generateNotes)) throw new InvalidArgumentError(`--config file ${path}: "generateNotes" must be an object`);
1558
1610
  if (commitStrategy !== void 0 && (typeof commitStrategy !== "string" || !isCommitStrategy(commitStrategy))) throw new InvalidArgumentError(`--config file ${path}: "commitStrategy" must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
@@ -1561,16 +1613,25 @@ async function readReleaseConfigFile(path) {
1561
1613
  dryRun,
1562
1614
  branches,
1563
1615
  plugins: plugins === void 0 ? void 0 : plugins.map((spec) => parseConfigFilePlugin(spec, path)),
1616
+ packagePlugins: packagePlugins === void 0 ? void 0 : parseConfigFilePackagePlugins(packagePlugins, path),
1564
1617
  analyzeCommits,
1565
1618
  generateNotes,
1566
1619
  commitStrategy,
1567
1620
  gatePublish
1568
1621
  };
1569
1622
  }
1570
- function parseConfigFilePlugin(spec, path) {
1623
+ function parseConfigFilePackagePlugins(packagePlugins, path) {
1624
+ const parsed = {};
1625
+ for (const [name, list] of Object.entries(packagePlugins)) {
1626
+ if (!Array.isArray(list)) throw new InvalidArgumentError(`--config file ${path}: the "packagePlugins" entry for "${name}" must be an array`);
1627
+ parsed[name] = list.map((spec) => parseConfigFilePlugin(spec, path, "packagePlugins"));
1628
+ }
1629
+ return parsed;
1630
+ }
1631
+ function parseConfigFilePlugin(spec, path, key = "plugins") {
1571
1632
  if (typeof spec === "string") return spec;
1572
1633
  if (isPluginSpecTuple(spec)) return spec;
1573
- throw new InvalidArgumentError(`--config file ${path}: each "plugins" entry must be a module name or a [name, config] array`);
1634
+ throw new InvalidArgumentError(`--config file ${path}: each "${key}" entry must be a module name or a [name, config] array`);
1574
1635
  }
1575
1636
  function parseJson(raw, flag) {
1576
1637
  try {
package/dist/index.cjs CHANGED
@@ -805,6 +805,26 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
805
805
  return resolved;
806
806
  }
807
807
  /**
808
+ * 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
+ *
810
+ * 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
+ */
812
+ function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, options) {
813
+ const overrides = specs.packagePlugins === void 0 ? [] : Object.entries(specs.packagePlugins);
814
+ const known = new Set(packageNames);
815
+ 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(", ")}.`);
817
+ const workspaceWide = resolvePublishPlugins(specs.plugins, workspaceRoot, options);
818
+ const resolvedOverrides = /* @__PURE__ */ new Map();
819
+ for (const [name, list] of overrides) try {
820
+ resolvedOverrides.set(name, resolvePublishPlugins(list, workspaceRoot, options));
821
+ } catch (cause) {
822
+ if (cause instanceof ReleaseConfigurationError) throw new ReleaseConfigurationError(`packagePlugins for "${name}": ${cause.message}`);
823
+ throw cause;
824
+ }
825
+ return new Map(packageNames.map((name) => [name, resolvedOverrides.get(name) ?? workspaceWide]));
826
+ }
827
+ /**
808
828
  * 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.
809
829
  */
810
830
  function resolvePluginModule(name, requireFromTool, requireFromWorkspace) {
@@ -837,6 +857,21 @@ async function regenerateLockfile(options) {
837
857
  throw new PnpmCommandError(options.cwd, detail);
838
858
  }
839
859
  }
860
+ /**
861
+ * Validate a caller-supplied tagFormat once per run, before any package releases. The template must contain `${version}` (semantic-release interpolates it; without it every package would compute the same tag and overwrite its predecessor) and may contain `${name}`, which the orchestrator substitutes per package. Any other `${...}` token is a typo or a placeholder semantic-release does not support in tagFormat, so it is rejected here rather than released as a literal into a tag name.
862
+ */
863
+ function validateTagFormat(tagFormat) {
864
+ if (!tagFormat.includes("${version}")) throw new ReleaseConfigurationError(`tagFormat must contain '\${version}' so each package's release tag carries its version; got: ${tagFormat}`);
865
+ const unknown = [...tagFormat.matchAll(/\$\{([^}]*)\}/g)].map((match) => match[1] ?? "").filter((token) => token !== "name" && token !== "version").filter((token) => token !== "");
866
+ if (unknown.length > 0) throw new ReleaseConfigurationError(`tagFormat supports only the '\${name}' and '\${version}' placeholders; unknown token(s): ${unknown.map((token) => `\${${token}}`).join(", ")}`);
867
+ return tagFormat;
868
+ }
869
+ /**
870
+ * Substitute `${name}` for one package. Called per package at release time; the returned template still carries the literal `${version}` for semantic-release to interpolate, exactly as the default `name@version` format always has.
871
+ */
872
+ function formatTagForPackage(tagFormat, name) {
873
+ return tagFormat.replaceAll("${name}", name);
874
+ }
840
875
  //#endregion
841
876
  //#region src/single-commit-release.ts
842
877
  /**
@@ -864,12 +899,16 @@ async function releaseWorkspaceSingleCommit(options) {
864
899
  validateDependencyRanges(graph);
865
900
  const order = topologicalOrder(graph);
866
901
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
867
- const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
902
+ const resolvedPlugins = resolveWorkspacePublishPlugins(order, {
903
+ plugins: options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS,
904
+ packagePlugins: options.packagePlugins
905
+ }, workspace.root, {
868
906
  requireGitPlugin: false,
869
907
  forbidGitPlugin: true
870
908
  });
871
909
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
872
910
  const generateNotesConfig = options.generateNotes ?? {};
911
+ const tagFormat = validateTagFormat(options.tagFormat ?? "${name}@${version}");
873
912
  const capturedCommits = /* @__PURE__ */ new Map();
874
913
  const captured = {
875
914
  branch: void 0,
@@ -883,7 +922,8 @@ async function releaseWorkspaceSingleCommit(options) {
883
922
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
884
923
  pendingBumps.delete(name);
885
924
  const nextRelease = await analysePackage(pkg, {
886
- resolvedPlugins,
925
+ tagFormat,
926
+ resolvedPlugins: mustGet(resolvedPlugins, name, "publish plugins"),
887
927
  analyzeCommitsConfig,
888
928
  generateNotesConfig,
889
929
  bumpsForThisPackage,
@@ -944,7 +984,7 @@ async function releaseWorkspaceSingleCommit(options) {
944
984
  log
945
985
  };
946
986
  const moduleCache = /* @__PURE__ */ new Map();
947
- for (const release of planned) for (const [modulePath, pluginConfig] of resolvedPlugins) {
987
+ for (const release of planned) for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
948
988
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
949
989
  if (plugin.verifyConditions) await plugin.verifyConditions(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
950
990
  }
@@ -955,7 +995,7 @@ async function releaseWorkspaceSingleCommit(options) {
955
995
  await writeDependencyRange(release.pkg.manifestPath, bump.field, bump.dependency, bump.range);
956
996
  anyRangeRewritten = true;
957
997
  }
958
- for (const [modulePath, pluginConfig] of resolvedPlugins) {
998
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
959
999
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
960
1000
  if (plugin.prepare) await plugin.prepare(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
961
1001
  }
@@ -975,14 +1015,14 @@ async function releaseWorkspaceSingleCommit(options) {
975
1015
  log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
976
1016
  for (const release of planned) {
977
1017
  const releases = [];
978
- for (const [modulePath, pluginConfig] of resolvedPlugins) {
1018
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
979
1019
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
980
1020
  if (plugin.publish) {
981
1021
  const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
982
1022
  if (result !== false && result !== void 0) releases.push(result);
983
1023
  }
984
1024
  }
985
- for (const [modulePath, pluginConfig] of resolvedPlugins) {
1025
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
986
1026
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
987
1027
  if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
988
1028
  }
@@ -1005,7 +1045,7 @@ async function analysePackage(pkg, options) {
1005
1045
  onCommitsResolved: options.onCommitsResolved
1006
1046
  });
1007
1047
  const semanticReleaseOptions = {
1008
- tagFormat: `${pkg.name}@\${version}`,
1048
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1009
1049
  plugins: options.resolvedPlugins,
1010
1050
  dryRun: true,
1011
1051
  async analyzeCommits(pluginConfig, context) {
@@ -1136,15 +1176,19 @@ async function detachWorkspaceRelease(options) {
1136
1176
  validateDependencyRanges(graph);
1137
1177
  const order = topologicalOrder(graph);
1138
1178
  log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1139
- const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1179
+ const publishPlugins = resolveWorkspacePublishPlugins(order, {
1180
+ plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1181
+ packagePlugins: options.packagePlugins
1182
+ }, workspace.root, { requireGitPlugin: !dryRun });
1140
1183
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1141
1184
  const generateNotesConfig = options.generateNotes ?? {};
1142
1185
  const entries = await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1143
1186
  const state = await runPackageDetach(pkg, {
1144
- publishPlugins,
1187
+ publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1145
1188
  analyzeCommitsConfig,
1146
1189
  generateNotesConfig,
1147
1190
  bumpsForThisPackage,
1191
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1148
1192
  dryRun,
1149
1193
  env,
1150
1194
  branches: options.branches
@@ -1187,7 +1231,7 @@ async function runPackageDetach(pkg, options) {
1187
1231
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1188
1232
  });
1189
1233
  const cliOptions = {
1190
- tagFormat: `${pkg.name}@\${version}`,
1234
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1191
1235
  plugins: options.publishPlugins,
1192
1236
  analyzeCommits: scoped.analyzeCommits,
1193
1237
  generateNotes: scoped.generateNotes
@@ -1274,14 +1318,18 @@ async function releaseWorkspace(options = {}) {
1274
1318
  validateDependencyRanges(graph);
1275
1319
  const order = topologicalOrder(graph);
1276
1320
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1277
- const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1321
+ const publishPlugins = resolveWorkspacePublishPlugins(order, {
1322
+ plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1323
+ packagePlugins: options.packagePlugins
1324
+ }, workspace.root, { requireGitPlugin: !dryRun });
1278
1325
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1279
1326
  const generateNotesConfig = options.generateNotes ?? {};
1280
1327
  return {
1281
1328
  order,
1282
1329
  packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1283
1330
  const result = await runPackageRelease(pkg, {
1284
- publishPlugins,
1331
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1332
+ publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1285
1333
  analyzeCommitsConfig,
1286
1334
  generateNotesConfig,
1287
1335
  bumpsForThisPackage,
@@ -1362,7 +1410,7 @@ async function runPackageRelease(pkg, options) {
1362
1410
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1363
1411
  });
1364
1412
  const semanticReleaseOptions = {
1365
- tagFormat: `${pkg.name}@\${version}`,
1413
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1366
1414
  plugins: options.publishPlugins,
1367
1415
  analyzeCommits: scoped.analyzeCommits,
1368
1416
  generateNotes: scoped.generateNotes
@@ -1441,6 +1489,7 @@ exports.packageName = packageName;
1441
1489
  exports.readManifest = readManifest;
1442
1490
  exports.releaseWorkspace = releaseWorkspace;
1443
1491
  exports.resolvePublishPlugins = resolvePublishPlugins;
1492
+ exports.resolveWorkspacePublishPlugins = resolveWorkspacePublishPlugins;
1444
1493
  exports.resumeWorkspaceRelease = resumeWorkspaceRelease;
1445
1494
  exports.topologicalOrder = topologicalOrder;
1446
1495
  exports.updateDependencyRange = updateDependencyRange;
package/dist/index.d.cts CHANGED
@@ -147,6 +147,8 @@ interface DependencyBumpSource {
147
147
  }
148
148
  /** A publish-pipeline plugin entry as the orchestrator accepts it: a module name, optionally with a config object. */
149
149
  type PublishPluginSpec = string | readonly [string] | readonly [string, Record<string, unknown>];
150
+ /** 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
+ type PackagePluginSpecs = Readonly<Record<string, readonly PublishPluginSpec[]>>;
150
152
  /** 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. */
151
153
  export declare const DEFAULT_PUBLISH_PLUGINS: readonly PublishPluginSpec[];
152
154
  /** 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. */
@@ -188,6 +190,18 @@ export declare function resolvePublishPlugins(specs: readonly PublishPluginSpec[
188
190
  readonly requireGitPlugin: boolean;
189
191
  readonly forbidGitPlugin?: boolean;
190
192
  }): readonly ResolvedPublishPlugin[];
193
+ /**
194
+ * 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
+ *
196
+ * 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
+ */
198
+ export declare function resolveWorkspacePublishPlugins(packageNames: readonly string[], specs: {
199
+ readonly plugins: readonly PublishPluginSpec[];
200
+ readonly packagePlugins: PackagePluginSpecs | undefined;
201
+ }, workspaceRoot: string, options: {
202
+ readonly requireGitPlugin: boolean;
203
+ readonly forbidGitPlugin?: boolean;
204
+ }): ReadonlyMap<string, readonly ResolvedPublishPlugin[]>;
191
205
  //#endregion
192
206
  //#region src/gate-publish.d.ts
193
207
  /**
@@ -232,6 +246,8 @@ interface ReleaseWorkspaceOptions {
232
246
  readonly branches?: readonly BranchSpec[];
233
247
  /** 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'`. */
234
248
  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. */
250
+ readonly packagePlugins?: PackagePluginSpecs;
235
251
  /** Options for the wrapped `@semantic-release/commit-analyzer`, applied per package after path filtering. */
236
252
  readonly analyzeCommits?: Record<string, unknown>;
237
253
  /** Options for the wrapped `@semantic-release/release-notes-generator`, applied per package after path filtering. */
@@ -244,6 +260,14 @@ interface ReleaseWorkspaceOptions {
244
260
  * Opt-in, orthogonal to `commitStrategy` -- it governs *when* publish/success run relative to tag+push, not *how many commits* the release makes. When `true`, each package's release is tagged and pushed via `@exadev/release-gate`'s `detachRelease` but never published: `WorkspaceReleaseOutcome.detached` carries the state `resumeWorkspaceRelease` needs to finish publishing later, from the same process or a different one, once an external gate confirms the release should actually go out. Rejected outright in combination with `commitStrategy: 'single'` -- `single-commit-release.ts`'s tag/publish machinery is entirely bespoke and never goes through semantic-release's own `run()`, so it has no insertion point for `release-gate`'s primitives. Defaults to `false`, today's exact existing behaviour.
245
261
  */
246
262
  readonly gatePublish?: boolean;
263
+ /**
264
+ * Lodash template for each package's release tag, with `${name}` and `${version}` placeholders.
265
+ * Defaults to `'${name}@${version}'`. Override it when the tags double as refs consumed outside
266
+ * git: GitHub Actions pins composite actions as `owner/repo/path@ref`, and GitHub's workflow
267
+ * parser rejects a ref containing `@`, so a repository of actions sets `'${name}-v${version}'`
268
+ * and pins `...@<action>-v1`. Validated once per run by `validateTagFormat`.
269
+ */
270
+ readonly tagFormat?: string;
247
271
  }
248
272
  /** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
249
273
  interface AppliedDependencyBump extends DependencyBump {
@@ -310,4 +334,4 @@ export declare class GitCommandError extends WorkspaceReleaseError {
310
334
  /** 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. */
311
335
  export declare class WorkspaceStateError extends WorkspaceReleaseError {}
312
336
  //#endregion
313
- export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackageReleaseOutcome, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
337
+ export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackagePluginSpecs, PackageReleaseOutcome, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
package/dist/index.d.ts CHANGED
@@ -147,6 +147,8 @@ interface DependencyBumpSource {
147
147
  }
148
148
  /** A publish-pipeline plugin entry as the orchestrator accepts it: a module name, optionally with a config object. */
149
149
  type PublishPluginSpec = string | readonly [string] | readonly [string, Record<string, unknown>];
150
+ /** 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
+ type PackagePluginSpecs = Readonly<Record<string, readonly PublishPluginSpec[]>>;
150
152
  /** 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. */
151
153
  export declare const DEFAULT_PUBLISH_PLUGINS: readonly PublishPluginSpec[];
152
154
  /** 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. */
@@ -188,6 +190,18 @@ export declare function resolvePublishPlugins(specs: readonly PublishPluginSpec[
188
190
  readonly requireGitPlugin: boolean;
189
191
  readonly forbidGitPlugin?: boolean;
190
192
  }): readonly ResolvedPublishPlugin[];
193
+ /**
194
+ * 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
+ *
196
+ * 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
+ */
198
+ export declare function resolveWorkspacePublishPlugins(packageNames: readonly string[], specs: {
199
+ readonly plugins: readonly PublishPluginSpec[];
200
+ readonly packagePlugins: PackagePluginSpecs | undefined;
201
+ }, workspaceRoot: string, options: {
202
+ readonly requireGitPlugin: boolean;
203
+ readonly forbidGitPlugin?: boolean;
204
+ }): ReadonlyMap<string, readonly ResolvedPublishPlugin[]>;
191
205
  //#endregion
192
206
  //#region src/gate-publish.d.ts
193
207
  /**
@@ -232,6 +246,8 @@ interface ReleaseWorkspaceOptions {
232
246
  readonly branches?: readonly BranchSpec[];
233
247
  /** 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'`. */
234
248
  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. */
250
+ readonly packagePlugins?: PackagePluginSpecs;
235
251
  /** Options for the wrapped `@semantic-release/commit-analyzer`, applied per package after path filtering. */
236
252
  readonly analyzeCommits?: Record<string, unknown>;
237
253
  /** Options for the wrapped `@semantic-release/release-notes-generator`, applied per package after path filtering. */
@@ -244,6 +260,14 @@ interface ReleaseWorkspaceOptions {
244
260
  * Opt-in, orthogonal to `commitStrategy` -- it governs *when* publish/success run relative to tag+push, not *how many commits* the release makes. When `true`, each package's release is tagged and pushed via `@exadev/release-gate`'s `detachRelease` but never published: `WorkspaceReleaseOutcome.detached` carries the state `resumeWorkspaceRelease` needs to finish publishing later, from the same process or a different one, once an external gate confirms the release should actually go out. Rejected outright in combination with `commitStrategy: 'single'` -- `single-commit-release.ts`'s tag/publish machinery is entirely bespoke and never goes through semantic-release's own `run()`, so it has no insertion point for `release-gate`'s primitives. Defaults to `false`, today's exact existing behaviour.
245
261
  */
246
262
  readonly gatePublish?: boolean;
263
+ /**
264
+ * Lodash template for each package's release tag, with `${name}` and `${version}` placeholders.
265
+ * Defaults to `'${name}@${version}'`. Override it when the tags double as refs consumed outside
266
+ * git: GitHub Actions pins composite actions as `owner/repo/path@ref`, and GitHub's workflow
267
+ * parser rejects a ref containing `@`, so a repository of actions sets `'${name}-v${version}'`
268
+ * and pins `...@<action>-v1`. Validated once per run by `validateTagFormat`.
269
+ */
270
+ readonly tagFormat?: string;
247
271
  }
248
272
  /** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
249
273
  interface AppliedDependencyBump extends DependencyBump {
@@ -310,4 +334,4 @@ export declare class GitCommandError extends WorkspaceReleaseError {
310
334
  /** 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. */
311
335
  export declare class WorkspaceStateError extends WorkspaceReleaseError {}
312
336
  //#endregion
313
- export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackageReleaseOutcome, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
337
+ export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackagePluginSpecs, PackageReleaseOutcome, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
package/dist/index.js CHANGED
@@ -780,6 +780,26 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
780
780
  return resolved;
781
781
  }
782
782
  /**
783
+ * 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
+ *
785
+ * 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
+ */
787
+ function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, options) {
788
+ const overrides = specs.packagePlugins === void 0 ? [] : Object.entries(specs.packagePlugins);
789
+ const known = new Set(packageNames);
790
+ 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(", ")}.`);
792
+ const workspaceWide = resolvePublishPlugins(specs.plugins, workspaceRoot, options);
793
+ const resolvedOverrides = /* @__PURE__ */ new Map();
794
+ for (const [name, list] of overrides) try {
795
+ resolvedOverrides.set(name, resolvePublishPlugins(list, workspaceRoot, options));
796
+ } catch (cause) {
797
+ if (cause instanceof ReleaseConfigurationError) throw new ReleaseConfigurationError(`packagePlugins for "${name}": ${cause.message}`);
798
+ throw cause;
799
+ }
800
+ return new Map(packageNames.map((name) => [name, resolvedOverrides.get(name) ?? workspaceWide]));
801
+ }
802
+ /**
783
803
  * 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.
784
804
  */
785
805
  function resolvePluginModule(name, requireFromTool, requireFromWorkspace) {
@@ -812,6 +832,21 @@ async function regenerateLockfile(options) {
812
832
  throw new PnpmCommandError(options.cwd, detail);
813
833
  }
814
834
  }
835
+ /**
836
+ * Validate a caller-supplied tagFormat once per run, before any package releases. The template must contain `${version}` (semantic-release interpolates it; without it every package would compute the same tag and overwrite its predecessor) and may contain `${name}`, which the orchestrator substitutes per package. Any other `${...}` token is a typo or a placeholder semantic-release does not support in tagFormat, so it is rejected here rather than released as a literal into a tag name.
837
+ */
838
+ function validateTagFormat(tagFormat) {
839
+ if (!tagFormat.includes("${version}")) throw new ReleaseConfigurationError(`tagFormat must contain '\${version}' so each package's release tag carries its version; got: ${tagFormat}`);
840
+ const unknown = [...tagFormat.matchAll(/\$\{([^}]*)\}/g)].map((match) => match[1] ?? "").filter((token) => token !== "name" && token !== "version").filter((token) => token !== "");
841
+ if (unknown.length > 0) throw new ReleaseConfigurationError(`tagFormat supports only the '\${name}' and '\${version}' placeholders; unknown token(s): ${unknown.map((token) => `\${${token}}`).join(", ")}`);
842
+ return tagFormat;
843
+ }
844
+ /**
845
+ * Substitute `${name}` for one package. Called per package at release time; the returned template still carries the literal `${version}` for semantic-release to interpolate, exactly as the default `name@version` format always has.
846
+ */
847
+ function formatTagForPackage(tagFormat, name) {
848
+ return tagFormat.replaceAll("${name}", name);
849
+ }
815
850
  //#endregion
816
851
  //#region src/single-commit-release.ts
817
852
  /**
@@ -839,12 +874,16 @@ async function releaseWorkspaceSingleCommit(options) {
839
874
  validateDependencyRanges(graph);
840
875
  const order = topologicalOrder(graph);
841
876
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
842
- const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
877
+ const resolvedPlugins = resolveWorkspacePublishPlugins(order, {
878
+ plugins: options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS,
879
+ packagePlugins: options.packagePlugins
880
+ }, workspace.root, {
843
881
  requireGitPlugin: false,
844
882
  forbidGitPlugin: true
845
883
  });
846
884
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
847
885
  const generateNotesConfig = options.generateNotes ?? {};
886
+ const tagFormat = validateTagFormat(options.tagFormat ?? "${name}@${version}");
848
887
  const capturedCommits = /* @__PURE__ */ new Map();
849
888
  const captured = {
850
889
  branch: void 0,
@@ -858,7 +897,8 @@ async function releaseWorkspaceSingleCommit(options) {
858
897
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
859
898
  pendingBumps.delete(name);
860
899
  const nextRelease = await analysePackage(pkg, {
861
- resolvedPlugins,
900
+ tagFormat,
901
+ resolvedPlugins: mustGet(resolvedPlugins, name, "publish plugins"),
862
902
  analyzeCommitsConfig,
863
903
  generateNotesConfig,
864
904
  bumpsForThisPackage,
@@ -919,7 +959,7 @@ async function releaseWorkspaceSingleCommit(options) {
919
959
  log
920
960
  };
921
961
  const moduleCache = /* @__PURE__ */ new Map();
922
- for (const release of planned) for (const [modulePath, pluginConfig] of resolvedPlugins) {
962
+ for (const release of planned) for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
923
963
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
924
964
  if (plugin.verifyConditions) await plugin.verifyConditions(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
925
965
  }
@@ -930,7 +970,7 @@ async function releaseWorkspaceSingleCommit(options) {
930
970
  await writeDependencyRange(release.pkg.manifestPath, bump.field, bump.dependency, bump.range);
931
971
  anyRangeRewritten = true;
932
972
  }
933
- for (const [modulePath, pluginConfig] of resolvedPlugins) {
973
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
934
974
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
935
975
  if (plugin.prepare) await plugin.prepare(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
936
976
  }
@@ -950,14 +990,14 @@ async function releaseWorkspaceSingleCommit(options) {
950
990
  log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
951
991
  for (const release of planned) {
952
992
  const releases = [];
953
- for (const [modulePath, pluginConfig] of resolvedPlugins) {
993
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
954
994
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
955
995
  if (plugin.publish) {
956
996
  const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
957
997
  if (result !== false && result !== void 0) releases.push(result);
958
998
  }
959
999
  }
960
- for (const [modulePath, pluginConfig] of resolvedPlugins) {
1000
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
961
1001
  const plugin = await loadReleasePlugin(modulePath, moduleCache);
962
1002
  if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
963
1003
  }
@@ -980,7 +1020,7 @@ async function analysePackage(pkg, options) {
980
1020
  onCommitsResolved: options.onCommitsResolved
981
1021
  });
982
1022
  const semanticReleaseOptions = {
983
- tagFormat: `${pkg.name}@\${version}`,
1023
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
984
1024
  plugins: options.resolvedPlugins,
985
1025
  dryRun: true,
986
1026
  async analyzeCommits(pluginConfig, context) {
@@ -1111,15 +1151,19 @@ async function detachWorkspaceRelease(options) {
1111
1151
  validateDependencyRanges(graph);
1112
1152
  const order = topologicalOrder(graph);
1113
1153
  log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1114
- const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1154
+ const publishPlugins = resolveWorkspacePublishPlugins(order, {
1155
+ plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1156
+ packagePlugins: options.packagePlugins
1157
+ }, workspace.root, { requireGitPlugin: !dryRun });
1115
1158
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1116
1159
  const generateNotesConfig = options.generateNotes ?? {};
1117
1160
  const entries = await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1118
1161
  const state = await runPackageDetach(pkg, {
1119
- publishPlugins,
1162
+ publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1120
1163
  analyzeCommitsConfig,
1121
1164
  generateNotesConfig,
1122
1165
  bumpsForThisPackage,
1166
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1123
1167
  dryRun,
1124
1168
  env,
1125
1169
  branches: options.branches
@@ -1162,7 +1206,7 @@ async function runPackageDetach(pkg, options) {
1162
1206
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1163
1207
  });
1164
1208
  const cliOptions = {
1165
- tagFormat: `${pkg.name}@\${version}`,
1209
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1166
1210
  plugins: options.publishPlugins,
1167
1211
  analyzeCommits: scoped.analyzeCommits,
1168
1212
  generateNotes: scoped.generateNotes
@@ -1249,14 +1293,18 @@ async function releaseWorkspace(options = {}) {
1249
1293
  validateDependencyRanges(graph);
1250
1294
  const order = topologicalOrder(graph);
1251
1295
  log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
1252
- const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1296
+ const publishPlugins = resolveWorkspacePublishPlugins(order, {
1297
+ plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1298
+ packagePlugins: options.packagePlugins
1299
+ }, workspace.root, { requireGitPlugin: !dryRun });
1253
1300
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1254
1301
  const generateNotesConfig = options.generateNotes ?? {};
1255
1302
  return {
1256
1303
  order,
1257
1304
  packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1258
1305
  const result = await runPackageRelease(pkg, {
1259
- publishPlugins,
1306
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1307
+ publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1260
1308
  analyzeCommitsConfig,
1261
1309
  generateNotesConfig,
1262
1310
  bumpsForThisPackage,
@@ -1337,7 +1385,7 @@ async function runPackageRelease(pkg, options) {
1337
1385
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1338
1386
  });
1339
1387
  const semanticReleaseOptions = {
1340
- tagFormat: `${pkg.name}@\${version}`,
1388
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1341
1389
  plugins: options.publishPlugins,
1342
1390
  analyzeCommits: scoped.analyzeCommits,
1343
1391
  generateNotes: scoped.generateNotes
@@ -1398,4 +1446,4 @@ async function bumpDependents(released, version, graph, options) {
1398
1446
  return applied;
1399
1447
  }
1400
1448
  //#endregion
1401
- 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, resumeWorkspaceRelease, topologicalOrder, updateDependencyRange, writeDependencyRange };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exadev/semantic-release-workspace",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.",
5
5
  "type": "module",
6
6
  "repository": {