@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.cjs CHANGED
@@ -34,6 +34,7 @@ let _semantic_release_commit_analyzer = require("@semantic-release/commit-analyz
34
34
  let _semantic_release_release_notes_generator = require("@semantic-release/release-notes-generator");
35
35
  let semantic_release = require("semantic-release");
36
36
  semantic_release = __toESM(semantic_release, 1);
37
+ let node_url = require("node:url");
37
38
  //#region src/package-name.ts
38
39
  const packageName = "@exadev/semantic-release-workspace";
39
40
  //#endregion
@@ -92,11 +93,36 @@ const BOT_IDENTITY = {
92
93
  name: "semantic-release-bot",
93
94
  email: "semantic-release-bot@martynus.net"
94
95
  };
96
+ /**
97
+ * 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.
98
+ */
99
+ const GIT_REPOSITORY_DISCOVERY_ENV_KEYS = [
100
+ "GIT_DIR",
101
+ "GIT_WORK_TREE",
102
+ "GIT_INDEX_FILE",
103
+ "GIT_PREFIX",
104
+ "GIT_OBJECT_DIRECTORY",
105
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
106
+ "GIT_COMMON_DIR",
107
+ "GIT_NAMESPACE"
108
+ ];
109
+ /**
110
+ * A copy of the given environment with every repository-discovery-affecting `GIT_*` key removed -- see `GIT_REPOSITORY_DISCOVERY_ENV_KEYS`.
111
+ *
112
+ * 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.
113
+ */
114
+ function sanitizeGitEnv(env) {
115
+ const sanitized = { ...env };
116
+ for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) delete sanitized[key];
117
+ return sanitized;
118
+ }
119
+ const SANITIZED_PROCESS_GIT_ENV = sanitizeGitEnv(process.env);
95
120
  async function git(args, options) {
96
121
  try {
97
122
  const { stdout } = await execFileAsync$1("git", [...args], {
98
123
  cwd: options.cwd,
99
- maxBuffer: GIT_MAX_BUFFER_BYTES
124
+ maxBuffer: GIT_MAX_BUFFER_BYTES,
125
+ env: SANITIZED_PROCESS_GIT_ENV
100
126
  });
101
127
  return stdout;
102
128
  } catch (cause) {
@@ -189,6 +215,50 @@ async function pushHead(options) {
189
215
  `HEAD:${await currentBranch(options)}`
190
216
  ], options);
191
217
  }
218
+ /** 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. */
219
+ async function createTag(name, ref, options) {
220
+ await git([
221
+ "tag",
222
+ name,
223
+ ref
224
+ ], options);
225
+ }
226
+ /**
227
+ * 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.
228
+ *
229
+ * `-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.
230
+ */
231
+ async function workingTreeChanges(options) {
232
+ const tokens = (await git([
233
+ "status",
234
+ "--porcelain=v1",
235
+ "--untracked-files=all",
236
+ "-z"
237
+ ], options)).split("\0").filter((token) => token !== "");
238
+ const paths = [];
239
+ for (let index = 0; index < tokens.length; index += 1) {
240
+ const entry = tokens[index];
241
+ if (entry === void 0 || entry.length < 4) continue;
242
+ const statusCode = entry.slice(0, 2);
243
+ paths.push(entry.slice(3));
244
+ if (statusCode.includes("R") || statusCode.includes("C")) index += 1;
245
+ }
246
+ return paths;
247
+ }
248
+ /** 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. */
249
+ async function assertCleanWorkingTree(options) {
250
+ const changes = await workingTreeChanges(options);
251
+ 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.`);
252
+ }
253
+ /** 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. */
254
+ async function pushHeadAndTags(tagNames, options) {
255
+ await git([
256
+ "push",
257
+ "origin",
258
+ `HEAD:${await currentBranch(options)}`,
259
+ ...tagNames
260
+ ], options);
261
+ }
192
262
  function toGitCommandError(args, cwd, cause) {
193
263
  const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
194
264
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
@@ -344,6 +414,70 @@ function toPosix(path) {
344
414
  return node_path.sep === "/" ? path : path.split(node_path.sep).join("/");
345
415
  }
346
416
  //#endregion
417
+ //#region src/version-range.ts
418
+ const WORKSPACE_PROTOCOL = "workspace:";
419
+ const CATALOG_PROTOCOL = "catalog:";
420
+ const NPM_ALIAS_PROTOCOL = "npm:";
421
+ /** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
422
+ const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
423
+ "*",
424
+ "^",
425
+ "~"
426
+ ];
427
+ /** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
428
+ const WILDCARD_RANGES = [
429
+ "",
430
+ "*",
431
+ "x",
432
+ "X",
433
+ "latest"
434
+ ];
435
+ /**
436
+ * 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.
437
+ */
438
+ const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
439
+ /**
440
+ * 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.
441
+ *
442
+ * 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.
443
+ */
444
+ function classifyDependencyRange(current) {
445
+ const range = current.trim();
446
+ if (range.startsWith(WORKSPACE_PROTOCOL)) {
447
+ const suffix = range.slice(10);
448
+ if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
449
+ const inner = classifyDependencyRange(suffix);
450
+ 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.`);
451
+ return {
452
+ kind: "rewritable",
453
+ workspacePrefixed: true,
454
+ comparator: inner.comparator
455
+ };
456
+ }
457
+ 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.`);
458
+ 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.`);
459
+ if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
460
+ const match = REWRITABLE_COMPARATOR.exec(range);
461
+ 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.`);
462
+ return {
463
+ kind: "rewritable",
464
+ workspacePrefixed: false,
465
+ comparator: match[1] ?? ""
466
+ };
467
+ }
468
+ /**
469
+ * 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.
470
+ */
471
+ function updateDependencyRange(current, version) {
472
+ const shape = classifyDependencyRange(current);
473
+ if (shape.kind !== "rewritable") return shape;
474
+ const rewritten = `${shape.comparator}${version}`;
475
+ return {
476
+ kind: "rewritten",
477
+ range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
478
+ };
479
+ }
480
+ //#endregion
347
481
  //#region src/graph.ts
348
482
  /**
349
483
  * Builds the inter-package dependency graph from the manifests alone.
@@ -418,69 +552,21 @@ function firstUnplacedDependency(name, graph, unplaced) {
418
552
  if (name === void 0) return [...unplaced].sort()[0];
419
553
  return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
420
554
  }
421
- //#endregion
422
- //#region src/version-range.ts
423
- const WORKSPACE_PROTOCOL = "workspace:";
424
- const CATALOG_PROTOCOL = "catalog:";
425
- const NPM_ALIAS_PROTOCOL = "npm:";
426
- /** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
427
- const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
428
- "*",
429
- "^",
430
- "~"
431
- ];
432
- /** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
433
- const WILDCARD_RANGES = [
434
- "",
435
- "*",
436
- "x",
437
- "X",
438
- "latest"
439
- ];
440
555
  /**
441
- * 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.
442
- */
443
- const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
444
- /**
445
- * 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.
556
+ * 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.
446
557
  *
447
- * 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.
558
+ * 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.
448
559
  */
449
- function classifyDependencyRange(current) {
450
- const range = current.trim();
451
- if (range.startsWith(WORKSPACE_PROTOCOL)) {
452
- const suffix = range.slice(10);
453
- if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
454
- const inner = classifyDependencyRange(suffix);
455
- 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.`);
456
- return {
457
- kind: "rewritable",
458
- workspacePrefixed: true,
459
- comparator: inner.comparator
460
- };
461
- }
462
- 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.`);
463
- 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.`);
464
- if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
465
- const match = REWRITABLE_COMPARATOR.exec(range);
466
- 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.`);
467
- return {
468
- kind: "rewritable",
469
- workspacePrefixed: false,
470
- comparator: match[1] ?? ""
471
- };
560
+ function validateDependencyRangeShapes(graph) {
561
+ for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
472
562
  }
473
563
  /**
474
- * 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.
564
+ * 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.
475
565
  */
476
- function updateDependencyRange(current, version) {
477
- const shape = classifyDependencyRange(current);
478
- if (shape.kind !== "rewritable") return shape;
479
- const rewritten = `${shape.comparator}${version}`;
480
- return {
481
- kind: "rewritten",
482
- range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
483
- };
566
+ function mustGet(map, key, what) {
567
+ const value = map.get(key);
568
+ if (value === void 0) throw new WorkspaceReleaseError(`Internal error: ${what} "${key}" disappeared from the dependency graph mid-run.`);
569
+ return value;
484
570
  }
485
571
  //#endregion
486
572
  //#region src/dependency-bump-commit.ts
@@ -531,6 +617,12 @@ const DEFAULT_PUBLISH_PLUGINS = [
531
617
  message: "chore(release): ${nextRelease.gitTag} [skip ci]"
532
618
  }]
533
619
  ];
620
+ /** 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. */
621
+ const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
622
+ "@semantic-release/changelog",
623
+ "@semantic-release/npm",
624
+ "@semantic-release/github"
625
+ ];
534
626
  const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator"]);
535
627
  /**
536
628
  * Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
@@ -551,7 +643,9 @@ function createScopedPlugins(scope) {
551
643
  from,
552
644
  paths: changedPathsSince(from, { cwd: context.cwd })
553
645
  };
554
- return filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
646
+ const commits = filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
647
+ scope.onCommitsResolved?.(commits);
648
+ return commits;
555
649
  }
556
650
  return {
557
651
  async analyzeCommits(_pluginConfig, context) {
@@ -622,7 +716,10 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
622
716
  for (const spec of specs) {
623
717
  const [name, config] = parsePublishPluginSpec(spec);
624
718
  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.`);
625
- if (name === "@semantic-release/git") hasGitPlugin = true;
719
+ if (name === "@semantic-release/git") {
720
+ hasGitPlugin = true;
721
+ 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.`);
722
+ }
626
723
  const entry = [resolvePluginModule(name, requireFromTool, requireFromWorkspace), config];
627
724
  resolved.push(entry);
628
725
  }
@@ -664,6 +761,275 @@ async function regenerateLockfile(options) {
664
761
  }
665
762
  }
666
763
  //#endregion
764
+ //#region src/single-commit-release.ts
765
+ /**
766
+ * `commitStrategy: 'single'`: one combined commit for the whole run instead of one commit per package release plus one per dependency bump.
767
+ *
768
+ * Five phases, all inside one `releaseWorkspaceSingleCommit` call:
769
+ *
770
+ * 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.
771
+ * 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.
772
+ * 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.
773
+ * 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.
774
+ * 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`.
775
+ *
776
+ * 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.
777
+ */
778
+ async function releaseWorkspaceSingleCommit(options) {
779
+ const root = (0, node_path.resolve)(options.root ?? process.cwd());
780
+ const log = options.log ?? console.log;
781
+ const dryRun = options.dryRun === true;
782
+ const env = sanitizeGitEnv(options.env ?? process.env);
783
+ const workspace = await discoverWorkspace(root);
784
+ const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
785
+ await assertCleanWorkingTree({ cwd: repoRoot });
786
+ const graph = buildDependencyGraph(workspace.packages);
787
+ validateDependencyRangeShapes(graph);
788
+ const order = topologicalOrder(graph);
789
+ log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
790
+ const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
791
+ requireGitPlugin: false,
792
+ forbidGitPlugin: true
793
+ });
794
+ const analyzeCommitsConfig = options.analyzeCommits ?? {};
795
+ const generateNotesConfig = options.generateNotes ?? {};
796
+ const capturedCommits = /* @__PURE__ */ new Map();
797
+ const captured = {
798
+ branch: void 0,
799
+ repositoryUrl: void 0
800
+ };
801
+ const pendingBumps = /* @__PURE__ */ new Map();
802
+ const outcomes = [];
803
+ const planned = [];
804
+ for (const name of order) {
805
+ const pkg = mustGet(graph.packages, name, "package");
806
+ const bumpsForThisPackage = pendingBumps.get(name) ?? [];
807
+ pendingBumps.delete(name);
808
+ const nextRelease = await analysePackage(pkg, {
809
+ resolvedPlugins,
810
+ analyzeCommitsConfig,
811
+ generateNotesConfig,
812
+ bumpsForThisPackage,
813
+ env,
814
+ branches: options.branches,
815
+ onCommitsResolved: (commits) => capturedCommits.set(name, commits),
816
+ onContextCaptured: (context) => {
817
+ captured.branch = context.branch;
818
+ captured.repositoryUrl = context.repositoryUrl;
819
+ }
820
+ });
821
+ outcomes.push({
822
+ name,
823
+ directory: pkg.directory,
824
+ released: nextRelease !== void 0,
825
+ version: nextRelease?.version,
826
+ gitTag: nextRelease?.gitTag,
827
+ type: nextRelease?.type,
828
+ dependencyBumps: bumpsForThisPackage
829
+ });
830
+ if (nextRelease === void 0) {
831
+ log(`${name}: no release`);
832
+ continue;
833
+ }
834
+ log(`${name}: would release ${nextRelease.gitTag}`);
835
+ planned.push({
836
+ pkg,
837
+ type: nextRelease.type,
838
+ version: nextRelease.version,
839
+ gitTag: nextRelease.gitTag,
840
+ notes: nextRelease.notes,
841
+ bumps: bumpsForThisPackage
842
+ });
843
+ for (const bump of planDependentBumps(pkg, nextRelease.version, graph)) {
844
+ const forDependent = pendingBumps.get(bump.dependent) ?? [];
845
+ forDependent.push(bump);
846
+ pendingBumps.set(bump.dependent, forDependent);
847
+ }
848
+ }
849
+ if (dryRun || planned.length === 0) return {
850
+ order,
851
+ packages: outcomes
852
+ };
853
+ const branch = captured.branch;
854
+ const repositoryUrl = captured.repositoryUrl;
855
+ 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.`);
856
+ const shared = {
857
+ env,
858
+ branch,
859
+ repositoryUrl,
860
+ log
861
+ };
862
+ const moduleCache = /* @__PURE__ */ new Map();
863
+ for (const release of planned) for (const [modulePath, pluginConfig] of resolvedPlugins) {
864
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
865
+ if (plugin.verifyConditions) await plugin.verifyConditions(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
866
+ }
867
+ let anyRangeRewritten = false;
868
+ for (const release of planned) {
869
+ for (const bump of release.bumps) {
870
+ if (bump.kind !== "rewritten") continue;
871
+ await writeDependencyRange(release.pkg.manifestPath, bump.field, bump.dependency, bump.range);
872
+ anyRangeRewritten = true;
873
+ }
874
+ for (const [modulePath, pluginConfig] of resolvedPlugins) {
875
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
876
+ if (plugin.prepare) await plugin.prepare(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
877
+ }
878
+ }
879
+ if (anyRangeRewritten) await regenerateLockfile({ cwd: workspace.root });
880
+ const touchedPaths = await workingTreeChanges({ cwd: repoRoot });
881
+ 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.`);
882
+ const identity = await resolveCommitIdentity({ cwd: repoRoot });
883
+ await commitFiles(touchedPaths, describeCombinedCommit(planned), {
884
+ cwd: repoRoot,
885
+ identity
886
+ });
887
+ const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
888
+ const tagNames = planned.map((release) => release.gitTag);
889
+ for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
890
+ await pushHeadAndTags(tagNames, { cwd: repoRoot });
891
+ log(`${packageName}: committed ${commitSha} and pushed ${tagNames.length} tag(s): ${tagNames.join(", ")}`);
892
+ for (const release of planned) {
893
+ const releases = [];
894
+ for (const [modulePath, pluginConfig] of resolvedPlugins) {
895
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
896
+ if (plugin.publish) {
897
+ const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
898
+ if (result !== false && result !== void 0) releases.push(result);
899
+ }
900
+ }
901
+ for (const [modulePath, pluginConfig] of resolvedPlugins) {
902
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
903
+ if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
904
+ }
905
+ log(`${release.pkg.name}: published ${release.gitTag}`);
906
+ }
907
+ return {
908
+ order,
909
+ packages: outcomes
910
+ };
911
+ }
912
+ /**
913
+ * 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.
914
+ */
915
+ async function analysePackage(pkg, options) {
916
+ const scoped = createScopedPlugins({
917
+ pkg,
918
+ analyzeCommitsConfig: options.analyzeCommitsConfig,
919
+ generateNotesConfig: options.generateNotesConfig,
920
+ bumps: { bumpsFor: () => options.bumpsForThisPackage },
921
+ onCommitsResolved: options.onCommitsResolved
922
+ });
923
+ const semanticReleaseOptions = {
924
+ tagFormat: `${pkg.name}@\${version}`,
925
+ plugins: options.resolvedPlugins,
926
+ dryRun: true,
927
+ async analyzeCommits(pluginConfig, context) {
928
+ if (context.options.repositoryUrl === void 0) throw new ReleaseConfigurationError("Internal error: semantic-release did not resolve a repository URL before analyzeCommits ran.");
929
+ options.onContextCaptured({
930
+ branch: context.branch,
931
+ repositoryUrl: context.options.repositoryUrl
932
+ });
933
+ return scoped.analyzeCommits(pluginConfig, context);
934
+ },
935
+ generateNotes: scoped.generateNotes
936
+ };
937
+ if (options.branches !== void 0) semanticReleaseOptions.branches = options.branches;
938
+ const result = await (0, semantic_release.default)(semanticReleaseOptions, {
939
+ cwd: pkg.directory,
940
+ env: { ...options.env }
941
+ });
942
+ if (result === false) return;
943
+ if (result.nextRelease.notes === void 0) throw new ReleaseConfigurationError(`Internal error: ${pkg.name} released with no notes computed.`);
944
+ return {
945
+ type: result.nextRelease.type,
946
+ version: result.nextRelease.version,
947
+ gitTag: result.nextRelease.gitTag,
948
+ notes: result.nextRelease.notes
949
+ };
950
+ }
951
+ /** 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. */
952
+ function planDependentBumps(released, version, graph) {
953
+ const applied = [];
954
+ const dependents = graph.dependents.get(released.name);
955
+ if (dependents === void 0) return applied;
956
+ for (const edge of dependents) {
957
+ const update = updateDependencyRange(edge.range, version);
958
+ if (update.kind === "wildcard") continue;
959
+ applied.push({
960
+ dependent: edge.dependent,
961
+ dependency: released.name,
962
+ field: edge.field,
963
+ version,
964
+ range: update.kind === "rewritten" ? update.range : edge.range,
965
+ kind: update.kind
966
+ });
967
+ }
968
+ return applied;
969
+ }
970
+ function describeCombinedCommit(planned) {
971
+ return [
972
+ "chore(release): batch release [skip ci]",
973
+ "",
974
+ ...planned.map((release) => {
975
+ const bumpDescriptions = release.bumps.map((bump) => `${bump.dependency} to ${bump.range}`);
976
+ const bumpSuffix = bumpDescriptions.length === 0 ? "" : ` (dependenc${bumpDescriptions.length === 1 ? "y" : "ies"} bumped: ${bumpDescriptions.join(", ")})`;
977
+ return `- ${release.gitTag} (${release.type})${bumpSuffix}`;
978
+ })
979
+ ].join("\n");
980
+ }
981
+ function buildPluginContext(release, shared, capturedCommits, releases) {
982
+ const logger = {
983
+ log: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`),
984
+ warn: (...args) => shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`),
985
+ error: (...args) => shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`),
986
+ success: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`)
987
+ };
988
+ return {
989
+ cwd: release.pkg.directory,
990
+ env: { ...shared.env },
991
+ stdout: process.stdout,
992
+ stderr: process.stderr,
993
+ logger,
994
+ options: { repositoryUrl: shared.repositoryUrl },
995
+ branch: shared.branch,
996
+ commits: capturedCommits.get(release.pkg.name) ?? [],
997
+ releases,
998
+ nextRelease: {
999
+ type: release.type,
1000
+ version: release.version,
1001
+ gitTag: release.gitTag,
1002
+ name: release.gitTag,
1003
+ notes: release.notes,
1004
+ channel: channelOrNull(shared.branch.channel)
1005
+ }
1006
+ };
1007
+ }
1008
+ /** 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. */
1009
+ function channelOrNull(channel) {
1010
+ return channel === void 0 || channel === false || channel === "" ? null : channel;
1011
+ }
1012
+ function isReleaseLifecycleFnOrUndefined(value) {
1013
+ return value === void 0 || typeof value === "function";
1014
+ }
1015
+ function isReleasePluginModule(value) {
1016
+ if (typeof value !== "object" || value === null) return false;
1017
+ if ("verifyConditions" in value && !isReleaseLifecycleFnOrUndefined(value.verifyConditions)) return false;
1018
+ if ("prepare" in value && !isReleaseLifecycleFnOrUndefined(value.prepare)) return false;
1019
+ if ("publish" in value && !isReleaseLifecycleFnOrUndefined(value.publish)) return false;
1020
+ if ("success" in value && !isReleaseLifecycleFnOrUndefined(value.success)) return false;
1021
+ return true;
1022
+ }
1023
+ /** 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. */
1024
+ async function loadReleasePlugin(absolutePath, cache) {
1025
+ const cached = cache.get(absolutePath);
1026
+ if (cached !== void 0) return cached;
1027
+ const loaded = await import((0, node_url.pathToFileURL)(absolutePath).href);
1028
+ 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.`);
1029
+ cache.set(absolutePath, loaded);
1030
+ return loaded;
1031
+ }
1032
+ //#endregion
667
1033
  //#region src/release.ts
668
1034
  /**
669
1035
  * Releases every package in a pnpm workspace with independent versions, in dependency order.
@@ -671,10 +1037,11 @@ async function regenerateLockfile(options) {
671
1037
  * 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.
672
1038
  */
673
1039
  async function releaseWorkspace(options = {}) {
1040
+ if ((options.commitStrategy ?? "per-package") === "single") return releaseWorkspaceSingleCommit(options);
674
1041
  const root = (0, node_path.resolve)(options.root ?? process.cwd());
675
1042
  const log = options.log ?? console.log;
676
1043
  const dryRun = options.dryRun === true;
677
- const env = options.env ?? process.env;
1044
+ const env = sanitizeGitEnv(options.env ?? process.env);
678
1045
  const workspace = await discoverWorkspace(root);
679
1046
  const graph = buildDependencyGraph(workspace.packages);
680
1047
  validateDependencyRangeShapes(graph);
@@ -733,12 +1100,6 @@ async function releaseWorkspace(options = {}) {
733
1100
  packages: outcomes
734
1101
  };
735
1102
  }
736
- /**
737
- * 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.
738
- */
739
- function validateDependencyRangeShapes(graph) {
740
- for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
741
- }
742
1103
  async function runPackageRelease(pkg, options) {
743
1104
  const scoped = createScopedPlugins({
744
1105
  pkg,
@@ -807,16 +1168,12 @@ async function bumpDependents(released, version, graph, options) {
807
1168
  }
808
1169
  return applied;
809
1170
  }
810
- function mustGet(map, key, what) {
811
- const value = map.get(key);
812
- if (value === void 0) throw new WorkspaceReleaseError(`Internal error: ${what} "${key}" disappeared from the dependency graph mid-run.`);
813
- return value;
814
- }
815
1171
  //#endregion
816
1172
  exports.DEFAULT_PUBLISH_PLUGINS = DEFAULT_PUBLISH_PLUGINS;
817
1173
  exports.DependencyCycleError = DependencyCycleError;
818
1174
  exports.GitCommandError = GitCommandError;
819
1175
  exports.ReleaseConfigurationError = ReleaseConfigurationError;
1176
+ exports.SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS;
820
1177
  exports.UnsupportedDependencyRangeError = UnsupportedDependencyRangeError;
821
1178
  exports.WorkspaceDiscoveryError = WorkspaceDiscoveryError;
822
1179
  exports.WorkspaceReleaseError = WorkspaceReleaseError;