@exadev/semantic-release-workspace 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -35,6 +35,7 @@ let _semantic_release_release_notes_generator = require("@semantic-release/relea
35
35
  let semantic_release = require("semantic-release");
36
36
  semantic_release = __toESM(semantic_release, 1);
37
37
  let node_url = require("node:url");
38
+ let _exadev_release_gate = require("@exadev/release-gate");
38
39
  //#region src/package-name.ts
39
40
  const packageName = "@exadev/semantic-release-workspace";
40
41
  //#endregion
@@ -1031,6 +1032,134 @@ async function loadReleasePlugin(absolutePath, cache) {
1031
1032
  return loaded;
1032
1033
  }
1033
1034
  //#endregion
1035
+ //#region src/gate-publish.ts
1036
+ /**
1037
+ * 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.
1038
+ */
1039
+ async function detachWorkspaceRelease(options) {
1040
+ const root = (0, node_path.resolve)(options.root ?? process.cwd());
1041
+ const log = options.log ?? console.log;
1042
+ const dryRun = options.dryRun === true;
1043
+ const env = sanitizeGitEnv(options.env ?? process.env);
1044
+ const workspace = await discoverWorkspace(root);
1045
+ const graph = buildDependencyGraph(workspace.packages);
1046
+ validateDependencyRangeShapes(graph);
1047
+ const order = topologicalOrder(graph);
1048
+ log(`${packageName}: ${order.length} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1049
+ const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1050
+ const analyzeCommitsConfig = options.analyzeCommits ?? {};
1051
+ const generateNotesConfig = options.generateNotes ?? {};
1052
+ const entries = await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1053
+ const state = await runPackageDetach(pkg, {
1054
+ publishPlugins,
1055
+ analyzeCommitsConfig,
1056
+ generateNotesConfig,
1057
+ bumpsForThisPackage,
1058
+ dryRun,
1059
+ env,
1060
+ branches: options.branches
1061
+ });
1062
+ return {
1063
+ released: state !== null,
1064
+ version: state?.nextRelease.version,
1065
+ result: state
1066
+ };
1067
+ });
1068
+ return {
1069
+ order,
1070
+ packages: entries.map((entry) => ({
1071
+ name: entry.name,
1072
+ directory: entry.directory,
1073
+ released: entry.result !== null,
1074
+ version: entry.result?.nextRelease.version,
1075
+ gitTag: entry.result?.nextRelease.gitTag,
1076
+ type: entry.result?.nextRelease.type,
1077
+ dependencyBumps: entry.dependencyBumps
1078
+ })),
1079
+ detached: entries.map((entry) => ({
1080
+ name: entry.name,
1081
+ relativeDirectory: entry.relativeDirectory,
1082
+ state: entry.result,
1083
+ dependencyBumps: entry.dependencyBumps
1084
+ }))
1085
+ };
1086
+ }
1087
+ async function runPackageDetach(pkg, options) {
1088
+ const scoped = createScopedPlugins({
1089
+ pkg,
1090
+ analyzeCommitsConfig: options.analyzeCommitsConfig,
1091
+ generateNotesConfig: options.generateNotesConfig,
1092
+ bumps: { bumpsFor: () => options.bumpsForThisPackage }
1093
+ });
1094
+ const cliOptions = {
1095
+ tagFormat: `${pkg.name}@\${version}`,
1096
+ plugins: options.publishPlugins,
1097
+ analyzeCommits: scoped.analyzeCommits,
1098
+ generateNotes: scoped.generateNotes
1099
+ };
1100
+ if (options.dryRun) cliOptions.dryRun = true;
1101
+ if (options.branches !== void 0) cliOptions.branches = options.branches;
1102
+ try {
1103
+ return await (0, _exadev_release_gate.detachRelease)(cliOptions, {
1104
+ cwd: pkg.directory,
1105
+ env: { ...options.env }
1106
+ });
1107
+ } catch (cause) {
1108
+ throw new WorkspaceReleaseError(`Detaching ${pkg.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1109
+ }
1110
+ }
1111
+ /**
1112
+ * 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.
1113
+ */
1114
+ async function resumeWorkspaceRelease(options) {
1115
+ const root = (0, node_path.resolve)(options.root ?? process.cwd());
1116
+ const log = options.log ?? console.log;
1117
+ const env = sanitizeGitEnv(options.env ?? process.env);
1118
+ const order = [];
1119
+ const packages = [];
1120
+ for (const entry of options.detached) {
1121
+ order.push(entry.name);
1122
+ if (entry.state === null) {
1123
+ log(`${entry.name}: no release to resume`);
1124
+ packages.push({
1125
+ name: entry.name,
1126
+ directory: (0, node_path.resolve)(root, entry.relativeDirectory),
1127
+ released: false,
1128
+ version: void 0,
1129
+ gitTag: void 0,
1130
+ type: void 0,
1131
+ dependencyBumps: entry.dependencyBumps
1132
+ });
1133
+ continue;
1134
+ }
1135
+ const directory = (0, node_path.resolve)(root, entry.relativeDirectory);
1136
+ log(`Resuming ${entry.name} from ${entry.relativeDirectory}`);
1137
+ let releases;
1138
+ try {
1139
+ releases = await (0, _exadev_release_gate.resumeRelease)(entry.state, {
1140
+ cwd: directory,
1141
+ env: { ...env }
1142
+ });
1143
+ } catch (cause) {
1144
+ throw new WorkspaceReleaseError(`Resuming ${entry.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1145
+ }
1146
+ log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${releases.length} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
1147
+ packages.push({
1148
+ name: entry.name,
1149
+ directory,
1150
+ released: true,
1151
+ version: entry.state.nextRelease.version,
1152
+ gitTag: entry.state.nextRelease.gitTag,
1153
+ type: entry.state.nextRelease.type,
1154
+ dependencyBumps: entry.dependencyBumps
1155
+ });
1156
+ }
1157
+ return {
1158
+ order,
1159
+ packages
1160
+ };
1161
+ }
1162
+ //#endregion
1034
1163
  //#region src/release.ts
1035
1164
  /**
1036
1165
  * Releases every package in a pnpm workspace with independent versions, in dependency order.
@@ -1038,6 +1167,10 @@ async function loadReleasePlugin(absolutePath, cache) {
1038
1167
  * 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.
1039
1168
  */
1040
1169
  async function releaseWorkspace(options = {}) {
1170
+ if (options.gatePublish === true) {
1171
+ 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.");
1172
+ return detachWorkspaceRelease(options);
1173
+ }
1041
1174
  if ((options.commitStrategy ?? "per-package") === "single") return releaseWorkspaceSingleCommit(options);
1042
1175
  const root = (0, node_path.resolve)(options.root ?? process.cwd());
1043
1176
  const log = options.log ?? console.log;
@@ -1051,40 +1184,65 @@ async function releaseWorkspace(options = {}) {
1051
1184
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1052
1185
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1053
1186
  const generateNotesConfig = options.generateNotes ?? {};
1187
+ return {
1188
+ order,
1189
+ packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1190
+ const result = await runPackageRelease(pkg, {
1191
+ publishPlugins,
1192
+ analyzeCommitsConfig,
1193
+ generateNotesConfig,
1194
+ bumpsForThisPackage,
1195
+ dryRun,
1196
+ env,
1197
+ branches: options.branches
1198
+ });
1199
+ const nextRelease = result === false ? void 0 : result.nextRelease;
1200
+ return {
1201
+ released: nextRelease !== void 0,
1202
+ version: nextRelease?.version,
1203
+ result
1204
+ };
1205
+ })).map((entry) => {
1206
+ const nextRelease = entry.result === false ? void 0 : entry.result.nextRelease;
1207
+ return {
1208
+ name: entry.name,
1209
+ directory: entry.directory,
1210
+ released: nextRelease !== void 0,
1211
+ version: nextRelease?.version,
1212
+ gitTag: nextRelease?.gitTag,
1213
+ type: nextRelease?.type,
1214
+ dependencyBumps: entry.dependencyBumps
1215
+ };
1216
+ })
1217
+ };
1218
+ }
1219
+ /**
1220
+ * 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`.
1221
+ */
1222
+ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne) {
1054
1223
  const pendingBumps = /* @__PURE__ */ new Map();
1055
1224
  let identity;
1056
- const outcomes = [];
1225
+ const entries = [];
1057
1226
  for (const name of order) {
1058
1227
  const pkg = mustGet(graph.packages, name, "package");
1059
1228
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1060
1229
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1061
- const result = await runPackageRelease(pkg, {
1062
- publishPlugins,
1063
- analyzeCommitsConfig,
1064
- generateNotesConfig,
1065
- bumpsForThisPackage,
1066
- dryRun,
1067
- env,
1068
- branches: options.branches
1069
- });
1070
- const nextRelease = result === false ? void 0 : result.nextRelease;
1071
- outcomes.push({
1230
+ const { released, version, result } = await releaseOne(pkg, bumpsForThisPackage);
1231
+ entries.push({
1072
1232
  name,
1073
1233
  directory: pkg.directory,
1074
- released: nextRelease !== void 0,
1075
- version: nextRelease?.version,
1076
- gitTag: nextRelease?.gitTag,
1077
- type: nextRelease?.type,
1234
+ relativeDirectory: pkg.relativeDirectory,
1235
+ result,
1078
1236
  dependencyBumps: bumpsForThisPackage
1079
1237
  });
1080
1238
  pendingBumps.delete(name);
1081
- if (nextRelease === void 0) {
1239
+ if (!released || version === void 0) {
1082
1240
  log(`${name}: no release`);
1083
1241
  continue;
1084
1242
  }
1085
- log(`${name}: released ${nextRelease.gitTag}`);
1243
+ log(`${name}: released ${version}`);
1086
1244
  identity ??= await resolveCommitIdentity({ cwd: workspace.root });
1087
- const bumps = await bumpDependents(pkg, nextRelease.version, graph, {
1245
+ const bumps = await bumpDependents(pkg, version, graph, {
1088
1246
  workspace,
1089
1247
  dryRun,
1090
1248
  identity,
@@ -1096,10 +1254,7 @@ async function releaseWorkspace(options = {}) {
1096
1254
  pendingBumps.set(bump.dependent, forDependent);
1097
1255
  }
1098
1256
  }
1099
- return {
1100
- order,
1101
- packages: outcomes
1102
- };
1257
+ return entries;
1103
1258
  }
1104
1259
  async function runPackageRelease(pkg, options) {
1105
1260
  const scoped = createScopedPlugins({
@@ -1188,6 +1343,7 @@ exports.packageName = packageName;
1188
1343
  exports.readManifest = readManifest;
1189
1344
  exports.releaseWorkspace = releaseWorkspace;
1190
1345
  exports.resolvePublishPlugins = resolvePublishPlugins;
1346
+ exports.resumeWorkspaceRelease = resumeWorkspaceRelease;
1191
1347
  exports.topologicalOrder = topologicalOrder;
1192
1348
  exports.updateDependencyRange = updateDependencyRange;
1193
1349
  exports.writeDependencyRange = writeDependencyRange;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AnalyzeCommitsContext, BranchSpec, Commit, GenerateNotesContext } from "semantic-release";
2
+ import { ReleaseGateState } from "@exadev/release-gate";
2
3
  //#region src/package-name.d.ts
3
4
  declare const packageName = "@exadev/semantic-release-workspace";
4
5
  //#endregion
@@ -184,6 +185,30 @@ declare function resolvePublishPlugins(specs: readonly PublishPluginSpec[], work
184
185
  readonly forbidGitPlugin?: boolean;
185
186
  }): readonly ResolvedPublishPlugin[];
186
187
  //#endregion
188
+ //#region src/gate-publish.d.ts
189
+ /**
190
+ * One package's outcome from a gated (`gatePublish: true`) detach pass -- what `resumeWorkspaceRelease` needs later to finish publishing it. `relativeDirectory`, not the absolute `directory`, is what a resume pass actually uses to resolve `cwd`: a resume can run against a different checkout of the same repository (a separate CI job, a separate clone) at a different absolute path, and `relativeDirectory` is the portable part.
191
+ */
192
+ interface DetachedPackageRelease {
193
+ readonly name: string;
194
+ readonly relativeDirectory: string;
195
+ /** The state to hand `resumeRelease`, or `null` if this package had nothing to release. */
196
+ readonly state: ReleaseGateState | null;
197
+ readonly dependencyBumps: readonly AppliedDependencyBump[];
198
+ }
199
+ interface ResumeWorkspaceReleaseOptions {
200
+ /** Directory holding the workspace's `pnpm-workspace.yaml` in *this* checkout -- not necessarily the same absolute path the detach pass ran from. Defaults to the process working directory. */
201
+ readonly root?: string;
202
+ readonly env?: NodeJS.ProcessEnv;
203
+ /** `WorkspaceReleaseOutcome.detached` from the earlier `releaseWorkspace({gatePublish: true})` call this resumes. */
204
+ readonly detached: readonly DetachedPackageRelease[];
205
+ readonly log?: (message: string) => void;
206
+ }
207
+ /**
208
+ * 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.
209
+ */
210
+ declare function resumeWorkspaceRelease(options: ResumeWorkspaceReleaseOptions): Promise<WorkspaceReleaseOutcome>;
211
+ //#endregion
187
212
  //#region src/release.d.ts
188
213
  /**
189
214
  * How a run commits its released changes:
@@ -211,6 +236,10 @@ interface ReleaseWorkspaceOptions {
211
236
  readonly log?: (message: string) => void;
212
237
  /** How the run commits its released changes. Defaults to `'per-package'`, today's exact existing behaviour. See `CommitStrategy` for what each mode does. */
213
238
  readonly commitStrategy?: CommitStrategy;
239
+ /**
240
+ * Opt-in, orthogonal to `commitStrategy` -- it governs *when* publish/success run relative to tag+push, not *how many commits* the release makes. When `true`, each package's release is tagged and pushed via `@exadev/release-gate`'s `detachRelease` but never published: `WorkspaceReleaseOutcome.detached` carries the state `resumeWorkspaceRelease` needs to finish publishing later, from the same process or a different one, once an external gate confirms the release should actually go out. Rejected outright in combination with `commitStrategy: 'single'` -- `single-commit-release.ts`'s tag/publish machinery is entirely bespoke and never goes through semantic-release's own `run()`, so it has no insertion point for `release-gate`'s primitives. Defaults to `false`, today's exact existing behaviour.
241
+ */
242
+ readonly gatePublish?: boolean;
214
243
  }
215
244
  /** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
216
245
  interface AppliedDependencyBump extends DependencyBump {
@@ -233,6 +262,8 @@ interface WorkspaceReleaseOutcome {
233
262
  /** The topological order the packages were released in. */
234
263
  readonly order: readonly string[];
235
264
  readonly packages: readonly PackageReleaseOutcome[];
265
+ /** Present only when `gatePublish: true`: one entry per package in `order`, carrying the `@exadev/release-gate` state `resumeWorkspaceRelease` needs to finish publishing it later, or `null` for a package that had nothing to release. */
266
+ readonly detached?: readonly DetachedPackageRelease[];
236
267
  }
237
268
  /**
238
269
  * Releases every package in a pnpm workspace with independent versions, in dependency order.
@@ -270,4 +301,4 @@ declare class GitCommandError extends WorkspaceReleaseError {
270
301
  /** The workspace's git state does not support the release operation -- for example a detached HEAD, which names no branch that dependency-bump commits could be pushed to. */
271
302
  declare class WorkspaceStateError extends WorkspaceReleaseError {}
272
303
  //#endregion
273
- export { type AppliedDependencyBump, type CommitStrategy, DEFAULT_PUBLISH_PLUGINS, type DependencyBump, type DependencyBumpSource, DependencyCycleError, type DependencyField, type DependencyGraph, type DependencyRangeShape, type DependencyRangeUpdate, GitCommandError, type PackageManifest, type PackageReleaseOutcome, type PublishPluginSpec, ReleaseConfigurationError, type ReleaseWorkspaceOptions, type ResolvedPublishPlugin, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, type ScopedPlugins, UnsupportedDependencyRangeError, type Workspace, type WorkspaceDependency, WorkspaceDiscoveryError, type WorkspacePackage, WorkspaceReleaseError, type WorkspaceReleaseOutcome, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, topologicalOrder, updateDependencyRange, writeDependencyRange };
304
+ export { type AppliedDependencyBump, type CommitStrategy, DEFAULT_PUBLISH_PLUGINS, type DependencyBump, type DependencyBumpSource, DependencyCycleError, type DependencyField, type DependencyGraph, type DependencyRangeShape, type DependencyRangeUpdate, type DetachedPackageRelease, GitCommandError, type PackageManifest, type PackageReleaseOutcome, type PublishPluginSpec, ReleaseConfigurationError, type ReleaseWorkspaceOptions, type ResolvedPublishPlugin, type ResumeWorkspaceReleaseOptions, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, type ScopedPlugins, UnsupportedDependencyRangeError, type Workspace, type WorkspaceDependency, WorkspaceDiscoveryError, type WorkspacePackage, WorkspaceReleaseError, type WorkspaceReleaseOutcome, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, resumeWorkspaceRelease, topologicalOrder, updateDependencyRange, writeDependencyRange };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AnalyzeCommitsContext, BranchSpec, Commit, GenerateNotesContext } from "semantic-release";
2
+ import { ReleaseGateState } from "@exadev/release-gate";
2
3
  //#region src/package-name.d.ts
3
4
  declare const packageName = "@exadev/semantic-release-workspace";
4
5
  //#endregion
@@ -184,6 +185,30 @@ declare function resolvePublishPlugins(specs: readonly PublishPluginSpec[], work
184
185
  readonly forbidGitPlugin?: boolean;
185
186
  }): readonly ResolvedPublishPlugin[];
186
187
  //#endregion
188
+ //#region src/gate-publish.d.ts
189
+ /**
190
+ * One package's outcome from a gated (`gatePublish: true`) detach pass -- what `resumeWorkspaceRelease` needs later to finish publishing it. `relativeDirectory`, not the absolute `directory`, is what a resume pass actually uses to resolve `cwd`: a resume can run against a different checkout of the same repository (a separate CI job, a separate clone) at a different absolute path, and `relativeDirectory` is the portable part.
191
+ */
192
+ interface DetachedPackageRelease {
193
+ readonly name: string;
194
+ readonly relativeDirectory: string;
195
+ /** The state to hand `resumeRelease`, or `null` if this package had nothing to release. */
196
+ readonly state: ReleaseGateState | null;
197
+ readonly dependencyBumps: readonly AppliedDependencyBump[];
198
+ }
199
+ interface ResumeWorkspaceReleaseOptions {
200
+ /** Directory holding the workspace's `pnpm-workspace.yaml` in *this* checkout -- not necessarily the same absolute path the detach pass ran from. Defaults to the process working directory. */
201
+ readonly root?: string;
202
+ readonly env?: NodeJS.ProcessEnv;
203
+ /** `WorkspaceReleaseOutcome.detached` from the earlier `releaseWorkspace({gatePublish: true})` call this resumes. */
204
+ readonly detached: readonly DetachedPackageRelease[];
205
+ readonly log?: (message: string) => void;
206
+ }
207
+ /**
208
+ * 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.
209
+ */
210
+ declare function resumeWorkspaceRelease(options: ResumeWorkspaceReleaseOptions): Promise<WorkspaceReleaseOutcome>;
211
+ //#endregion
187
212
  //#region src/release.d.ts
188
213
  /**
189
214
  * How a run commits its released changes:
@@ -211,6 +236,10 @@ interface ReleaseWorkspaceOptions {
211
236
  readonly log?: (message: string) => void;
212
237
  /** How the run commits its released changes. Defaults to `'per-package'`, today's exact existing behaviour. See `CommitStrategy` for what each mode does. */
213
238
  readonly commitStrategy?: CommitStrategy;
239
+ /**
240
+ * Opt-in, orthogonal to `commitStrategy` -- it governs *when* publish/success run relative to tag+push, not *how many commits* the release makes. When `true`, each package's release is tagged and pushed via `@exadev/release-gate`'s `detachRelease` but never published: `WorkspaceReleaseOutcome.detached` carries the state `resumeWorkspaceRelease` needs to finish publishing later, from the same process or a different one, once an external gate confirms the release should actually go out. Rejected outright in combination with `commitStrategy: 'single'` -- `single-commit-release.ts`'s tag/publish machinery is entirely bespoke and never goes through semantic-release's own `run()`, so it has no insertion point for `release-gate`'s primitives. Defaults to `false`, today's exact existing behaviour.
241
+ */
242
+ readonly gatePublish?: boolean;
214
243
  }
215
244
  /** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
216
245
  interface AppliedDependencyBump extends DependencyBump {
@@ -233,6 +262,8 @@ interface WorkspaceReleaseOutcome {
233
262
  /** The topological order the packages were released in. */
234
263
  readonly order: readonly string[];
235
264
  readonly packages: readonly PackageReleaseOutcome[];
265
+ /** Present only when `gatePublish: true`: one entry per package in `order`, carrying the `@exadev/release-gate` state `resumeWorkspaceRelease` needs to finish publishing it later, or `null` for a package that had nothing to release. */
266
+ readonly detached?: readonly DetachedPackageRelease[];
236
267
  }
237
268
  /**
238
269
  * Releases every package in a pnpm workspace with independent versions, in dependency order.
@@ -270,4 +301,4 @@ declare class GitCommandError extends WorkspaceReleaseError {
270
301
  /** The workspace's git state does not support the release operation -- for example a detached HEAD, which names no branch that dependency-bump commits could be pushed to. */
271
302
  declare class WorkspaceStateError extends WorkspaceReleaseError {}
272
303
  //#endregion
273
- export { type AppliedDependencyBump, type CommitStrategy, DEFAULT_PUBLISH_PLUGINS, type DependencyBump, type DependencyBumpSource, DependencyCycleError, type DependencyField, type DependencyGraph, type DependencyRangeShape, type DependencyRangeUpdate, GitCommandError, type PackageManifest, type PackageReleaseOutcome, type PublishPluginSpec, ReleaseConfigurationError, type ReleaseWorkspaceOptions, type ResolvedPublishPlugin, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, type ScopedPlugins, UnsupportedDependencyRangeError, type Workspace, type WorkspaceDependency, WorkspaceDiscoveryError, type WorkspacePackage, WorkspaceReleaseError, type WorkspaceReleaseOutcome, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, topologicalOrder, updateDependencyRange, writeDependencyRange };
304
+ export { type AppliedDependencyBump, type CommitStrategy, DEFAULT_PUBLISH_PLUGINS, type DependencyBump, type DependencyBumpSource, DependencyCycleError, type DependencyField, type DependencyGraph, type DependencyRangeShape, type DependencyRangeUpdate, type DetachedPackageRelease, GitCommandError, type PackageManifest, type PackageReleaseOutcome, type PublishPluginSpec, ReleaseConfigurationError, type ReleaseWorkspaceOptions, type ResolvedPublishPlugin, type ResumeWorkspaceReleaseOptions, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, type ScopedPlugins, UnsupportedDependencyRangeError, type Workspace, type WorkspaceDependency, WorkspaceDiscoveryError, type WorkspacePackage, WorkspaceReleaseError, type WorkspaceReleaseOutcome, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, resumeWorkspaceRelease, topologicalOrder, updateDependencyRange, writeDependencyRange };
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { analyzeCommits } from "@semantic-release/commit-analyzer";
10
10
  import { generateNotes } from "@semantic-release/release-notes-generator";
11
11
  import semanticRelease from "semantic-release";
12
12
  import { pathToFileURL } from "node:url";
13
+ import { detachRelease, resumeRelease } from "@exadev/release-gate";
13
14
  //#region src/package-name.ts
14
15
  const packageName = "@exadev/semantic-release-workspace";
15
16
  //#endregion
@@ -1006,6 +1007,134 @@ async function loadReleasePlugin(absolutePath, cache) {
1006
1007
  return loaded;
1007
1008
  }
1008
1009
  //#endregion
1010
+ //#region src/gate-publish.ts
1011
+ /**
1012
+ * 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.
1013
+ */
1014
+ async function detachWorkspaceRelease(options) {
1015
+ const root = resolve(options.root ?? process.cwd());
1016
+ const log = options.log ?? console.log;
1017
+ const dryRun = options.dryRun === true;
1018
+ const env = sanitizeGitEnv(options.env ?? process.env);
1019
+ const workspace = await discoverWorkspace(root);
1020
+ const graph = buildDependencyGraph(workspace.packages);
1021
+ validateDependencyRangeShapes(graph);
1022
+ const order = topologicalOrder(graph);
1023
+ log(`${packageName}: ${order.length} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
1024
+ const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1025
+ const analyzeCommitsConfig = options.analyzeCommits ?? {};
1026
+ const generateNotesConfig = options.generateNotes ?? {};
1027
+ const entries = await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1028
+ const state = await runPackageDetach(pkg, {
1029
+ publishPlugins,
1030
+ analyzeCommitsConfig,
1031
+ generateNotesConfig,
1032
+ bumpsForThisPackage,
1033
+ dryRun,
1034
+ env,
1035
+ branches: options.branches
1036
+ });
1037
+ return {
1038
+ released: state !== null,
1039
+ version: state?.nextRelease.version,
1040
+ result: state
1041
+ };
1042
+ });
1043
+ return {
1044
+ order,
1045
+ packages: entries.map((entry) => ({
1046
+ name: entry.name,
1047
+ directory: entry.directory,
1048
+ released: entry.result !== null,
1049
+ version: entry.result?.nextRelease.version,
1050
+ gitTag: entry.result?.nextRelease.gitTag,
1051
+ type: entry.result?.nextRelease.type,
1052
+ dependencyBumps: entry.dependencyBumps
1053
+ })),
1054
+ detached: entries.map((entry) => ({
1055
+ name: entry.name,
1056
+ relativeDirectory: entry.relativeDirectory,
1057
+ state: entry.result,
1058
+ dependencyBumps: entry.dependencyBumps
1059
+ }))
1060
+ };
1061
+ }
1062
+ async function runPackageDetach(pkg, options) {
1063
+ const scoped = createScopedPlugins({
1064
+ pkg,
1065
+ analyzeCommitsConfig: options.analyzeCommitsConfig,
1066
+ generateNotesConfig: options.generateNotesConfig,
1067
+ bumps: { bumpsFor: () => options.bumpsForThisPackage }
1068
+ });
1069
+ const cliOptions = {
1070
+ tagFormat: `${pkg.name}@\${version}`,
1071
+ plugins: options.publishPlugins,
1072
+ analyzeCommits: scoped.analyzeCommits,
1073
+ generateNotes: scoped.generateNotes
1074
+ };
1075
+ if (options.dryRun) cliOptions.dryRun = true;
1076
+ if (options.branches !== void 0) cliOptions.branches = options.branches;
1077
+ try {
1078
+ return await detachRelease(cliOptions, {
1079
+ cwd: pkg.directory,
1080
+ env: { ...options.env }
1081
+ });
1082
+ } catch (cause) {
1083
+ throw new WorkspaceReleaseError(`Detaching ${pkg.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1084
+ }
1085
+ }
1086
+ /**
1087
+ * 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.
1088
+ */
1089
+ async function resumeWorkspaceRelease(options) {
1090
+ const root = resolve(options.root ?? process.cwd());
1091
+ const log = options.log ?? console.log;
1092
+ const env = sanitizeGitEnv(options.env ?? process.env);
1093
+ const order = [];
1094
+ const packages = [];
1095
+ for (const entry of options.detached) {
1096
+ order.push(entry.name);
1097
+ if (entry.state === null) {
1098
+ log(`${entry.name}: no release to resume`);
1099
+ packages.push({
1100
+ name: entry.name,
1101
+ directory: resolve(root, entry.relativeDirectory),
1102
+ released: false,
1103
+ version: void 0,
1104
+ gitTag: void 0,
1105
+ type: void 0,
1106
+ dependencyBumps: entry.dependencyBumps
1107
+ });
1108
+ continue;
1109
+ }
1110
+ const directory = resolve(root, entry.relativeDirectory);
1111
+ log(`Resuming ${entry.name} from ${entry.relativeDirectory}`);
1112
+ let releases;
1113
+ try {
1114
+ releases = await resumeRelease(entry.state, {
1115
+ cwd: directory,
1116
+ env: { ...env }
1117
+ });
1118
+ } catch (cause) {
1119
+ throw new WorkspaceReleaseError(`Resuming ${entry.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1120
+ }
1121
+ log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${releases.length} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
1122
+ packages.push({
1123
+ name: entry.name,
1124
+ directory,
1125
+ released: true,
1126
+ version: entry.state.nextRelease.version,
1127
+ gitTag: entry.state.nextRelease.gitTag,
1128
+ type: entry.state.nextRelease.type,
1129
+ dependencyBumps: entry.dependencyBumps
1130
+ });
1131
+ }
1132
+ return {
1133
+ order,
1134
+ packages
1135
+ };
1136
+ }
1137
+ //#endregion
1009
1138
  //#region src/release.ts
1010
1139
  /**
1011
1140
  * Releases every package in a pnpm workspace with independent versions, in dependency order.
@@ -1013,6 +1142,10 @@ async function loadReleasePlugin(absolutePath, cache) {
1013
1142
  * 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.
1014
1143
  */
1015
1144
  async function releaseWorkspace(options = {}) {
1145
+ if (options.gatePublish === true) {
1146
+ 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.");
1147
+ return detachWorkspaceRelease(options);
1148
+ }
1016
1149
  if ((options.commitStrategy ?? "per-package") === "single") return releaseWorkspaceSingleCommit(options);
1017
1150
  const root = resolve(options.root ?? process.cwd());
1018
1151
  const log = options.log ?? console.log;
@@ -1026,40 +1159,65 @@ async function releaseWorkspace(options = {}) {
1026
1159
  const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
1027
1160
  const analyzeCommitsConfig = options.analyzeCommits ?? {};
1028
1161
  const generateNotesConfig = options.generateNotes ?? {};
1162
+ return {
1163
+ order,
1164
+ packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
1165
+ const result = await runPackageRelease(pkg, {
1166
+ publishPlugins,
1167
+ analyzeCommitsConfig,
1168
+ generateNotesConfig,
1169
+ bumpsForThisPackage,
1170
+ dryRun,
1171
+ env,
1172
+ branches: options.branches
1173
+ });
1174
+ const nextRelease = result === false ? void 0 : result.nextRelease;
1175
+ return {
1176
+ released: nextRelease !== void 0,
1177
+ version: nextRelease?.version,
1178
+ result
1179
+ };
1180
+ })).map((entry) => {
1181
+ const nextRelease = entry.result === false ? void 0 : entry.result.nextRelease;
1182
+ return {
1183
+ name: entry.name,
1184
+ directory: entry.directory,
1185
+ released: nextRelease !== void 0,
1186
+ version: nextRelease?.version,
1187
+ gitTag: nextRelease?.gitTag,
1188
+ type: nextRelease?.type,
1189
+ dependencyBumps: entry.dependencyBumps
1190
+ };
1191
+ })
1192
+ };
1193
+ }
1194
+ /**
1195
+ * 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`.
1196
+ */
1197
+ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne) {
1029
1198
  const pendingBumps = /* @__PURE__ */ new Map();
1030
1199
  let identity;
1031
- const outcomes = [];
1200
+ const entries = [];
1032
1201
  for (const name of order) {
1033
1202
  const pkg = mustGet(graph.packages, name, "package");
1034
1203
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1035
1204
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1036
- const result = await runPackageRelease(pkg, {
1037
- publishPlugins,
1038
- analyzeCommitsConfig,
1039
- generateNotesConfig,
1040
- bumpsForThisPackage,
1041
- dryRun,
1042
- env,
1043
- branches: options.branches
1044
- });
1045
- const nextRelease = result === false ? void 0 : result.nextRelease;
1046
- outcomes.push({
1205
+ const { released, version, result } = await releaseOne(pkg, bumpsForThisPackage);
1206
+ entries.push({
1047
1207
  name,
1048
1208
  directory: pkg.directory,
1049
- released: nextRelease !== void 0,
1050
- version: nextRelease?.version,
1051
- gitTag: nextRelease?.gitTag,
1052
- type: nextRelease?.type,
1209
+ relativeDirectory: pkg.relativeDirectory,
1210
+ result,
1053
1211
  dependencyBumps: bumpsForThisPackage
1054
1212
  });
1055
1213
  pendingBumps.delete(name);
1056
- if (nextRelease === void 0) {
1214
+ if (!released || version === void 0) {
1057
1215
  log(`${name}: no release`);
1058
1216
  continue;
1059
1217
  }
1060
- log(`${name}: released ${nextRelease.gitTag}`);
1218
+ log(`${name}: released ${version}`);
1061
1219
  identity ??= await resolveCommitIdentity({ cwd: workspace.root });
1062
- const bumps = await bumpDependents(pkg, nextRelease.version, graph, {
1220
+ const bumps = await bumpDependents(pkg, version, graph, {
1063
1221
  workspace,
1064
1222
  dryRun,
1065
1223
  identity,
@@ -1071,10 +1229,7 @@ async function releaseWorkspace(options = {}) {
1071
1229
  pendingBumps.set(bump.dependent, forDependent);
1072
1230
  }
1073
1231
  }
1074
- return {
1075
- order,
1076
- packages: outcomes
1077
- };
1232
+ return entries;
1078
1233
  }
1079
1234
  async function runPackageRelease(pkg, options) {
1080
1235
  const scoped = createScopedPlugins({
@@ -1145,4 +1300,4 @@ async function bumpDependents(released, version, graph, options) {
1145
1300
  return applied;
1146
1301
  }
1147
1302
  //#endregion
1148
- export { DEFAULT_PUBLISH_PLUGINS, DependencyCycleError, GitCommandError, ReleaseConfigurationError, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, UnsupportedDependencyRangeError, WorkspaceDiscoveryError, WorkspaceReleaseError, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, topologicalOrder, updateDependencyRange, writeDependencyRange };
1303
+ export { DEFAULT_PUBLISH_PLUGINS, DependencyCycleError, GitCommandError, ReleaseConfigurationError, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, UnsupportedDependencyRangeError, WorkspaceDiscoveryError, WorkspaceReleaseError, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, resumeWorkspaceRelease, topologicalOrder, updateDependencyRange, writeDependencyRange };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exadev/semantic-release-workspace",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -77,6 +77,7 @@
77
77
  "license": "MIT",
78
78
  "packageManager": "pnpm@11.6.0",
79
79
  "dependencies": {
80
+ "@exadev/release-gate": "^1.0.0",
80
81
  "commander": "^15.0.0",
81
82
  "cosmiconfig": "^9.0.2",
82
83
  "tinyglobby": "^0.2.17",