@exadev/semantic-release-workspace 3.0.0 → 3.0.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
@@ -14,7 +14,7 @@ import { generateNotes } from "@semantic-release/release-notes-generator";
14
14
  import semanticRelease from "semantic-release";
15
15
  import { pathToFileURL } from "node:url";
16
16
  //#region package.json
17
- var version = "3.0.0";
17
+ var version = "3.0.1";
18
18
  //#endregion
19
19
  //#region src/errors.ts
20
20
  /**
@@ -279,6 +279,53 @@ async function pushHeadAndTags(tagNames, options) {
279
279
  ...tagNames
280
280
  ], options);
281
281
  }
282
+ /**
283
+ * Fetches `branch` from origin and returns the commit it now points at.
284
+ *
285
+ * This is how a failed push is classified, in preference to reading the reason git printed for the rejection. The wording is not stable enough to branch on: the same race produces `! [rejected] ... (non-fast-forward)` from one server and `cannot lock ref 'refs/heads/main': is at <x> but expected <y>` from another (both observed, the first against GitHub and the second against a local bare repository under `--atomic`), and neither is a documented interface. Whether the branch actually moved is a fact about the remote rather than a string, it is the precise condition a retry can recover from, and it is knowable by asking.
286
+ */
287
+ async function fetchBranchTip(branch, options) {
288
+ await git([
289
+ "fetch",
290
+ "origin",
291
+ branch
292
+ ], options);
293
+ return (await git(["rev-parse", "FETCH_HEAD"], options)).trim();
294
+ }
295
+ /** Whether `ancestor` is reachable from `descendant`. `git merge-base --is-ancestor` reports the answer as an exit status rather than on stdout: 0 for yes and 1 for no are both answers, so only any other exit code is a failure. */
296
+ async function isAncestor(ancestor, descendant, options) {
297
+ const args = [
298
+ "merge-base",
299
+ "--is-ancestor",
300
+ ancestor,
301
+ descendant
302
+ ];
303
+ try {
304
+ await execGit(args, options.cwd);
305
+ return true;
306
+ } catch (cause) {
307
+ const error = toGitCommandError(args, options.cwd, cause);
308
+ if (error.exitCode === 1) return false;
309
+ throw error;
310
+ }
311
+ }
312
+ /** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
313
+ async function resetHardTo(ref, options) {
314
+ await git([
315
+ "reset",
316
+ "--hard",
317
+ ref
318
+ ], options);
319
+ }
320
+ /** Deletes local tags. A retried attempt must drop the tags its predecessor created before it recreates them: `git tag` refuses a name that already exists, so without this the second attempt fails while tagging, before it ever reaches the push it was retrying. */
321
+ async function deleteLocalTags(tagNames, options) {
322
+ if (tagNames.length === 0) return;
323
+ await git([
324
+ "tag",
325
+ "-d",
326
+ ...tagNames
327
+ ], options);
328
+ }
282
329
  function toGitCommandError(args, cwd, cause) {
283
330
  const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
284
331
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
@@ -887,6 +934,27 @@ async function regenerateLockfile(options) {
887
934
  * Why publish is not just another semantic-release() call: semantic-release's own `run()` unconditionally derives `lastRelease` from the newest tag already on the branch matching `tagFormat`, and this mode has, by the time phase 5 runs, already created and pushed that exact tag itself. A second real `semanticRelease()` call would see its own just-created tag as the already-published release and compute the wrong next version from it. Phase 5 instead calls each resolved plugin module's own exported `verifyConditions`/`publish`/`success` functions directly, with a hand-built context -- the same public per-plugin API surface semantic-release's own core calls internally, just invoked without going through the parts of `run()` that assume a not-yet-tagged repository.
888
935
  */
889
936
  async function releaseWorkspaceSingleCommit(options) {
937
+ const log = options.log ?? console.log;
938
+ const maxAttempts = resolvePushAttempts(options.pushAttempts);
939
+ let lastLoss;
940
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
941
+ const result = await attemptSingleCommitRelease(options);
942
+ if (result.kind === "complete") return result.outcome;
943
+ if (result.kind === "pushed") {
944
+ await result.publish();
945
+ return result.outcome;
946
+ }
947
+ lastLoss = result.error;
948
+ log(`${packageName}: attempt ${String(attempt)} of ${String(maxAttempts)} lost the push, because ${result.branch} advanced to ${result.remoteTip} before this run's own refs reached the remote. This attempt's commit and tags have been discarded; recomputing the release against the new tip.`);
949
+ }
950
+ throw new WorkspaceStateError(`${packageName}: gave up after ${String(maxAttempts)} attempt(s) to push the release, each overtaken by another commit landing on the release branch first. Nothing was published and no tag reached the remote, so re-running is safe and loses nothing. Raise "pushAttempts" if this branch is busy enough that ${String(maxAttempts)} is genuinely too few. The last push failed with: ${lastLoss === void 0 ? "unknown" : lastLoss.message}`);
951
+ }
952
+ function resolvePushAttempts(configured) {
953
+ if (configured === void 0) return 5;
954
+ if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
955
+ return configured;
956
+ }
957
+ async function attemptSingleCommitRelease(options) {
890
958
  const root = resolve(options.root ?? process.cwd());
891
959
  const log = options.log ?? console.log;
892
960
  const dryRun = options.dryRun === true;
@@ -970,8 +1038,11 @@ async function releaseWorkspaceSingleCommit(options) {
970
1038
  }
971
1039
  }
972
1040
  if (dryRun || planned.length === 0) return {
973
- order,
974
- packages: outcomes
1041
+ kind: "complete",
1042
+ outcome: {
1043
+ order,
1044
+ packages: outcomes
1045
+ }
975
1046
  };
976
1047
  const branch = captured.branch;
977
1048
  const repositoryUrl = captured.repositoryUrl;
@@ -1010,26 +1081,51 @@ async function releaseWorkspaceSingleCommit(options) {
1010
1081
  const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
1011
1082
  const tagNames = planned.map((release) => release.gitTag);
1012
1083
  for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
1013
- await pushHeadAndTags(tagNames, { cwd: repoRoot });
1014
- log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
1015
- for (const release of planned) {
1016
- const releases = [];
1017
- for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1018
- const plugin = await loadReleasePlugin(modulePath, moduleCache);
1019
- if (plugin.publish) {
1020
- const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1021
- if (result !== false && result !== void 0) releases.push(result);
1022
- }
1023
- }
1024
- for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1025
- const plugin = await loadReleasePlugin(modulePath, moduleCache);
1026
- if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1084
+ const branchName = await currentBranch({ cwd: repoRoot });
1085
+ try {
1086
+ await pushHeadAndTags(tagNames, { cwd: repoRoot });
1087
+ } catch (error) {
1088
+ if (!(error instanceof GitCommandError)) throw error;
1089
+ let remoteTip;
1090
+ try {
1091
+ remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
1092
+ } catch {
1093
+ throw error;
1027
1094
  }
1028
- log(`${release.pkg.name}: published ${release.gitTag}`);
1095
+ if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
1096
+ await deleteLocalTags(tagNames, { cwd: repoRoot });
1097
+ await resetHardTo(remoteTip, { cwd: repoRoot });
1098
+ return {
1099
+ kind: "lost",
1100
+ error,
1101
+ branch: branchName,
1102
+ remoteTip
1103
+ };
1029
1104
  }
1105
+ log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
1030
1106
  return {
1031
- order,
1032
- packages: outcomes
1107
+ kind: "pushed",
1108
+ outcome: {
1109
+ order,
1110
+ packages: outcomes
1111
+ },
1112
+ publish: async () => {
1113
+ for (const release of planned) {
1114
+ const releases = [];
1115
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1116
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1117
+ if (plugin.publish) {
1118
+ const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1119
+ if (result !== false && result !== void 0) releases.push(result);
1120
+ }
1121
+ }
1122
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1123
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1124
+ if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1125
+ }
1126
+ log(`${release.pkg.name}: published ${release.gitTag}`);
1127
+ }
1128
+ }
1033
1129
  };
1034
1130
  }
1035
1131
  /**
package/dist/index.cjs CHANGED
@@ -286,6 +286,53 @@ async function pushHeadAndTags(tagNames, options) {
286
286
  ...tagNames
287
287
  ], options);
288
288
  }
289
+ /**
290
+ * Fetches `branch` from origin and returns the commit it now points at.
291
+ *
292
+ * This is how a failed push is classified, in preference to reading the reason git printed for the rejection. The wording is not stable enough to branch on: the same race produces `! [rejected] ... (non-fast-forward)` from one server and `cannot lock ref 'refs/heads/main': is at <x> but expected <y>` from another (both observed, the first against GitHub and the second against a local bare repository under `--atomic`), and neither is a documented interface. Whether the branch actually moved is a fact about the remote rather than a string, it is the precise condition a retry can recover from, and it is knowable by asking.
293
+ */
294
+ async function fetchBranchTip(branch, options) {
295
+ await git([
296
+ "fetch",
297
+ "origin",
298
+ branch
299
+ ], options);
300
+ return (await git(["rev-parse", "FETCH_HEAD"], options)).trim();
301
+ }
302
+ /** Whether `ancestor` is reachable from `descendant`. `git merge-base --is-ancestor` reports the answer as an exit status rather than on stdout: 0 for yes and 1 for no are both answers, so only any other exit code is a failure. */
303
+ async function isAncestor(ancestor, descendant, options) {
304
+ const args = [
305
+ "merge-base",
306
+ "--is-ancestor",
307
+ ancestor,
308
+ descendant
309
+ ];
310
+ try {
311
+ await execGit(args, options.cwd);
312
+ return true;
313
+ } catch (cause) {
314
+ const error = toGitCommandError(args, options.cwd, cause);
315
+ if (error.exitCode === 1) return false;
316
+ throw error;
317
+ }
318
+ }
319
+ /** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
320
+ async function resetHardTo(ref, options) {
321
+ await git([
322
+ "reset",
323
+ "--hard",
324
+ ref
325
+ ], options);
326
+ }
327
+ /** Deletes local tags. A retried attempt must drop the tags its predecessor created before it recreates them: `git tag` refuses a name that already exists, so without this the second attempt fails while tagging, before it ever reaches the push it was retrying. */
328
+ async function deleteLocalTags(tagNames, options) {
329
+ if (tagNames.length === 0) return;
330
+ await git([
331
+ "tag",
332
+ "-d",
333
+ ...tagNames
334
+ ], options);
335
+ }
289
336
  function toGitCommandError(args, cwd, cause) {
290
337
  const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
291
338
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
@@ -900,6 +947,27 @@ function formatTagForPackage(tagFormat, name) {
900
947
  * Why publish is not just another semantic-release() call: semantic-release's own `run()` unconditionally derives `lastRelease` from the newest tag already on the branch matching `tagFormat`, and this mode has, by the time phase 5 runs, already created and pushed that exact tag itself. A second real `semanticRelease()` call would see its own just-created tag as the already-published release and compute the wrong next version from it. Phase 5 instead calls each resolved plugin module's own exported `verifyConditions`/`publish`/`success` functions directly, with a hand-built context -- the same public per-plugin API surface semantic-release's own core calls internally, just invoked without going through the parts of `run()` that assume a not-yet-tagged repository.
901
948
  */
902
949
  async function releaseWorkspaceSingleCommit(options) {
950
+ const log = options.log ?? console.log;
951
+ const maxAttempts = resolvePushAttempts(options.pushAttempts);
952
+ let lastLoss;
953
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
954
+ const result = await attemptSingleCommitRelease(options);
955
+ if (result.kind === "complete") return result.outcome;
956
+ if (result.kind === "pushed") {
957
+ await result.publish();
958
+ return result.outcome;
959
+ }
960
+ lastLoss = result.error;
961
+ log(`${packageName}: attempt ${String(attempt)} of ${String(maxAttempts)} lost the push, because ${result.branch} advanced to ${result.remoteTip} before this run's own refs reached the remote. This attempt's commit and tags have been discarded; recomputing the release against the new tip.`);
962
+ }
963
+ throw new WorkspaceStateError(`${packageName}: gave up after ${String(maxAttempts)} attempt(s) to push the release, each overtaken by another commit landing on the release branch first. Nothing was published and no tag reached the remote, so re-running is safe and loses nothing. Raise "pushAttempts" if this branch is busy enough that ${String(maxAttempts)} is genuinely too few. The last push failed with: ${lastLoss === void 0 ? "unknown" : lastLoss.message}`);
964
+ }
965
+ function resolvePushAttempts(configured) {
966
+ if (configured === void 0) return 5;
967
+ if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
968
+ return configured;
969
+ }
970
+ async function attemptSingleCommitRelease(options) {
903
971
  const root = (0, node_path.resolve)(options.root ?? process.cwd());
904
972
  const log = options.log ?? console.log;
905
973
  const dryRun = options.dryRun === true;
@@ -983,8 +1051,11 @@ async function releaseWorkspaceSingleCommit(options) {
983
1051
  }
984
1052
  }
985
1053
  if (dryRun || planned.length === 0) return {
986
- order,
987
- packages: outcomes
1054
+ kind: "complete",
1055
+ outcome: {
1056
+ order,
1057
+ packages: outcomes
1058
+ }
988
1059
  };
989
1060
  const branch = captured.branch;
990
1061
  const repositoryUrl = captured.repositoryUrl;
@@ -1023,26 +1094,51 @@ async function releaseWorkspaceSingleCommit(options) {
1023
1094
  const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
1024
1095
  const tagNames = planned.map((release) => release.gitTag);
1025
1096
  for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
1026
- await pushHeadAndTags(tagNames, { cwd: repoRoot });
1027
- log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
1028
- for (const release of planned) {
1029
- const releases = [];
1030
- for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1031
- const plugin = await loadReleasePlugin(modulePath, moduleCache);
1032
- if (plugin.publish) {
1033
- const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1034
- if (result !== false && result !== void 0) releases.push(result);
1035
- }
1036
- }
1037
- for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1038
- const plugin = await loadReleasePlugin(modulePath, moduleCache);
1039
- if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1097
+ const branchName = await currentBranch({ cwd: repoRoot });
1098
+ try {
1099
+ await pushHeadAndTags(tagNames, { cwd: repoRoot });
1100
+ } catch (error) {
1101
+ if (!(error instanceof GitCommandError)) throw error;
1102
+ let remoteTip;
1103
+ try {
1104
+ remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
1105
+ } catch {
1106
+ throw error;
1040
1107
  }
1041
- log(`${release.pkg.name}: published ${release.gitTag}`);
1108
+ if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
1109
+ await deleteLocalTags(tagNames, { cwd: repoRoot });
1110
+ await resetHardTo(remoteTip, { cwd: repoRoot });
1111
+ return {
1112
+ kind: "lost",
1113
+ error,
1114
+ branch: branchName,
1115
+ remoteTip
1116
+ };
1042
1117
  }
1118
+ log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
1043
1119
  return {
1044
- order,
1045
- packages: outcomes
1120
+ kind: "pushed",
1121
+ outcome: {
1122
+ order,
1123
+ packages: outcomes
1124
+ },
1125
+ publish: async () => {
1126
+ for (const release of planned) {
1127
+ const releases = [];
1128
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1129
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1130
+ if (plugin.publish) {
1131
+ const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1132
+ if (result !== false && result !== void 0) releases.push(result);
1133
+ }
1134
+ }
1135
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1136
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1137
+ if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1138
+ }
1139
+ log(`${release.pkg.name}: published ${release.gitTag}`);
1140
+ }
1141
+ }
1046
1142
  };
1047
1143
  }
1048
1144
  /**
package/dist/index.d.cts CHANGED
@@ -279,6 +279,14 @@ interface ReleaseWorkspaceOptions {
279
279
  * and pins `...@<action>-v1`. Validated once per run by `validateTagFormat`.
280
280
  */
281
281
  readonly tagFormat?: string;
282
+ /**
283
+ * How many times `commitStrategy: 'single'` will compute and push a release before giving up, when each attempt is lost to another commit landing on the release branch first. Defaults to `DEFAULT_PUSH_ATTEMPTS`. Ignored by `commitStrategy: 'per-package'`, which pushes incrementally and has no all-or-nothing attempt to repeat.
284
+ *
285
+ * The default is derived rather than chosen. An attempt is lost if any commit lands during its own window, so with pushes arriving at rate `L` and an attempt taking `W`, an attempt survives with probability `e^(-L*W)` and `n` attempts all fail with probability `(1 - e^(-L*W))^n`. On the busiest repository this tool serves, pushes to the release branch arrived at roughly one every 7.4 minutes while a merge queue was landing pull requests continuously (measured over the busiest 60, 90 and 120 minute windows, which agreed to within 6%), and analysing, preparing, committing and pushing 23 packages took about 2.2 minutes, so `W = 3` minutes leaves headroom for a larger workspace. That gives about a one-in-three chance of losing any single attempt, which matches what was observed when there was no retry at all, and 5 attempts put the chance of losing all of them near 0.4%, or roughly one lost release a month at ten releases a day. Five attempts also bound the added time at about 15 minutes, well inside the hour-long job timeouts these releases run under.
286
+ *
287
+ * Raise it for a busier branch or a slower workspace; the cost of a higher bound is only paid when attempts are actually being lost.
288
+ */
289
+ readonly pushAttempts?: number;
282
290
  }
283
291
  /** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
284
292
  interface AppliedDependencyBump extends DependencyBump {
package/dist/index.d.ts CHANGED
@@ -279,6 +279,14 @@ interface ReleaseWorkspaceOptions {
279
279
  * and pins `...@<action>-v1`. Validated once per run by `validateTagFormat`.
280
280
  */
281
281
  readonly tagFormat?: string;
282
+ /**
283
+ * How many times `commitStrategy: 'single'` will compute and push a release before giving up, when each attempt is lost to another commit landing on the release branch first. Defaults to `DEFAULT_PUSH_ATTEMPTS`. Ignored by `commitStrategy: 'per-package'`, which pushes incrementally and has no all-or-nothing attempt to repeat.
284
+ *
285
+ * The default is derived rather than chosen. An attempt is lost if any commit lands during its own window, so with pushes arriving at rate `L` and an attempt taking `W`, an attempt survives with probability `e^(-L*W)` and `n` attempts all fail with probability `(1 - e^(-L*W))^n`. On the busiest repository this tool serves, pushes to the release branch arrived at roughly one every 7.4 minutes while a merge queue was landing pull requests continuously (measured over the busiest 60, 90 and 120 minute windows, which agreed to within 6%), and analysing, preparing, committing and pushing 23 packages took about 2.2 minutes, so `W = 3` minutes leaves headroom for a larger workspace. That gives about a one-in-three chance of losing any single attempt, which matches what was observed when there was no retry at all, and 5 attempts put the chance of losing all of them near 0.4%, or roughly one lost release a month at ten releases a day. Five attempts also bound the added time at about 15 minutes, well inside the hour-long job timeouts these releases run under.
286
+ *
287
+ * Raise it for a busier branch or a slower workspace; the cost of a higher bound is only paid when attempts are actually being lost.
288
+ */
289
+ readonly pushAttempts?: number;
282
290
  }
283
291
  /** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
284
292
  interface AppliedDependencyBump extends DependencyBump {
package/dist/index.js CHANGED
@@ -261,6 +261,53 @@ async function pushHeadAndTags(tagNames, options) {
261
261
  ...tagNames
262
262
  ], options);
263
263
  }
264
+ /**
265
+ * Fetches `branch` from origin and returns the commit it now points at.
266
+ *
267
+ * This is how a failed push is classified, in preference to reading the reason git printed for the rejection. The wording is not stable enough to branch on: the same race produces `! [rejected] ... (non-fast-forward)` from one server and `cannot lock ref 'refs/heads/main': is at <x> but expected <y>` from another (both observed, the first against GitHub and the second against a local bare repository under `--atomic`), and neither is a documented interface. Whether the branch actually moved is a fact about the remote rather than a string, it is the precise condition a retry can recover from, and it is knowable by asking.
268
+ */
269
+ async function fetchBranchTip(branch, options) {
270
+ await git([
271
+ "fetch",
272
+ "origin",
273
+ branch
274
+ ], options);
275
+ return (await git(["rev-parse", "FETCH_HEAD"], options)).trim();
276
+ }
277
+ /** Whether `ancestor` is reachable from `descendant`. `git merge-base --is-ancestor` reports the answer as an exit status rather than on stdout: 0 for yes and 1 for no are both answers, so only any other exit code is a failure. */
278
+ async function isAncestor(ancestor, descendant, options) {
279
+ const args = [
280
+ "merge-base",
281
+ "--is-ancestor",
282
+ ancestor,
283
+ descendant
284
+ ];
285
+ try {
286
+ await execGit(args, options.cwd);
287
+ return true;
288
+ } catch (cause) {
289
+ const error = toGitCommandError(args, options.cwd, cause);
290
+ if (error.exitCode === 1) return false;
291
+ throw error;
292
+ }
293
+ }
294
+ /** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
295
+ async function resetHardTo(ref, options) {
296
+ await git([
297
+ "reset",
298
+ "--hard",
299
+ ref
300
+ ], options);
301
+ }
302
+ /** Deletes local tags. A retried attempt must drop the tags its predecessor created before it recreates them: `git tag` refuses a name that already exists, so without this the second attempt fails while tagging, before it ever reaches the push it was retrying. */
303
+ async function deleteLocalTags(tagNames, options) {
304
+ if (tagNames.length === 0) return;
305
+ await git([
306
+ "tag",
307
+ "-d",
308
+ ...tagNames
309
+ ], options);
310
+ }
264
311
  function toGitCommandError(args, cwd, cause) {
265
312
  const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
266
313
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
@@ -875,6 +922,27 @@ function formatTagForPackage(tagFormat, name) {
875
922
  * Why publish is not just another semantic-release() call: semantic-release's own `run()` unconditionally derives `lastRelease` from the newest tag already on the branch matching `tagFormat`, and this mode has, by the time phase 5 runs, already created and pushed that exact tag itself. A second real `semanticRelease()` call would see its own just-created tag as the already-published release and compute the wrong next version from it. Phase 5 instead calls each resolved plugin module's own exported `verifyConditions`/`publish`/`success` functions directly, with a hand-built context -- the same public per-plugin API surface semantic-release's own core calls internally, just invoked without going through the parts of `run()` that assume a not-yet-tagged repository.
876
923
  */
877
924
  async function releaseWorkspaceSingleCommit(options) {
925
+ const log = options.log ?? console.log;
926
+ const maxAttempts = resolvePushAttempts(options.pushAttempts);
927
+ let lastLoss;
928
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
929
+ const result = await attemptSingleCommitRelease(options);
930
+ if (result.kind === "complete") return result.outcome;
931
+ if (result.kind === "pushed") {
932
+ await result.publish();
933
+ return result.outcome;
934
+ }
935
+ lastLoss = result.error;
936
+ log(`${packageName}: attempt ${String(attempt)} of ${String(maxAttempts)} lost the push, because ${result.branch} advanced to ${result.remoteTip} before this run's own refs reached the remote. This attempt's commit and tags have been discarded; recomputing the release against the new tip.`);
937
+ }
938
+ throw new WorkspaceStateError(`${packageName}: gave up after ${String(maxAttempts)} attempt(s) to push the release, each overtaken by another commit landing on the release branch first. Nothing was published and no tag reached the remote, so re-running is safe and loses nothing. Raise "pushAttempts" if this branch is busy enough that ${String(maxAttempts)} is genuinely too few. The last push failed with: ${lastLoss === void 0 ? "unknown" : lastLoss.message}`);
939
+ }
940
+ function resolvePushAttempts(configured) {
941
+ if (configured === void 0) return 5;
942
+ if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
943
+ return configured;
944
+ }
945
+ async function attemptSingleCommitRelease(options) {
878
946
  const root = resolve(options.root ?? process.cwd());
879
947
  const log = options.log ?? console.log;
880
948
  const dryRun = options.dryRun === true;
@@ -958,8 +1026,11 @@ async function releaseWorkspaceSingleCommit(options) {
958
1026
  }
959
1027
  }
960
1028
  if (dryRun || planned.length === 0) return {
961
- order,
962
- packages: outcomes
1029
+ kind: "complete",
1030
+ outcome: {
1031
+ order,
1032
+ packages: outcomes
1033
+ }
963
1034
  };
964
1035
  const branch = captured.branch;
965
1036
  const repositoryUrl = captured.repositoryUrl;
@@ -998,26 +1069,51 @@ async function releaseWorkspaceSingleCommit(options) {
998
1069
  const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
999
1070
  const tagNames = planned.map((release) => release.gitTag);
1000
1071
  for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
1001
- await pushHeadAndTags(tagNames, { cwd: repoRoot });
1002
- log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
1003
- for (const release of planned) {
1004
- const releases = [];
1005
- for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1006
- const plugin = await loadReleasePlugin(modulePath, moduleCache);
1007
- if (plugin.publish) {
1008
- const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1009
- if (result !== false && result !== void 0) releases.push(result);
1010
- }
1011
- }
1012
- for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1013
- const plugin = await loadReleasePlugin(modulePath, moduleCache);
1014
- if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1072
+ const branchName = await currentBranch({ cwd: repoRoot });
1073
+ try {
1074
+ await pushHeadAndTags(tagNames, { cwd: repoRoot });
1075
+ } catch (error) {
1076
+ if (!(error instanceof GitCommandError)) throw error;
1077
+ let remoteTip;
1078
+ try {
1079
+ remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
1080
+ } catch {
1081
+ throw error;
1015
1082
  }
1016
- log(`${release.pkg.name}: published ${release.gitTag}`);
1083
+ if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
1084
+ await deleteLocalTags(tagNames, { cwd: repoRoot });
1085
+ await resetHardTo(remoteTip, { cwd: repoRoot });
1086
+ return {
1087
+ kind: "lost",
1088
+ error,
1089
+ branch: branchName,
1090
+ remoteTip
1091
+ };
1017
1092
  }
1093
+ log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
1018
1094
  return {
1019
- order,
1020
- packages: outcomes
1095
+ kind: "pushed",
1096
+ outcome: {
1097
+ order,
1098
+ packages: outcomes
1099
+ },
1100
+ publish: async () => {
1101
+ for (const release of planned) {
1102
+ const releases = [];
1103
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1104
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1105
+ if (plugin.publish) {
1106
+ const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1107
+ if (result !== false && result !== void 0) releases.push(result);
1108
+ }
1109
+ }
1110
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1111
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1112
+ if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1113
+ }
1114
+ log(`${release.pkg.name}: published ${release.gitTag}`);
1115
+ }
1116
+ }
1021
1117
  };
1022
1118
  }
1023
1119
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exadev/semantic-release-workspace",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.",
5
5
  "type": "module",
6
6
  "repository": {