@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 +14 -5
- package/dist/cli.js +51 -29
- package/dist/index.cjs +27 -25
- package/dist/index.js +27 -25
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
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
|
|
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
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
|
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
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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