@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/cli.js CHANGED
@@ -12,8 +12,9 @@ import { readFile, writeFile } from "node:fs/promises";
12
12
  import validateNpmPackageName from "validate-npm-package-name";
13
13
  import { glob } from "tinyglobby";
14
14
  import { parse } from "yaml";
15
+ import { pathToFileURL } from "node:url";
15
16
  //#region package.json
16
- var version = "1.1.7";
17
+ var version = "1.2.1";
17
18
  //#endregion
18
19
  //#region src/errors.ts
19
20
  /**
@@ -141,11 +142,36 @@ const BOT_IDENTITY = {
141
142
  name: "semantic-release-bot",
142
143
  email: "semantic-release-bot@martynus.net"
143
144
  };
145
+ /**
146
+ * 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.
147
+ */
148
+ const GIT_REPOSITORY_DISCOVERY_ENV_KEYS = [
149
+ "GIT_DIR",
150
+ "GIT_WORK_TREE",
151
+ "GIT_INDEX_FILE",
152
+ "GIT_PREFIX",
153
+ "GIT_OBJECT_DIRECTORY",
154
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
155
+ "GIT_COMMON_DIR",
156
+ "GIT_NAMESPACE"
157
+ ];
158
+ /**
159
+ * A copy of the given environment with every repository-discovery-affecting `GIT_*` key removed -- see `GIT_REPOSITORY_DISCOVERY_ENV_KEYS`.
160
+ *
161
+ * 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.
162
+ */
163
+ function sanitizeGitEnv(env) {
164
+ const sanitized = { ...env };
165
+ for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) delete sanitized[key];
166
+ return sanitized;
167
+ }
168
+ const SANITIZED_PROCESS_GIT_ENV = sanitizeGitEnv(process.env);
144
169
  async function git(args, options) {
145
170
  try {
146
171
  const { stdout } = await execFileAsync$1("git", [...args], {
147
172
  cwd: options.cwd,
148
- maxBuffer: GIT_MAX_BUFFER_BYTES
173
+ maxBuffer: GIT_MAX_BUFFER_BYTES,
174
+ env: SANITIZED_PROCESS_GIT_ENV
149
175
  });
150
176
  return stdout;
151
177
  } catch (cause) {
@@ -238,6 +264,50 @@ async function pushHead(options) {
238
264
  `HEAD:${await currentBranch(options)}`
239
265
  ], options);
240
266
  }
267
+ /** 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. */
268
+ async function createTag(name, ref, options) {
269
+ await git([
270
+ "tag",
271
+ name,
272
+ ref
273
+ ], options);
274
+ }
275
+ /**
276
+ * 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.
277
+ *
278
+ * `-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.
279
+ */
280
+ async function workingTreeChanges(options) {
281
+ const tokens = (await git([
282
+ "status",
283
+ "--porcelain=v1",
284
+ "--untracked-files=all",
285
+ "-z"
286
+ ], options)).split("\0").filter((token) => token !== "");
287
+ const paths = [];
288
+ for (let index = 0; index < tokens.length; index += 1) {
289
+ const entry = tokens[index];
290
+ if (entry === void 0 || entry.length < 4) continue;
291
+ const statusCode = entry.slice(0, 2);
292
+ paths.push(entry.slice(3));
293
+ if (statusCode.includes("R") || statusCode.includes("C")) index += 1;
294
+ }
295
+ return paths;
296
+ }
297
+ /** 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. */
298
+ async function assertCleanWorkingTree(options) {
299
+ const changes = await workingTreeChanges(options);
300
+ 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.`);
301
+ }
302
+ /** 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. */
303
+ async function pushHeadAndTags(tagNames, options) {
304
+ await git([
305
+ "push",
306
+ "origin",
307
+ `HEAD:${await currentBranch(options)}`,
308
+ ...tagNames
309
+ ], options);
310
+ }
241
311
  function toGitCommandError(args, cwd, cause) {
242
312
  const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
243
313
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
@@ -255,6 +325,12 @@ const DEFAULT_PUBLISH_PLUGINS = [
255
325
  message: "chore(release): ${nextRelease.gitTag} [skip ci]"
256
326
  }]
257
327
  ];
328
+ /** 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. */
329
+ const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
330
+ "@semantic-release/changelog",
331
+ "@semantic-release/npm",
332
+ "@semantic-release/github"
333
+ ];
258
334
  const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator"]);
259
335
  /**
260
336
  * Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
@@ -275,7 +351,9 @@ function createScopedPlugins(scope) {
275
351
  from,
276
352
  paths: changedPathsSince(from, { cwd: context.cwd })
277
353
  };
278
- return filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
354
+ const commits = filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
355
+ scope.onCommitsResolved?.(commits);
356
+ return commits;
279
357
  }
280
358
  return {
281
359
  async analyzeCommits(_pluginConfig, context) {
@@ -346,7 +424,10 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
346
424
  for (const spec of specs) {
347
425
  const [name, config] = parsePublishPluginSpec(spec);
348
426
  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.`);
349
- if (name === "@semantic-release/git") hasGitPlugin = true;
427
+ if (name === "@semantic-release/git") {
428
+ hasGitPlugin = true;
429
+ 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.`);
430
+ }
350
431
  const entry = [resolvePluginModule(name, requireFromTool, requireFromWorkspace), config];
351
432
  resolved.push(entry);
352
433
  }
@@ -415,6 +496,70 @@ async function writeDependencyRange(path, field, dependency, range) {
415
496
  await writeFile(path, stringifyJsonLike(parsed, text), "utf8");
416
497
  }
417
498
  //#endregion
499
+ //#region src/version-range.ts
500
+ const WORKSPACE_PROTOCOL = "workspace:";
501
+ const CATALOG_PROTOCOL = "catalog:";
502
+ const NPM_ALIAS_PROTOCOL = "npm:";
503
+ /** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
504
+ const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
505
+ "*",
506
+ "^",
507
+ "~"
508
+ ];
509
+ /** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
510
+ const WILDCARD_RANGES = [
511
+ "",
512
+ "*",
513
+ "x",
514
+ "X",
515
+ "latest"
516
+ ];
517
+ /**
518
+ * 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.
519
+ */
520
+ const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
521
+ /**
522
+ * 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.
523
+ *
524
+ * 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.
525
+ */
526
+ function classifyDependencyRange(current) {
527
+ const range = current.trim();
528
+ if (range.startsWith(WORKSPACE_PROTOCOL)) {
529
+ const suffix = range.slice(10);
530
+ if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
531
+ const inner = classifyDependencyRange(suffix);
532
+ 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.`);
533
+ return {
534
+ kind: "rewritable",
535
+ workspacePrefixed: true,
536
+ comparator: inner.comparator
537
+ };
538
+ }
539
+ 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.`);
540
+ 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.`);
541
+ if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
542
+ const match = REWRITABLE_COMPARATOR.exec(range);
543
+ 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.`);
544
+ return {
545
+ kind: "rewritable",
546
+ workspacePrefixed: false,
547
+ comparator: match[1] ?? ""
548
+ };
549
+ }
550
+ /**
551
+ * 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.
552
+ */
553
+ function updateDependencyRange(current, version) {
554
+ const shape = classifyDependencyRange(current);
555
+ if (shape.kind !== "rewritable") return shape;
556
+ const rewritten = `${shape.comparator}${version}`;
557
+ return {
558
+ kind: "rewritten",
559
+ range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
560
+ };
561
+ }
562
+ //#endregion
418
563
  //#region src/workspace.ts
419
564
  /** The one filename pnpm recognises as a workspace definition. */
420
565
  const WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
@@ -569,6 +714,22 @@ function firstUnplacedDependency(name, graph, unplaced) {
569
714
  if (name === void 0) return [...unplaced].sort()[0];
570
715
  return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
571
716
  }
717
+ /**
718
+ * 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.
719
+ *
720
+ * 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.
721
+ */
722
+ function validateDependencyRangeShapes(graph) {
723
+ for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
724
+ }
725
+ /**
726
+ * 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.
727
+ */
728
+ function mustGet(map, key, what) {
729
+ const value = map.get(key);
730
+ if (value === void 0) throw new WorkspaceReleaseError(`Internal error: ${what} "${key}" disappeared from the dependency graph mid-run.`);
731
+ return value;
732
+ }
572
733
  //#endregion
573
734
  //#region src/pnpm.ts
574
735
  const execFileAsync = promisify(execFile);
@@ -587,69 +748,274 @@ async function regenerateLockfile(options) {
587
748
  }
588
749
  }
589
750
  //#endregion
590
- //#region src/version-range.ts
591
- const WORKSPACE_PROTOCOL = "workspace:";
592
- const CATALOG_PROTOCOL = "catalog:";
593
- const NPM_ALIAS_PROTOCOL = "npm:";
594
- /** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
595
- const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
596
- "*",
597
- "^",
598
- "~"
599
- ];
600
- /** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
601
- const WILDCARD_RANGES = [
602
- "",
603
- "*",
604
- "x",
605
- "X",
606
- "latest"
607
- ];
608
- /**
609
- * 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.
610
- */
611
- const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
751
+ //#region src/single-commit-release.ts
612
752
  /**
613
- * 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.
753
+ * `commitStrategy: 'single'`: one combined commit for the whole run instead of one commit per package release plus one per dependency bump.
614
754
  *
615
- * 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.
755
+ * Five phases, all inside one `releaseWorkspaceSingleCommit` call:
756
+ *
757
+ * 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.
758
+ * 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.
759
+ * 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.
760
+ * 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.
761
+ * 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`.
762
+ *
763
+ * 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.
616
764
  */
617
- function classifyDependencyRange(current) {
618
- const range = current.trim();
619
- if (range.startsWith(WORKSPACE_PROTOCOL)) {
620
- const suffix = range.slice(10);
621
- if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
622
- const inner = classifyDependencyRange(suffix);
623
- 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.`);
624
- return {
625
- kind: "rewritable",
626
- workspacePrefixed: true,
627
- comparator: inner.comparator
628
- };
765
+ async function releaseWorkspaceSingleCommit(options) {
766
+ const root = resolve(options.root ?? process.cwd());
767
+ const log = options.log ?? console.log;
768
+ const dryRun = options.dryRun === true;
769
+ const env = sanitizeGitEnv(options.env ?? process.env);
770
+ const workspace = await discoverWorkspace(root);
771
+ const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
772
+ await assertCleanWorkingTree({ cwd: repoRoot });
773
+ const graph = buildDependencyGraph(workspace.packages);
774
+ validateDependencyRangeShapes(graph);
775
+ const order = topologicalOrder(graph);
776
+ log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
777
+ const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
778
+ requireGitPlugin: false,
779
+ forbidGitPlugin: true
780
+ });
781
+ const analyzeCommitsConfig = options.analyzeCommits ?? {};
782
+ const generateNotesConfig = options.generateNotes ?? {};
783
+ const capturedCommits = /* @__PURE__ */ new Map();
784
+ const captured = {
785
+ branch: void 0,
786
+ repositoryUrl: void 0
787
+ };
788
+ const pendingBumps = /* @__PURE__ */ new Map();
789
+ const outcomes = [];
790
+ const planned = [];
791
+ for (const name of order) {
792
+ const pkg = mustGet(graph.packages, name, "package");
793
+ const bumpsForThisPackage = pendingBumps.get(name) ?? [];
794
+ pendingBumps.delete(name);
795
+ const nextRelease = await analysePackage(pkg, {
796
+ resolvedPlugins,
797
+ analyzeCommitsConfig,
798
+ generateNotesConfig,
799
+ bumpsForThisPackage,
800
+ env,
801
+ branches: options.branches,
802
+ onCommitsResolved: (commits) => capturedCommits.set(name, commits),
803
+ onContextCaptured: (context) => {
804
+ captured.branch = context.branch;
805
+ captured.repositoryUrl = context.repositoryUrl;
806
+ }
807
+ });
808
+ outcomes.push({
809
+ name,
810
+ directory: pkg.directory,
811
+ released: nextRelease !== void 0,
812
+ version: nextRelease?.version,
813
+ gitTag: nextRelease?.gitTag,
814
+ type: nextRelease?.type,
815
+ dependencyBumps: bumpsForThisPackage
816
+ });
817
+ if (nextRelease === void 0) {
818
+ log(`${name}: no release`);
819
+ continue;
820
+ }
821
+ log(`${name}: would release ${nextRelease.gitTag}`);
822
+ planned.push({
823
+ pkg,
824
+ type: nextRelease.type,
825
+ version: nextRelease.version,
826
+ gitTag: nextRelease.gitTag,
827
+ notes: nextRelease.notes,
828
+ bumps: bumpsForThisPackage
829
+ });
830
+ for (const bump of planDependentBumps(pkg, nextRelease.version, graph)) {
831
+ const forDependent = pendingBumps.get(bump.dependent) ?? [];
832
+ forDependent.push(bump);
833
+ pendingBumps.set(bump.dependent, forDependent);
834
+ }
835
+ }
836
+ if (dryRun || planned.length === 0) return {
837
+ order,
838
+ packages: outcomes
839
+ };
840
+ const branch = captured.branch;
841
+ const repositoryUrl = captured.repositoryUrl;
842
+ 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.`);
843
+ const shared = {
844
+ env,
845
+ branch,
846
+ repositoryUrl,
847
+ log
848
+ };
849
+ const moduleCache = /* @__PURE__ */ new Map();
850
+ for (const release of planned) for (const [modulePath, pluginConfig] of resolvedPlugins) {
851
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
852
+ if (plugin.verifyConditions) await plugin.verifyConditions(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
853
+ }
854
+ let anyRangeRewritten = false;
855
+ for (const release of planned) {
856
+ for (const bump of release.bumps) {
857
+ if (bump.kind !== "rewritten") continue;
858
+ await writeDependencyRange(release.pkg.manifestPath, bump.field, bump.dependency, bump.range);
859
+ anyRangeRewritten = true;
860
+ }
861
+ for (const [modulePath, pluginConfig] of resolvedPlugins) {
862
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
863
+ if (plugin.prepare) await plugin.prepare(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
864
+ }
865
+ }
866
+ if (anyRangeRewritten) await regenerateLockfile({ cwd: workspace.root });
867
+ const touchedPaths = await workingTreeChanges({ cwd: repoRoot });
868
+ 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.`);
869
+ const identity = await resolveCommitIdentity({ cwd: repoRoot });
870
+ await commitFiles(touchedPaths, describeCombinedCommit(planned), {
871
+ cwd: repoRoot,
872
+ identity
873
+ });
874
+ const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
875
+ const tagNames = planned.map((release) => release.gitTag);
876
+ for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
877
+ await pushHeadAndTags(tagNames, { cwd: repoRoot });
878
+ log(`${packageName}: committed ${commitSha} and pushed ${tagNames.length} tag(s): ${tagNames.join(", ")}`);
879
+ for (const release of planned) {
880
+ const releases = [];
881
+ for (const [modulePath, pluginConfig] of resolvedPlugins) {
882
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
883
+ if (plugin.publish) {
884
+ const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
885
+ if (result !== false && result !== void 0) releases.push(result);
886
+ }
887
+ }
888
+ for (const [modulePath, pluginConfig] of resolvedPlugins) {
889
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
890
+ if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
891
+ }
892
+ log(`${release.pkg.name}: published ${release.gitTag}`);
629
893
  }
630
- 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.`);
631
- 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.`);
632
- if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
633
- const match = REWRITABLE_COMPARATOR.exec(range);
634
- 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.`);
635
894
  return {
636
- kind: "rewritable",
637
- workspacePrefixed: false,
638
- comparator: match[1] ?? ""
895
+ order,
896
+ packages: outcomes
639
897
  };
640
898
  }
641
899
  /**
642
- * 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.
900
+ * 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.
643
901
  */
644
- function updateDependencyRange(current, version) {
645
- const shape = classifyDependencyRange(current);
646
- if (shape.kind !== "rewritable") return shape;
647
- const rewritten = `${shape.comparator}${version}`;
902
+ async function analysePackage(pkg, options) {
903
+ const scoped = createScopedPlugins({
904
+ pkg,
905
+ analyzeCommitsConfig: options.analyzeCommitsConfig,
906
+ generateNotesConfig: options.generateNotesConfig,
907
+ bumps: { bumpsFor: () => options.bumpsForThisPackage },
908
+ onCommitsResolved: options.onCommitsResolved
909
+ });
910
+ const semanticReleaseOptions = {
911
+ tagFormat: `${pkg.name}@\${version}`,
912
+ plugins: options.resolvedPlugins,
913
+ dryRun: true,
914
+ async analyzeCommits(pluginConfig, context) {
915
+ if (context.options.repositoryUrl === void 0) throw new ReleaseConfigurationError("Internal error: semantic-release did not resolve a repository URL before analyzeCommits ran.");
916
+ options.onContextCaptured({
917
+ branch: context.branch,
918
+ repositoryUrl: context.options.repositoryUrl
919
+ });
920
+ return scoped.analyzeCommits(pluginConfig, context);
921
+ },
922
+ generateNotes: scoped.generateNotes
923
+ };
924
+ if (options.branches !== void 0) semanticReleaseOptions.branches = options.branches;
925
+ const result = await semanticRelease(semanticReleaseOptions, {
926
+ cwd: pkg.directory,
927
+ env: { ...options.env }
928
+ });
929
+ if (result === false) return;
930
+ if (result.nextRelease.notes === void 0) throw new ReleaseConfigurationError(`Internal error: ${pkg.name} released with no notes computed.`);
648
931
  return {
649
- kind: "rewritten",
650
- range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
932
+ type: result.nextRelease.type,
933
+ version: result.nextRelease.version,
934
+ gitTag: result.nextRelease.gitTag,
935
+ notes: result.nextRelease.notes
651
936
  };
652
937
  }
938
+ /** 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. */
939
+ function planDependentBumps(released, version, graph) {
940
+ const applied = [];
941
+ const dependents = graph.dependents.get(released.name);
942
+ if (dependents === void 0) return applied;
943
+ for (const edge of dependents) {
944
+ const update = updateDependencyRange(edge.range, version);
945
+ if (update.kind === "wildcard") continue;
946
+ applied.push({
947
+ dependent: edge.dependent,
948
+ dependency: released.name,
949
+ field: edge.field,
950
+ version,
951
+ range: update.kind === "rewritten" ? update.range : edge.range,
952
+ kind: update.kind
953
+ });
954
+ }
955
+ return applied;
956
+ }
957
+ function describeCombinedCommit(planned) {
958
+ return [
959
+ "chore(release): batch release [skip ci]",
960
+ "",
961
+ ...planned.map((release) => {
962
+ const bumpDescriptions = release.bumps.map((bump) => `${bump.dependency} to ${bump.range}`);
963
+ const bumpSuffix = bumpDescriptions.length === 0 ? "" : ` (dependenc${bumpDescriptions.length === 1 ? "y" : "ies"} bumped: ${bumpDescriptions.join(", ")})`;
964
+ return `- ${release.gitTag} (${release.type})${bumpSuffix}`;
965
+ })
966
+ ].join("\n");
967
+ }
968
+ function buildPluginContext(release, shared, capturedCommits, releases) {
969
+ const logger = {
970
+ log: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`),
971
+ warn: (...args) => shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`),
972
+ error: (...args) => shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`),
973
+ success: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`)
974
+ };
975
+ return {
976
+ cwd: release.pkg.directory,
977
+ env: { ...shared.env },
978
+ stdout: process.stdout,
979
+ stderr: process.stderr,
980
+ logger,
981
+ options: { repositoryUrl: shared.repositoryUrl },
982
+ branch: shared.branch,
983
+ commits: capturedCommits.get(release.pkg.name) ?? [],
984
+ releases,
985
+ nextRelease: {
986
+ type: release.type,
987
+ version: release.version,
988
+ gitTag: release.gitTag,
989
+ name: release.gitTag,
990
+ notes: release.notes,
991
+ channel: channelOrNull(shared.branch.channel)
992
+ }
993
+ };
994
+ }
995
+ /** 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. */
996
+ function channelOrNull(channel) {
997
+ return channel === void 0 || channel === false || channel === "" ? null : channel;
998
+ }
999
+ function isReleaseLifecycleFnOrUndefined(value) {
1000
+ return value === void 0 || typeof value === "function";
1001
+ }
1002
+ function isReleasePluginModule(value) {
1003
+ if (typeof value !== "object" || value === null) return false;
1004
+ if ("verifyConditions" in value && !isReleaseLifecycleFnOrUndefined(value.verifyConditions)) return false;
1005
+ if ("prepare" in value && !isReleaseLifecycleFnOrUndefined(value.prepare)) return false;
1006
+ if ("publish" in value && !isReleaseLifecycleFnOrUndefined(value.publish)) return false;
1007
+ if ("success" in value && !isReleaseLifecycleFnOrUndefined(value.success)) return false;
1008
+ return true;
1009
+ }
1010
+ /** 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. */
1011
+ async function loadReleasePlugin(absolutePath, cache) {
1012
+ const cached = cache.get(absolutePath);
1013
+ if (cached !== void 0) return cached;
1014
+ const loaded = await import(pathToFileURL(absolutePath).href);
1015
+ 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.`);
1016
+ cache.set(absolutePath, loaded);
1017
+ return loaded;
1018
+ }
653
1019
  //#endregion
654
1020
  //#region src/release.ts
655
1021
  /**
@@ -658,10 +1024,11 @@ function updateDependencyRange(current, version) {
658
1024
  * 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.
659
1025
  */
660
1026
  async function releaseWorkspace(options = {}) {
1027
+ if ((options.commitStrategy ?? "per-package") === "single") return releaseWorkspaceSingleCommit(options);
661
1028
  const root = resolve(options.root ?? process.cwd());
662
1029
  const log = options.log ?? console.log;
663
1030
  const dryRun = options.dryRun === true;
664
- const env = options.env ?? process.env;
1031
+ const env = sanitizeGitEnv(options.env ?? process.env);
665
1032
  const workspace = await discoverWorkspace(root);
666
1033
  const graph = buildDependencyGraph(workspace.packages);
667
1034
  validateDependencyRangeShapes(graph);
@@ -720,12 +1087,6 @@ async function releaseWorkspace(options = {}) {
720
1087
  packages: outcomes
721
1088
  };
722
1089
  }
723
- /**
724
- * 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.
725
- */
726
- function validateDependencyRangeShapes(graph) {
727
- for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
728
- }
729
1090
  async function runPackageRelease(pkg, options) {
730
1091
  const scoped = createScopedPlugins({
731
1092
  pkg,
@@ -794,11 +1155,6 @@ async function bumpDependents(released, version, graph, options) {
794
1155
  }
795
1156
  return applied;
796
1157
  }
797
- function mustGet(map, key, what) {
798
- const value = map.get(key);
799
- if (value === void 0) throw new WorkspaceReleaseError(`Internal error: ${what} "${key}" disappeared from the dependency graph mid-run.`);
800
- return value;
801
- }
802
1158
  //#endregion
803
1159
  //#region src/cli.ts
804
1160
  const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
@@ -806,8 +1162,13 @@ const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
806
1162
  "branches",
807
1163
  "plugins",
808
1164
  "analyzeCommits",
809
- "generateNotes"
1165
+ "generateNotes",
1166
+ "commitStrategy"
810
1167
  ]);
1168
+ const COMMIT_STRATEGIES = /* @__PURE__ */ new Set(["per-package", "single"]);
1169
+ function isCommitStrategy(value) {
1170
+ return COMMIT_STRATEGIES.has(value);
1171
+ }
811
1172
  /**
812
1173
  * Builds the commander program but never parses argv or exits the process itself in construction, so the command tree stays testable in isolation. `release` is the orchestration entry point: discover the workspace, order it topologically, and run semantic-release per package.
813
1174
  */
@@ -823,16 +1184,22 @@ function createProgram() {
823
1184
  release.option("--plugin <spec>", "publish-pipeline plugin, repeatable: a module name, or a JSON array of [name, config]; defaults to the standard changelog/npm/github/git pipeline", collectRepeated, []);
824
1185
  release.option("--analyze-commits <json>", "options for the wrapped @semantic-release/commit-analyzer, as a JSON object");
825
1186
  release.option("--generate-notes <json>", "options for the wrapped @semantic-release/release-notes-generator, as a JSON object");
826
- release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, analyzeCommits, generateNotes); explicit flags win");
1187
+ release.option("--commit-strategy <mode>", "how the run commits its released changes: \"per-package\" (default; today's behaviour, one commit per release plus one per dependency bump) or \"single\" (one combined commit for the whole run, tagged once per released package)", parseCommitStrategy);
1188
+ release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy); explicit flags win");
827
1189
  release.action(runRelease);
828
1190
  return program;
829
1191
  }
1192
+ function parseCommitStrategy(value) {
1193
+ if (!isCommitStrategy(value)) throw new InvalidArgumentError(`--commit-strategy must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
1194
+ return value;
1195
+ }
830
1196
  const NO_CONFIG_FILE = {
831
1197
  dryRun: void 0,
832
1198
  branches: void 0,
833
1199
  plugins: void 0,
834
1200
  analyzeCommits: void 0,
835
- generateNotes: void 0
1201
+ generateNotes: void 0,
1202
+ commitStrategy: void 0
836
1203
  };
837
1204
  async function runRelease(flags) {
838
1205
  const file = flags.config === void 0 ? NO_CONFIG_FILE : readReleaseConfigFile(flags.config);
@@ -840,9 +1207,10 @@ async function runRelease(flags) {
840
1207
  root: flags.root,
841
1208
  dryRun: flags.dryRun ?? (file.dryRun === true ? true : void 0),
842
1209
  branches: flags.branches.length > 0 ? flags.branches : file.branches,
843
- plugins: flags.plugin.length > 0 ? flags.plugin.map((spec) => parsePluginSpec(spec)) : file.plugins ?? DEFAULT_PUBLISH_PLUGINS,
1210
+ plugins: flags.plugin.length > 0 ? flags.plugin.map((spec) => parsePluginSpec(spec)) : file.plugins,
844
1211
  analyzeCommits: flags.analyzeCommits === void 0 ? file.analyzeCommits : parseJsonObjectFlag(flags.analyzeCommits, "--analyze-commits"),
845
- generateNotes: flags.generateNotes === void 0 ? file.generateNotes : parseJsonObjectFlag(flags.generateNotes, "--generate-notes")
1212
+ generateNotes: flags.generateNotes === void 0 ? file.generateNotes : parseJsonObjectFlag(flags.generateNotes, "--generate-notes"),
1213
+ commitStrategy: flags.commitStrategy ?? file.commitStrategy
846
1214
  });
847
1215
  for (const pkg of outcome.packages) console.log(describeOutcome(pkg));
848
1216
  }
@@ -888,18 +1256,20 @@ function readReleaseConfigFile(path) {
888
1256
  const parsed = readConfigFile(path);
889
1257
  if (!isJsonObject(parsed)) throw new InvalidArgumentError(`--config file ${path} must contain a JSON object`);
890
1258
  for (const key of Object.keys(parsed)) if (!CONFIG_OPTION_KEYS.has(key)) throw new InvalidArgumentError(`--config file ${path} has an unknown option "${key}"; recognised options: ${[...CONFIG_OPTION_KEYS].join(", ")}`);
891
- const { dryRun, branches, plugins, analyzeCommits, generateNotes } = parsed;
1259
+ const { dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy } = parsed;
892
1260
  if (dryRun !== void 0 && typeof dryRun !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "dryRun" must be a boolean`);
893
1261
  if (branches !== void 0 && !isStringArray(branches)) throw new InvalidArgumentError(`--config file ${path}: "branches" must be an array of branch name strings`);
894
1262
  if (plugins !== void 0 && !Array.isArray(plugins)) throw new InvalidArgumentError(`--config file ${path}: "plugins" must be an array`);
895
1263
  if (analyzeCommits !== void 0 && !isJsonObject(analyzeCommits)) throw new InvalidArgumentError(`--config file ${path}: "analyzeCommits" must be an object`);
896
1264
  if (generateNotes !== void 0 && !isJsonObject(generateNotes)) throw new InvalidArgumentError(`--config file ${path}: "generateNotes" must be an object`);
1265
+ if (commitStrategy !== void 0 && (typeof commitStrategy !== "string" || !isCommitStrategy(commitStrategy))) throw new InvalidArgumentError(`--config file ${path}: "commitStrategy" must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
897
1266
  return {
898
1267
  dryRun,
899
1268
  branches,
900
1269
  plugins: plugins === void 0 ? void 0 : plugins.map((spec) => parseConfigFilePlugin(spec, path)),
901
1270
  analyzeCommits,
902
- generateNotes
1271
+ generateNotes,
1272
+ commitStrategy
903
1273
  };
904
1274
  }
905
1275
  function parseConfigFilePlugin(spec, path) {