@exadev/semantic-release-workspace 1.1.7 → 1.2.1

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/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import validateNpmPackageName from "validate-npm-package-name";
9
9
  import { analyzeCommits } from "@semantic-release/commit-analyzer";
10
10
  import { generateNotes } from "@semantic-release/release-notes-generator";
11
11
  import semanticRelease from "semantic-release";
12
+ import { pathToFileURL } from "node:url";
12
13
  //#region src/package-name.ts
13
14
  const packageName = "@exadev/semantic-release-workspace";
14
15
  //#endregion
@@ -67,11 +68,36 @@ const BOT_IDENTITY = {
67
68
  name: "semantic-release-bot",
68
69
  email: "semantic-release-bot@martynus.net"
69
70
  };
71
+ /**
72
+ * The environment variables git itself sets when it invokes a hook (`pre-push`, `pre-commit`, and friends), which then leak into every child process a hook spawns unless a child explicitly clears them: `GIT_DIR` (and, in a worktree checkout, its value points at that worktree's own git-dir under the main repository's `.git/worktrees/<name>`), `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_PREFIX`, `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_COMMON_DIR`, and `GIT_NAMESPACE`. Every one of these overrides git's normal cwd-based repository discovery, so if any of them survive into a `git()` call this package makes with an explicit `cwd` (most obviously `git init` on a throwaway temporary directory in the test suite's own fixtures), the command silently targets whatever repository the *outer* invocation was hooked from instead of the directory this package actually asked for -- observed directly as `git init` in a fixture's temp directory failing to lock the real repository's own `.git/config`, because `GIT_DIR` inherited from this package's own `pre-push` hook pointed straight back at it. `GIT_EXEC_PATH`, `GIT_EDITOR`, and networking/auth-related `GIT_*` variables (SSH, proxy, credential helpers) are deliberately left alone: they do not affect which repository a command targets, only how it behaves once it has found one.
73
+ */
74
+ const GIT_REPOSITORY_DISCOVERY_ENV_KEYS = [
75
+ "GIT_DIR",
76
+ "GIT_WORK_TREE",
77
+ "GIT_INDEX_FILE",
78
+ "GIT_PREFIX",
79
+ "GIT_OBJECT_DIRECTORY",
80
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
81
+ "GIT_COMMON_DIR",
82
+ "GIT_NAMESPACE"
83
+ ];
84
+ /**
85
+ * A copy of the given environment with every repository-discovery-affecting `GIT_*` key removed -- see `GIT_REPOSITORY_DISCOVERY_ENV_KEYS`.
86
+ *
87
+ * Exported (not just used internally by `git()`) because this package hands an environment to two different kinds of subprocess: its own `git()` calls (sanitized unconditionally below, from `process.env`, since those never take a caller-supplied environment) and semantic-release's own programmatic API, which spawns its *own* internal git subprocesses (tag, push, verifyAuth) using whatever `env` this package passes it -- release.ts and single-commit-release.ts both call this on the environment they construct for that, so a `GIT_DIR` inherited from an outer hook cannot leak into semantic-release's internal git calls either, not just into this package's own.
88
+ */
89
+ function sanitizeGitEnv(env) {
90
+ const sanitized = { ...env };
91
+ for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) delete sanitized[key];
92
+ return sanitized;
93
+ }
94
+ const SANITIZED_PROCESS_GIT_ENV = sanitizeGitEnv(process.env);
70
95
  async function git(args, options) {
71
96
  try {
72
97
  const { stdout } = await execFileAsync$1("git", [...args], {
73
98
  cwd: options.cwd,
74
- maxBuffer: GIT_MAX_BUFFER_BYTES
99
+ maxBuffer: GIT_MAX_BUFFER_BYTES,
100
+ env: SANITIZED_PROCESS_GIT_ENV
75
101
  });
76
102
  return stdout;
77
103
  } catch (cause) {
@@ -164,6 +190,50 @@ async function pushHead(options) {
164
190
  `HEAD:${await currentBranch(options)}`
165
191
  ], options);
166
192
  }
193
+ /** Creates a lightweight tag (`git tag <name> <ref>`, no annotation) at the given commit -- the same form semantic-release's own core creates its release tags with (see its `lib/git.js`), so a tag this tool creates directly is indistinguishable from one semantic-release would have made itself. */
194
+ async function createTag(name, ref, options) {
195
+ await git([
196
+ "tag",
197
+ name,
198
+ ref
199
+ ], options);
200
+ }
201
+ /**
202
+ * Lists every path with a working-tree or index change (modified, added, deleted, untracked), repository-root-relative, via `git status --porcelain=v1 -z`. `commitStrategy: 'single'` uses this rather than predicting which files each configured prepare plugin touched (a version bump, a changelog write, a dependency-range rewrite, a regenerated lockfile) by name: asking git what actually changed is correct regardless of which prepare plugins are configured or how they name their own output files.
203
+ *
204
+ * `-z` NUL-terminates every field so a path containing a space or newline cannot be misread as two paths; a rename or copy (status codes `R`/`C`) carries two NUL-terminated fields (the new path first, then the origin path), so it consumes two tokens instead of one.
205
+ */
206
+ async function workingTreeChanges(options) {
207
+ const tokens = (await git([
208
+ "status",
209
+ "--porcelain=v1",
210
+ "--untracked-files=all",
211
+ "-z"
212
+ ], options)).split("\0").filter((token) => token !== "");
213
+ const paths = [];
214
+ for (let index = 0; index < tokens.length; index += 1) {
215
+ const entry = tokens[index];
216
+ if (entry === void 0 || entry.length < 4) continue;
217
+ const statusCode = entry.slice(0, 2);
218
+ paths.push(entry.slice(3));
219
+ if (statusCode.includes("R") || statusCode.includes("C")) index += 1;
220
+ }
221
+ return paths;
222
+ }
223
+ /** Fails loudly if the working tree is not clean, rather than silently folding pre-existing, unrelated dirty state into a release commit. `commitStrategy: 'single'` calls this before it starts, since it relies on `workingTreeChanges` to discover exactly what its own run touches. */
224
+ async function assertCleanWorkingTree(options) {
225
+ const changes = await workingTreeChanges(options);
226
+ if (changes.length > 0) throw new WorkspaceStateError(`The working tree in ${options.cwd} is not clean: ${changes.join(", ")}. commitStrategy "single" discovers what it touched via "git status", so it requires a clean tree to start from; commit, stash elsewhere, or discard these changes first.`);
227
+ }
228
+ /** Pushes the current branch's head and a set of tags to origin in one push, so a combined release commit and every tag pointing at it land on the remote as a single atomic-looking update rather than as separate pushes an interrupted run could split across. */
229
+ async function pushHeadAndTags(tagNames, options) {
230
+ await git([
231
+ "push",
232
+ "origin",
233
+ `HEAD:${await currentBranch(options)}`,
234
+ ...tagNames
235
+ ], options);
236
+ }
167
237
  function toGitCommandError(args, cwd, cause) {
168
238
  const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
169
239
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
@@ -319,6 +389,70 @@ function toPosix(path) {
319
389
  return sep === "/" ? path : path.split(sep).join("/");
320
390
  }
321
391
  //#endregion
392
+ //#region src/version-range.ts
393
+ const WORKSPACE_PROTOCOL = "workspace:";
394
+ const CATALOG_PROTOCOL = "catalog:";
395
+ const NPM_ALIAS_PROTOCOL = "npm:";
396
+ /** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
397
+ const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
398
+ "*",
399
+ "^",
400
+ "~"
401
+ ];
402
+ /** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
403
+ const WILDCARD_RANGES = [
404
+ "",
405
+ "*",
406
+ "x",
407
+ "X",
408
+ "latest"
409
+ ];
410
+ /**
411
+ * A single comparator whose version can be replaced in place without changing the comparator's intent. `<` and `<=` are deliberately absent: rewriting `<2.0.0` to `<1.4.0` narrows an upper bound to the very version being released, which is never what the author meant, so such a range is rejected rather than mangled.
412
+ */
413
+ const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
414
+ /**
415
+ * Classifies a dependency range's shape, throwing `UnsupportedDependencyRangeError` for anything this tool cannot rewrite with confidence: a compound range (`>=1.0.0 <2.0.0`), a union (`1.x || 2.x`), a `catalog:` reference whose real version lives in `pnpm-workspace.yaml`, an `npm:` alias, a git or tarball URL. Guessing at those would either corrupt the range or silently leave it pointing at a version that no longer exists in the workspace, and a stale published range is exactly the divergence this tool exists to prevent.
416
+ *
417
+ * This never needs the version a sibling is releasing: every case above depends only on the shape of `current` itself, which is what lets `releaseWorkspace` validate every workspace dependency edge up front, before any package has published anything, rather than discovering an unsupported range only when the first dependency it names happens to release.
418
+ */
419
+ function classifyDependencyRange(current) {
420
+ const range = current.trim();
421
+ if (range.startsWith(WORKSPACE_PROTOCOL)) {
422
+ const suffix = range.slice(10);
423
+ if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
424
+ const inner = classifyDependencyRange(suffix);
425
+ if (inner.kind !== "rewritable") throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only "workspace:*", "workspace:^", "workspace:~", and "workspace:" followed by a single concrete version range are supported.`);
426
+ return {
427
+ kind: "rewritable",
428
+ workspacePrefixed: true,
429
+ comparator: inner.comparator
430
+ };
431
+ }
432
+ if (range.startsWith(CATALOG_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": the version of a "catalog:" dependency lives in pnpm-workspace.yaml, not in the package manifest, so bumping it here would leave the catalog entry stale. Depend on the sibling directly (for example "workspace:^") instead.`);
433
+ if (range.startsWith(NPM_ALIAS_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": an "npm:" alias points at a differently-named package, so the version released in this workspace is not necessarily the version this range refers to.`);
434
+ if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
435
+ const match = REWRITABLE_COMPARATOR.exec(range);
436
+ if (match === null) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only a single "^", "~", ">=", "=", or bare version comparator can be rewritten in place.`);
437
+ return {
438
+ kind: "rewritable",
439
+ workspacePrefixed: false,
440
+ comparator: match[1] ?? ""
441
+ };
442
+ }
443
+ /**
444
+ * Computes what a dependency range on a workspace sibling becomes once that sibling releases `version`, by classifying the range's shape and then, for a rewritable shape, substituting `version` in place of the version it currently names.
445
+ */
446
+ function updateDependencyRange(current, version) {
447
+ const shape = classifyDependencyRange(current);
448
+ if (shape.kind !== "rewritable") return shape;
449
+ const rewritten = `${shape.comparator}${version}`;
450
+ return {
451
+ kind: "rewritten",
452
+ range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
453
+ };
454
+ }
455
+ //#endregion
322
456
  //#region src/graph.ts
323
457
  /**
324
458
  * Builds the inter-package dependency graph from the manifests alone.
@@ -393,69 +527,21 @@ function firstUnplacedDependency(name, graph, unplaced) {
393
527
  if (name === void 0) return [...unplaced].sort()[0];
394
528
  return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
395
529
  }
396
- //#endregion
397
- //#region src/version-range.ts
398
- const WORKSPACE_PROTOCOL = "workspace:";
399
- const CATALOG_PROTOCOL = "catalog:";
400
- const NPM_ALIAS_PROTOCOL = "npm:";
401
- /** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
402
- const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
403
- "*",
404
- "^",
405
- "~"
406
- ];
407
- /** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
408
- const WILDCARD_RANGES = [
409
- "",
410
- "*",
411
- "x",
412
- "X",
413
- "latest"
414
- ];
415
530
  /**
416
- * A single comparator whose version can be replaced in place without changing the comparator's intent. `<` and `<=` are deliberately absent: rewriting `<2.0.0` to `<1.4.0` narrows an upper bound to the very version being released, which is never what the author meant, so such a range is rejected rather than mangled.
417
- */
418
- const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
419
- /**
420
- * Classifies a dependency range's shape, throwing `UnsupportedDependencyRangeError` for anything this tool cannot rewrite with confidence: a compound range (`>=1.0.0 <2.0.0`), a union (`1.x || 2.x`), a `catalog:` reference whose real version lives in `pnpm-workspace.yaml`, an `npm:` alias, a git or tarball URL. Guessing at those would either corrupt the range or silently leave it pointing at a version that no longer exists in the workspace, and a stale published range is exactly the divergence this tool exists to prevent.
531
+ * Checks every workspace dependency edge's range shape before anything releases, so an `UnsupportedDependencyRangeError` stops a run before the first publish rather than after some sibling has already been published, tagged, committed, and pushed. The shape a range supports depends only on the range text itself (see `classifyDependencyRange`), never on which version a sibling ends up releasing, so this can run once up front for the whole graph instead of only being discovered edge by edge as each dependency happens to release.
421
532
  *
422
- * This never needs the version a sibling is releasing: every case above depends only on the shape of `current` itself, which is what lets `releaseWorkspace` validate every workspace dependency edge up front, before any package has published anything, rather than discovering an unsupported range only when the first dependency it names happens to release.
533
+ * Shared by both commit strategies (`release.ts`'s per-package loop and `single-commit-release.ts`'s analysis phase), which is why it lives alongside the graph it validates rather than inside either strategy's own module.
423
534
  */
424
- function classifyDependencyRange(current) {
425
- const range = current.trim();
426
- if (range.startsWith(WORKSPACE_PROTOCOL)) {
427
- const suffix = range.slice(10);
428
- if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
429
- const inner = classifyDependencyRange(suffix);
430
- if (inner.kind !== "rewritable") throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only "workspace:*", "workspace:^", "workspace:~", and "workspace:" followed by a single concrete version range are supported.`);
431
- return {
432
- kind: "rewritable",
433
- workspacePrefixed: true,
434
- comparator: inner.comparator
435
- };
436
- }
437
- if (range.startsWith(CATALOG_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": the version of a "catalog:" dependency lives in pnpm-workspace.yaml, not in the package manifest, so bumping it here would leave the catalog entry stale. Depend on the sibling directly (for example "workspace:^") instead.`);
438
- if (range.startsWith(NPM_ALIAS_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": an "npm:" alias points at a differently-named package, so the version released in this workspace is not necessarily the version this range refers to.`);
439
- if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
440
- const match = REWRITABLE_COMPARATOR.exec(range);
441
- if (match === null) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only a single "^", "~", ">=", "=", or bare version comparator can be rewritten in place.`);
442
- return {
443
- kind: "rewritable",
444
- workspacePrefixed: false,
445
- comparator: match[1] ?? ""
446
- };
535
+ function validateDependencyRangeShapes(graph) {
536
+ for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
447
537
  }
448
538
  /**
449
- * Computes what a dependency range on a workspace sibling becomes once that sibling releases `version`, by classifying the range's shape and then, for a rewritable shape, substituting `version` in place of the version it currently names.
539
+ * Looks up a value the caller already knows must be present -- a package or edge the topological order or graph just produced -- throwing rather than returning `undefined` if it somehow is not. An internal-consistency guard, not a user-facing validation, so both commit strategies share it rather than each carrying its own copy.
450
540
  */
451
- function updateDependencyRange(current, version) {
452
- const shape = classifyDependencyRange(current);
453
- if (shape.kind !== "rewritable") return shape;
454
- const rewritten = `${shape.comparator}${version}`;
455
- return {
456
- kind: "rewritten",
457
- range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
458
- };
541
+ function mustGet(map, key, what) {
542
+ const value = map.get(key);
543
+ if (value === void 0) throw new WorkspaceReleaseError(`Internal error: ${what} "${key}" disappeared from the dependency graph mid-run.`);
544
+ return value;
459
545
  }
460
546
  //#endregion
461
547
  //#region src/dependency-bump-commit.ts
@@ -506,6 +592,12 @@ const DEFAULT_PUBLISH_PLUGINS = [
506
592
  message: "chore(release): ${nextRelease.gitTag} [skip ci]"
507
593
  }]
508
594
  ];
595
+ /** 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. */
596
+ const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
597
+ "@semantic-release/changelog",
598
+ "@semantic-release/npm",
599
+ "@semantic-release/github"
600
+ ];
509
601
  const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator"]);
510
602
  /**
511
603
  * Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
@@ -526,7 +618,9 @@ function createScopedPlugins(scope) {
526
618
  from,
527
619
  paths: changedPathsSince(from, { cwd: context.cwd })
528
620
  };
529
- return filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
621
+ const commits = filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
622
+ scope.onCommitsResolved?.(commits);
623
+ return commits;
530
624
  }
531
625
  return {
532
626
  async analyzeCommits(_pluginConfig, context) {
@@ -597,7 +691,10 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
597
691
  for (const spec of specs) {
598
692
  const [name, config] = parsePublishPluginSpec(spec);
599
693
  if (STEP_PLUGINS_THE_ORCHESTRATOR_OWNS.has(name)) throw new ReleaseConfigurationError(`"${name}" is listed as a publish plugin, but ${packageName} always provides the ${name === "@semantic-release/commit-analyzer" ? "analyzeCommits" : "generateNotes"} step itself, wrapped around that plugin. Passing it here would make its configuration a silent no-op; set that configuration on the orchestrator's analyzeCommits/generateNotes options instead.`);
600
- if (name === "@semantic-release/git") hasGitPlugin = true;
694
+ if (name === "@semantic-release/git") {
695
+ hasGitPlugin = true;
696
+ if (options.forbidGitPlugin === true) throw new ReleaseConfigurationError(`"@semantic-release/git" is listed as a publish plugin, but commitStrategy "single" does its own committing -- one combined commit for every released package, tagged once every package has been analysed -- rather than letting each package's own release commit itself. Remove @semantic-release/git from the plugin list; its version bump and changelog write still happen (via its sibling prepare plugins), just folded into the combined commit instead of made on their own.`);
697
+ }
601
698
  const entry = [resolvePluginModule(name, requireFromTool, requireFromWorkspace), config];
602
699
  resolved.push(entry);
603
700
  }
@@ -639,6 +736,275 @@ async function regenerateLockfile(options) {
639
736
  }
640
737
  }
641
738
  //#endregion
739
+ //#region src/single-commit-release.ts
740
+ /**
741
+ * `commitStrategy: 'single'`: one combined commit for the whole run instead of one commit per package release plus one per dependency bump.
742
+ *
743
+ * Five phases, all inside one `releaseWorkspaceSingleCommit` call:
744
+ *
745
+ * 1. **Analyse** (this file's `analysePackage`): for every package, in topological order, run semantic-release with `dryRun: true` forced (regardless of the caller's own `dryRun` option) using the same path-scoped `analyzeCommits`/`generateNotes` wrapper `commitStrategy: 'per-package'` uses -- computing each package's next version and notes without writing, committing, tagging, or publishing anything. Cross-package dependency bumps are tracked purely in memory during this phase (`pendingBumps`), exactly as the per-package strategy tracks them for the span of one run; nothing is committed yet for a later run to recover from, because this strategy never leaves a partial commit for a crash to recover from in the first place -- either the whole combined commit lands, or nothing does.
746
+ * 2. **Verify** every released package's configured publish plugins' `verifyConditions` step (npm registry auth, GitHub token/repo access), before any file is written -- the same fail-fast-before-anything-releases discipline `validateDependencyRangeShapes` already applies to dependency ranges.
747
+ * 3. **Prepare**: for every released package, in topological order, apply any dependency-range bump its own manifest received (writing `package.json` directly, the same `writeDependencyRange` the per-package strategy uses), then run every configured publish plugin's own `prepare` step generically (whichever it defines -- @semantic-release/npm bumps `package.json`'s version, @semantic-release/changelog writes `CHANGELOG.md`). @semantic-release/git is rejected outright from this mode's plugin list (see `resolvePublishPlugins`'s `forbidGitPlugin`), since its own `prepare` step would create exactly the per-package commit this mode exists to avoid. The lockfile is regenerated once at the end, not once per bump, since `pnpm install --lockfile-only` recomputes it from whatever is on disk regardless of how many manifests changed.
748
+ * 4. **Commit**: discover every file phase 3 touched via `git status` (rather than predicting filenames per plugin), make one commit, tag it once per released package (`name@version`, lightweight, matching semantic-release's own tag form), and push the commit and every tag together.
749
+ * 5. **Publish**: for every released package, in topological order, call each configured plugin's own `publish` step directly (not through semantic-release's top-level orchestrator -- see the note below), then `success`.
750
+ *
751
+ * Why publish is not just another semantic-release() call: semantic-release's own `run()` unconditionally derives `lastRelease` from the newest tag already on the branch matching `tagFormat`, and this mode has, by the time phase 5 runs, already created and pushed that exact tag itself. A second real `semanticRelease()` call would see its own just-created tag as the already-published release and compute the wrong next version from it. Phase 5 instead calls each resolved plugin module's own exported `verifyConditions`/`publish`/`success` functions directly, with a hand-built context -- the same public per-plugin API surface semantic-release's own core calls internally, just invoked without going through the parts of `run()` that assume a not-yet-tagged repository.
752
+ */
753
+ async function releaseWorkspaceSingleCommit(options) {
754
+ const root = resolve(options.root ?? process.cwd());
755
+ const log = options.log ?? console.log;
756
+ const dryRun = options.dryRun === true;
757
+ const env = sanitizeGitEnv(options.env ?? process.env);
758
+ const workspace = await discoverWorkspace(root);
759
+ const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
760
+ await assertCleanWorkingTree({ cwd: repoRoot });
761
+ const graph = buildDependencyGraph(workspace.packages);
762
+ validateDependencyRangeShapes(graph);
763
+ const order = topologicalOrder(graph);
764
+ log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
765
+ const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
766
+ requireGitPlugin: false,
767
+ forbidGitPlugin: true
768
+ });
769
+ const analyzeCommitsConfig = options.analyzeCommits ?? {};
770
+ const generateNotesConfig = options.generateNotes ?? {};
771
+ const capturedCommits = /* @__PURE__ */ new Map();
772
+ const captured = {
773
+ branch: void 0,
774
+ repositoryUrl: void 0
775
+ };
776
+ const pendingBumps = /* @__PURE__ */ new Map();
777
+ const outcomes = [];
778
+ const planned = [];
779
+ for (const name of order) {
780
+ const pkg = mustGet(graph.packages, name, "package");
781
+ const bumpsForThisPackage = pendingBumps.get(name) ?? [];
782
+ pendingBumps.delete(name);
783
+ const nextRelease = await analysePackage(pkg, {
784
+ resolvedPlugins,
785
+ analyzeCommitsConfig,
786
+ generateNotesConfig,
787
+ bumpsForThisPackage,
788
+ env,
789
+ branches: options.branches,
790
+ onCommitsResolved: (commits) => capturedCommits.set(name, commits),
791
+ onContextCaptured: (context) => {
792
+ captured.branch = context.branch;
793
+ captured.repositoryUrl = context.repositoryUrl;
794
+ }
795
+ });
796
+ outcomes.push({
797
+ name,
798
+ directory: pkg.directory,
799
+ released: nextRelease !== void 0,
800
+ version: nextRelease?.version,
801
+ gitTag: nextRelease?.gitTag,
802
+ type: nextRelease?.type,
803
+ dependencyBumps: bumpsForThisPackage
804
+ });
805
+ if (nextRelease === void 0) {
806
+ log(`${name}: no release`);
807
+ continue;
808
+ }
809
+ log(`${name}: would release ${nextRelease.gitTag}`);
810
+ planned.push({
811
+ pkg,
812
+ type: nextRelease.type,
813
+ version: nextRelease.version,
814
+ gitTag: nextRelease.gitTag,
815
+ notes: nextRelease.notes,
816
+ bumps: bumpsForThisPackage
817
+ });
818
+ for (const bump of planDependentBumps(pkg, nextRelease.version, graph)) {
819
+ const forDependent = pendingBumps.get(bump.dependent) ?? [];
820
+ forDependent.push(bump);
821
+ pendingBumps.set(bump.dependent, forDependent);
822
+ }
823
+ }
824
+ if (dryRun || planned.length === 0) return {
825
+ order,
826
+ packages: outcomes
827
+ };
828
+ const branch = captured.branch;
829
+ const repositoryUrl = captured.repositoryUrl;
830
+ if (branch === void 0 || repositoryUrl === void 0) throw new ReleaseConfigurationError(`Internal error: ${packageName} analysed ${planned.length} package release(s) but never captured a branch/repositoryUrl from semantic-release's own context. This should be impossible when at least one package releases.`);
831
+ const shared = {
832
+ env,
833
+ branch,
834
+ repositoryUrl,
835
+ log
836
+ };
837
+ const moduleCache = /* @__PURE__ */ new Map();
838
+ for (const release of planned) for (const [modulePath, pluginConfig] of resolvedPlugins) {
839
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
840
+ if (plugin.verifyConditions) await plugin.verifyConditions(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
841
+ }
842
+ let anyRangeRewritten = false;
843
+ for (const release of planned) {
844
+ for (const bump of release.bumps) {
845
+ if (bump.kind !== "rewritten") continue;
846
+ await writeDependencyRange(release.pkg.manifestPath, bump.field, bump.dependency, bump.range);
847
+ anyRangeRewritten = true;
848
+ }
849
+ for (const [modulePath, pluginConfig] of resolvedPlugins) {
850
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
851
+ if (plugin.prepare) await plugin.prepare(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
852
+ }
853
+ }
854
+ if (anyRangeRewritten) await regenerateLockfile({ cwd: workspace.root });
855
+ const touchedPaths = await workingTreeChanges({ cwd: repoRoot });
856
+ if (touchedPaths.length === 0) throw new ReleaseConfigurationError(`${packageName}: analysis planned ${planned.length} release(s), but no files changed while preparing them. Every configured publish plugin's own "prepare" step (bumping package.json, writing CHANGELOG.md) produced nothing to commit -- check the plugin list includes something that writes the version, e.g. @semantic-release/npm.`);
857
+ const identity = await resolveCommitIdentity({ cwd: repoRoot });
858
+ await commitFiles(touchedPaths, describeCombinedCommit(planned), {
859
+ cwd: repoRoot,
860
+ identity
861
+ });
862
+ const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
863
+ const tagNames = planned.map((release) => release.gitTag);
864
+ for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
865
+ await pushHeadAndTags(tagNames, { cwd: repoRoot });
866
+ log(`${packageName}: committed ${commitSha} and pushed ${tagNames.length} tag(s): ${tagNames.join(", ")}`);
867
+ for (const release of planned) {
868
+ const releases = [];
869
+ for (const [modulePath, pluginConfig] of resolvedPlugins) {
870
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
871
+ if (plugin.publish) {
872
+ const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
873
+ if (result !== false && result !== void 0) releases.push(result);
874
+ }
875
+ }
876
+ for (const [modulePath, pluginConfig] of resolvedPlugins) {
877
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
878
+ if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
879
+ }
880
+ log(`${release.pkg.name}: published ${release.gitTag}`);
881
+ }
882
+ return {
883
+ order,
884
+ packages: outcomes
885
+ };
886
+ }
887
+ /**
888
+ * Runs one package's analysis with semantic-release's own real `dryRun: true` mode (which still fully computes `nextRelease.version`/`notes` and the branch/repository context; only the plugin steps whose own definition opts out of dry runs -- prepare, publish, addChannel, success, fail -- are skipped), using the exact same path-scoped `analyzeCommits`/`generateNotes` wrapper `commitStrategy: 'per-package'` uses. `dryRun: true` is forced here regardless of the caller's own `dryRun` option: this is always how phase 1 computes what *would* release, whether or not the run goes on to actually commit it.
889
+ */
890
+ async function analysePackage(pkg, options) {
891
+ const scoped = createScopedPlugins({
892
+ pkg,
893
+ analyzeCommitsConfig: options.analyzeCommitsConfig,
894
+ generateNotesConfig: options.generateNotesConfig,
895
+ bumps: { bumpsFor: () => options.bumpsForThisPackage },
896
+ onCommitsResolved: options.onCommitsResolved
897
+ });
898
+ const semanticReleaseOptions = {
899
+ tagFormat: `${pkg.name}@\${version}`,
900
+ plugins: options.resolvedPlugins,
901
+ dryRun: true,
902
+ async analyzeCommits(pluginConfig, context) {
903
+ if (context.options.repositoryUrl === void 0) throw new ReleaseConfigurationError("Internal error: semantic-release did not resolve a repository URL before analyzeCommits ran.");
904
+ options.onContextCaptured({
905
+ branch: context.branch,
906
+ repositoryUrl: context.options.repositoryUrl
907
+ });
908
+ return scoped.analyzeCommits(pluginConfig, context);
909
+ },
910
+ generateNotes: scoped.generateNotes
911
+ };
912
+ if (options.branches !== void 0) semanticReleaseOptions.branches = options.branches;
913
+ const result = await semanticRelease(semanticReleaseOptions, {
914
+ cwd: pkg.directory,
915
+ env: { ...options.env }
916
+ });
917
+ if (result === false) return;
918
+ if (result.nextRelease.notes === void 0) throw new ReleaseConfigurationError(`Internal error: ${pkg.name} released with no notes computed.`);
919
+ return {
920
+ type: result.nextRelease.type,
921
+ version: result.nextRelease.version,
922
+ gitTag: result.nextRelease.gitTag,
923
+ notes: result.nextRelease.notes
924
+ };
925
+ }
926
+ /** Pure classification of what a released package's version does to each dependent's declared range -- the same logic `bumpDependents` in `release.ts` applies, minus the file write/commit/push: `commitStrategy: 'single'` defers every write to phase 3, applying the same classification once analysis has finished for the whole run. */
927
+ function planDependentBumps(released, version, graph) {
928
+ const applied = [];
929
+ const dependents = graph.dependents.get(released.name);
930
+ if (dependents === void 0) return applied;
931
+ for (const edge of dependents) {
932
+ const update = updateDependencyRange(edge.range, version);
933
+ if (update.kind === "wildcard") continue;
934
+ applied.push({
935
+ dependent: edge.dependent,
936
+ dependency: released.name,
937
+ field: edge.field,
938
+ version,
939
+ range: update.kind === "rewritten" ? update.range : edge.range,
940
+ kind: update.kind
941
+ });
942
+ }
943
+ return applied;
944
+ }
945
+ function describeCombinedCommit(planned) {
946
+ return [
947
+ "chore(release): batch release [skip ci]",
948
+ "",
949
+ ...planned.map((release) => {
950
+ const bumpDescriptions = release.bumps.map((bump) => `${bump.dependency} to ${bump.range}`);
951
+ const bumpSuffix = bumpDescriptions.length === 0 ? "" : ` (dependenc${bumpDescriptions.length === 1 ? "y" : "ies"} bumped: ${bumpDescriptions.join(", ")})`;
952
+ return `- ${release.gitTag} (${release.type})${bumpSuffix}`;
953
+ })
954
+ ].join("\n");
955
+ }
956
+ function buildPluginContext(release, shared, capturedCommits, releases) {
957
+ const logger = {
958
+ log: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`),
959
+ warn: (...args) => shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`),
960
+ error: (...args) => shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`),
961
+ success: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`)
962
+ };
963
+ return {
964
+ cwd: release.pkg.directory,
965
+ env: { ...shared.env },
966
+ stdout: process.stdout,
967
+ stderr: process.stderr,
968
+ logger,
969
+ options: { repositoryUrl: shared.repositoryUrl },
970
+ branch: shared.branch,
971
+ commits: capturedCommits.get(release.pkg.name) ?? [],
972
+ releases,
973
+ nextRelease: {
974
+ type: release.type,
975
+ version: release.version,
976
+ gitTag: release.gitTag,
977
+ name: release.gitTag,
978
+ notes: release.notes,
979
+ channel: channelOrNull(shared.branch.channel)
980
+ }
981
+ };
982
+ }
983
+ /** Matches semantic-release's own core exactly (`context.branch.channel || null` in its `index.js`): a branch's `channel` is `string | false | undefined`, and any falsy value (including `false` and an empty string) means "the default channel", represented here as `null`. Written as explicit comparisons rather than `||`/`??` so the falsy-to-null collapse (deliberately including `false` and `''`, which `??` alone would not fold) reads as intentional rather than as a fallback for `undefined`/`null` alone. */
984
+ function channelOrNull(channel) {
985
+ return channel === void 0 || channel === false || channel === "" ? null : channel;
986
+ }
987
+ function isReleaseLifecycleFnOrUndefined(value) {
988
+ return value === void 0 || typeof value === "function";
989
+ }
990
+ function isReleasePluginModule(value) {
991
+ if (typeof value !== "object" || value === null) return false;
992
+ if ("verifyConditions" in value && !isReleaseLifecycleFnOrUndefined(value.verifyConditions)) return false;
993
+ if ("prepare" in value && !isReleaseLifecycleFnOrUndefined(value.prepare)) return false;
994
+ if ("publish" in value && !isReleaseLifecycleFnOrUndefined(value.publish)) return false;
995
+ if ("success" in value && !isReleaseLifecycleFnOrUndefined(value.success)) return false;
996
+ return true;
997
+ }
998
+ /** Loads a publish plugin module directly (bypassing semantic-release's own step-pipeline machinery, since phase 5 cannot go through it -- see this file's own top-of-file note), caching by resolved path so a plugin shared by several packages is imported once per run rather than once per package per phase. */
999
+ async function loadReleasePlugin(absolutePath, cache) {
1000
+ const cached = cache.get(absolutePath);
1001
+ if (cached !== void 0) return cached;
1002
+ const loaded = await import(pathToFileURL(absolutePath).href);
1003
+ if (!isReleasePluginModule(loaded)) throw new ReleaseConfigurationError(`The publish plugin resolved to ${absolutePath} does not export a recognised semantic-release plugin interface (verifyConditions/prepare/publish/success functions), so commitStrategy "single" cannot call its lifecycle steps directly.`);
1004
+ cache.set(absolutePath, loaded);
1005
+ return loaded;
1006
+ }
1007
+ //#endregion
642
1008
  //#region src/release.ts
643
1009
  /**
644
1010
  * Releases every package in a pnpm workspace with independent versions, in dependency order.
@@ -646,10 +1012,11 @@ async function regenerateLockfile(options) {
646
1012
  * For each package, in topological order: run semantic-release's programmatic API with `cwd` scoped to the package directory, a `name@version` tag format to keep each package's tags distinct in the one shared tag namespace, and inline `analyzeCommits`/`generateNotes` plugins that filter the release range's commits down to the package's own directory before delegating to the standard plugins. When a package releases, every workspace package that depends on it and has not run yet gets its dependency range rewritten in its manifest and committed immediately -- before its own turn, so its commit analysis and its published manifest both see the new range.
647
1013
  */
648
1014
  async function releaseWorkspace(options = {}) {
1015
+ if ((options.commitStrategy ?? "per-package") === "single") return releaseWorkspaceSingleCommit(options);
649
1016
  const root = resolve(options.root ?? process.cwd());
650
1017
  const log = options.log ?? console.log;
651
1018
  const dryRun = options.dryRun === true;
652
- const env = options.env ?? process.env;
1019
+ const env = sanitizeGitEnv(options.env ?? process.env);
653
1020
  const workspace = await discoverWorkspace(root);
654
1021
  const graph = buildDependencyGraph(workspace.packages);
655
1022
  validateDependencyRangeShapes(graph);
@@ -708,12 +1075,6 @@ async function releaseWorkspace(options = {}) {
708
1075
  packages: outcomes
709
1076
  };
710
1077
  }
711
- /**
712
- * Checks every workspace dependency edge's range shape before anything releases, so an `UnsupportedDependencyRangeError` stops the run before the first publish rather than after some sibling has already been published, tagged, committed, and pushed. The shape a range supports depends only on the range text itself (see `classifyDependencyRange`), never on which version a sibling ends up releasing, so this can run once up front for the whole graph instead of only being discovered edge by edge as each dependency happens to release.
713
- */
714
- function validateDependencyRangeShapes(graph) {
715
- for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
716
- }
717
1078
  async function runPackageRelease(pkg, options) {
718
1079
  const scoped = createScopedPlugins({
719
1080
  pkg,
@@ -782,10 +1143,5 @@ async function bumpDependents(released, version, graph, options) {
782
1143
  }
783
1144
  return applied;
784
1145
  }
785
- function mustGet(map, key, what) {
786
- const value = map.get(key);
787
- if (value === void 0) throw new WorkspaceReleaseError(`Internal error: ${what} "${key}" disappeared from the dependency graph mid-run.`);
788
- return value;
789
- }
790
1146
  //#endregion
791
- export { DEFAULT_PUBLISH_PLUGINS, DependencyCycleError, GitCommandError, ReleaseConfigurationError, UnsupportedDependencyRangeError, WorkspaceDiscoveryError, WorkspaceReleaseError, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, topologicalOrder, updateDependencyRange, writeDependencyRange };
1147
+ 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, topologicalOrder, updateDependencyRange, writeDependencyRange };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exadev/semantic-release-workspace",
3
- "version": "1.1.7",
3
+ "version": "1.2.1",
4
4
  "description": "Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.",
5
5
  "type": "module",
6
6
  "repository": {