@exadev/semantic-release-workspace 3.0.0 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/cli.js +165 -21
- package/dist/index.cjs +164 -20
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +164 -20
- package/package.json +1 -1
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.
|
|
17
|
+
var version = "3.0.2";
|
|
18
18
|
//#endregion
|
|
19
19
|
//#region src/errors.ts
|
|
20
20
|
/**
|
|
@@ -279,6 +279,61 @@ async function pushHeadAndTags(tagNames, options) {
|
|
|
279
279
|
...tagNames
|
|
280
280
|
], options);
|
|
281
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Fetches `branch` from origin and returns the commit it now points at.
|
|
284
|
+
*
|
|
285
|
+
* This is how a failed push is classified, in preference to reading the reason git printed for the rejection. The wording is not stable enough to branch on: the same race produces `! [rejected] ... (non-fast-forward)` from one server and `cannot lock ref 'refs/heads/main': is at <x> but expected <y>` from another (both observed, the first against GitHub and the second against a local bare repository under `--atomic`), and neither is a documented interface. Whether the branch actually moved is a fact about the remote rather than a string, it is the precise condition a retry can recover from, and it is knowable by asking.
|
|
286
|
+
*/
|
|
287
|
+
async function fetchBranchTip(branch, options) {
|
|
288
|
+
await git([
|
|
289
|
+
"fetch",
|
|
290
|
+
"origin",
|
|
291
|
+
branch
|
|
292
|
+
], options);
|
|
293
|
+
return (await git(["rev-parse", "FETCH_HEAD"], options)).trim();
|
|
294
|
+
}
|
|
295
|
+
/** Whether `ancestor` is reachable from `descendant`. `git merge-base --is-ancestor` reports the answer as an exit status rather than on stdout: 0 for yes and 1 for no are both answers, so only any other exit code is a failure. */
|
|
296
|
+
async function isAncestor(ancestor, descendant, options) {
|
|
297
|
+
const args = [
|
|
298
|
+
"merge-base",
|
|
299
|
+
"--is-ancestor",
|
|
300
|
+
ancestor,
|
|
301
|
+
descendant
|
|
302
|
+
];
|
|
303
|
+
try {
|
|
304
|
+
await execGit(args, options.cwd);
|
|
305
|
+
return true;
|
|
306
|
+
} catch (cause) {
|
|
307
|
+
const error = toGitCommandError(args, options.cwd, cause);
|
|
308
|
+
if (error.exitCode === 1) return false;
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Advances the current branch to `ref`, refusing unless that is a fast-forward. Used where every local commit is already on the remote, so anything that is not a fast-forward means the checkout holds work the remote does not, and stopping is better than the alternative of discarding it. */
|
|
313
|
+
async function fastForwardTo(ref, options) {
|
|
314
|
+
await git([
|
|
315
|
+
"merge",
|
|
316
|
+
"--ff-only",
|
|
317
|
+
ref
|
|
318
|
+
], options);
|
|
319
|
+
}
|
|
320
|
+
/** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
|
|
321
|
+
async function resetHardTo(ref, options) {
|
|
322
|
+
await git([
|
|
323
|
+
"reset",
|
|
324
|
+
"--hard",
|
|
325
|
+
ref
|
|
326
|
+
], options);
|
|
327
|
+
}
|
|
328
|
+
/** Deletes local tags. A retried attempt must drop the tags its predecessor created before it recreates them: `git tag` refuses a name that already exists, so without this the second attempt fails while tagging, before it ever reaches the push it was retrying. */
|
|
329
|
+
async function deleteLocalTags(tagNames, options) {
|
|
330
|
+
if (tagNames.length === 0) return;
|
|
331
|
+
await git([
|
|
332
|
+
"tag",
|
|
333
|
+
"-d",
|
|
334
|
+
...tagNames
|
|
335
|
+
], options);
|
|
336
|
+
}
|
|
282
337
|
function toGitCommandError(args, cwd, cause) {
|
|
283
338
|
const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
|
|
284
339
|
const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
|
|
@@ -887,6 +942,27 @@ async function regenerateLockfile(options) {
|
|
|
887
942
|
* Why publish is not just another semantic-release() call: semantic-release's own `run()` unconditionally derives `lastRelease` from the newest tag already on the branch matching `tagFormat`, and this mode has, by the time phase 5 runs, already created and pushed that exact tag itself. A second real `semanticRelease()` call would see its own just-created tag as the already-published release and compute the wrong next version from it. Phase 5 instead calls each resolved plugin module's own exported `verifyConditions`/`publish`/`success` functions directly, with a hand-built context -- the same public per-plugin API surface semantic-release's own core calls internally, just invoked without going through the parts of `run()` that assume a not-yet-tagged repository.
|
|
888
943
|
*/
|
|
889
944
|
async function releaseWorkspaceSingleCommit(options) {
|
|
945
|
+
const log = options.log ?? console.log;
|
|
946
|
+
const maxAttempts = resolvePushAttempts(options.pushAttempts);
|
|
947
|
+
let lastLoss;
|
|
948
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
949
|
+
const result = await attemptSingleCommitRelease(options);
|
|
950
|
+
if (result.kind === "complete") return result.outcome;
|
|
951
|
+
if (result.kind === "pushed") {
|
|
952
|
+
await result.publish();
|
|
953
|
+
return result.outcome;
|
|
954
|
+
}
|
|
955
|
+
lastLoss = result.error;
|
|
956
|
+
log(`${packageName}: attempt ${String(attempt)} of ${String(maxAttempts)} lost the push, because ${result.branch} advanced to ${result.remoteTip} before this run's own refs reached the remote. This attempt's commit and tags have been discarded; recomputing the release against the new tip.`);
|
|
957
|
+
}
|
|
958
|
+
throw new WorkspaceStateError(`${packageName}: gave up after ${String(maxAttempts)} attempt(s) to push the release, each overtaken by another commit landing on the release branch first. Nothing was published and no tag reached the remote, so re-running is safe and loses nothing. Raise "pushAttempts" if this branch is busy enough that ${String(maxAttempts)} is genuinely too few. The last push failed with: ${lastLoss === void 0 ? "unknown" : lastLoss.message}`);
|
|
959
|
+
}
|
|
960
|
+
function resolvePushAttempts(configured) {
|
|
961
|
+
if (configured === void 0) return 5;
|
|
962
|
+
if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
|
|
963
|
+
return configured;
|
|
964
|
+
}
|
|
965
|
+
async function attemptSingleCommitRelease(options) {
|
|
890
966
|
const root = resolve(options.root ?? process.cwd());
|
|
891
967
|
const log = options.log ?? console.log;
|
|
892
968
|
const dryRun = options.dryRun === true;
|
|
@@ -970,8 +1046,11 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
970
1046
|
}
|
|
971
1047
|
}
|
|
972
1048
|
if (dryRun || planned.length === 0) return {
|
|
973
|
-
|
|
974
|
-
|
|
1049
|
+
kind: "complete",
|
|
1050
|
+
outcome: {
|
|
1051
|
+
order,
|
|
1052
|
+
packages: outcomes
|
|
1053
|
+
}
|
|
975
1054
|
};
|
|
976
1055
|
const branch = captured.branch;
|
|
977
1056
|
const repositoryUrl = captured.repositoryUrl;
|
|
@@ -1010,26 +1089,51 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
1010
1089
|
const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
|
|
1011
1090
|
const tagNames = planned.map((release) => release.gitTag);
|
|
1012
1091
|
for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
|
|
1013
|
-
await
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
}
|
|
1024
|
-
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1025
|
-
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1026
|
-
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1092
|
+
const branchName = await currentBranch({ cwd: repoRoot });
|
|
1093
|
+
try {
|
|
1094
|
+
await pushHeadAndTags(tagNames, { cwd: repoRoot });
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
if (!(error instanceof GitCommandError)) throw error;
|
|
1097
|
+
let remoteTip;
|
|
1098
|
+
try {
|
|
1099
|
+
remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
|
|
1100
|
+
} catch {
|
|
1101
|
+
throw error;
|
|
1027
1102
|
}
|
|
1028
|
-
|
|
1103
|
+
if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
|
|
1104
|
+
await deleteLocalTags(tagNames, { cwd: repoRoot });
|
|
1105
|
+
await resetHardTo(remoteTip, { cwd: repoRoot });
|
|
1106
|
+
return {
|
|
1107
|
+
kind: "lost",
|
|
1108
|
+
error,
|
|
1109
|
+
branch: branchName,
|
|
1110
|
+
remoteTip
|
|
1111
|
+
};
|
|
1029
1112
|
}
|
|
1113
|
+
log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
|
|
1030
1114
|
return {
|
|
1031
|
-
|
|
1032
|
-
|
|
1115
|
+
kind: "pushed",
|
|
1116
|
+
outcome: {
|
|
1117
|
+
order,
|
|
1118
|
+
packages: outcomes
|
|
1119
|
+
},
|
|
1120
|
+
publish: async () => {
|
|
1121
|
+
for (const release of planned) {
|
|
1122
|
+
const releases = [];
|
|
1123
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1124
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1125
|
+
if (plugin.publish) {
|
|
1126
|
+
const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1127
|
+
if (result !== false && result !== void 0) releases.push(result);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1131
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1132
|
+
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1133
|
+
}
|
|
1134
|
+
log(`${release.pkg.name}: published ${release.gitTag}`);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1033
1137
|
};
|
|
1034
1138
|
}
|
|
1035
1139
|
/**
|
|
@@ -1237,7 +1341,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
|
|
|
1237
1341
|
const pkg = mustGet(graph.packages, name, "package");
|
|
1238
1342
|
const bumpsForThisPackage = pendingBumps.get(name) ?? [];
|
|
1239
1343
|
log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
|
|
1240
|
-
const { released, version, result } = await
|
|
1344
|
+
const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
|
|
1241
1345
|
entries.push({
|
|
1242
1346
|
name,
|
|
1243
1347
|
directory: pkg.directory,
|
|
@@ -1334,6 +1438,46 @@ async function bumpDependents(released, version, graph, options) {
|
|
|
1334
1438
|
}
|
|
1335
1439
|
return applied;
|
|
1336
1440
|
}
|
|
1441
|
+
/**
|
|
1442
|
+
* How many times one package may be re-released after the branch moved underneath its own attempt, before the run gives up loudly.
|
|
1443
|
+
*
|
|
1444
|
+
* There is no option for this and no derivation from a measured rate, unlike `pushAttempts`, because the shape of the problem is different. Integration happens at most once per package boundary, so the loop is already bounded by the size of the workspace; this only bounds the pathological case where the branch moves during the same package's own attempt over and over. Each round costs one package's analysis, and a branch would have to move during three consecutive attempts at the same package to exhaust it, at which point the run is being starved rather than raced and saying so is more useful than trying again.
|
|
1445
|
+
*/
|
|
1446
|
+
const MAX_BRANCH_MOVES_PER_PACKAGE = 3;
|
|
1447
|
+
/**
|
|
1448
|
+
* Brings the checkout up to the release branch's current tip, and reports where that tip is.
|
|
1449
|
+
*
|
|
1450
|
+
* At a package boundary every commit this run has made is already on the remote (each release commit by `@semantic-release/git`'s own prepare push, each tag by semantic-release's own, each dependency bump by `pushHead`), so advancing onto a moved branch is a fast-forward and loses nothing. `fastForwardTo` refuses anything else rather than discarding local work, which would mean the assumption above had broken.
|
|
1451
|
+
*/
|
|
1452
|
+
async function integrateBranchTip(workspace, log) {
|
|
1453
|
+
const cwd = workspace.root;
|
|
1454
|
+
const branch = await currentBranch({ cwd });
|
|
1455
|
+
const tip = await fetchBranchTip(branch, { cwd });
|
|
1456
|
+
if (await isAncestor(tip, "HEAD", { cwd })) return tip;
|
|
1457
|
+
if (!await isAncestor("HEAD", tip, { cwd })) throw new WorkspaceStateError(`${packageName}: ${branch} has moved to ${tip}, which this checkout has not and cannot reach by fast-forward, so the two have diverged. A release run expects every commit it has made to already be on the remote at this point; stopping rather than discarding whatever is here.`);
|
|
1458
|
+
await fastForwardTo(tip, { cwd });
|
|
1459
|
+
log(`${packageName}: ${branch} advanced to ${tip} during this run; integrated it before continuing.`);
|
|
1460
|
+
return tip;
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Releases one package, integrating the release branch first and again whenever it moves under the attempt.
|
|
1464
|
+
*
|
|
1465
|
+
* This exists because semantic-release cannot tell the orchestrator the difference between the two reasons it declines to release. When the branch has moved since the run began, its `verifyAuth` fails, `isBranchUpToDate` reports the branch behind, and it returns the same "no release" the orchestrator gets for a package that genuinely had nothing to publish. Taking that at face value is how a run used to finish green having released only the packages that happened to come before the branch moved.
|
|
1466
|
+
*
|
|
1467
|
+
* The ambiguity is resolved by observation rather than by reading semantic-release's logs: if the branch did not move while the package was being released, "no release" is true. If it did, the answer is not trustworthy, so the branch is integrated and the package is released again against the new tip. That is safe to repeat because a package that really has nothing to release still has nothing after a fast-forward, and a package that does release is recorded by its tag, which every later run reads as the release having happened.
|
|
1468
|
+
*/
|
|
1469
|
+
async function releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne) {
|
|
1470
|
+
if (dryRun) return await releaseOne(pkg, bumpsForThisPackage);
|
|
1471
|
+
for (let moves = 0;; moves += 1) {
|
|
1472
|
+
const tipBefore = await integrateBranchTip(workspace, log);
|
|
1473
|
+
const outcome = await releaseOne(pkg, bumpsForThisPackage);
|
|
1474
|
+
if (outcome.released) return outcome;
|
|
1475
|
+
const tipAfter = await fetchBranchTip(await currentBranch({ cwd: workspace.root }), { cwd: workspace.root });
|
|
1476
|
+
if (tipAfter === tipBefore) return outcome;
|
|
1477
|
+
if (moves + 1 >= MAX_BRANCH_MOVES_PER_PACKAGE) throw new WorkspaceStateError(`${packageName}: ${name} reported no release ${String(moves + 1)} times, each time while the release branch was moving underneath it, so whether it had anything to release was never established. Failing rather than finishing the run as though it had nothing, which would leave this package and every one after it silently unreleased. Re-running once the branch is quieter is safe: packages that already released are recorded by their tags and are not released again.`);
|
|
1478
|
+
log(`${name}: reported no release while the branch moved from ${tipBefore} to ${tipAfter}, which is also what a stale branch looks like; integrating and releasing it again.`);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1337
1481
|
//#endregion
|
|
1338
1482
|
//#region src/gate-publish.ts
|
|
1339
1483
|
/**
|
package/dist/index.cjs
CHANGED
|
@@ -286,6 +286,61 @@ async function pushHeadAndTags(tagNames, options) {
|
|
|
286
286
|
...tagNames
|
|
287
287
|
], options);
|
|
288
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Fetches `branch` from origin and returns the commit it now points at.
|
|
291
|
+
*
|
|
292
|
+
* This is how a failed push is classified, in preference to reading the reason git printed for the rejection. The wording is not stable enough to branch on: the same race produces `! [rejected] ... (non-fast-forward)` from one server and `cannot lock ref 'refs/heads/main': is at <x> but expected <y>` from another (both observed, the first against GitHub and the second against a local bare repository under `--atomic`), and neither is a documented interface. Whether the branch actually moved is a fact about the remote rather than a string, it is the precise condition a retry can recover from, and it is knowable by asking.
|
|
293
|
+
*/
|
|
294
|
+
async function fetchBranchTip(branch, options) {
|
|
295
|
+
await git([
|
|
296
|
+
"fetch",
|
|
297
|
+
"origin",
|
|
298
|
+
branch
|
|
299
|
+
], options);
|
|
300
|
+
return (await git(["rev-parse", "FETCH_HEAD"], options)).trim();
|
|
301
|
+
}
|
|
302
|
+
/** Whether `ancestor` is reachable from `descendant`. `git merge-base --is-ancestor` reports the answer as an exit status rather than on stdout: 0 for yes and 1 for no are both answers, so only any other exit code is a failure. */
|
|
303
|
+
async function isAncestor(ancestor, descendant, options) {
|
|
304
|
+
const args = [
|
|
305
|
+
"merge-base",
|
|
306
|
+
"--is-ancestor",
|
|
307
|
+
ancestor,
|
|
308
|
+
descendant
|
|
309
|
+
];
|
|
310
|
+
try {
|
|
311
|
+
await execGit(args, options.cwd);
|
|
312
|
+
return true;
|
|
313
|
+
} catch (cause) {
|
|
314
|
+
const error = toGitCommandError(args, options.cwd, cause);
|
|
315
|
+
if (error.exitCode === 1) return false;
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Advances the current branch to `ref`, refusing unless that is a fast-forward. Used where every local commit is already on the remote, so anything that is not a fast-forward means the checkout holds work the remote does not, and stopping is better than the alternative of discarding it. */
|
|
320
|
+
async function fastForwardTo(ref, options) {
|
|
321
|
+
await git([
|
|
322
|
+
"merge",
|
|
323
|
+
"--ff-only",
|
|
324
|
+
ref
|
|
325
|
+
], options);
|
|
326
|
+
}
|
|
327
|
+
/** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
|
|
328
|
+
async function resetHardTo(ref, options) {
|
|
329
|
+
await git([
|
|
330
|
+
"reset",
|
|
331
|
+
"--hard",
|
|
332
|
+
ref
|
|
333
|
+
], options);
|
|
334
|
+
}
|
|
335
|
+
/** Deletes local tags. A retried attempt must drop the tags its predecessor created before it recreates them: `git tag` refuses a name that already exists, so without this the second attempt fails while tagging, before it ever reaches the push it was retrying. */
|
|
336
|
+
async function deleteLocalTags(tagNames, options) {
|
|
337
|
+
if (tagNames.length === 0) return;
|
|
338
|
+
await git([
|
|
339
|
+
"tag",
|
|
340
|
+
"-d",
|
|
341
|
+
...tagNames
|
|
342
|
+
], options);
|
|
343
|
+
}
|
|
289
344
|
function toGitCommandError(args, cwd, cause) {
|
|
290
345
|
const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
|
|
291
346
|
const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
|
|
@@ -900,6 +955,27 @@ function formatTagForPackage(tagFormat, name) {
|
|
|
900
955
|
* Why publish is not just another semantic-release() call: semantic-release's own `run()` unconditionally derives `lastRelease` from the newest tag already on the branch matching `tagFormat`, and this mode has, by the time phase 5 runs, already created and pushed that exact tag itself. A second real `semanticRelease()` call would see its own just-created tag as the already-published release and compute the wrong next version from it. Phase 5 instead calls each resolved plugin module's own exported `verifyConditions`/`publish`/`success` functions directly, with a hand-built context -- the same public per-plugin API surface semantic-release's own core calls internally, just invoked without going through the parts of `run()` that assume a not-yet-tagged repository.
|
|
901
956
|
*/
|
|
902
957
|
async function releaseWorkspaceSingleCommit(options) {
|
|
958
|
+
const log = options.log ?? console.log;
|
|
959
|
+
const maxAttempts = resolvePushAttempts(options.pushAttempts);
|
|
960
|
+
let lastLoss;
|
|
961
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
962
|
+
const result = await attemptSingleCommitRelease(options);
|
|
963
|
+
if (result.kind === "complete") return result.outcome;
|
|
964
|
+
if (result.kind === "pushed") {
|
|
965
|
+
await result.publish();
|
|
966
|
+
return result.outcome;
|
|
967
|
+
}
|
|
968
|
+
lastLoss = result.error;
|
|
969
|
+
log(`${packageName}: attempt ${String(attempt)} of ${String(maxAttempts)} lost the push, because ${result.branch} advanced to ${result.remoteTip} before this run's own refs reached the remote. This attempt's commit and tags have been discarded; recomputing the release against the new tip.`);
|
|
970
|
+
}
|
|
971
|
+
throw new WorkspaceStateError(`${packageName}: gave up after ${String(maxAttempts)} attempt(s) to push the release, each overtaken by another commit landing on the release branch first. Nothing was published and no tag reached the remote, so re-running is safe and loses nothing. Raise "pushAttempts" if this branch is busy enough that ${String(maxAttempts)} is genuinely too few. The last push failed with: ${lastLoss === void 0 ? "unknown" : lastLoss.message}`);
|
|
972
|
+
}
|
|
973
|
+
function resolvePushAttempts(configured) {
|
|
974
|
+
if (configured === void 0) return 5;
|
|
975
|
+
if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
|
|
976
|
+
return configured;
|
|
977
|
+
}
|
|
978
|
+
async function attemptSingleCommitRelease(options) {
|
|
903
979
|
const root = (0, node_path.resolve)(options.root ?? process.cwd());
|
|
904
980
|
const log = options.log ?? console.log;
|
|
905
981
|
const dryRun = options.dryRun === true;
|
|
@@ -983,8 +1059,11 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
983
1059
|
}
|
|
984
1060
|
}
|
|
985
1061
|
if (dryRun || planned.length === 0) return {
|
|
986
|
-
|
|
987
|
-
|
|
1062
|
+
kind: "complete",
|
|
1063
|
+
outcome: {
|
|
1064
|
+
order,
|
|
1065
|
+
packages: outcomes
|
|
1066
|
+
}
|
|
988
1067
|
};
|
|
989
1068
|
const branch = captured.branch;
|
|
990
1069
|
const repositoryUrl = captured.repositoryUrl;
|
|
@@ -1023,26 +1102,51 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
1023
1102
|
const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
|
|
1024
1103
|
const tagNames = planned.map((release) => release.gitTag);
|
|
1025
1104
|
for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
|
|
1026
|
-
await
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
}
|
|
1037
|
-
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1038
|
-
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1039
|
-
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1105
|
+
const branchName = await currentBranch({ cwd: repoRoot });
|
|
1106
|
+
try {
|
|
1107
|
+
await pushHeadAndTags(tagNames, { cwd: repoRoot });
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
if (!(error instanceof GitCommandError)) throw error;
|
|
1110
|
+
let remoteTip;
|
|
1111
|
+
try {
|
|
1112
|
+
remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
|
|
1113
|
+
} catch {
|
|
1114
|
+
throw error;
|
|
1040
1115
|
}
|
|
1041
|
-
|
|
1116
|
+
if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
|
|
1117
|
+
await deleteLocalTags(tagNames, { cwd: repoRoot });
|
|
1118
|
+
await resetHardTo(remoteTip, { cwd: repoRoot });
|
|
1119
|
+
return {
|
|
1120
|
+
kind: "lost",
|
|
1121
|
+
error,
|
|
1122
|
+
branch: branchName,
|
|
1123
|
+
remoteTip
|
|
1124
|
+
};
|
|
1042
1125
|
}
|
|
1126
|
+
log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
|
|
1043
1127
|
return {
|
|
1044
|
-
|
|
1045
|
-
|
|
1128
|
+
kind: "pushed",
|
|
1129
|
+
outcome: {
|
|
1130
|
+
order,
|
|
1131
|
+
packages: outcomes
|
|
1132
|
+
},
|
|
1133
|
+
publish: async () => {
|
|
1134
|
+
for (const release of planned) {
|
|
1135
|
+
const releases = [];
|
|
1136
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1137
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1138
|
+
if (plugin.publish) {
|
|
1139
|
+
const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1140
|
+
if (result !== false && result !== void 0) releases.push(result);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1144
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1145
|
+
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1146
|
+
}
|
|
1147
|
+
log(`${release.pkg.name}: published ${release.gitTag}`);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1046
1150
|
};
|
|
1047
1151
|
}
|
|
1048
1152
|
/**
|
|
@@ -1385,7 +1489,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
|
|
|
1385
1489
|
const pkg = mustGet(graph.packages, name, "package");
|
|
1386
1490
|
const bumpsForThisPackage = pendingBumps.get(name) ?? [];
|
|
1387
1491
|
log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
|
|
1388
|
-
const { released, version, result } = await
|
|
1492
|
+
const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
|
|
1389
1493
|
entries.push({
|
|
1390
1494
|
name,
|
|
1391
1495
|
directory: pkg.directory,
|
|
@@ -1482,6 +1586,46 @@ async function bumpDependents(released, version, graph, options) {
|
|
|
1482
1586
|
}
|
|
1483
1587
|
return applied;
|
|
1484
1588
|
}
|
|
1589
|
+
/**
|
|
1590
|
+
* How many times one package may be re-released after the branch moved underneath its own attempt, before the run gives up loudly.
|
|
1591
|
+
*
|
|
1592
|
+
* There is no option for this and no derivation from a measured rate, unlike `pushAttempts`, because the shape of the problem is different. Integration happens at most once per package boundary, so the loop is already bounded by the size of the workspace; this only bounds the pathological case where the branch moves during the same package's own attempt over and over. Each round costs one package's analysis, and a branch would have to move during three consecutive attempts at the same package to exhaust it, at which point the run is being starved rather than raced and saying so is more useful than trying again.
|
|
1593
|
+
*/
|
|
1594
|
+
const MAX_BRANCH_MOVES_PER_PACKAGE = 3;
|
|
1595
|
+
/**
|
|
1596
|
+
* Brings the checkout up to the release branch's current tip, and reports where that tip is.
|
|
1597
|
+
*
|
|
1598
|
+
* At a package boundary every commit this run has made is already on the remote (each release commit by `@semantic-release/git`'s own prepare push, each tag by semantic-release's own, each dependency bump by `pushHead`), so advancing onto a moved branch is a fast-forward and loses nothing. `fastForwardTo` refuses anything else rather than discarding local work, which would mean the assumption above had broken.
|
|
1599
|
+
*/
|
|
1600
|
+
async function integrateBranchTip(workspace, log) {
|
|
1601
|
+
const cwd = workspace.root;
|
|
1602
|
+
const branch = await currentBranch({ cwd });
|
|
1603
|
+
const tip = await fetchBranchTip(branch, { cwd });
|
|
1604
|
+
if (await isAncestor(tip, "HEAD", { cwd })) return tip;
|
|
1605
|
+
if (!await isAncestor("HEAD", tip, { cwd })) throw new WorkspaceStateError(`${packageName}: ${branch} has moved to ${tip}, which this checkout has not and cannot reach by fast-forward, so the two have diverged. A release run expects every commit it has made to already be on the remote at this point; stopping rather than discarding whatever is here.`);
|
|
1606
|
+
await fastForwardTo(tip, { cwd });
|
|
1607
|
+
log(`${packageName}: ${branch} advanced to ${tip} during this run; integrated it before continuing.`);
|
|
1608
|
+
return tip;
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Releases one package, integrating the release branch first and again whenever it moves under the attempt.
|
|
1612
|
+
*
|
|
1613
|
+
* This exists because semantic-release cannot tell the orchestrator the difference between the two reasons it declines to release. When the branch has moved since the run began, its `verifyAuth` fails, `isBranchUpToDate` reports the branch behind, and it returns the same "no release" the orchestrator gets for a package that genuinely had nothing to publish. Taking that at face value is how a run used to finish green having released only the packages that happened to come before the branch moved.
|
|
1614
|
+
*
|
|
1615
|
+
* The ambiguity is resolved by observation rather than by reading semantic-release's logs: if the branch did not move while the package was being released, "no release" is true. If it did, the answer is not trustworthy, so the branch is integrated and the package is released again against the new tip. That is safe to repeat because a package that really has nothing to release still has nothing after a fast-forward, and a package that does release is recorded by its tag, which every later run reads as the release having happened.
|
|
1616
|
+
*/
|
|
1617
|
+
async function releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne) {
|
|
1618
|
+
if (dryRun) return await releaseOne(pkg, bumpsForThisPackage);
|
|
1619
|
+
for (let moves = 0;; moves += 1) {
|
|
1620
|
+
const tipBefore = await integrateBranchTip(workspace, log);
|
|
1621
|
+
const outcome = await releaseOne(pkg, bumpsForThisPackage);
|
|
1622
|
+
if (outcome.released) return outcome;
|
|
1623
|
+
const tipAfter = await fetchBranchTip(await currentBranch({ cwd: workspace.root }), { cwd: workspace.root });
|
|
1624
|
+
if (tipAfter === tipBefore) return outcome;
|
|
1625
|
+
if (moves + 1 >= MAX_BRANCH_MOVES_PER_PACKAGE) throw new WorkspaceStateError(`${packageName}: ${name} reported no release ${String(moves + 1)} times, each time while the release branch was moving underneath it, so whether it had anything to release was never established. Failing rather than finishing the run as though it had nothing, which would leave this package and every one after it silently unreleased. Re-running once the branch is quieter is safe: packages that already released are recorded by their tags and are not released again.`);
|
|
1626
|
+
log(`${name}: reported no release while the branch moved from ${tipBefore} to ${tipAfter}, which is also what a stale branch looks like; integrating and releasing it again.`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1485
1629
|
//#endregion
|
|
1486
1630
|
exports.DEFAULT_PUBLISH_PLUGINS = DEFAULT_PUBLISH_PLUGINS;
|
|
1487
1631
|
exports.DependencyCycleError = DependencyCycleError;
|
package/dist/index.d.cts
CHANGED
|
@@ -279,6 +279,14 @@ interface ReleaseWorkspaceOptions {
|
|
|
279
279
|
* and pins `...@<action>-v1`. Validated once per run by `validateTagFormat`.
|
|
280
280
|
*/
|
|
281
281
|
readonly tagFormat?: string;
|
|
282
|
+
/**
|
|
283
|
+
* How many times `commitStrategy: 'single'` will compute and push a release before giving up, when each attempt is lost to another commit landing on the release branch first. Defaults to `DEFAULT_PUSH_ATTEMPTS`. Ignored by `commitStrategy: 'per-package'`, which pushes incrementally and has no all-or-nothing attempt to repeat.
|
|
284
|
+
*
|
|
285
|
+
* The default is derived rather than chosen. An attempt is lost if any commit lands during its own window, so with pushes arriving at rate `L` and an attempt taking `W`, an attempt survives with probability `e^(-L*W)` and `n` attempts all fail with probability `(1 - e^(-L*W))^n`. On the busiest repository this tool serves, pushes to the release branch arrived at roughly one every 7.4 minutes while a merge queue was landing pull requests continuously (measured over the busiest 60, 90 and 120 minute windows, which agreed to within 6%), and analysing, preparing, committing and pushing 23 packages took about 2.2 minutes, so `W = 3` minutes leaves headroom for a larger workspace. That gives about a one-in-three chance of losing any single attempt, which matches what was observed when there was no retry at all, and 5 attempts put the chance of losing all of them near 0.4%, or roughly one lost release a month at ten releases a day. Five attempts also bound the added time at about 15 minutes, well inside the hour-long job timeouts these releases run under.
|
|
286
|
+
*
|
|
287
|
+
* Raise it for a busier branch or a slower workspace; the cost of a higher bound is only paid when attempts are actually being lost.
|
|
288
|
+
*/
|
|
289
|
+
readonly pushAttempts?: number;
|
|
282
290
|
}
|
|
283
291
|
/** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
|
|
284
292
|
interface AppliedDependencyBump extends DependencyBump {
|
package/dist/index.d.ts
CHANGED
|
@@ -279,6 +279,14 @@ interface ReleaseWorkspaceOptions {
|
|
|
279
279
|
* and pins `...@<action>-v1`. Validated once per run by `validateTagFormat`.
|
|
280
280
|
*/
|
|
281
281
|
readonly tagFormat?: string;
|
|
282
|
+
/**
|
|
283
|
+
* How many times `commitStrategy: 'single'` will compute and push a release before giving up, when each attempt is lost to another commit landing on the release branch first. Defaults to `DEFAULT_PUSH_ATTEMPTS`. Ignored by `commitStrategy: 'per-package'`, which pushes incrementally and has no all-or-nothing attempt to repeat.
|
|
284
|
+
*
|
|
285
|
+
* The default is derived rather than chosen. An attempt is lost if any commit lands during its own window, so with pushes arriving at rate `L` and an attempt taking `W`, an attempt survives with probability `e^(-L*W)` and `n` attempts all fail with probability `(1 - e^(-L*W))^n`. On the busiest repository this tool serves, pushes to the release branch arrived at roughly one every 7.4 minutes while a merge queue was landing pull requests continuously (measured over the busiest 60, 90 and 120 minute windows, which agreed to within 6%), and analysing, preparing, committing and pushing 23 packages took about 2.2 minutes, so `W = 3` minutes leaves headroom for a larger workspace. That gives about a one-in-three chance of losing any single attempt, which matches what was observed when there was no retry at all, and 5 attempts put the chance of losing all of them near 0.4%, or roughly one lost release a month at ten releases a day. Five attempts also bound the added time at about 15 minutes, well inside the hour-long job timeouts these releases run under.
|
|
286
|
+
*
|
|
287
|
+
* Raise it for a busier branch or a slower workspace; the cost of a higher bound is only paid when attempts are actually being lost.
|
|
288
|
+
*/
|
|
289
|
+
readonly pushAttempts?: number;
|
|
282
290
|
}
|
|
283
291
|
/** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
|
|
284
292
|
interface AppliedDependencyBump extends DependencyBump {
|
package/dist/index.js
CHANGED
|
@@ -261,6 +261,61 @@ async function pushHeadAndTags(tagNames, options) {
|
|
|
261
261
|
...tagNames
|
|
262
262
|
], options);
|
|
263
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Fetches `branch` from origin and returns the commit it now points at.
|
|
266
|
+
*
|
|
267
|
+
* This is how a failed push is classified, in preference to reading the reason git printed for the rejection. The wording is not stable enough to branch on: the same race produces `! [rejected] ... (non-fast-forward)` from one server and `cannot lock ref 'refs/heads/main': is at <x> but expected <y>` from another (both observed, the first against GitHub and the second against a local bare repository under `--atomic`), and neither is a documented interface. Whether the branch actually moved is a fact about the remote rather than a string, it is the precise condition a retry can recover from, and it is knowable by asking.
|
|
268
|
+
*/
|
|
269
|
+
async function fetchBranchTip(branch, options) {
|
|
270
|
+
await git([
|
|
271
|
+
"fetch",
|
|
272
|
+
"origin",
|
|
273
|
+
branch
|
|
274
|
+
], options);
|
|
275
|
+
return (await git(["rev-parse", "FETCH_HEAD"], options)).trim();
|
|
276
|
+
}
|
|
277
|
+
/** Whether `ancestor` is reachable from `descendant`. `git merge-base --is-ancestor` reports the answer as an exit status rather than on stdout: 0 for yes and 1 for no are both answers, so only any other exit code is a failure. */
|
|
278
|
+
async function isAncestor(ancestor, descendant, options) {
|
|
279
|
+
const args = [
|
|
280
|
+
"merge-base",
|
|
281
|
+
"--is-ancestor",
|
|
282
|
+
ancestor,
|
|
283
|
+
descendant
|
|
284
|
+
];
|
|
285
|
+
try {
|
|
286
|
+
await execGit(args, options.cwd);
|
|
287
|
+
return true;
|
|
288
|
+
} catch (cause) {
|
|
289
|
+
const error = toGitCommandError(args, options.cwd, cause);
|
|
290
|
+
if (error.exitCode === 1) return false;
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Advances the current branch to `ref`, refusing unless that is a fast-forward. Used where every local commit is already on the remote, so anything that is not a fast-forward means the checkout holds work the remote does not, and stopping is better than the alternative of discarding it. */
|
|
295
|
+
async function fastForwardTo(ref, options) {
|
|
296
|
+
await git([
|
|
297
|
+
"merge",
|
|
298
|
+
"--ff-only",
|
|
299
|
+
ref
|
|
300
|
+
], options);
|
|
301
|
+
}
|
|
302
|
+
/** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
|
|
303
|
+
async function resetHardTo(ref, options) {
|
|
304
|
+
await git([
|
|
305
|
+
"reset",
|
|
306
|
+
"--hard",
|
|
307
|
+
ref
|
|
308
|
+
], options);
|
|
309
|
+
}
|
|
310
|
+
/** Deletes local tags. A retried attempt must drop the tags its predecessor created before it recreates them: `git tag` refuses a name that already exists, so without this the second attempt fails while tagging, before it ever reaches the push it was retrying. */
|
|
311
|
+
async function deleteLocalTags(tagNames, options) {
|
|
312
|
+
if (tagNames.length === 0) return;
|
|
313
|
+
await git([
|
|
314
|
+
"tag",
|
|
315
|
+
"-d",
|
|
316
|
+
...tagNames
|
|
317
|
+
], options);
|
|
318
|
+
}
|
|
264
319
|
function toGitCommandError(args, cwd, cause) {
|
|
265
320
|
const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
|
|
266
321
|
const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
|
|
@@ -875,6 +930,27 @@ function formatTagForPackage(tagFormat, name) {
|
|
|
875
930
|
* Why publish is not just another semantic-release() call: semantic-release's own `run()` unconditionally derives `lastRelease` from the newest tag already on the branch matching `tagFormat`, and this mode has, by the time phase 5 runs, already created and pushed that exact tag itself. A second real `semanticRelease()` call would see its own just-created tag as the already-published release and compute the wrong next version from it. Phase 5 instead calls each resolved plugin module's own exported `verifyConditions`/`publish`/`success` functions directly, with a hand-built context -- the same public per-plugin API surface semantic-release's own core calls internally, just invoked without going through the parts of `run()` that assume a not-yet-tagged repository.
|
|
876
931
|
*/
|
|
877
932
|
async function releaseWorkspaceSingleCommit(options) {
|
|
933
|
+
const log = options.log ?? console.log;
|
|
934
|
+
const maxAttempts = resolvePushAttempts(options.pushAttempts);
|
|
935
|
+
let lastLoss;
|
|
936
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
937
|
+
const result = await attemptSingleCommitRelease(options);
|
|
938
|
+
if (result.kind === "complete") return result.outcome;
|
|
939
|
+
if (result.kind === "pushed") {
|
|
940
|
+
await result.publish();
|
|
941
|
+
return result.outcome;
|
|
942
|
+
}
|
|
943
|
+
lastLoss = result.error;
|
|
944
|
+
log(`${packageName}: attempt ${String(attempt)} of ${String(maxAttempts)} lost the push, because ${result.branch} advanced to ${result.remoteTip} before this run's own refs reached the remote. This attempt's commit and tags have been discarded; recomputing the release against the new tip.`);
|
|
945
|
+
}
|
|
946
|
+
throw new WorkspaceStateError(`${packageName}: gave up after ${String(maxAttempts)} attempt(s) to push the release, each overtaken by another commit landing on the release branch first. Nothing was published and no tag reached the remote, so re-running is safe and loses nothing. Raise "pushAttempts" if this branch is busy enough that ${String(maxAttempts)} is genuinely too few. The last push failed with: ${lastLoss === void 0 ? "unknown" : lastLoss.message}`);
|
|
947
|
+
}
|
|
948
|
+
function resolvePushAttempts(configured) {
|
|
949
|
+
if (configured === void 0) return 5;
|
|
950
|
+
if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
|
|
951
|
+
return configured;
|
|
952
|
+
}
|
|
953
|
+
async function attemptSingleCommitRelease(options) {
|
|
878
954
|
const root = resolve(options.root ?? process.cwd());
|
|
879
955
|
const log = options.log ?? console.log;
|
|
880
956
|
const dryRun = options.dryRun === true;
|
|
@@ -958,8 +1034,11 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
958
1034
|
}
|
|
959
1035
|
}
|
|
960
1036
|
if (dryRun || planned.length === 0) return {
|
|
961
|
-
|
|
962
|
-
|
|
1037
|
+
kind: "complete",
|
|
1038
|
+
outcome: {
|
|
1039
|
+
order,
|
|
1040
|
+
packages: outcomes
|
|
1041
|
+
}
|
|
963
1042
|
};
|
|
964
1043
|
const branch = captured.branch;
|
|
965
1044
|
const repositoryUrl = captured.repositoryUrl;
|
|
@@ -998,26 +1077,51 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
998
1077
|
const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
|
|
999
1078
|
const tagNames = planned.map((release) => release.gitTag);
|
|
1000
1079
|
for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
|
|
1001
|
-
await
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}
|
|
1012
|
-
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1013
|
-
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1014
|
-
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1080
|
+
const branchName = await currentBranch({ cwd: repoRoot });
|
|
1081
|
+
try {
|
|
1082
|
+
await pushHeadAndTags(tagNames, { cwd: repoRoot });
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
if (!(error instanceof GitCommandError)) throw error;
|
|
1085
|
+
let remoteTip;
|
|
1086
|
+
try {
|
|
1087
|
+
remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
|
|
1088
|
+
} catch {
|
|
1089
|
+
throw error;
|
|
1015
1090
|
}
|
|
1016
|
-
|
|
1091
|
+
if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
|
|
1092
|
+
await deleteLocalTags(tagNames, { cwd: repoRoot });
|
|
1093
|
+
await resetHardTo(remoteTip, { cwd: repoRoot });
|
|
1094
|
+
return {
|
|
1095
|
+
kind: "lost",
|
|
1096
|
+
error,
|
|
1097
|
+
branch: branchName,
|
|
1098
|
+
remoteTip
|
|
1099
|
+
};
|
|
1017
1100
|
}
|
|
1101
|
+
log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
|
|
1018
1102
|
return {
|
|
1019
|
-
|
|
1020
|
-
|
|
1103
|
+
kind: "pushed",
|
|
1104
|
+
outcome: {
|
|
1105
|
+
order,
|
|
1106
|
+
packages: outcomes
|
|
1107
|
+
},
|
|
1108
|
+
publish: async () => {
|
|
1109
|
+
for (const release of planned) {
|
|
1110
|
+
const releases = [];
|
|
1111
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1112
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1113
|
+
if (plugin.publish) {
|
|
1114
|
+
const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1115
|
+
if (result !== false && result !== void 0) releases.push(result);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1119
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1120
|
+
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1121
|
+
}
|
|
1122
|
+
log(`${release.pkg.name}: published ${release.gitTag}`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1021
1125
|
};
|
|
1022
1126
|
}
|
|
1023
1127
|
/**
|
|
@@ -1360,7 +1464,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
|
|
|
1360
1464
|
const pkg = mustGet(graph.packages, name, "package");
|
|
1361
1465
|
const bumpsForThisPackage = pendingBumps.get(name) ?? [];
|
|
1362
1466
|
log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
|
|
1363
|
-
const { released, version, result } = await
|
|
1467
|
+
const { released, version, result } = await releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne);
|
|
1364
1468
|
entries.push({
|
|
1365
1469
|
name,
|
|
1366
1470
|
directory: pkg.directory,
|
|
@@ -1457,5 +1561,45 @@ async function bumpDependents(released, version, graph, options) {
|
|
|
1457
1561
|
}
|
|
1458
1562
|
return applied;
|
|
1459
1563
|
}
|
|
1564
|
+
/**
|
|
1565
|
+
* How many times one package may be re-released after the branch moved underneath its own attempt, before the run gives up loudly.
|
|
1566
|
+
*
|
|
1567
|
+
* There is no option for this and no derivation from a measured rate, unlike `pushAttempts`, because the shape of the problem is different. Integration happens at most once per package boundary, so the loop is already bounded by the size of the workspace; this only bounds the pathological case where the branch moves during the same package's own attempt over and over. Each round costs one package's analysis, and a branch would have to move during three consecutive attempts at the same package to exhaust it, at which point the run is being starved rather than raced and saying so is more useful than trying again.
|
|
1568
|
+
*/
|
|
1569
|
+
const MAX_BRANCH_MOVES_PER_PACKAGE = 3;
|
|
1570
|
+
/**
|
|
1571
|
+
* Brings the checkout up to the release branch's current tip, and reports where that tip is.
|
|
1572
|
+
*
|
|
1573
|
+
* At a package boundary every commit this run has made is already on the remote (each release commit by `@semantic-release/git`'s own prepare push, each tag by semantic-release's own, each dependency bump by `pushHead`), so advancing onto a moved branch is a fast-forward and loses nothing. `fastForwardTo` refuses anything else rather than discarding local work, which would mean the assumption above had broken.
|
|
1574
|
+
*/
|
|
1575
|
+
async function integrateBranchTip(workspace, log) {
|
|
1576
|
+
const cwd = workspace.root;
|
|
1577
|
+
const branch = await currentBranch({ cwd });
|
|
1578
|
+
const tip = await fetchBranchTip(branch, { cwd });
|
|
1579
|
+
if (await isAncestor(tip, "HEAD", { cwd })) return tip;
|
|
1580
|
+
if (!await isAncestor("HEAD", tip, { cwd })) throw new WorkspaceStateError(`${packageName}: ${branch} has moved to ${tip}, which this checkout has not and cannot reach by fast-forward, so the two have diverged. A release run expects every commit it has made to already be on the remote at this point; stopping rather than discarding whatever is here.`);
|
|
1581
|
+
await fastForwardTo(tip, { cwd });
|
|
1582
|
+
log(`${packageName}: ${branch} advanced to ${tip} during this run; integrated it before continuing.`);
|
|
1583
|
+
return tip;
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Releases one package, integrating the release branch first and again whenever it moves under the attempt.
|
|
1587
|
+
*
|
|
1588
|
+
* This exists because semantic-release cannot tell the orchestrator the difference between the two reasons it declines to release. When the branch has moved since the run began, its `verifyAuth` fails, `isBranchUpToDate` reports the branch behind, and it returns the same "no release" the orchestrator gets for a package that genuinely had nothing to publish. Taking that at face value is how a run used to finish green having released only the packages that happened to come before the branch moved.
|
|
1589
|
+
*
|
|
1590
|
+
* The ambiguity is resolved by observation rather than by reading semantic-release's logs: if the branch did not move while the package was being released, "no release" is true. If it did, the answer is not trustworthy, so the branch is integrated and the package is released again against the new tip. That is safe to repeat because a package that really has nothing to release still has nothing after a fast-forward, and a package that does release is recorded by its tag, which every later run reads as the release having happened.
|
|
1591
|
+
*/
|
|
1592
|
+
async function releasePackageAcrossBranchMoves(name, pkg, bumpsForThisPackage, workspace, dryRun, log, releaseOne) {
|
|
1593
|
+
if (dryRun) return await releaseOne(pkg, bumpsForThisPackage);
|
|
1594
|
+
for (let moves = 0;; moves += 1) {
|
|
1595
|
+
const tipBefore = await integrateBranchTip(workspace, log);
|
|
1596
|
+
const outcome = await releaseOne(pkg, bumpsForThisPackage);
|
|
1597
|
+
if (outcome.released) return outcome;
|
|
1598
|
+
const tipAfter = await fetchBranchTip(await currentBranch({ cwd: workspace.root }), { cwd: workspace.root });
|
|
1599
|
+
if (tipAfter === tipBefore) return outcome;
|
|
1600
|
+
if (moves + 1 >= MAX_BRANCH_MOVES_PER_PACKAGE) throw new WorkspaceStateError(`${packageName}: ${name} reported no release ${String(moves + 1)} times, each time while the release branch was moving underneath it, so whether it had anything to release was never established. Failing rather than finishing the run as though it had nothing, which would leave this package and every one after it silently unreleased. Re-running once the branch is quieter is safe: packages that already released are recorded by their tags and are not released again.`);
|
|
1601
|
+
log(`${name}: reported no release while the branch moved from ${tipBefore} to ${tipAfter}, which is also what a stale branch looks like; integrating and releasing it again.`);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1460
1604
|
//#endregion
|
|
1461
1605
|
export { DEFAULT_PUBLISH_PLUGINS, DependencyCycleError, GitCommandError, ReleaseConfigurationError, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, UnsupportedDependencyRangeError, WorkspaceDiscoveryError, WorkspaceReleaseError, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, orderedPackages, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, resolveWorkspacePublishPlugins, resumeWorkspaceRelease, topologicalOrder, updateDependencyRange, writeDependencyRange };
|
package/package.json
CHANGED