@exadev/semantic-release-workspace 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,11 +66,7 @@ Everything above describes `commitStrategy: 'per-package'`, the default: unchang
66
66
  | Default publish plugins | `DEFAULT_PUBLISH_PLUGINS` (changelog, npm, github, git) | `SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS` — the same list minus git |
67
67
  | Crash recovery | A bump commit already sitting in git history (from this run or an earlier one) is recognised via a machine-parseable trailer, so a run that resumes after a partial push still forces the right dependent releases | Not needed: either the whole combined commit lands and pushes, or the run fails before touching git at all, so there is never a partial push to recover from |
68
68
 
69
- Everything about *what* releases (topological order, forced-patch dependents, dependency-range classification, path-scoped commit analysis, unsupported-range rejection) is identical between the two modes — `commitStrategy` only changes how the result is committed, tagged, and pushed. ## Tag format
70
-
71
- Each package's release tag comes from the `tagFormat` option, a lodash template with `${name}` and `${version}` placeholders, defaulting to `'${name}@${version}'`. Set it via the `tagFormat` field in a `--config` file or the `tagFormat` option to `releaseWorkspace()`. The template must contain `${version}` and may contain `${name}`; anything else is rejected before any package releases.
72
-
73
- Override it when the tags are consumed as refs outside git: GitHub Actions pins composite actions as `owner/repo/path@ref`, and GitHub's workflow parser rejects a ref containing `@` -- so a repository of actions sets `'${name}-v${version}'` and every workflow pins `...@<action>-v<major>`. Note that switching an existing repository's format starts a fresh tag namespace: semantic-release will not see prior releases recorded under the old format, so rename existing tags to the new template as part of the switch.
69
+ Everything about *what* releases (topological order, forced-patch dependents, dependency-range classification, path-scoped commit analysis, unsupported-range rejection) is identical between the two modes — `commitStrategy` only changes how the result is committed, tagged, and pushed.
74
70
 
75
71
  Set it via the `--commit-strategy <mode>` CLI flag, the `commitStrategy` field in a `--config` file, or the `commitStrategy` option to `releaseWorkspace()`; omit it and nothing changes.
76
72
 
@@ -84,6 +80,18 @@ One window is outside this tool's control: semantic-release pushes a package's t
84
80
 
85
81
  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
82
 
83
+ ## Tag format
84
+
85
+ Each package's release tag comes from the `tagFormat` option, a lodash template with `${name}` and `${version}` placeholders, defaulting to `'${name}@${version}'`. Set it with the `--tag-format <template>` CLI flag, the `tagFormat` field in a `--config` file, or the `tagFormat` option to `releaseWorkspace()`; when both the flag and the config file set it, the flag wins. The template must contain `${version}` and may contain `${name}`; anything else is rejected before any package releases.
86
+
87
+ Override it when the tags are consumed as refs outside git: GitHub Actions pins composite actions as `owner/repo/path@ref`, and GitHub's workflow parser rejects a ref containing `@` -- so a repository of actions sets `'${name}-v${version}'` and every workflow pins `...@<action>-v<major>`. Note that switching an existing repository's format starts a fresh tag namespace: semantic-release will not see prior releases recorded under the old format, so rename existing tags to the new template as part of the switch.
88
+
89
+ ```sh
90
+ pnpm exec semantic-release-workspace release --tag-format '${name}-v${version}'
91
+ ```
92
+
93
+ A gated run records each package's tag in its state file, so `resume` takes no tag format of its own.
94
+
87
95
  ## Gating publish
88
96
 
89
97
  `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).
@@ -219,6 +227,7 @@ Run it from the workspace root (or pass `--root <directory>`). A dry run analyse
219
227
  | `--analyze-commits <json>` | Options for the wrapped @semantic-release/commit-analyzer (e.g. `'{"preset":"conventionalcommits","releaseRules":[...]}'`) |
220
228
  | `--generate-notes <json>` | Options for the wrapped @semantic-release/release-notes-generator |
221
229
  | `--commit-strategy <mode>` | `per-package` (default) or `single` — see [Commit strategies](#commit-strategies) |
230
+ | `--tag-format <template>` | Template for each package's release tag, with `${name}` and `${version}` placeholders; must contain `${version}` (default: `${name}@${version}`) — see [Tag format](#tag-format) |
222
231
  | `--gate-publish` | Tag and push each due package, but defer publishing — see [Gating publish](#gating-publish). Requires `--gate-state-file`; rejected with `--commit-strategy single` |
223
232
  | `--gate-state-file <path>` | With `--gate-publish`: where to write the state a later `resume` run needs |
224
233
  | `--config <file>` | A config file (`.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.cts`, or `.mts`, loaded via [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig)) providing any of the above, plus `packagePlugins` (see [Per-package publish plugins](#per-package-publish-plugins)), through its default export; explicit flags win. TypeScript files are run by Node's own type stripping, so they may use only erasable type syntax (annotations and `import type`, not `enum` or `namespace`) |
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.2";
17
+ var version = "3.1.0";
18
18
  //#endregion
19
19
  //#region src/errors.ts
20
20
  /**
@@ -910,6 +910,26 @@ function parsePublishPluginSpec(spec) {
910
910
  const [name, config] = spec;
911
911
  return [name, config ?? {}];
912
912
  }
913
+ /**
914
+ * Releases one package, integrating the release branch first and again whenever it moves under the attempt.
915
+ *
916
+ * This exists because semantic-release cannot tell the orchestrator apart 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.
917
+ *
918
+ * 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.
919
+ *
920
+ * A package whose answer is never established fails the run. Returning "nothing to release" for it would be a guess, and the guess that loses is exactly the silent partial release this loop exists to stop.
921
+ */
922
+ async function releasePackageAcrossBranchMoves(name, branch, attempt, log) {
923
+ for (let moves = 1;; moves += 1) {
924
+ const tipBefore = await branch.integrate();
925
+ const outcome = await attempt();
926
+ if (outcome.released) return outcome;
927
+ const tipAfter = await branch.tip();
928
+ if (tipAfter === tipBefore) return outcome;
929
+ if (moves >= 3) throw new WorkspaceStateError(`${packageName}: ${name} reported no release ${String(moves)} 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.`);
930
+ 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.`);
931
+ }
932
+ }
913
933
  //#endregion
914
934
  //#region src/pnpm.ts
915
935
  /**
@@ -1341,7 +1361,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1341
1361
  const pkg = mustGet(graph.packages, name, "package");
1342
1362
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1343
1363
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1344
- const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
1364
+ const { released, version, result } = dryRun ? await releaseOne(pkg, bumpsForThisPackage) : await releasePackageAcrossBranchMoves(name, branchTracker(workspace, log), async () => await releaseOne(pkg, bumpsForThisPackage), log);
1345
1365
  entries.push({
1346
1366
  name,
1347
1367
  directory: pkg.directory,
@@ -1439,12 +1459,6 @@ async function bumpDependents(released, version, graph, options) {
1439
1459
  return applied;
1440
1460
  }
1441
1461
  /**
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
1462
  * Brings the checkout up to the release branch's current tip, and reports where that tip is.
1449
1463
  *
1450
1464
  * 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.
@@ -1459,24 +1473,12 @@ async function integrateBranchTip(workspace, log) {
1459
1473
  log(`${packageName}: ${branch} advanced to ${tip} during this run; integrated it before continuing.`);
1460
1474
  return tip;
1461
1475
  }
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
- }
1476
+ /** The release branch as `releasePackageAcrossBranchMoves` needs to see it, backed by the real repository. */
1477
+ function branchTracker(workspace, log) {
1478
+ return {
1479
+ integrate: async () => await integrateBranchTip(workspace, log),
1480
+ tip: async () => await fetchBranchTip(await currentBranch({ cwd: workspace.root }), { cwd: workspace.root })
1481
+ };
1480
1482
  }
1481
1483
  //#endregion
1482
1484
  //#region src/gate-publish.ts
@@ -1635,7 +1637,8 @@ const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
1635
1637
  "analyzeCommits",
1636
1638
  "generateNotes",
1637
1639
  "commitStrategy",
1638
- "gatePublish"
1640
+ "gatePublish",
1641
+ "tagFormat"
1639
1642
  ]);
1640
1643
  const COMMIT_STRATEGIES = /* @__PURE__ */ new Set(["per-package", "single"]);
1641
1644
  function isCommitStrategy(value) {
@@ -1657,9 +1660,10 @@ function createProgram() {
1657
1660
  release.option("--analyze-commits <json>", "options for the wrapped @semantic-release/commit-analyzer, as a JSON object");
1658
1661
  release.option("--generate-notes <json>", "options for the wrapped @semantic-release/release-notes-generator, as a JSON object");
1659
1662
  release.option("--commit-strategy <mode>", "how the run commits its released changes: \"per-package\" (default; today's behaviour, one commit per release plus one per dependency bump) or \"single\" (one combined commit for the whole run, tagged once per released package)", parseCommitStrategy);
1663
+ release.option("--tag-format <template>", "template for each package's release tag, with ${name} and ${version} placeholders; must contain ${version} (default: ${name}@${version})", parseTagFormat);
1660
1664
  release.option("--gate-publish", "tag and push each due package via @exadev/release-gate, but defer publishing -- requires --gate-state-file, and cannot be combined with --commit-strategy single");
1661
1665
  release.option("--gate-state-file <path>", "with --gate-publish: where to write the state a later \"resume\" run needs to finish publishing");
1662
- release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, gatePublish); explicit flags win");
1666
+ release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, tagFormat, gatePublish); explicit flags win");
1663
1667
  release.action(runRelease);
1664
1668
  const resume = program.command("resume");
1665
1669
  resume.description("Finish publishing every package a --gate-publish release tagged and pushed but did not publish.");
@@ -1672,6 +1676,20 @@ function parseCommitStrategy(value) {
1672
1676
  if (!isCommitStrategy(value)) throw new InvalidArgumentError(`--commit-strategy must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
1673
1677
  return value;
1674
1678
  }
1679
+ function parseTagFormat(value) {
1680
+ return validateTagFormatOption(value, (message) => message);
1681
+ }
1682
+ /**
1683
+ * Runs `validateTagFormat` and turns its `ReleaseConfigurationError` into the `InvalidArgumentError` the CLI reports as a usage mistake. `describe` lets each caller say where the value came from (a flag needs no prefix, since commander already names the option; a config file entry needs the file's path).
1684
+ */
1685
+ function validateTagFormatOption(value, describe) {
1686
+ try {
1687
+ return validateTagFormat(value);
1688
+ } catch (cause) {
1689
+ if (cause instanceof ReleaseConfigurationError) throw new InvalidArgumentError(describe(cause.message));
1690
+ throw cause;
1691
+ }
1692
+ }
1675
1693
  const NO_CONFIG_FILE = {
1676
1694
  dryRun: void 0,
1677
1695
  branches: void 0,
@@ -1680,6 +1698,7 @@ const NO_CONFIG_FILE = {
1680
1698
  analyzeCommits: void 0,
1681
1699
  generateNotes: void 0,
1682
1700
  commitStrategy: void 0,
1701
+ tagFormat: void 0,
1683
1702
  gatePublish: void 0
1684
1703
  };
1685
1704
  async function runRelease(flags) {
@@ -1695,6 +1714,7 @@ async function runRelease(flags) {
1695
1714
  analyzeCommits: flags.analyzeCommits === void 0 ? file.analyzeCommits : parseJsonObjectFlag(flags.analyzeCommits, "--analyze-commits"),
1696
1715
  generateNotes: flags.generateNotes === void 0 ? file.generateNotes : parseJsonObjectFlag(flags.generateNotes, "--generate-notes"),
1697
1716
  commitStrategy: flags.commitStrategy ?? file.commitStrategy,
1717
+ tagFormat: flags.tagFormat ?? file.tagFormat,
1698
1718
  gatePublish
1699
1719
  });
1700
1720
  for (const pkg of outcome.packages) console.log(describeOutcome(pkg));
@@ -1756,7 +1776,7 @@ async function readReleaseConfigFile(path) {
1756
1776
  const parsed = await readConfigFile(path);
1757
1777
  if (!isJsonObject(parsed)) throw new InvalidArgumentError(`--config file ${path} must contain a JSON object`);
1758
1778
  for (const key of Object.keys(parsed)) if (!CONFIG_OPTION_KEYS.has(key)) throw new InvalidArgumentError(`--config file ${path} has an unknown option "${key}"; recognised options: ${[...CONFIG_OPTION_KEYS].join(", ")}`);
1759
- const { dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, gatePublish } = parsed;
1779
+ const { dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, tagFormat, gatePublish } = parsed;
1760
1780
  if (dryRun !== void 0 && typeof dryRun !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "dryRun" must be a boolean`);
1761
1781
  if (branches !== void 0 && !isStringArray(branches)) throw new InvalidArgumentError(`--config file ${path}: "branches" must be an array of branch name strings`);
1762
1782
  if (plugins !== void 0 && !Array.isArray(plugins)) throw new InvalidArgumentError(`--config file ${path}: "plugins" must be an array`);
@@ -1764,6 +1784,7 @@ async function readReleaseConfigFile(path) {
1764
1784
  if (analyzeCommits !== void 0 && !isJsonObject(analyzeCommits)) throw new InvalidArgumentError(`--config file ${path}: "analyzeCommits" must be an object`);
1765
1785
  if (generateNotes !== void 0 && !isJsonObject(generateNotes)) throw new InvalidArgumentError(`--config file ${path}: "generateNotes" must be an object`);
1766
1786
  if (commitStrategy !== void 0 && (typeof commitStrategy !== "string" || !isCommitStrategy(commitStrategy))) throw new InvalidArgumentError(`--config file ${path}: "commitStrategy" must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
1787
+ if (tagFormat !== void 0 && typeof tagFormat !== "string") throw new InvalidArgumentError(`--config file ${path}: "tagFormat" must be a string`);
1767
1788
  if (gatePublish !== void 0 && typeof gatePublish !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "gatePublish" must be a boolean`);
1768
1789
  return {
1769
1790
  dryRun,
@@ -1773,6 +1794,7 @@ async function readReleaseConfigFile(path) {
1773
1794
  analyzeCommits,
1774
1795
  generateNotes,
1775
1796
  commitStrategy,
1797
+ tagFormat: tagFormat === void 0 ? void 0 : validateTagFormatOption(tagFormat, (message) => `--config file ${path}: ${message}`),
1776
1798
  gatePublish
1777
1799
  };
1778
1800
  }
package/dist/index.cjs CHANGED
@@ -908,6 +908,26 @@ function parsePublishPluginSpec(spec) {
908
908
  const [name, config] = spec;
909
909
  return [name, config ?? {}];
910
910
  }
911
+ /**
912
+ * Releases one package, integrating the release branch first and again whenever it moves under the attempt.
913
+ *
914
+ * This exists because semantic-release cannot tell the orchestrator apart 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.
915
+ *
916
+ * 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.
917
+ *
918
+ * A package whose answer is never established fails the run. Returning "nothing to release" for it would be a guess, and the guess that loses is exactly the silent partial release this loop exists to stop.
919
+ */
920
+ async function releasePackageAcrossBranchMoves(name, branch, attempt, log) {
921
+ for (let moves = 1;; moves += 1) {
922
+ const tipBefore = await branch.integrate();
923
+ const outcome = await attempt();
924
+ if (outcome.released) return outcome;
925
+ const tipAfter = await branch.tip();
926
+ if (tipAfter === tipBefore) return outcome;
927
+ if (moves >= 3) throw new WorkspaceStateError(`${packageName}: ${name} reported no release ${String(moves)} 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.`);
928
+ 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.`);
929
+ }
930
+ }
911
931
  //#endregion
912
932
  //#region src/pnpm.ts
913
933
  /**
@@ -1489,7 +1509,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1489
1509
  const pkg = mustGet(graph.packages, name, "package");
1490
1510
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1491
1511
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1492
- const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
1512
+ const { released, version, result } = dryRun ? await releaseOne(pkg, bumpsForThisPackage) : await releasePackageAcrossBranchMoves(name, branchTracker(workspace, log), async () => await releaseOne(pkg, bumpsForThisPackage), log);
1493
1513
  entries.push({
1494
1514
  name,
1495
1515
  directory: pkg.directory,
@@ -1587,12 +1607,6 @@ async function bumpDependents(released, version, graph, options) {
1587
1607
  return applied;
1588
1608
  }
1589
1609
  /**
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
1610
  * Brings the checkout up to the release branch's current tip, and reports where that tip is.
1597
1611
  *
1598
1612
  * 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.
@@ -1607,24 +1621,12 @@ async function integrateBranchTip(workspace, log) {
1607
1621
  log(`${packageName}: ${branch} advanced to ${tip} during this run; integrated it before continuing.`);
1608
1622
  return tip;
1609
1623
  }
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
- }
1624
+ /** The release branch as `releasePackageAcrossBranchMoves` needs to see it, backed by the real repository. */
1625
+ function branchTracker(workspace, log) {
1626
+ return {
1627
+ integrate: async () => await integrateBranchTip(workspace, log),
1628
+ tip: async () => await fetchBranchTip(await currentBranch({ cwd: workspace.root }), { cwd: workspace.root })
1629
+ };
1628
1630
  }
1629
1631
  //#endregion
1630
1632
  exports.DEFAULT_PUBLISH_PLUGINS = DEFAULT_PUBLISH_PLUGINS;
package/dist/index.js CHANGED
@@ -883,6 +883,26 @@ function parsePublishPluginSpec(spec) {
883
883
  const [name, config] = spec;
884
884
  return [name, config ?? {}];
885
885
  }
886
+ /**
887
+ * Releases one package, integrating the release branch first and again whenever it moves under the attempt.
888
+ *
889
+ * This exists because semantic-release cannot tell the orchestrator apart 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.
890
+ *
891
+ * 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.
892
+ *
893
+ * A package whose answer is never established fails the run. Returning "nothing to release" for it would be a guess, and the guess that loses is exactly the silent partial release this loop exists to stop.
894
+ */
895
+ async function releasePackageAcrossBranchMoves(name, branch, attempt, log) {
896
+ for (let moves = 1;; moves += 1) {
897
+ const tipBefore = await branch.integrate();
898
+ const outcome = await attempt();
899
+ if (outcome.released) return outcome;
900
+ const tipAfter = await branch.tip();
901
+ if (tipAfter === tipBefore) return outcome;
902
+ if (moves >= 3) throw new WorkspaceStateError(`${packageName}: ${name} reported no release ${String(moves)} 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.`);
903
+ 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.`);
904
+ }
905
+ }
886
906
  //#endregion
887
907
  //#region src/pnpm.ts
888
908
  /**
@@ -1464,7 +1484,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
1464
1484
  const pkg = mustGet(graph.packages, name, "package");
1465
1485
  const bumpsForThisPackage = pendingBumps.get(name) ?? [];
1466
1486
  log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
1467
- const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
1487
+ const { released, version, result } = dryRun ? await releaseOne(pkg, bumpsForThisPackage) : await releasePackageAcrossBranchMoves(name, branchTracker(workspace, log), async () => await releaseOne(pkg, bumpsForThisPackage), log);
1468
1488
  entries.push({
1469
1489
  name,
1470
1490
  directory: pkg.directory,
@@ -1562,12 +1582,6 @@ async function bumpDependents(released, version, graph, options) {
1562
1582
  return applied;
1563
1583
  }
1564
1584
  /**
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
1585
  * Brings the checkout up to the release branch's current tip, and reports where that tip is.
1572
1586
  *
1573
1587
  * 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.
@@ -1582,24 +1596,12 @@ async function integrateBranchTip(workspace, log) {
1582
1596
  log(`${packageName}: ${branch} advanced to ${tip} during this run; integrated it before continuing.`);
1583
1597
  return tip;
1584
1598
  }
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
- }
1599
+ /** The release branch as `releasePackageAcrossBranchMoves` needs to see it, backed by the real repository. */
1600
+ function branchTracker(workspace, log) {
1601
+ return {
1602
+ integrate: async () => await integrateBranchTip(workspace, log),
1603
+ tip: async () => await fetchBranchTip(await currentBranch({ cwd: workspace.root }), { cwd: workspace.root })
1604
+ };
1603
1605
  }
1604
1606
  //#endregion
1605
1607
  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.2",
3
+ "version": "3.1.0",
4
4
  "description": "Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.",
5
5
  "type": "module",
6
6
  "repository": {