@exadev/semantic-release-workspace 3.0.1 → 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.1";
17
+ var version = "3.0.2";
18
18
  //#endregion
19
19
  //#region src/errors.ts
20
20
  /**
@@ -309,6 +309,14 @@ async function isAncestor(ancestor, descendant, options) {
309
309
  throw error;
310
310
  }
311
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
+ }
312
320
  /** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
313
321
  async function resetHardTo(ref, options) {
314
322
  await git([
@@ -1333,7 +1341,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1333
1341
  const pkg = mustGet(graph.packages, name, "package");
1334
1342
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1335
1343
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1336
- const { released, version, result } = await releaseOne(pkg, bumpsForThisPackage);
1344
+ const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
1337
1345
  entries.push({
1338
1346
  name,
1339
1347
  directory: pkg.directory,
@@ -1430,6 +1438,46 @@ async function bumpDependents(released, version, graph, options) {
1430
1438
  }
1431
1439
  return applied;
1432
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
+ }
1433
1481
  //#endregion
1434
1482
  //#region src/gate-publish.ts
1435
1483
  /**
package/dist/index.cjs CHANGED
@@ -316,6 +316,14 @@ async function isAncestor(ancestor, descendant, options) {
316
316
  throw error;
317
317
  }
318
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
+ }
319
327
  /** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
320
328
  async function resetHardTo(ref, options) {
321
329
  await git([
@@ -1481,7 +1489,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1481
1489
  const pkg = mustGet(graph.packages, name, "package");
1482
1490
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1483
1491
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1484
- const { released, version, result } = await releaseOne(pkg, bumpsForThisPackage);
1492
+ const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
1485
1493
  entries.push({
1486
1494
  name,
1487
1495
  directory: pkg.directory,
@@ -1578,6 +1586,46 @@ async function bumpDependents(released, version, graph, options) {
1578
1586
  }
1579
1587
  return applied;
1580
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
+ }
1581
1629
  //#endregion
1582
1630
  exports.DEFAULT_PUBLISH_PLUGINS = DEFAULT_PUBLISH_PLUGINS;
1583
1631
  exports.DependencyCycleError = DependencyCycleError;
package/dist/index.js CHANGED
@@ -291,6 +291,14 @@ async function isAncestor(ancestor, descendant, options) {
291
291
  throw error;
292
292
  }
293
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
+ }
294
302
  /** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
295
303
  async function resetHardTo(ref, options) {
296
304
  await git([
@@ -1456,7 +1464,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1456
1464
  const pkg = mustGet(graph.packages, name, "package");
1457
1465
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1458
1466
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1459
- const { released, version, result } = await releaseOne(pkg, bumpsForThisPackage);
1467
+ const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
1460
1468
  entries.push({
1461
1469
  name,
1462
1470
  directory: pkg.directory,
@@ -1553,5 +1561,45 @@ async function bumpDependents(released, version, graph, options) {
1553
1561
  }
1554
1562
  return applied;
1555
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
+ }
1556
1604
  //#endregion
1557
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.1",
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": {