@exadev/semantic-release-workspace 2.1.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
 
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.1.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
  /**
@@ -880,6 +895,7 @@ async function releaseWorkspaceSingleCommit(options) {
880
895
  });
881
896
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
882
897
  const generateNotesConfig = options.generateNotes ?? {};
898
+ const tagFormat = validateTagFormat(options.tagFormat ?? "${name}@${version}");
883
899
  const capturedCommits = /* @__PURE__ */ new Map();
884
900
  const captured = {
885
901
  branch: void 0,
@@ -893,6 +909,7 @@ async function releaseWorkspaceSingleCommit(options) {
893
909
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
894
910
  pendingBumps.delete(name);
895
911
  const nextRelease = await analysePackage(pkg, {
912
+ tagFormat,
896
913
  resolvedPlugins: mustGet(resolvedPlugins, name, "publish plugins"),
897
914
  analyzeCommitsConfig,
898
915
  generateNotesConfig,
@@ -1015,7 +1032,7 @@ async function analysePackage(pkg, options) {
1015
1032
  onCommitsResolved: options.onCommitsResolved
1016
1033
  });
1017
1034
  const semanticReleaseOptions = {
1018
- tagFormat: `${pkg.name}@\${version}`,
1035
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1019
1036
  plugins: options.resolvedPlugins,
1020
1037
  dryRun: true,
1021
1038
  async analyzeCommits(pluginConfig, context) {
@@ -1163,6 +1180,7 @@ async function releaseWorkspace(options = {}) {
1163
1180
  order,
1164
1181
  packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1165
1182
  const result = await runPackageRelease(pkg, {
1183
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1166
1184
  publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1167
1185
  analyzeCommitsConfig,
1168
1186
  generateNotesConfig,
@@ -1244,7 +1262,7 @@ async function runPackageRelease(pkg, options) {
1244
1262
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1245
1263
  });
1246
1264
  const semanticReleaseOptions = {
1247
- tagFormat: `${pkg.name}@\${version}`,
1265
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1248
1266
  plugins: options.publishPlugins,
1249
1267
  analyzeCommits: scoped.analyzeCommits,
1250
1268
  generateNotes: scoped.generateNotes
@@ -1331,6 +1349,7 @@ async function detachWorkspaceRelease(options) {
1331
1349
  analyzeCommitsConfig,
1332
1350
  generateNotesConfig,
1333
1351
  bumpsForThisPackage,
1352
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1334
1353
  dryRun,
1335
1354
  env,
1336
1355
  branches: options.branches
@@ -1373,7 +1392,7 @@ async function runPackageDetach(pkg, options) {
1373
1392
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1374
1393
  });
1375
1394
  const cliOptions = {
1376
- tagFormat: `${pkg.name}@\${version}`,
1395
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1377
1396
  plugins: options.publishPlugins,
1378
1397
  analyzeCommits: scoped.analyzeCommits,
1379
1398
  generateNotes: scoped.generateNotes
package/dist/index.cjs CHANGED
@@ -857,6 +857,21 @@ async function regenerateLockfile(options) {
857
857
  throw new PnpmCommandError(options.cwd, detail);
858
858
  }
859
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
+ }
860
875
  //#endregion
861
876
  //#region src/single-commit-release.ts
862
877
  /**
@@ -893,6 +908,7 @@ async function releaseWorkspaceSingleCommit(options) {
893
908
  });
894
909
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
895
910
  const generateNotesConfig = options.generateNotes ?? {};
911
+ const tagFormat = validateTagFormat(options.tagFormat ?? "${name}@${version}");
896
912
  const capturedCommits = /* @__PURE__ */ new Map();
897
913
  const captured = {
898
914
  branch: void 0,
@@ -906,6 +922,7 @@ async function releaseWorkspaceSingleCommit(options) {
906
922
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
907
923
  pendingBumps.delete(name);
908
924
  const nextRelease = await analysePackage(pkg, {
925
+ tagFormat,
909
926
  resolvedPlugins: mustGet(resolvedPlugins, name, "publish plugins"),
910
927
  analyzeCommitsConfig,
911
928
  generateNotesConfig,
@@ -1028,7 +1045,7 @@ async function analysePackage(pkg, options) {
1028
1045
  onCommitsResolved: options.onCommitsResolved
1029
1046
  });
1030
1047
  const semanticReleaseOptions = {
1031
- tagFormat: `${pkg.name}@\${version}`,
1048
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1032
1049
  plugins: options.resolvedPlugins,
1033
1050
  dryRun: true,
1034
1051
  async analyzeCommits(pluginConfig, context) {
@@ -1171,6 +1188,7 @@ async function detachWorkspaceRelease(options) {
1171
1188
  analyzeCommitsConfig,
1172
1189
  generateNotesConfig,
1173
1190
  bumpsForThisPackage,
1191
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1174
1192
  dryRun,
1175
1193
  env,
1176
1194
  branches: options.branches
@@ -1213,7 +1231,7 @@ async function runPackageDetach(pkg, options) {
1213
1231
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1214
1232
  });
1215
1233
  const cliOptions = {
1216
- tagFormat: `${pkg.name}@\${version}`,
1234
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1217
1235
  plugins: options.publishPlugins,
1218
1236
  analyzeCommits: scoped.analyzeCommits,
1219
1237
  generateNotes: scoped.generateNotes
@@ -1310,6 +1328,7 @@ async function releaseWorkspace(options = {}) {
1310
1328
  order,
1311
1329
  packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1312
1330
  const result = await runPackageRelease(pkg, {
1331
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1313
1332
  publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1314
1333
  analyzeCommitsConfig,
1315
1334
  generateNotesConfig,
@@ -1391,7 +1410,7 @@ async function runPackageRelease(pkg, options) {
1391
1410
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1392
1411
  });
1393
1412
  const semanticReleaseOptions = {
1394
- tagFormat: `${pkg.name}@\${version}`,
1413
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1395
1414
  plugins: options.publishPlugins,
1396
1415
  analyzeCommits: scoped.analyzeCommits,
1397
1416
  generateNotes: scoped.generateNotes
package/dist/index.d.cts CHANGED
@@ -260,6 +260,14 @@ interface ReleaseWorkspaceOptions {
260
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.
261
261
  */
262
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;
263
271
  }
264
272
  /** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
265
273
  interface AppliedDependencyBump extends DependencyBump {
package/dist/index.d.ts CHANGED
@@ -260,6 +260,14 @@ interface ReleaseWorkspaceOptions {
260
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.
261
261
  */
262
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;
263
271
  }
264
272
  /** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
265
273
  interface AppliedDependencyBump extends DependencyBump {
package/dist/index.js CHANGED
@@ -832,6 +832,21 @@ async function regenerateLockfile(options) {
832
832
  throw new PnpmCommandError(options.cwd, detail);
833
833
  }
834
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
+ }
835
850
  //#endregion
836
851
  //#region src/single-commit-release.ts
837
852
  /**
@@ -868,6 +883,7 @@ async function releaseWorkspaceSingleCommit(options) {
868
883
  });
869
884
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
870
885
  const generateNotesConfig = options.generateNotes ?? {};
886
+ const tagFormat = validateTagFormat(options.tagFormat ?? "${name}@${version}");
871
887
  const capturedCommits = /* @__PURE__ */ new Map();
872
888
  const captured = {
873
889
  branch: void 0,
@@ -881,6 +897,7 @@ async function releaseWorkspaceSingleCommit(options) {
881
897
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
882
898
  pendingBumps.delete(name);
883
899
  const nextRelease = await analysePackage(pkg, {
900
+ tagFormat,
884
901
  resolvedPlugins: mustGet(resolvedPlugins, name, "publish plugins"),
885
902
  analyzeCommitsConfig,
886
903
  generateNotesConfig,
@@ -1003,7 +1020,7 @@ async function analysePackage(pkg, options) {
1003
1020
  onCommitsResolved: options.onCommitsResolved
1004
1021
  });
1005
1022
  const semanticReleaseOptions = {
1006
- tagFormat: `${pkg.name}@\${version}`,
1023
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1007
1024
  plugins: options.resolvedPlugins,
1008
1025
  dryRun: true,
1009
1026
  async analyzeCommits(pluginConfig, context) {
@@ -1146,6 +1163,7 @@ async function detachWorkspaceRelease(options) {
1146
1163
  analyzeCommitsConfig,
1147
1164
  generateNotesConfig,
1148
1165
  bumpsForThisPackage,
1166
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1149
1167
  dryRun,
1150
1168
  env,
1151
1169
  branches: options.branches
@@ -1188,7 +1206,7 @@ async function runPackageDetach(pkg, options) {
1188
1206
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1189
1207
  });
1190
1208
  const cliOptions = {
1191
- tagFormat: `${pkg.name}@\${version}`,
1209
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1192
1210
  plugins: options.publishPlugins,
1193
1211
  analyzeCommits: scoped.analyzeCommits,
1194
1212
  generateNotes: scoped.generateNotes
@@ -1285,6 +1303,7 @@ async function releaseWorkspace(options = {}) {
1285
1303
  order,
1286
1304
  packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1287
1305
  const result = await runPackageRelease(pkg, {
1306
+ tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
1288
1307
  publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
1289
1308
  analyzeCommitsConfig,
1290
1309
  generateNotesConfig,
@@ -1366,7 +1385,7 @@ async function runPackageRelease(pkg, options) {
1366
1385
  bumps: { bumpsFor: () => options.bumpsForThisPackage }
1367
1386
  });
1368
1387
  const semanticReleaseOptions = {
1369
- tagFormat: `${pkg.name}@\${version}`,
1388
+ tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
1370
1389
  plugins: options.publishPlugins,
1371
1390
  analyzeCommits: scoped.analyzeCommits,
1372
1391
  generateNotes: scoped.generateNotes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exadev/semantic-release-workspace",
3
- "version": "2.1.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": {