@exadev/semantic-release-workspace 3.0.0 → 3.0.2

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/README.md CHANGED
@@ -76,6 +76,14 @@ Set it via the `--commit-strategy <mode>` CLI flag, the `commitStrategy` field i
76
76
 
77
77
  `'single'` mode still runs each package's own configured publish plugins afterward (npm publish with provenance, GitHub releases), scoped per package exactly as `'per-package'` mode does — it just runs their `verifyConditions`/`publish`/`success` steps directly against the already-committed-and-tagged repository state, since by that point semantic-release's own top-level orchestrator would misread the tag this mode already created as an existing release. `addChannel` and `fail` are not called in this mode (pre-release channel promotion and posting an automated failure comment/issue, respectively) — a deliberate scope boundary, not a silent gap: raise an issue if your workflow needs them.
78
78
 
79
+ ### When the release branch moves during a run
80
+
81
+ Under `commitStrategy: 'per-package'` a release is a sequence of independent pushes, so a commit landing on the release branch mid-run can interrupt it between packages. The orchestrator integrates the new tip and carries on with the packages that have not released yet, and packages already published are never republished, since their tags record them. Where semantic-release declines to release a package while the branch is moving underneath it, which is indistinguishable from that package genuinely having nothing to release, the package is released again against the integrated tip rather than assumed finished; a run that can never establish the answer fails rather than finishing as though the package had nothing, which would leave it and every package after it silently unreleased.
82
+
83
+ One window is outside this tool's control: semantic-release pushes a package's tag before running its publish plugins, and that push is not atomic, so a rejection there can leave a tag on the remote for a version that never reached npm. Nothing is corrupted and no later release is blocked, but that version number is skipped. If it happens, find the tag with no matching published version (`npm view <name> versions`) and either publish from it by hand or delete the tag locally and on the remote and let the next run recompute it.
84
+
85
+ If you need a run to be strictly all-or-nothing, use `commitStrategy: 'single'`, which makes one commit and one atomic push before anything publishes, and which retries the whole attempt against the new tip when that push loses a race.
86
+
79
87
  ## Gating publish
80
88
 
81
89
  `commitStrategy` changes how a release is committed; `gatePublish` changes *when* it gets published relative to being tagged and pushed -- a different, orthogonal axis. With `gatePublish: true`, each due package is tagged and pushed via [@exadev/release-gate](https://www.npmjs.com/package/@exadev/release-gate) (a real dependency of this package -- no separate install needed) but never published: nothing calls `npm publish`, creates a GitHub Release, or runs any other configured publish step, until a separate `resume` step does so explicitly -- from the same process, or a completely different one (a later CI job, once a deploy or smoke test has confirmed the release should actually go out).
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.2";
18
18
  //#endregion
19
19
  //#region src/errors.ts
20
20
  /**
@@ -279,6 +279,61 @@ 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
+ /** Advances the current branch to `ref`, refusing unless that is a fast-forward. Used where every local commit is already on the remote, so anything that is not a fast-forward means the checkout holds work the remote does not, and stopping is better than the alternative of discarding it. */
313
+ async function fastForwardTo(ref, options) {
314
+ await git([
315
+ "merge",
316
+ "--ff-only",
317
+ ref
318
+ ], options);
319
+ }
320
+ /** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
321
+ async function resetHardTo(ref, options) {
322
+ await git([
323
+ "reset",
324
+ "--hard",
325
+ ref
326
+ ], options);
327
+ }
328
+ /** 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. */
329
+ async function deleteLocalTags(tagNames, options) {
330
+ if (tagNames.length === 0) return;
331
+ await git([
332
+ "tag",
333
+ "-d",
334
+ ...tagNames
335
+ ], options);
336
+ }
282
337
  function toGitCommandError(args, cwd, cause) {
283
338
  const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
284
339
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
@@ -887,6 +942,27 @@ async function regenerateLockfile(options) {
887
942
  * 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
943
  */
889
944
  async function releaseWorkspaceSingleCommit(options) {
945
+ const log = options.log ?? console.log;
946
+ const maxAttempts = resolvePushAttempts(options.pushAttempts);
947
+ let lastLoss;
948
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
949
+ const result = await attemptSingleCommitRelease(options);
950
+ if (result.kind === "complete") return result.outcome;
951
+ if (result.kind === "pushed") {
952
+ await result.publish();
953
+ return result.outcome;
954
+ }
955
+ lastLoss = result.error;
956
+ 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.`);
957
+ }
958
+ 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}`);
959
+ }
960
+ function resolvePushAttempts(configured) {
961
+ if (configured === void 0) return 5;
962
+ if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
963
+ return configured;
964
+ }
965
+ async function attemptSingleCommitRelease(options) {
890
966
  const root = resolve(options.root ?? process.cwd());
891
967
  const log = options.log ?? console.log;
892
968
  const dryRun = options.dryRun === true;
@@ -970,8 +1046,11 @@ async function releaseWorkspaceSingleCommit(options) {
970
1046
  }
971
1047
  }
972
1048
  if (dryRun || planned.length === 0) return {
973
- order,
974
- packages: outcomes
1049
+ kind: "complete",
1050
+ outcome: {
1051
+ order,
1052
+ packages: outcomes
1053
+ }
975
1054
  };
976
1055
  const branch = captured.branch;
977
1056
  const repositoryUrl = captured.repositoryUrl;
@@ -1010,26 +1089,51 @@ async function releaseWorkspaceSingleCommit(options) {
1010
1089
  const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
1011
1090
  const tagNames = planned.map((release) => release.gitTag);
1012
1091
  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));
1092
+ const branchName = await currentBranch({ cwd: repoRoot });
1093
+ try {
1094
+ await pushHeadAndTags(tagNames, { cwd: repoRoot });
1095
+ } catch (error) {
1096
+ if (!(error instanceof GitCommandError)) throw error;
1097
+ let remoteTip;
1098
+ try {
1099
+ remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
1100
+ } catch {
1101
+ throw error;
1027
1102
  }
1028
- log(`${release.pkg.name}: published ${release.gitTag}`);
1103
+ if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
1104
+ await deleteLocalTags(tagNames, { cwd: repoRoot });
1105
+ await resetHardTo(remoteTip, { cwd: repoRoot });
1106
+ return {
1107
+ kind: "lost",
1108
+ error,
1109
+ branch: branchName,
1110
+ remoteTip
1111
+ };
1029
1112
  }
1113
+ log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
1030
1114
  return {
1031
- order,
1032
- packages: outcomes
1115
+ kind: "pushed",
1116
+ outcome: {
1117
+ order,
1118
+ packages: outcomes
1119
+ },
1120
+ publish: async () => {
1121
+ for (const release of planned) {
1122
+ const releases = [];
1123
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1124
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1125
+ if (plugin.publish) {
1126
+ const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1127
+ if (result !== false && result !== void 0) releases.push(result);
1128
+ }
1129
+ }
1130
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1131
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1132
+ if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1133
+ }
1134
+ log(`${release.pkg.name}: published ${release.gitTag}`);
1135
+ }
1136
+ }
1033
1137
  };
1034
1138
  }
1035
1139
  /**
@@ -1237,7 +1341,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1237
1341
  const pkg = mustGet(graph.packages, name, "package");
1238
1342
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1239
1343
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1240
- const { released, version, result } = await releaseOne(pkg, bumpsForThisPackage);
1344
+ const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
1241
1345
  entries.push({
1242
1346
  name,
1243
1347
  directory: pkg.directory,
@@ -1334,6 +1438,46 @@ async function bumpDependents(released, version, graph, options) {
1334
1438
  }
1335
1439
  return applied;
1336
1440
  }
1441
+ /**
1442
+ * How many times one package may be re-released after the branch moved underneath its own attempt, before the run gives up loudly.
1443
+ *
1444
+ * There is no option for this and no derivation from a measured rate, unlike `pushAttempts`, because the shape of the problem is different. Integration happens at most once per package boundary, so the loop is already bounded by the size of the workspace; this only bounds the pathological case where the branch moves during the same package's own attempt over and over. Each round costs one package's analysis, and a branch would have to move during three consecutive attempts at the same package to exhaust it, at which point the run is being starved rather than raced and saying so is more useful than trying again.
1445
+ */
1446
+ const MAX_BRANCH_MOVES_PER_PACKAGE = 3;
1447
+ /**
1448
+ * Brings the checkout up to the release branch's current tip, and reports where that tip is.
1449
+ *
1450
+ * At a package boundary every commit this run has made is already on the remote (each release commit by `@semantic-release/git`'s own prepare push, each tag by semantic-release's own, each dependency bump by `pushHead`), so advancing onto a moved branch is a fast-forward and loses nothing. `fastForwardTo` refuses anything else rather than discarding local work, which would mean the assumption above had broken.
1451
+ */
1452
+ async function integrateBranchTip(workspace, log) {
1453
+ const cwd = workspace.root;
1454
+ const branch = await currentBranch({ cwd });
1455
+ const tip = await fetchBranchTip(branch, { cwd });
1456
+ if (await isAncestor(tip, "HEAD", { cwd })) return tip;
1457
+ if (!await isAncestor("HEAD", tip, { cwd })) throw new WorkspaceStateError(`${packageName}: ${branch} has moved to ${tip}, which this checkout has not and cannot reach by fast-forward, so the two have diverged. A release run expects every commit it has made to already be on the remote at this point; stopping rather than discarding whatever is here.`);
1458
+ await fastForwardTo(tip, { cwd });
1459
+ log(`${packageName}: ${branch} advanced to ${tip} during this run; integrated it before continuing.`);
1460
+ return tip;
1461
+ }
1462
+ /**
1463
+ * Releases one package, integrating the release branch first and again whenever it moves under the attempt.
1464
+ *
1465
+ * This exists because semantic-release cannot tell the orchestrator the difference between the two reasons it declines to release. When the branch has moved since the run began, its `verifyAuth` fails, `isBranchUpToDate` reports the branch behind, and it returns the same "no release" the orchestrator gets for a package that genuinely had nothing to publish. Taking that at face value is how a run used to finish green having released only the packages that happened to come before the branch moved.
1466
+ *
1467
+ * The ambiguity is resolved by observation rather than by reading semantic-release's logs: if the branch did not move while the package was being released, "no release" is true. If it did, the answer is not trustworthy, so the branch is integrated and the package is released again against the new tip. That is safe to repeat because a package that really has nothing to release still has nothing after a fast-forward, and a package that does release is recorded by its tag, which every later run reads as the release having happened.
1468
+ */
1469
+ async function releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne) {
1470
+ if (dryRun) return await releaseOne(pkg, bumpsForThisPackage);
1471
+ for (let moves = 0;; moves += 1) {
1472
+ const tipBefore = await integrateBranchTip(workspace, log);
1473
+ const outcome = await releaseOne(pkg, bumpsForThisPackage);
1474
+ if (outcome.released) return outcome;
1475
+ const tipAfter = await fetchBranchTip(await currentBranch({ cwd: workspace.root }), { cwd: workspace.root });
1476
+ if (tipAfter === tipBefore) return outcome;
1477
+ if (moves + 1 >= MAX_BRANCH_MOVES_PER_PACKAGE) throw new WorkspaceStateError(`${packageName}: ${name} reported no release ${String(moves + 1)} times, each time while the release branch was moving underneath it, so whether it had anything to release was never established. Failing rather than finishing the run as though it had nothing, which would leave this package and every one after it silently unreleased. Re-running once the branch is quieter is safe: packages that already released are recorded by their tags and are not released again.`);
1478
+ log(`${name}: reported no release while the branch moved from ${tipBefore} to ${tipAfter}, which is also what a stale branch looks like; integrating and releasing it again.`);
1479
+ }
1480
+ }
1337
1481
  //#endregion
1338
1482
  //#region src/gate-publish.ts
1339
1483
  /**
package/dist/index.cjs CHANGED
@@ -286,6 +286,61 @@ 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
+ /** Advances the current branch to `ref`, refusing unless that is a fast-forward. Used where every local commit is already on the remote, so anything that is not a fast-forward means the checkout holds work the remote does not, and stopping is better than the alternative of discarding it. */
320
+ async function fastForwardTo(ref, options) {
321
+ await git([
322
+ "merge",
323
+ "--ff-only",
324
+ ref
325
+ ], options);
326
+ }
327
+ /** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
328
+ async function resetHardTo(ref, options) {
329
+ await git([
330
+ "reset",
331
+ "--hard",
332
+ ref
333
+ ], options);
334
+ }
335
+ /** 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. */
336
+ async function deleteLocalTags(tagNames, options) {
337
+ if (tagNames.length === 0) return;
338
+ await git([
339
+ "tag",
340
+ "-d",
341
+ ...tagNames
342
+ ], options);
343
+ }
289
344
  function toGitCommandError(args, cwd, cause) {
290
345
  const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
291
346
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
@@ -900,6 +955,27 @@ function formatTagForPackage(tagFormat, name) {
900
955
  * 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
956
  */
902
957
  async function releaseWorkspaceSingleCommit(options) {
958
+ const log = options.log ?? console.log;
959
+ const maxAttempts = resolvePushAttempts(options.pushAttempts);
960
+ let lastLoss;
961
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
962
+ const result = await attemptSingleCommitRelease(options);
963
+ if (result.kind === "complete") return result.outcome;
964
+ if (result.kind === "pushed") {
965
+ await result.publish();
966
+ return result.outcome;
967
+ }
968
+ lastLoss = result.error;
969
+ 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.`);
970
+ }
971
+ 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}`);
972
+ }
973
+ function resolvePushAttempts(configured) {
974
+ if (configured === void 0) return 5;
975
+ if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
976
+ return configured;
977
+ }
978
+ async function attemptSingleCommitRelease(options) {
903
979
  const root = (0, node_path.resolve)(options.root ?? process.cwd());
904
980
  const log = options.log ?? console.log;
905
981
  const dryRun = options.dryRun === true;
@@ -983,8 +1059,11 @@ async function releaseWorkspaceSingleCommit(options) {
983
1059
  }
984
1060
  }
985
1061
  if (dryRun || planned.length === 0) return {
986
- order,
987
- packages: outcomes
1062
+ kind: "complete",
1063
+ outcome: {
1064
+ order,
1065
+ packages: outcomes
1066
+ }
988
1067
  };
989
1068
  const branch = captured.branch;
990
1069
  const repositoryUrl = captured.repositoryUrl;
@@ -1023,26 +1102,51 @@ async function releaseWorkspaceSingleCommit(options) {
1023
1102
  const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
1024
1103
  const tagNames = planned.map((release) => release.gitTag);
1025
1104
  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));
1105
+ const branchName = await currentBranch({ cwd: repoRoot });
1106
+ try {
1107
+ await pushHeadAndTags(tagNames, { cwd: repoRoot });
1108
+ } catch (error) {
1109
+ if (!(error instanceof GitCommandError)) throw error;
1110
+ let remoteTip;
1111
+ try {
1112
+ remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
1113
+ } catch {
1114
+ throw error;
1040
1115
  }
1041
- log(`${release.pkg.name}: published ${release.gitTag}`);
1116
+ if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
1117
+ await deleteLocalTags(tagNames, { cwd: repoRoot });
1118
+ await resetHardTo(remoteTip, { cwd: repoRoot });
1119
+ return {
1120
+ kind: "lost",
1121
+ error,
1122
+ branch: branchName,
1123
+ remoteTip
1124
+ };
1042
1125
  }
1126
+ log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
1043
1127
  return {
1044
- order,
1045
- packages: outcomes
1128
+ kind: "pushed",
1129
+ outcome: {
1130
+ order,
1131
+ packages: outcomes
1132
+ },
1133
+ publish: async () => {
1134
+ for (const release of planned) {
1135
+ const releases = [];
1136
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1137
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1138
+ if (plugin.publish) {
1139
+ const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1140
+ if (result !== false && result !== void 0) releases.push(result);
1141
+ }
1142
+ }
1143
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1144
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1145
+ if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1146
+ }
1147
+ log(`${release.pkg.name}: published ${release.gitTag}`);
1148
+ }
1149
+ }
1046
1150
  };
1047
1151
  }
1048
1152
  /**
@@ -1385,7 +1489,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1385
1489
  const pkg = mustGet(graph.packages, name, "package");
1386
1490
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1387
1491
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1388
- const { released, version, result } = await releaseOne(pkg, bumpsForThisPackage);
1492
+ const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
1389
1493
  entries.push({
1390
1494
  name,
1391
1495
  directory: pkg.directory,
@@ -1482,6 +1586,46 @@ async function bumpDependents(released, version, graph, options) {
1482
1586
  }
1483
1587
  return applied;
1484
1588
  }
1589
+ /**
1590
+ * How many times one package may be re-released after the branch moved underneath its own attempt, before the run gives up loudly.
1591
+ *
1592
+ * There is no option for this and no derivation from a measured rate, unlike `pushAttempts`, because the shape of the problem is different. Integration happens at most once per package boundary, so the loop is already bounded by the size of the workspace; this only bounds the pathological case where the branch moves during the same package's own attempt over and over. Each round costs one package's analysis, and a branch would have to move during three consecutive attempts at the same package to exhaust it, at which point the run is being starved rather than raced and saying so is more useful than trying again.
1593
+ */
1594
+ const MAX_BRANCH_MOVES_PER_PACKAGE = 3;
1595
+ /**
1596
+ * Brings the checkout up to the release branch's current tip, and reports where that tip is.
1597
+ *
1598
+ * At a package boundary every commit this run has made is already on the remote (each release commit by `@semantic-release/git`'s own prepare push, each tag by semantic-release's own, each dependency bump by `pushHead`), so advancing onto a moved branch is a fast-forward and loses nothing. `fastForwardTo` refuses anything else rather than discarding local work, which would mean the assumption above had broken.
1599
+ */
1600
+ async function integrateBranchTip(workspace, log) {
1601
+ const cwd = workspace.root;
1602
+ const branch = await currentBranch({ cwd });
1603
+ const tip = await fetchBranchTip(branch, { cwd });
1604
+ if (await isAncestor(tip, "HEAD", { cwd })) return tip;
1605
+ if (!await isAncestor("HEAD", tip, { cwd })) throw new WorkspaceStateError(`${packageName}: ${branch} has moved to ${tip}, which this checkout has not and cannot reach by fast-forward, so the two have diverged. A release run expects every commit it has made to already be on the remote at this point; stopping rather than discarding whatever is here.`);
1606
+ await fastForwardTo(tip, { cwd });
1607
+ log(`${packageName}: ${branch} advanced to ${tip} during this run; integrated it before continuing.`);
1608
+ return tip;
1609
+ }
1610
+ /**
1611
+ * Releases one package, integrating the release branch first and again whenever it moves under the attempt.
1612
+ *
1613
+ * This exists because semantic-release cannot tell the orchestrator the difference between the two reasons it declines to release. When the branch has moved since the run began, its `verifyAuth` fails, `isBranchUpToDate` reports the branch behind, and it returns the same "no release" the orchestrator gets for a package that genuinely had nothing to publish. Taking that at face value is how a run used to finish green having released only the packages that happened to come before the branch moved.
1614
+ *
1615
+ * The ambiguity is resolved by observation rather than by reading semantic-release's logs: if the branch did not move while the package was being released, "no release" is true. If it did, the answer is not trustworthy, so the branch is integrated and the package is released again against the new tip. That is safe to repeat because a package that really has nothing to release still has nothing after a fast-forward, and a package that does release is recorded by its tag, which every later run reads as the release having happened.
1616
+ */
1617
+ async function releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne) {
1618
+ if (dryRun) return await releaseOne(pkg, bumpsForThisPackage);
1619
+ for (let moves = 0;; moves += 1) {
1620
+ const tipBefore = await integrateBranchTip(workspace, log);
1621
+ const outcome = await releaseOne(pkg, bumpsForThisPackage);
1622
+ if (outcome.released) return outcome;
1623
+ const tipAfter = await fetchBranchTip(await currentBranch({ cwd: workspace.root }), { cwd: workspace.root });
1624
+ if (tipAfter === tipBefore) return outcome;
1625
+ if (moves + 1 >= MAX_BRANCH_MOVES_PER_PACKAGE) throw new WorkspaceStateError(`${packageName}: ${name} reported no release ${String(moves + 1)} times, each time while the release branch was moving underneath it, so whether it had anything to release was never established. Failing rather than finishing the run as though it had nothing, which would leave this package and every one after it silently unreleased. Re-running once the branch is quieter is safe: packages that already released are recorded by their tags and are not released again.`);
1626
+ log(`${name}: reported no release while the branch moved from ${tipBefore} to ${tipAfter}, which is also what a stale branch looks like; integrating and releasing it again.`);
1627
+ }
1628
+ }
1485
1629
  //#endregion
1486
1630
  exports.DEFAULT_PUBLISH_PLUGINS = DEFAULT_PUBLISH_PLUGINS;
1487
1631
  exports.DependencyCycleError = DependencyCycleError;
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,61 @@ 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
+ /** Advances the current branch to `ref`, refusing unless that is a fast-forward. Used where every local commit is already on the remote, so anything that is not a fast-forward means the checkout holds work the remote does not, and stopping is better than the alternative of discarding it. */
295
+ async function fastForwardTo(ref, options) {
296
+ await git([
297
+ "merge",
298
+ "--ff-only",
299
+ ref
300
+ ], options);
301
+ }
302
+ /** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
303
+ async function resetHardTo(ref, options) {
304
+ await git([
305
+ "reset",
306
+ "--hard",
307
+ ref
308
+ ], options);
309
+ }
310
+ /** 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. */
311
+ async function deleteLocalTags(tagNames, options) {
312
+ if (tagNames.length === 0) return;
313
+ await git([
314
+ "tag",
315
+ "-d",
316
+ ...tagNames
317
+ ], options);
318
+ }
264
319
  function toGitCommandError(args, cwd, cause) {
265
320
  const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
266
321
  const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
@@ -875,6 +930,27 @@ function formatTagForPackage(tagFormat, name) {
875
930
  * 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
931
  */
877
932
  async function releaseWorkspaceSingleCommit(options) {
933
+ const log = options.log ?? console.log;
934
+ const maxAttempts = resolvePushAttempts(options.pushAttempts);
935
+ let lastLoss;
936
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
937
+ const result = await attemptSingleCommitRelease(options);
938
+ if (result.kind === "complete") return result.outcome;
939
+ if (result.kind === "pushed") {
940
+ await result.publish();
941
+ return result.outcome;
942
+ }
943
+ lastLoss = result.error;
944
+ 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.`);
945
+ }
946
+ 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}`);
947
+ }
948
+ function resolvePushAttempts(configured) {
949
+ if (configured === void 0) return 5;
950
+ if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
951
+ return configured;
952
+ }
953
+ async function attemptSingleCommitRelease(options) {
878
954
  const root = resolve(options.root ?? process.cwd());
879
955
  const log = options.log ?? console.log;
880
956
  const dryRun = options.dryRun === true;
@@ -958,8 +1034,11 @@ async function releaseWorkspaceSingleCommit(options) {
958
1034
  }
959
1035
  }
960
1036
  if (dryRun || planned.length === 0) return {
961
- order,
962
- packages: outcomes
1037
+ kind: "complete",
1038
+ outcome: {
1039
+ order,
1040
+ packages: outcomes
1041
+ }
963
1042
  };
964
1043
  const branch = captured.branch;
965
1044
  const repositoryUrl = captured.repositoryUrl;
@@ -998,26 +1077,51 @@ async function releaseWorkspaceSingleCommit(options) {
998
1077
  const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
999
1078
  const tagNames = planned.map((release) => release.gitTag);
1000
1079
  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));
1080
+ const branchName = await currentBranch({ cwd: repoRoot });
1081
+ try {
1082
+ await pushHeadAndTags(tagNames, { cwd: repoRoot });
1083
+ } catch (error) {
1084
+ if (!(error instanceof GitCommandError)) throw error;
1085
+ let remoteTip;
1086
+ try {
1087
+ remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
1088
+ } catch {
1089
+ throw error;
1015
1090
  }
1016
- log(`${release.pkg.name}: published ${release.gitTag}`);
1091
+ if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
1092
+ await deleteLocalTags(tagNames, { cwd: repoRoot });
1093
+ await resetHardTo(remoteTip, { cwd: repoRoot });
1094
+ return {
1095
+ kind: "lost",
1096
+ error,
1097
+ branch: branchName,
1098
+ remoteTip
1099
+ };
1017
1100
  }
1101
+ log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
1018
1102
  return {
1019
- order,
1020
- packages: outcomes
1103
+ kind: "pushed",
1104
+ outcome: {
1105
+ order,
1106
+ packages: outcomes
1107
+ },
1108
+ publish: async () => {
1109
+ for (const release of planned) {
1110
+ const releases = [];
1111
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1112
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1113
+ if (plugin.publish) {
1114
+ const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1115
+ if (result !== false && result !== void 0) releases.push(result);
1116
+ }
1117
+ }
1118
+ for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
1119
+ const plugin = await loadReleasePlugin(modulePath, moduleCache);
1120
+ if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
1121
+ }
1122
+ log(`${release.pkg.name}: published ${release.gitTag}`);
1123
+ }
1124
+ }
1021
1125
  };
1022
1126
  }
1023
1127
  /**
@@ -1360,7 +1464,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1360
1464
  const pkg = mustGet(graph.packages, name, "package");
1361
1465
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1362
1466
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1363
- const { released, version, result } = await releaseOne(pkg, bumpsForThisPackage);
1467
+ const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
1364
1468
  entries.push({
1365
1469
  name,
1366
1470
  directory: pkg.directory,
@@ -1457,5 +1561,45 @@ async function bumpDependents(released, version, graph, options) {
1457
1561
  }
1458
1562
  return applied;
1459
1563
  }
1564
+ /**
1565
+ * How many times one package may be re-released after the branch moved underneath its own attempt, before the run gives up loudly.
1566
+ *
1567
+ * There is no option for this and no derivation from a measured rate, unlike `pushAttempts`, because the shape of the problem is different. Integration happens at most once per package boundary, so the loop is already bounded by the size of the workspace; this only bounds the pathological case where the branch moves during the same package's own attempt over and over. Each round costs one package's analysis, and a branch would have to move during three consecutive attempts at the same package to exhaust it, at which point the run is being starved rather than raced and saying so is more useful than trying again.
1568
+ */
1569
+ const MAX_BRANCH_MOVES_PER_PACKAGE = 3;
1570
+ /**
1571
+ * Brings the checkout up to the release branch's current tip, and reports where that tip is.
1572
+ *
1573
+ * At a package boundary every commit this run has made is already on the remote (each release commit by `@semantic-release/git`'s own prepare push, each tag by semantic-release's own, each dependency bump by `pushHead`), so advancing onto a moved branch is a fast-forward and loses nothing. `fastForwardTo` refuses anything else rather than discarding local work, which would mean the assumption above had broken.
1574
+ */
1575
+ async function integrateBranchTip(workspace, log) {
1576
+ const cwd = workspace.root;
1577
+ const branch = await currentBranch({ cwd });
1578
+ const tip = await fetchBranchTip(branch, { cwd });
1579
+ if (await isAncestor(tip, "HEAD", { cwd })) return tip;
1580
+ if (!await isAncestor("HEAD", tip, { cwd })) throw new WorkspaceStateError(`${packageName}: ${branch} has moved to ${tip}, which this checkout has not and cannot reach by fast-forward, so the two have diverged. A release run expects every commit it has made to already be on the remote at this point; stopping rather than discarding whatever is here.`);
1581
+ await fastForwardTo(tip, { cwd });
1582
+ log(`${packageName}: ${branch} advanced to ${tip} during this run; integrated it before continuing.`);
1583
+ return tip;
1584
+ }
1585
+ /**
1586
+ * Releases one package, integrating the release branch first and again whenever it moves under the attempt.
1587
+ *
1588
+ * This exists because semantic-release cannot tell the orchestrator the difference between the two reasons it declines to release. When the branch has moved since the run began, its `verifyAuth` fails, `isBranchUpToDate` reports the branch behind, and it returns the same "no release" the orchestrator gets for a package that genuinely had nothing to publish. Taking that at face value is how a run used to finish green having released only the packages that happened to come before the branch moved.
1589
+ *
1590
+ * The ambiguity is resolved by observation rather than by reading semantic-release's logs: if the branch did not move while the package was being released, "no release" is true. If it did, the answer is not trustworthy, so the branch is integrated and the package is released again against the new tip. That is safe to repeat because a package that really has nothing to release still has nothing after a fast-forward, and a package that does release is recorded by its tag, which every later run reads as the release having happened.
1591
+ */
1592
+ async function releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne) {
1593
+ if (dryRun) return await releaseOne(pkg, bumpsForThisPackage);
1594
+ for (let moves = 0;; moves += 1) {
1595
+ const tipBefore = await integrateBranchTip(workspace, log);
1596
+ const outcome = await releaseOne(pkg, bumpsForThisPackage);
1597
+ if (outcome.released) return outcome;
1598
+ const tipAfter = await fetchBranchTip(await currentBranch({ cwd: workspace.root }), { cwd: workspace.root });
1599
+ if (tipAfter === tipBefore) return outcome;
1600
+ if (moves + 1 >= MAX_BRANCH_MOVES_PER_PACKAGE) throw new WorkspaceStateError(`${packageName}: ${name} reported no release ${String(moves + 1)} times, each time while the release branch was moving underneath it, so whether it had anything to release was never established. Failing rather than finishing the run as though it had nothing, which would leave this package and every one after it silently unreleased. Re-running once the branch is quieter is safe: packages that already released are recorded by their tags and are not released again.`);
1601
+ log(`${name}: reported no release while the branch moved from ${tipBefore} to ${tipAfter}, which is also what a stale branch looks like; integrating and releasing it again.`);
1602
+ }
1603
+ }
1460
1604
  //#endregion
1461
1605
  export { DEFAULT_PUBLISH_PLUGINS, DependencyCycleError, GitCommandError, ReleaseConfigurationError, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, UnsupportedDependencyRangeError, WorkspaceDiscoveryError, WorkspaceReleaseError, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, orderedPackages, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, resolveWorkspacePublishPlugins, resumeWorkspaceRelease, topologicalOrder, updateDependencyRange, writeDependencyRange };
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.2",
4
4
  "description": "Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.",
5
5
  "type": "module",
6
6
  "repository": {