@exadev/semantic-release-workspace 1.2.4 → 1.3.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
@@ -1,20 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
+ import { readFile, writeFile } from "node:fs/promises";
3
4
  import { cosmiconfigSync } from "cosmiconfig";
4
5
  import { Command, InvalidArgumentError } from "commander";
5
6
  import { dirname, relative, resolve, sep } from "node:path";
6
- import { analyzeCommits } from "@semantic-release/commit-analyzer";
7
- import { generateNotes } from "@semantic-release/release-notes-generator";
7
+ import { detachRelease, resumeRelease } from "@exadev/release-gate";
8
8
  import { execFile } from "node:child_process";
9
9
  import { promisify } from "node:util";
10
- import semanticRelease from "semantic-release";
11
- import { readFile, writeFile } from "node:fs/promises";
12
10
  import validateNpmPackageName from "validate-npm-package-name";
13
11
  import { glob } from "tinyglobby";
14
12
  import { parse } from "yaml";
13
+ import { analyzeCommits } from "@semantic-release/commit-analyzer";
14
+ import { generateNotes } from "@semantic-release/release-notes-generator";
15
+ import semanticRelease from "semantic-release";
15
16
  import { pathToFileURL } from "node:url";
16
17
  //#region package.json
17
- var version = "1.2.4";
18
+ var version = "1.3.1";
18
19
  //#endregion
19
20
  //#region src/errors.ts
20
21
  /**
@@ -60,77 +61,6 @@ var PnpmCommandError = class extends WorkspaceReleaseError {
60
61
  }
61
62
  };
62
63
  //#endregion
63
- //#region src/json.ts
64
- function isJsonObject(value) {
65
- return typeof value === "object" && value !== null && !Array.isArray(value);
66
- }
67
- function isStringRecord(value) {
68
- return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
69
- }
70
- function isStringArray(value) {
71
- return Array.isArray(value) && value.every((entry) => typeof entry === "string");
72
- }
73
- /**
74
- * Narrows `unknown` to `unknown[]`. `Array.isArray` alone narrows to `any[]`, whose elements flow as `any` into any later destructuring; going through this guard keeps the elements `unknown` so they must still be narrowed by hand.
75
- */
76
- function isUnknownArray(value) {
77
- return Array.isArray(value);
78
- }
79
- /**
80
- * The indentation of the first indented line in a JSON document, so a rewritten manifest keeps the formatting the repository already uses instead of being reflowed to whatever `JSON.stringify` defaults to. A file with no indented line at all (`{}` on one line) has no evidence either way, in which case two spaces -- npm's own default when it writes a `package.json` -- is the closest thing to a neutral choice.
81
- */
82
- const FIRST_INDENTED_LINE = /^[ \t]+(?=")/m;
83
- const NPM_DEFAULT_INDENT = " ";
84
- function detectIndent(text) {
85
- const match = FIRST_INDENTED_LINE.exec(text);
86
- return match === null ? NPM_DEFAULT_INDENT : match[0];
87
- }
88
- /** Serialises a JSON document back to text with the indentation and trailing-newline convention of the text it was read from, so rewriting one dependency range produces a one-line diff rather than a whole-file reformat. */
89
- function stringifyJsonLike(value, originalText) {
90
- const serialised = JSON.stringify(value, null, detectIndent(originalText));
91
- return originalText.endsWith("\n") ? `${serialised}\n` : serialised;
92
- }
93
- //#endregion
94
- //#region src/package-name.ts
95
- const packageName = "@exadev/semantic-release-workspace";
96
- //#endregion
97
- //#region src/dependency-bump-commit.ts
98
- /**
99
- * The commit message format for a cross-package dependency-range bump, shared between the code that writes it (`bumpDependents` in `release.ts`) and the code that reads it back (`createScopedPlugins` in `plugins.ts`).
100
- *
101
- * The subject line alone (`chore(deps): bump @scope/a to ^1.1.0 in @scope/b [skip ci]`) is for humans reading `git log`. Recovering the forced-patch decision from a commit already sitting in history -- rather than only from the in-memory record a single run builds as it goes -- needs a machine-parseable form as well, because a run that starts after a previous run already committed and pushed the bump (whether that previous run crashed immediately afterwards, or simply finished days ago) has no in-memory record at all: the only place the fact "this dependency range changed because a sibling released" is stated is the repository itself. The trailer below is that statement.
102
- */
103
- const TRAILER_DEPENDENCY = "Bumped-Workspace-Dependency";
104
- const TRAILER_VERSION = "Bumped-Workspace-Dependency-Version";
105
- const TRAILER_RANGE = "Bumped-Workspace-Dependency-Range";
106
- /** Builds the full commit message (subject and trailer) for one dependency-range bump. */
107
- function formatDependencyBumpMessage(info) {
108
- return `${`chore(deps): bump ${info.dependency} to ${info.range} in ${info.dependent} [skip ci]`}\n\n${[
109
- `${TRAILER_DEPENDENCY}: ${info.dependency}`,
110
- `${TRAILER_VERSION}: ${info.version}`,
111
- `${TRAILER_RANGE}: ${info.range}`
112
- ].join("\n")}`;
113
- }
114
- /**
115
- * Recovers the dependency bump a `formatDependencyBumpMessage` commit recorded, from its full git message (subject and body), or `undefined` if the message carries no such trailer. All three lines must be present for the commit to be treated as a bump commit at all -- a message missing even one is left alone rather than partially trusted.
116
- */
117
- function parseDependencyBumpTrailer(message) {
118
- const dependency = matchTrailerLine(message, TRAILER_DEPENDENCY);
119
- const version = matchTrailerLine(message, TRAILER_VERSION);
120
- const range = matchTrailerLine(message, TRAILER_RANGE);
121
- if (dependency === void 0 || version === void 0 || range === void 0) return;
122
- return {
123
- dependency,
124
- version,
125
- range
126
- };
127
- }
128
- function matchTrailerLine(message, key) {
129
- const prefix = `${key}: `;
130
- const line = message.split("\n").find((candidate) => candidate.startsWith(prefix));
131
- return line === void 0 ? void 0 : line.slice(prefix.length).trim();
132
- }
133
- //#endregion
134
64
  //#region src/git.ts
135
65
  const execFileAsync$1 = promisify(execFile);
136
66
  /** `git log --name-only` over everything since a package's last release tag can legitimately produce tens of megabytes of path output on a long-lived monorepo, well past execFile's default buffer, failing on exactly the big workspaces this tool exists for. */
@@ -315,142 +245,35 @@ function toGitCommandError(args, cwd, cause) {
315
245
  return new GitCommandError(args, cwd, exitCode, stderr !== "" ? stderr : cause instanceof Error ? cause.message : String(cause));
316
246
  }
317
247
  //#endregion
318
- //#region src/plugins.ts
319
- /** The standard publish pipeline this orchestrator coordinates when a workspace configures none of its own. Every entry reuses the corresponding official plugin -- the orchestrator scopes and sequences them per package, it does not reimplement npm publishing, GitHub release creation, or changelog writing. */
320
- const DEFAULT_PUBLISH_PLUGINS = [
321
- "@semantic-release/changelog",
322
- "@semantic-release/npm",
323
- "@semantic-release/github",
324
- ["@semantic-release/git", {
325
- assets: ["CHANGELOG.md", "package.json"],
326
- message: "chore(release): ${nextRelease.gitTag} [skip ci]"
327
- }]
328
- ];
329
- /** 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. */
330
- const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
331
- "@semantic-release/changelog",
332
- "@semantic-release/npm",
333
- "@semantic-release/github"
334
- ];
335
- const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator"]);
336
- /**
337
- * Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
338
- *
339
- * Both apply the same path scoping before delegating to the real @semantic-release/commit-analyzer and @semantic-release/release-notes-generator: the commit list semantic-release already fetched for the release range is filtered down to commits whose `git log --name-only` file list intersects the package's own directory, and only the filtered list reaches the standard plugin. Conventional-commit parsing and changelog formatting stay entirely inside the standard plugins.
340
- *
341
- * The `analyzeCommits` wrapper carries one addition beyond filtering: when the standard analyzer finds no releasable commits but a workspace dependency range of the package's has changed, it returns 'patch' anyway. A dependent whose only change is a dependency bump still needs a release for that range to reach the registry. "Has changed" is read from two sources, merged: bumps recorded in memory earlier in the current run (`scope.bumps`), and bumps recorded in the package's own filtered commit history via the trailer `dependency-bump-commit.ts` writes and reads -- the latter is what lets a run that starts after a previous run already committed and pushed the bump (a crash recovery, or simply a later run) reach the same decision, rather than depending on state that existed only inside the process that made the commit.
342
- */
343
- function createScopedPlugins(scope) {
344
- let cached;
345
- async function commitsForPackage(context) {
346
- const from = context.lastRelease?.gitHead ?? void 0;
347
- if (cached === void 0) cached = {
348
- from,
349
- paths: changedPathsSince(from, { cwd: context.cwd })
350
- };
351
- else if (cached.from !== from) cached = {
352
- from,
353
- paths: changedPathsSince(from, { cwd: context.cwd })
354
- };
355
- const commits = filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
356
- scope.onCommitsResolved?.(commits);
357
- return commits;
358
- }
359
- return {
360
- async analyzeCommits(_pluginConfig, context) {
361
- const commits = await commitsForPackage(context);
362
- const type = await analyzeCommits(scope.analyzeCommitsConfig, {
363
- ...context,
364
- commits
365
- });
366
- if (type) return type;
367
- const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
368
- if (bumps.length === 0) return false;
369
- context.logger.log(`No releasable commits under ${scope.pkg.relativeDirectory}, but ${bumps.length === 1 ? "a workspace dependency range changed" : `${bumps.length} workspace dependency ranges changed`}; forcing a patch release.`);
370
- return "patch";
371
- },
372
- async generateNotes(_pluginConfig, context) {
373
- const commits = await commitsForPackage(context);
374
- const notes = await generateNotes(scope.generateNotesConfig, {
375
- ...context,
376
- commits
377
- });
378
- const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
379
- if (bumps.length === 0) return notes;
380
- const section = [
381
- "### Dependencies",
382
- "",
383
- ...bumps.map((bump) => describeDependencyBump(bump))
384
- ].join("\n");
385
- return notes ? `${notes}\n\n${section}` : section;
386
- }
387
- };
248
+ //#region src/json.ts
249
+ function isJsonObject(value) {
250
+ return typeof value === "object" && value !== null && !Array.isArray(value);
388
251
  }
389
- /**
390
- * Combines the bumps recorded in memory earlier in the current run with bumps recovered from the package's own filtered commit history (a bump commit from this run, already visible because it touches the package's own directory, or one left over from a previous run), de-duplicated by dependency name. The in-memory entry wins on overlap: it carries the manifest field and dependent name a `resolved-at-publish` bump has no commit to recover from at all.
391
- */
392
- function mergeDependencyBumps(runtimeBumps, commits) {
393
- const byDependency = /* @__PURE__ */ new Map();
394
- for (const commit of commits) {
395
- const parsed = parseDependencyBumpTrailer(commit.message);
396
- if (parsed !== void 0) byDependency.set(parsed.dependency, {
397
- ...parsed,
398
- kind: "rewritten"
399
- });
400
- }
401
- for (const bump of runtimeBumps) byDependency.set(bump.dependency, bump);
402
- return [...byDependency.values()];
252
+ function isStringRecord(value) {
253
+ return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
403
254
  }
404
- function describeDependencyBump(bump) {
405
- return bump.kind === "rewritten" ? `- Updated ${bump.dependency} to ${bump.range}` : `- Updated ${bump.dependency} to ${bump.version} (declared as \`${bump.range}\`, resolved by pnpm at publish time)`;
255
+ function isStringArray(value) {
256
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
406
257
  }
407
258
  /**
408
- * Keeps a commit for the package when any path it changed lies under the package's directory. The trailing-slash prefix comparison stops `packages/a` from matching `packages/abc/x`.
409
- *
410
- * A commit missing from the changed-paths map is kept rather than dropped: it is inside the package's release range (semantic-release put it there), so a failure to parse its file list must not silently swallow a release. Absent evidence errs towards publishing, which is the visible direction for a release tool.
259
+ * Narrows `unknown` to `unknown[]`. `Array.isArray` alone narrows to `any[]`, whose elements flow as `any` into any later destructuring; going through this guard keeps the elements `unknown` so they must still be narrowed by hand.
411
260
  */
412
- function filterCommitsToDirectory(commits, changedPaths, directory) {
413
- const prefix = `${directory}/`;
414
- return commits.filter((commit) => {
415
- const paths = changedPaths.get(commit.hash);
416
- if (paths === void 0) return true;
417
- return [...paths].some((path) => path === directory || path.startsWith(prefix));
418
- });
419
- }
420
- function resolvePublishPlugins(specs, workspaceRoot, options) {
421
- const requireFromTool = createRequire(import.meta.url);
422
- const requireFromWorkspace = createRequire(resolve(workspaceRoot, "package.json"));
423
- const resolved = [];
424
- let hasGitPlugin = false;
425
- for (const spec of specs) {
426
- const [name, config] = parsePublishPluginSpec(spec);
427
- 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.`);
428
- if (name === "@semantic-release/git") {
429
- hasGitPlugin = true;
430
- 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.`);
431
- }
432
- const entry = [resolvePluginModule(name, requireFromTool, requireFromWorkspace), config];
433
- resolved.push(entry);
434
- }
435
- if (options.requireGitPlugin && !hasGitPlugin) throw new ReleaseConfigurationError(`The publish plugin list does not include @semantic-release/git. Without it, nothing commits each released package's manifest and changelog back to the branch, so the repository would drift out of agreement with the published versions -- the exact divergence this tool exists to prevent. (Dry runs are exempt.)`);
436
- return resolved;
261
+ function isUnknownArray(value) {
262
+ return Array.isArray(value);
437
263
  }
438
264
  /**
439
- * Resolves a plugin module name to an absolute file path, first from this tool's own module context (its peer dependencies, which every workspace installing the orchestrator must provide) and then from the workspace root (a workspace's own plugin dependencies, such as a custom changelog plugin). Both bases are named in the error when neither can resolve the name.
265
+ * The indentation of the first indented line in a JSON document, so a rewritten manifest keeps the formatting the repository already uses instead of being reflowed to whatever `JSON.stringify` defaults to. A file with no indented line at all (`{}` on one line) has no evidence either way, in which case two spaces -- npm's own default when it writes a `package.json` -- is the closest thing to a neutral choice.
440
266
  */
441
- function resolvePluginModule(name, requireFromTool, requireFromWorkspace) {
442
- const attempts = [];
443
- for (const [label, requirer] of [["this tool", requireFromTool], ["the workspace root", requireFromWorkspace]]) try {
444
- return requirer.resolve(name);
445
- } catch (cause) {
446
- attempts.push(`${label}: ${cause instanceof Error ? cause.message : String(cause)}`);
447
- }
448
- throw new ReleaseConfigurationError(`Cannot resolve the publish plugin "${name}". Tried resolving it from ${attempts.join("; and from ")}.`);
267
+ const FIRST_INDENTED_LINE = /^[ \t]+(?=")/m;
268
+ const NPM_DEFAULT_INDENT = " ";
269
+ function detectIndent(text) {
270
+ const match = FIRST_INDENTED_LINE.exec(text);
271
+ return match === null ? NPM_DEFAULT_INDENT : match[0];
449
272
  }
450
- function parsePublishPluginSpec(spec) {
451
- if (typeof spec === "string") return [spec, {}];
452
- const [name, config] = spec;
453
- return [name, config ?? {}];
273
+ /** Serialises a JSON document back to text with the indentation and trailing-newline convention of the text it was read from, so rewriting one dependency range produces a one-line diff rather than a whole-file reformat. */
274
+ function stringifyJsonLike(value, originalText) {
275
+ const serialised = JSON.stringify(value, null, detectIndent(originalText));
276
+ return originalText.endsWith("\n") ? `${serialised}\n` : serialised;
454
277
  }
455
278
  //#endregion
456
279
  //#region src/manifest.ts
@@ -732,6 +555,184 @@ function mustGet(map, key, what) {
732
555
  return value;
733
556
  }
734
557
  //#endregion
558
+ //#region src/package-name.ts
559
+ const packageName = "@exadev/semantic-release-workspace";
560
+ //#endregion
561
+ //#region src/dependency-bump-commit.ts
562
+ /**
563
+ * The commit message format for a cross-package dependency-range bump, shared between the code that writes it (`bumpDependents` in `release.ts`) and the code that reads it back (`createScopedPlugins` in `plugins.ts`).
564
+ *
565
+ * The subject line alone (`chore(deps): bump @scope/a to ^1.1.0 in @scope/b [skip ci]`) is for humans reading `git log`. Recovering the forced-patch decision from a commit already sitting in history -- rather than only from the in-memory record a single run builds as it goes -- needs a machine-parseable form as well, because a run that starts after a previous run already committed and pushed the bump (whether that previous run crashed immediately afterwards, or simply finished days ago) has no in-memory record at all: the only place the fact "this dependency range changed because a sibling released" is stated is the repository itself. The trailer below is that statement.
566
+ */
567
+ const TRAILER_DEPENDENCY = "Bumped-Workspace-Dependency";
568
+ const TRAILER_VERSION = "Bumped-Workspace-Dependency-Version";
569
+ const TRAILER_RANGE = "Bumped-Workspace-Dependency-Range";
570
+ /** Builds the full commit message (subject and trailer) for one dependency-range bump. */
571
+ function formatDependencyBumpMessage(info) {
572
+ return `${`chore(deps): bump ${info.dependency} to ${info.range} in ${info.dependent} [skip ci]`}\n\n${[
573
+ `${TRAILER_DEPENDENCY}: ${info.dependency}`,
574
+ `${TRAILER_VERSION}: ${info.version}`,
575
+ `${TRAILER_RANGE}: ${info.range}`
576
+ ].join("\n")}`;
577
+ }
578
+ /**
579
+ * Recovers the dependency bump a `formatDependencyBumpMessage` commit recorded, from its full git message (subject and body), or `undefined` if the message carries no such trailer. All three lines must be present for the commit to be treated as a bump commit at all -- a message missing even one is left alone rather than partially trusted.
580
+ */
581
+ function parseDependencyBumpTrailer(message) {
582
+ const dependency = matchTrailerLine(message, TRAILER_DEPENDENCY);
583
+ const version = matchTrailerLine(message, TRAILER_VERSION);
584
+ const range = matchTrailerLine(message, TRAILER_RANGE);
585
+ if (dependency === void 0 || version === void 0 || range === void 0) return;
586
+ return {
587
+ dependency,
588
+ version,
589
+ range
590
+ };
591
+ }
592
+ function matchTrailerLine(message, key) {
593
+ const prefix = `${key}: `;
594
+ const line = message.split("\n").find((candidate) => candidate.startsWith(prefix));
595
+ return line === void 0 ? void 0 : line.slice(prefix.length).trim();
596
+ }
597
+ //#endregion
598
+ //#region src/plugins.ts
599
+ /** The standard publish pipeline this orchestrator coordinates when a workspace configures none of its own. Every entry reuses the corresponding official plugin -- the orchestrator scopes and sequences them per package, it does not reimplement npm publishing, GitHub release creation, or changelog writing. */
600
+ const DEFAULT_PUBLISH_PLUGINS = [
601
+ "@semantic-release/changelog",
602
+ "@semantic-release/npm",
603
+ "@semantic-release/github",
604
+ ["@semantic-release/git", {
605
+ assets: ["CHANGELOG.md", "package.json"],
606
+ message: "chore(release): ${nextRelease.gitTag} [skip ci]"
607
+ }]
608
+ ];
609
+ /** 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. */
610
+ const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
611
+ "@semantic-release/changelog",
612
+ "@semantic-release/npm",
613
+ "@semantic-release/github"
614
+ ];
615
+ const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator"]);
616
+ /**
617
+ * Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
618
+ *
619
+ * Both apply the same path scoping before delegating to the real @semantic-release/commit-analyzer and @semantic-release/release-notes-generator: the commit list semantic-release already fetched for the release range is filtered down to commits whose `git log --name-only` file list intersects the package's own directory, and only the filtered list reaches the standard plugin. Conventional-commit parsing and changelog formatting stay entirely inside the standard plugins.
620
+ *
621
+ * The `analyzeCommits` wrapper carries one addition beyond filtering: when the standard analyzer finds no releasable commits but a workspace dependency range of the package's has changed, it returns 'patch' anyway. A dependent whose only change is a dependency bump still needs a release for that range to reach the registry. "Has changed" is read from two sources, merged: bumps recorded in memory earlier in the current run (`scope.bumps`), and bumps recorded in the package's own filtered commit history via the trailer `dependency-bump-commit.ts` writes and reads -- the latter is what lets a run that starts after a previous run already committed and pushed the bump (a crash recovery, or simply a later run) reach the same decision, rather than depending on state that existed only inside the process that made the commit.
622
+ */
623
+ function createScopedPlugins(scope) {
624
+ let cached;
625
+ async function commitsForPackage(context) {
626
+ const from = context.lastRelease?.gitHead ?? void 0;
627
+ if (cached === void 0) cached = {
628
+ from,
629
+ paths: changedPathsSince(from, { cwd: context.cwd })
630
+ };
631
+ else if (cached.from !== from) cached = {
632
+ from,
633
+ paths: changedPathsSince(from, { cwd: context.cwd })
634
+ };
635
+ const commits = filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
636
+ scope.onCommitsResolved?.(commits);
637
+ return commits;
638
+ }
639
+ return {
640
+ async analyzeCommits(_pluginConfig, context) {
641
+ const commits = await commitsForPackage(context);
642
+ const type = await analyzeCommits(scope.analyzeCommitsConfig, {
643
+ ...context,
644
+ commits
645
+ });
646
+ if (type) return type;
647
+ const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
648
+ if (bumps.length === 0) return false;
649
+ context.logger.log(`No releasable commits under ${scope.pkg.relativeDirectory}, but ${bumps.length === 1 ? "a workspace dependency range changed" : `${bumps.length} workspace dependency ranges changed`}; forcing a patch release.`);
650
+ return "patch";
651
+ },
652
+ async generateNotes(_pluginConfig, context) {
653
+ const commits = await commitsForPackage(context);
654
+ const notes = await generateNotes(scope.generateNotesConfig, {
655
+ ...context,
656
+ commits
657
+ });
658
+ const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
659
+ if (bumps.length === 0) return notes;
660
+ const section = [
661
+ "### Dependencies",
662
+ "",
663
+ ...bumps.map((bump) => describeDependencyBump(bump))
664
+ ].join("\n");
665
+ return notes ? `${notes}\n\n${section}` : section;
666
+ }
667
+ };
668
+ }
669
+ /**
670
+ * Combines the bumps recorded in memory earlier in the current run with bumps recovered from the package's own filtered commit history (a bump commit from this run, already visible because it touches the package's own directory, or one left over from a previous run), de-duplicated by dependency name. The in-memory entry wins on overlap: it carries the manifest field and dependent name a `resolved-at-publish` bump has no commit to recover from at all.
671
+ */
672
+ function mergeDependencyBumps(runtimeBumps, commits) {
673
+ const byDependency = /* @__PURE__ */ new Map();
674
+ for (const commit of commits) {
675
+ const parsed = parseDependencyBumpTrailer(commit.message);
676
+ if (parsed !== void 0) byDependency.set(parsed.dependency, {
677
+ ...parsed,
678
+ kind: "rewritten"
679
+ });
680
+ }
681
+ for (const bump of runtimeBumps) byDependency.set(bump.dependency, bump);
682
+ return [...byDependency.values()];
683
+ }
684
+ function describeDependencyBump(bump) {
685
+ return bump.kind === "rewritten" ? `- Updated ${bump.dependency} to ${bump.range}` : `- Updated ${bump.dependency} to ${bump.version} (declared as \`${bump.range}\`, resolved by pnpm at publish time)`;
686
+ }
687
+ /**
688
+ * Keeps a commit for the package when any path it changed lies under the package's directory. The trailing-slash prefix comparison stops `packages/a` from matching `packages/abc/x`.
689
+ *
690
+ * A commit missing from the changed-paths map is kept rather than dropped: it is inside the package's release range (semantic-release put it there), so a failure to parse its file list must not silently swallow a release. Absent evidence errs towards publishing, which is the visible direction for a release tool.
691
+ */
692
+ function filterCommitsToDirectory(commits, changedPaths, directory) {
693
+ const prefix = `${directory}/`;
694
+ return commits.filter((commit) => {
695
+ const paths = changedPaths.get(commit.hash);
696
+ if (paths === void 0) return true;
697
+ return [...paths].some((path) => path === directory || path.startsWith(prefix));
698
+ });
699
+ }
700
+ function resolvePublishPlugins(specs, workspaceRoot, options) {
701
+ const requireFromTool = createRequire(import.meta.url);
702
+ const requireFromWorkspace = createRequire(resolve(workspaceRoot, "package.json"));
703
+ const resolved = [];
704
+ let hasGitPlugin = false;
705
+ for (const spec of specs) {
706
+ const [name, config] = parsePublishPluginSpec(spec);
707
+ 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.`);
708
+ if (name === "@semantic-release/git") {
709
+ hasGitPlugin = true;
710
+ 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.`);
711
+ }
712
+ const entry = [resolvePluginModule(name, requireFromTool, requireFromWorkspace), config];
713
+ resolved.push(entry);
714
+ }
715
+ if (options.requireGitPlugin && !hasGitPlugin) throw new ReleaseConfigurationError(`The publish plugin list does not include @semantic-release/git. Without it, nothing commits each released package's manifest and changelog back to the branch, so the repository would drift out of agreement with the published versions -- the exact divergence this tool exists to prevent. (Dry runs are exempt.)`);
716
+ return resolved;
717
+ }
718
+ /**
719
+ * Resolves a plugin module name to an absolute file path, first from this tool's own module context (its peer dependencies, which every workspace installing the orchestrator must provide) and then from the workspace root (a workspace's own plugin dependencies, such as a custom changelog plugin). Both bases are named in the error when neither can resolve the name.
720
+ */
721
+ function resolvePluginModule(name, requireFromTool, requireFromWorkspace) {
722
+ const attempts = [];
723
+ for (const [label, requirer] of [["this tool", requireFromTool], ["the workspace root", requireFromWorkspace]]) try {
724
+ return requirer.resolve(name);
725
+ } catch (cause) {
726
+ attempts.push(`${label}: ${cause instanceof Error ? cause.message : String(cause)}`);
727
+ }
728
+ throw new ReleaseConfigurationError(`Cannot resolve the publish plugin "${name}". Tried resolving it from ${attempts.join("; and from ")}.`);
729
+ }
730
+ function parsePublishPluginSpec(spec) {
731
+ if (typeof spec === "string") return [spec, {}];
732
+ const [name, config] = spec;
733
+ return [name, config ?? {}];
734
+ }
735
+ //#endregion
735
736
  //#region src/pnpm.ts
736
737
  const execFileAsync = promisify(execFile);
737
738
  /**
@@ -1025,6 +1026,10 @@ async function loadReleasePlugin(absolutePath, cache) {
1025
1026
  * 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.
1026
1027
  */
1027
1028
  async function releaseWorkspace(options = {}) {
1029
+ if (options.gatePublish === true) {
1030
+ if ((options.commitStrategy ?? "per-package") === "single") throw new ReleaseConfigurationError("gatePublish is not supported together with commitStrategy: \"single\" -- single-commit-release.ts's tag/publish machinery is entirely bespoke and never goes through semantic-release's own run(), so it has no insertion point for release-gate's detach/resume primitives.");
1031
+ return detachWorkspaceRelease(options);
1032
+ }
1028
1033
  if ((options.commitStrategy ?? "per-package") === "single") return releaseWorkspaceSingleCommit(options);
1029
1034
  const root = resolve(options.root ?? process.cwd());
1030
1035
  const log = options.log ?? console.log;
@@ -1038,40 +1043,65 @@ async function releaseWorkspace(options = {}) {
1038
1043
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1039
1044
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1040
1045
  const generateNotesConfig = options.generateNotes ?? {};
1046
+ return {
1047
+ order,
1048
+ packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1049
+ const result = await runPackageRelease(pkg, {
1050
+ publishPlugins,
1051
+ analyzeCommitsConfig,
1052
+ generateNotesConfig,
1053
+ bumpsForThisPackage,
1054
+ dryRun,
1055
+ env,
1056
+ branches: options.branches
1057
+ });
1058
+ const nextRelease = result === false ? void 0 : result.nextRelease;
1059
+ return {
1060
+ released: nextRelease !== void 0,
1061
+ version: nextRelease?.version,
1062
+ result
1063
+ };
1064
+ })).map((entry) => {
1065
+ const nextRelease = entry.result === false ? void 0 : entry.result.nextRelease;
1066
+ return {
1067
+ name: entry.name,
1068
+ directory: entry.directory,
1069
+ released: nextRelease !== void 0,
1070
+ version: nextRelease?.version,
1071
+ gitTag: nextRelease?.gitTag,
1072
+ type: nextRelease?.type,
1073
+ dependencyBumps: entry.dependencyBumps
1074
+ };
1075
+ })
1076
+ };
1077
+ }
1078
+ /**
1079
+ * The per-package release loop shared by `releaseWorkspace` (normal per-package releases) and `detachWorkspaceRelease` (gated tag-only releases): iterate the topological `order`, call the caller's own `releaseOne` for each package, record which dependency-range bumps were already applied to it, and -- when it released -- bump every workspace dependent's manifest before that dependent's own turn (see `bumpDependents`'s doc comment for why this happens immediately rather than being staged). The two call sites differ only in how they call semantic-release (`semanticRelease(...)` vs `detachRelease(...)`) and how they read "did it release, and what version" off the result, both captured by `releaseOne`.
1080
+ */
1081
+ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne) {
1041
1082
  const pendingBumps = /* @__PURE__ */ new Map();
1042
1083
  let identity;
1043
- const outcomes = [];
1084
+ const entries = [];
1044
1085
  for (const name of order) {
1045
1086
  const pkg = mustGet(graph.packages, name, "package");
1046
1087
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1047
1088
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1048
- const result = await runPackageRelease(pkg, {
1049
- publishPlugins,
1050
- analyzeCommitsConfig,
1051
- generateNotesConfig,
1052
- bumpsForThisPackage,
1053
- dryRun,
1054
- env,
1055
- branches: options.branches
1056
- });
1057
- const nextRelease = result === false ? void 0 : result.nextRelease;
1058
- outcomes.push({
1089
+ const { released, version, result } = await releaseOne(pkg, bumpsForThisPackage);
1090
+ entries.push({
1059
1091
  name,
1060
1092
  directory: pkg.directory,
1061
- released: nextRelease !== void 0,
1062
- version: nextRelease?.version,
1063
- gitTag: nextRelease?.gitTag,
1064
- type: nextRelease?.type,
1093
+ relativeDirectory: pkg.relativeDirectory,
1094
+ result,
1065
1095
  dependencyBumps: bumpsForThisPackage
1066
1096
  });
1067
1097
  pendingBumps.delete(name);
1068
- if (nextRelease === void 0) {
1098
+ if (!released || version === void 0) {
1069
1099
  log(`${name}: no release`);
1070
1100
  continue;
1071
1101
  }
1072
- log(`${name}: released ${nextRelease.gitTag}`);
1102
+ log(`${name}: released ${version}`);
1073
1103
  identity ??= await resolveCommitIdentity({ cwd: workspace.root });
1074
- const bumps = await bumpDependents(pkg, nextRelease.version, graph, {
1104
+ const bumps = await bumpDependents(pkg, version, graph, {
1075
1105
  workspace,
1076
1106
  dryRun,
1077
1107
  identity,
@@ -1083,10 +1113,7 @@ async function releaseWorkspace(options = {}) {
1083
1113
  pendingBumps.set(bump.dependent, forDependent);
1084
1114
  }
1085
1115
  }
1086
- return {
1087
- order,
1088
- packages: outcomes
1089
- };
1116
+ return entries;
1090
1117
  }
1091
1118
  async function runPackageRelease(pkg, options) {
1092
1119
  const scoped = createScopedPlugins({
@@ -1157,6 +1184,146 @@ async function bumpDependents(released, version, graph, options) {
1157
1184
  return applied;
1158
1185
  }
1159
1186
  //#endregion
1187
+ //#region src/gate-publish.ts
1188
+ /**
1189
+ * The `gatePublish: true` half of `releaseWorkspace`: tags and pushes every due package via `@exadev/release-gate`'s `detachRelease`, but never publishes. Built on the same `runReleaseLoop` the normal per-package path uses, so dependency-range bumps between packages happen identically either way -- only the release call itself (`detachRelease` instead of `semanticRelease`) and how "did it release" gets read differ.
1190
+ */
1191
+ async function detachWorkspaceRelease(options) {
1192
+ const root = resolve(options.root ?? process.cwd());
1193
+ const log = options.log ?? console.log;
1194
+ const dryRun = options.dryRun === true;
1195
+ const env = sanitizeGitEnv(options.env ?? process.env);
1196
+ const workspace = await discoverWorkspace(root);
1197
+ const graph = buildDependencyGraph(workspace.packages);
1198
+ validateDependencyRangeShapes(graph);
1199
+ const order = topologicalOrder(graph);
1200
+ log(`${packageName}: ${order.length} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1201
+ const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1202
+ const analyzeCommitsConfig = options.analyzeCommits ?? {};
1203
+ const generateNotesConfig = options.generateNotes ?? {};
1204
+ const entries = await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1205
+ const state = await runPackageDetach(pkg, {
1206
+ publishPlugins,
1207
+ analyzeCommitsConfig,
1208
+ generateNotesConfig,
1209
+ bumpsForThisPackage,
1210
+ dryRun,
1211
+ env,
1212
+ branches: options.branches
1213
+ });
1214
+ return {
1215
+ released: state !== null,
1216
+ version: state?.nextRelease.version,
1217
+ result: state
1218
+ };
1219
+ });
1220
+ return {
1221
+ order,
1222
+ packages: entries.map((entry) => ({
1223
+ name: entry.name,
1224
+ directory: entry.directory,
1225
+ released: entry.result !== null,
1226
+ version: entry.result?.nextRelease.version,
1227
+ gitTag: entry.result?.nextRelease.gitTag,
1228
+ type: entry.result?.nextRelease.type,
1229
+ dependencyBumps: entry.dependencyBumps
1230
+ })),
1231
+ detached: entries.map((entry) => ({
1232
+ name: entry.name,
1233
+ relativeDirectory: entry.relativeDirectory,
1234
+ state: entry.result,
1235
+ dependencyBumps: entry.dependencyBumps
1236
+ }))
1237
+ };
1238
+ }
1239
+ async function runPackageDetach(pkg, options) {
1240
+ const scoped = createScopedPlugins({
1241
+ pkg,
1242
+ analyzeCommitsConfig: options.analyzeCommitsConfig,
1243
+ generateNotesConfig: options.generateNotesConfig,
1244
+ bumps: { bumpsFor: () => options.bumpsForThisPackage }
1245
+ });
1246
+ const cliOptions = {
1247
+ tagFormat: `${pkg.name}@\${version}`,
1248
+ plugins: options.publishPlugins,
1249
+ analyzeCommits: scoped.analyzeCommits,
1250
+ generateNotes: scoped.generateNotes
1251
+ };
1252
+ if (options.dryRun) cliOptions.dryRun = true;
1253
+ if (options.branches !== void 0) cliOptions.branches = options.branches;
1254
+ try {
1255
+ return await detachRelease(cliOptions, {
1256
+ cwd: pkg.directory,
1257
+ env: { ...options.env }
1258
+ });
1259
+ } catch (cause) {
1260
+ throw new WorkspaceReleaseError(`Detaching ${pkg.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1261
+ }
1262
+ }
1263
+ /**
1264
+ * Runtime guard for a `DetachedPackageRelease[]` read back off disk (the CLI's `resume` subcommand reads `WorkspaceReleaseOutcome.detached` from a JSON file `release --gate-publish` wrote) -- `JSON.parse` returns `any`, so this is the boundary that turns it back into something safe to pass to `resumeWorkspaceRelease`. Only checks the outer shape (`name`, `relativeDirectory`, `dependencyBumps`, and that `state` is either `null` or an object); `state`'s own inner shape is validated by `@exadev/release-gate`'s `resumeRelease` itself, once per entry, which is where a genuinely malformed state fails loudly with a specific error rather than here.
1265
+ */
1266
+ function isDetachedPackageReleaseArray(value) {
1267
+ if (!isUnknownArray(value)) return false;
1268
+ return value.every((entry) => {
1269
+ if (!isJsonObject(entry)) return false;
1270
+ if (typeof entry.name !== "string" || typeof entry.relativeDirectory !== "string") return false;
1271
+ if (entry.state !== null && !isJsonObject(entry.state)) return false;
1272
+ return isUnknownArray(entry.dependencyBumps);
1273
+ });
1274
+ }
1275
+ /**
1276
+ * Finishes every package a `gatePublish: true` `releaseWorkspace` run tagged and pushed but did not publish. Resumes each `detached` entry in the array's own order -- not re-derived from the workspace's dependency graph, since a resume pass may run in a separate process or checkout, where re-deriving order should only ever matter for directory lookup, not sequencing.
1277
+ */
1278
+ async function resumeWorkspaceRelease(options) {
1279
+ const root = resolve(options.root ?? process.cwd());
1280
+ const log = options.log ?? console.log;
1281
+ const env = sanitizeGitEnv(options.env ?? process.env);
1282
+ const order = [];
1283
+ const packages = [];
1284
+ for (const entry of options.detached) {
1285
+ order.push(entry.name);
1286
+ if (entry.state === null) {
1287
+ log(`${entry.name}: no release to resume`);
1288
+ packages.push({
1289
+ name: entry.name,
1290
+ directory: resolve(root, entry.relativeDirectory),
1291
+ released: false,
1292
+ version: void 0,
1293
+ gitTag: void 0,
1294
+ type: void 0,
1295
+ dependencyBumps: entry.dependencyBumps
1296
+ });
1297
+ continue;
1298
+ }
1299
+ const directory = resolve(root, entry.relativeDirectory);
1300
+ log(`Resuming ${entry.name} from ${entry.relativeDirectory}`);
1301
+ let releases;
1302
+ try {
1303
+ releases = await resumeRelease(entry.state, {
1304
+ cwd: directory,
1305
+ env: { ...env }
1306
+ });
1307
+ } catch (cause) {
1308
+ throw new WorkspaceReleaseError(`Resuming ${entry.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1309
+ }
1310
+ log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${releases.length} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
1311
+ packages.push({
1312
+ name: entry.name,
1313
+ directory,
1314
+ released: true,
1315
+ version: entry.state.nextRelease.version,
1316
+ gitTag: entry.state.nextRelease.gitTag,
1317
+ type: entry.state.nextRelease.type,
1318
+ dependencyBumps: entry.dependencyBumps
1319
+ });
1320
+ }
1321
+ return {
1322
+ order,
1323
+ packages
1324
+ };
1325
+ }
1326
+ //#endregion
1160
1327
  //#region src/cli.ts
1161
1328
  const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
1162
1329
  "dryRun",
@@ -1164,7 +1331,8 @@ const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
1164
1331
  "plugins",
1165
1332
  "analyzeCommits",
1166
1333
  "generateNotes",
1167
- "commitStrategy"
1334
+ "commitStrategy",
1335
+ "gatePublish"
1168
1336
  ]);
1169
1337
  const COMMIT_STRATEGIES = /* @__PURE__ */ new Set(["per-package", "single"]);
1170
1338
  function isCommitStrategy(value) {
@@ -1186,8 +1354,15 @@ function createProgram() {
1186
1354
  release.option("--analyze-commits <json>", "options for the wrapped @semantic-release/commit-analyzer, as a JSON object");
1187
1355
  release.option("--generate-notes <json>", "options for the wrapped @semantic-release/release-notes-generator, as a JSON object");
1188
1356
  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);
1189
- 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");
1357
+ release.option("--gate-publish", "tag and push each due package via @exadev/release-gate, but defer publishing -- requires --gate-state-file, and cannot be combined with --commit-strategy single");
1358
+ release.option("--gate-state-file <path>", "with --gate-publish: where to write the state a later \"resume\" run needs to finish publishing");
1359
+ release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy, gatePublish); explicit flags win");
1190
1360
  release.action(runRelease);
1361
+ const resume = program.command("resume");
1362
+ resume.description("Finish publishing every package a --gate-publish release tagged and pushed but did not publish.");
1363
+ resume.option("--root <directory>", "workspace root holding pnpm-workspace.yaml (this checkout, which may differ from the one that ran release --gate-publish)", process.cwd());
1364
+ resume.requiredOption("--gate-state-file <path>", "the state file a \"release --gate-publish\" run wrote");
1365
+ resume.action(runResume);
1191
1366
  return program;
1192
1367
  }
1193
1368
  function parseCommitStrategy(value) {
@@ -1200,10 +1375,13 @@ const NO_CONFIG_FILE = {
1200
1375
  plugins: void 0,
1201
1376
  analyzeCommits: void 0,
1202
1377
  generateNotes: void 0,
1203
- commitStrategy: void 0
1378
+ commitStrategy: void 0,
1379
+ gatePublish: void 0
1204
1380
  };
1205
1381
  async function runRelease(flags) {
1206
1382
  const file = flags.config === void 0 ? NO_CONFIG_FILE : readReleaseConfigFile(flags.config);
1383
+ const gatePublish = flags.gatePublish ?? file.gatePublish ?? false;
1384
+ if (gatePublish && flags.gateStateFile === void 0) throw new InvalidArgumentError("--gate-publish requires --gate-state-file, since that is where the state a later \"resume\" run needs gets written");
1207
1385
  const outcome = await releaseWorkspace({
1208
1386
  root: flags.root,
1209
1387
  dryRun: flags.dryRun ?? (file.dryRun === true ? true : void 0),
@@ -1211,7 +1389,23 @@ async function runRelease(flags) {
1211
1389
  plugins: flags.plugin.length > 0 ? flags.plugin.map((spec) => parsePluginSpec(spec)) : file.plugins,
1212
1390
  analyzeCommits: flags.analyzeCommits === void 0 ? file.analyzeCommits : parseJsonObjectFlag(flags.analyzeCommits, "--analyze-commits"),
1213
1391
  generateNotes: flags.generateNotes === void 0 ? file.generateNotes : parseJsonObjectFlag(flags.generateNotes, "--generate-notes"),
1214
- commitStrategy: flags.commitStrategy ?? file.commitStrategy
1392
+ commitStrategy: flags.commitStrategy ?? file.commitStrategy,
1393
+ gatePublish
1394
+ });
1395
+ for (const pkg of outcome.packages) console.log(describeOutcome(pkg));
1396
+ if (gatePublish) {
1397
+ if (flags.gateStateFile === void 0) throw new WorkspaceReleaseError("gatePublish was true but no --gate-state-file was resolved -- this should be unreachable.");
1398
+ await writeFile(flags.gateStateFile, JSON.stringify(outcome.detached ?? [], null, 2));
1399
+ console.log(`${packageName}: wrote gate state for ${(outcome.detached ?? []).length} package(s) to ${flags.gateStateFile}`);
1400
+ }
1401
+ }
1402
+ async function runResume(flags) {
1403
+ const raw = await readFile(flags.gateStateFile, "utf8");
1404
+ const parsed = JSON.parse(raw);
1405
+ if (!isDetachedPackageReleaseArray(parsed)) throw new InvalidArgumentError(`--gate-state-file ${flags.gateStateFile} does not contain a valid gate state array`);
1406
+ const outcome = await resumeWorkspaceRelease({
1407
+ root: flags.root,
1408
+ detached: parsed
1215
1409
  });
1216
1410
  for (const pkg of outcome.packages) console.log(describeOutcome(pkg));
1217
1411
  }
@@ -1257,20 +1451,22 @@ function readReleaseConfigFile(path) {
1257
1451
  const parsed = readConfigFile(path);
1258
1452
  if (!isJsonObject(parsed)) throw new InvalidArgumentError(`--config file ${path} must contain a JSON object`);
1259
1453
  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(", ")}`);
1260
- const { dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy } = parsed;
1454
+ const { dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy, gatePublish } = parsed;
1261
1455
  if (dryRun !== void 0 && typeof dryRun !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "dryRun" must be a boolean`);
1262
1456
  if (branches !== void 0 && !isStringArray(branches)) throw new InvalidArgumentError(`--config file ${path}: "branches" must be an array of branch name strings`);
1263
1457
  if (plugins !== void 0 && !Array.isArray(plugins)) throw new InvalidArgumentError(`--config file ${path}: "plugins" must be an array`);
1264
1458
  if (analyzeCommits !== void 0 && !isJsonObject(analyzeCommits)) throw new InvalidArgumentError(`--config file ${path}: "analyzeCommits" must be an object`);
1265
1459
  if (generateNotes !== void 0 && !isJsonObject(generateNotes)) throw new InvalidArgumentError(`--config file ${path}: "generateNotes" must be an object`);
1266
1460
  if (commitStrategy !== void 0 && (typeof commitStrategy !== "string" || !isCommitStrategy(commitStrategy))) throw new InvalidArgumentError(`--config file ${path}: "commitStrategy" must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
1461
+ if (gatePublish !== void 0 && typeof gatePublish !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "gatePublish" must be a boolean`);
1267
1462
  return {
1268
1463
  dryRun,
1269
1464
  branches,
1270
1465
  plugins: plugins === void 0 ? void 0 : plugins.map((spec) => parseConfigFilePlugin(spec, path)),
1271
1466
  analyzeCommits,
1272
1467
  generateNotes,
1273
- commitStrategy
1468
+ commitStrategy,
1469
+ gatePublish
1274
1470
  };
1275
1471
  }
1276
1472
  function parseConfigFilePlugin(spec, path) {