@exadev/semantic-release-workspace 2.2.0 → 3.0.1
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 -4
- package/dist/cli.js +135 -27
- package/dist/index.cjs +135 -26
- package/dist/index.d.cts +22 -3
- package/dist/index.d.ts +22 -3
- package/dist/index.js +135 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -158,6 +158,7 @@ The core technique is the same one multi-semantic-release proved in production:
|
|
|
158
158
|
- **Bump-only dependents always release.** multi-semantic-release rewrites dependency ranges in the working tree without committing them, so a dependent whose only change is a dependency update can go unreleased until some other commit triggers it. Here the bump is committed before the dependent's turn and a patch release is forced deterministically (see the timing section above).
|
|
159
159
|
- **Loud failures by design.** A dependency cycle, an unsupported dependency range, a publish pipeline without @semantic-release/git (which would leave released manifests uncommitted), an unresolvable plugin, a duplicate package name — each stops the run with a specific error rather than degrading silently. There is deliberately no "skip this package and carry on" path: a partially-consistent set of publishes is worse than none.
|
|
160
160
|
- **Only installable specifiers reach the registry.** `workspace:`, `catalog:`, `link:`, and `file:` specifiers in a publishable package's installed dependency fields are rejected instead of being published as written. In private packages and `devDependencies`, `workspace:*`/`workspace:^`/`workspace:~` are understood as naming no version (bump the release, not the manifest text), and `catalog:` and `npm:` aliases on a workspace sibling are rejected with an explanation instead of being mangled.
|
|
161
|
+
- **Private packages release without advertising themselves.** A package marked `private` takes part in the run exactly as any other, tag and version bump and dependency cascade included, but creates no GitHub Release, because a Release for a package that never reaches a registry both points at nothing installable and takes the repository's Latest label off a package that does.
|
|
161
162
|
- **Workspace-agnostic discovery.** Everything comes from `pnpm-workspace.yaml` and the manifests its globs match; pointing the orchestrator at any pnpm workspace is the entire configuration.
|
|
162
163
|
|
|
163
164
|
Out of scope, on purpose: parallelising independent branches of the dependency graph (packages release sequentially in topological order for correctness first — a real future optimisation, not attempted here), and any Changesets-style explicit-changeset mode, which is a different paradigm rather than a missing feature.
|
|
@@ -225,11 +226,17 @@ Listing `@semantic-release/commit-analyzer` or `@semantic-release/release-notes-
|
|
|
225
226
|
|
|
226
227
|
Note that the orchestrator sets `tagFormat`, `plugins`, `analyzeCommits`, and `generateNotes` explicitly on every per-package run, so those keys in any `release.config.*` found in the workspace are overridden by construction — configure the release through the orchestrator, not through a leftover single-package config.
|
|
227
228
|
|
|
228
|
-
###
|
|
229
|
+
### Private packages and GitHub Releases
|
|
230
|
+
|
|
231
|
+
A package whose manifest sets `"private": true` gets the workspace-wide plugin list minus `@semantic-release/github`, without being configured to. Nothing else about its release changes: it still gets its version bump, its `name@version` tag and the dependency cascade to its dependents, all of which a dependent's own release can hinge on (a private package whose build output ships inside a published one, for example). What it loses is a public GitHub Release for something nobody can install.
|
|
232
|
+
|
|
233
|
+
That release is not merely redundant. `@semantic-release/github` sets the REST API's `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. Topological order puts a package that depends on the published ones at the end of the run, which is exactly where a private package usually sits, so without this rule the repository's front page advertises an unpublishable package as its current release.
|
|
229
234
|
|
|
230
|
-
|
|
235
|
+
To keep the Release for a private package anyway, name it in `packagePlugins` below: an explicit list is taken exactly as written.
|
|
236
|
+
|
|
237
|
+
### Per-package publish plugins
|
|
231
238
|
|
|
232
|
-
|
|
239
|
+
`plugins` is one list for the whole workspace. `packagePlugins` (config file and programmatic API only, since a list keyed by package name has no natural flag form) replaces that list outright for the packages it names, and every other package keeps the workspace-wide list, or the private-package variant of it described above. The override is not merged with either: it is the complete list for that package, subject to the same rules as any other (for instance `@semantic-release/git` is required under `commitStrategy: 'per-package'` and rejected under `'single'`). A name that is not a package in the workspace is rejected, so a misspelling cannot leave a package on the default list unnoticed.
|
|
233
240
|
|
|
234
241
|
```ts
|
|
235
242
|
// release-workspace.config.ts
|
|
@@ -237,7 +244,10 @@ import { DEFAULT_PUBLISH_PLUGINS, type ReleaseWorkspaceOptions } from '@exadev/s
|
|
|
237
244
|
|
|
238
245
|
const config: ReleaseWorkspaceOptions = {
|
|
239
246
|
packagePlugins: {
|
|
240
|
-
|
|
247
|
+
// A private package that does want its GitHub Release, opting back in to the list every public package gets.
|
|
248
|
+
'@acme/internal-tooling': DEFAULT_PUBLISH_PLUGINS,
|
|
249
|
+
// A published package kept out of a step the rest need.
|
|
250
|
+
'@acme/docs-site': DEFAULT_PUBLISH_PLUGINS.filter((plugin) => plugin !== '@semantic-release/npm'),
|
|
241
251
|
},
|
|
242
252
|
};
|
|
243
253
|
|
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 = "
|
|
17
|
+
var version = "3.0.1";
|
|
18
18
|
//#endregion
|
|
19
19
|
//#region src/errors.ts
|
|
20
20
|
/**
|
|
@@ -279,6 +279,53 @@ 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
|
+
/** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
|
|
313
|
+
async function resetHardTo(ref, options) {
|
|
314
|
+
await git([
|
|
315
|
+
"reset",
|
|
316
|
+
"--hard",
|
|
317
|
+
ref
|
|
318
|
+
], options);
|
|
319
|
+
}
|
|
320
|
+
/** 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. */
|
|
321
|
+
async function deleteLocalTags(tagNames, options) {
|
|
322
|
+
if (tagNames.length === 0) return;
|
|
323
|
+
await git([
|
|
324
|
+
"tag",
|
|
325
|
+
"-d",
|
|
326
|
+
...tagNames
|
|
327
|
+
], options);
|
|
328
|
+
}
|
|
282
329
|
function toGitCommandError(args, cwd, cause) {
|
|
283
330
|
const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
|
|
284
331
|
const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
|
|
@@ -602,6 +649,10 @@ function topologicalOrder(graph) {
|
|
|
602
649
|
if (pending.size > 0) throw new DependencyCycleError(findCycle(graph, new Set(pending.keys())));
|
|
603
650
|
return ordered;
|
|
604
651
|
}
|
|
652
|
+
/** The packages a release order names, in that order, for the stages that need each package's manifest rather than only its name. */
|
|
653
|
+
function orderedPackages(graph, order) {
|
|
654
|
+
return order.map((name) => mustGet(graph.packages, name, "package"));
|
|
655
|
+
}
|
|
605
656
|
/**
|
|
606
657
|
* Walks dependency edges between the packages Kahn's algorithm could not place, until it revisits one, so the error can name a concrete loop rather than just a set of packages. Every unplaced package is unplaced precisely because at least one of its own dependencies is too, so the walk always reaches a repeat.
|
|
607
658
|
*/
|
|
@@ -683,6 +734,8 @@ function matchTrailerLine(message, key) {
|
|
|
683
734
|
}
|
|
684
735
|
//#endregion
|
|
685
736
|
//#region src/plugins.ts
|
|
737
|
+
/** The one plugin a private package is kept off by default: see `resolveWorkspacePublishPlugins` for why a package that never reaches a registry does not get a public GitHub Release either. */
|
|
738
|
+
const GITHUB_RELEASE_PLUGIN = "@semantic-release/github";
|
|
686
739
|
/** semantic-release's own `getLastRelease` returns `{}` for a package with no prior tag -- not `undefined`, and not a fully-populated `LastRelease` -- contradicting the `gitHead: string` its own type declares. Narrows structurally rather than trusting that declared type, so a first-release context's `lastRelease` (correctly, at runtime) never claims a `gitHead` it does not have. */
|
|
687
740
|
function hasGitHead(lastRelease) {
|
|
688
741
|
return typeof lastRelease === "object" && lastRelease !== null && "gitHead" in lastRelease && typeof lastRelease.gitHead === "string";
|
|
@@ -809,13 +862,17 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
|
|
|
809
862
|
/**
|
|
810
863
|
* Resolves the publish plugin list of every package in the workspace: the workspace-wide list for each package, except where `packagePlugins` names a package, whose own list replaces it. Every list is held to the same rules as `resolvePublishPlugins` applies to a workspace-wide one, and a failure in an override names the package it belongs to.
|
|
811
864
|
*
|
|
865
|
+
* A package whose manifest sets `private: true` and which no `packagePlugins` entry names gets the workspace-wide list minus `@semantic-release/github`. A private package is never published, so a public GitHub Release for it advertises something nobody can install, and that release is not merely redundant: `@semantic-release/github` sets `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. A private package sitting at the end of the topological order (which is where a package that depends on the published ones necessarily sits) therefore takes the label on every run. Everything the private package actually needs from the run is untouched: its version bump, its tag, and the dependency cascade to its dependents all come from the other plugins and from the orchestrator itself.
|
|
866
|
+
*
|
|
867
|
+
* A `packagePlugins` entry naming a private package is taken exactly as written, `@semantic-release/github` included, so a workspace that does want a Release for a private package can still say so.
|
|
868
|
+
*
|
|
812
869
|
* A `packagePlugins` key that matches no package is rejected rather than ignored: a misspelt name would otherwise leave the package it meant on the workspace-wide list with nothing to say so, which for the intended use (keeping a package out of a step such as GitHub Release creation) is a silent wrong result.
|
|
813
870
|
*/
|
|
814
|
-
function resolveWorkspacePublishPlugins(
|
|
871
|
+
function resolveWorkspacePublishPlugins(packages, specs, workspaceRoot, options) {
|
|
815
872
|
const overrides = specs.packagePlugins === void 0 ? [] : Object.entries(specs.packagePlugins);
|
|
816
|
-
const known = new Set(
|
|
873
|
+
const known = new Set(packages.map((pkg) => pkg.name));
|
|
817
874
|
const unknown = overrides.map(([name]) => name).filter((name) => !known.has(name));
|
|
818
|
-
if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${
|
|
875
|
+
if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${[...known].join(", ")}.`);
|
|
819
876
|
const workspaceWide = resolvePublishPlugins(specs.plugins, workspaceRoot, options);
|
|
820
877
|
const resolvedOverrides = /* @__PURE__ */ new Map();
|
|
821
878
|
for (const [name, list] of overrides) try {
|
|
@@ -824,7 +881,9 @@ function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, opti
|
|
|
824
881
|
if (cause instanceof ReleaseConfigurationError) throw new ReleaseConfigurationError(`packagePlugins for "${name}": ${cause.message}`);
|
|
825
882
|
throw cause;
|
|
826
883
|
}
|
|
827
|
-
|
|
884
|
+
const privateSpecs = specs.plugins.filter((spec) => parsePublishPluginSpec(spec)[0] !== GITHUB_RELEASE_PLUGIN);
|
|
885
|
+
const forPrivatePackages = privateSpecs.length === specs.plugins.length ? workspaceWide : resolvePublishPlugins(privateSpecs, workspaceRoot, options);
|
|
886
|
+
return new Map(packages.map((pkg) => [pkg.name, resolvedOverrides.get(pkg.name) ?? (pkg.private ? forPrivatePackages : workspaceWide)]));
|
|
828
887
|
}
|
|
829
888
|
/**
|
|
830
889
|
* Resolves a plugin module name to an absolute file path, first from this tool's own module context (its peer dependencies, which every workspace installing the orchestrator must provide) and then from the workspace root (a workspace's own plugin dependencies, such as a custom changelog plugin). Both bases are named in the error when neither can resolve the name.
|
|
@@ -875,6 +934,27 @@ async function regenerateLockfile(options) {
|
|
|
875
934
|
* 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
935
|
*/
|
|
877
936
|
async function releaseWorkspaceSingleCommit(options) {
|
|
937
|
+
const log = options.log ?? console.log;
|
|
938
|
+
const maxAttempts = resolvePushAttempts(options.pushAttempts);
|
|
939
|
+
let lastLoss;
|
|
940
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
941
|
+
const result = await attemptSingleCommitRelease(options);
|
|
942
|
+
if (result.kind === "complete") return result.outcome;
|
|
943
|
+
if (result.kind === "pushed") {
|
|
944
|
+
await result.publish();
|
|
945
|
+
return result.outcome;
|
|
946
|
+
}
|
|
947
|
+
lastLoss = result.error;
|
|
948
|
+
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.`);
|
|
949
|
+
}
|
|
950
|
+
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}`);
|
|
951
|
+
}
|
|
952
|
+
function resolvePushAttempts(configured) {
|
|
953
|
+
if (configured === void 0) return 5;
|
|
954
|
+
if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
|
|
955
|
+
return configured;
|
|
956
|
+
}
|
|
957
|
+
async function attemptSingleCommitRelease(options) {
|
|
878
958
|
const root = resolve(options.root ?? process.cwd());
|
|
879
959
|
const log = options.log ?? console.log;
|
|
880
960
|
const dryRun = options.dryRun === true;
|
|
@@ -886,7 +966,7 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
886
966
|
validateDependencyRanges(graph);
|
|
887
967
|
const order = topologicalOrder(graph);
|
|
888
968
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
|
|
889
|
-
const resolvedPlugins = resolveWorkspacePublishPlugins(order, {
|
|
969
|
+
const resolvedPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
|
|
890
970
|
plugins: options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS,
|
|
891
971
|
packagePlugins: options.packagePlugins
|
|
892
972
|
}, workspace.root, {
|
|
@@ -958,8 +1038,11 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
958
1038
|
}
|
|
959
1039
|
}
|
|
960
1040
|
if (dryRun || planned.length === 0) return {
|
|
961
|
-
|
|
962
|
-
|
|
1041
|
+
kind: "complete",
|
|
1042
|
+
outcome: {
|
|
1043
|
+
order,
|
|
1044
|
+
packages: outcomes
|
|
1045
|
+
}
|
|
963
1046
|
};
|
|
964
1047
|
const branch = captured.branch;
|
|
965
1048
|
const repositoryUrl = captured.repositoryUrl;
|
|
@@ -998,26 +1081,51 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
998
1081
|
const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
|
|
999
1082
|
const tagNames = planned.map((release) => release.gitTag);
|
|
1000
1083
|
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));
|
|
1084
|
+
const branchName = await currentBranch({ cwd: repoRoot });
|
|
1085
|
+
try {
|
|
1086
|
+
await pushHeadAndTags(tagNames, { cwd: repoRoot });
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
if (!(error instanceof GitCommandError)) throw error;
|
|
1089
|
+
let remoteTip;
|
|
1090
|
+
try {
|
|
1091
|
+
remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
|
|
1092
|
+
} catch {
|
|
1093
|
+
throw error;
|
|
1015
1094
|
}
|
|
1016
|
-
|
|
1095
|
+
if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
|
|
1096
|
+
await deleteLocalTags(tagNames, { cwd: repoRoot });
|
|
1097
|
+
await resetHardTo(remoteTip, { cwd: repoRoot });
|
|
1098
|
+
return {
|
|
1099
|
+
kind: "lost",
|
|
1100
|
+
error,
|
|
1101
|
+
branch: branchName,
|
|
1102
|
+
remoteTip
|
|
1103
|
+
};
|
|
1017
1104
|
}
|
|
1105
|
+
log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
|
|
1018
1106
|
return {
|
|
1019
|
-
|
|
1020
|
-
|
|
1107
|
+
kind: "pushed",
|
|
1108
|
+
outcome: {
|
|
1109
|
+
order,
|
|
1110
|
+
packages: outcomes
|
|
1111
|
+
},
|
|
1112
|
+
publish: async () => {
|
|
1113
|
+
for (const release of planned) {
|
|
1114
|
+
const releases = [];
|
|
1115
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1116
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1117
|
+
if (plugin.publish) {
|
|
1118
|
+
const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1119
|
+
if (result !== false && result !== void 0) releases.push(result);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1123
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1124
|
+
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1125
|
+
}
|
|
1126
|
+
log(`${release.pkg.name}: published ${release.gitTag}`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1021
1129
|
};
|
|
1022
1130
|
}
|
|
1023
1131
|
/**
|
|
@@ -1170,7 +1278,7 @@ async function releaseWorkspace(options = {}) {
|
|
|
1170
1278
|
validateDependencyRanges(graph);
|
|
1171
1279
|
const order = topologicalOrder(graph);
|
|
1172
1280
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
|
|
1173
|
-
const publishPlugins = resolveWorkspacePublishPlugins(order, {
|
|
1281
|
+
const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
|
|
1174
1282
|
plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
|
|
1175
1283
|
packagePlugins: options.packagePlugins
|
|
1176
1284
|
}, workspace.root, { requireGitPlugin: !dryRun });
|
|
@@ -1337,7 +1445,7 @@ async function detachWorkspaceRelease(options) {
|
|
|
1337
1445
|
validateDependencyRanges(graph);
|
|
1338
1446
|
const order = topologicalOrder(graph);
|
|
1339
1447
|
log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
|
|
1340
|
-
const publishPlugins = resolveWorkspacePublishPlugins(order, {
|
|
1448
|
+
const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
|
|
1341
1449
|
plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
|
|
1342
1450
|
packagePlugins: options.packagePlugins
|
|
1343
1451
|
}, workspace.root, { requireGitPlugin: !dryRun });
|
package/dist/index.cjs
CHANGED
|
@@ -286,6 +286,53 @@ 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
|
+
/** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
|
|
320
|
+
async function resetHardTo(ref, options) {
|
|
321
|
+
await git([
|
|
322
|
+
"reset",
|
|
323
|
+
"--hard",
|
|
324
|
+
ref
|
|
325
|
+
], options);
|
|
326
|
+
}
|
|
327
|
+
/** 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. */
|
|
328
|
+
async function deleteLocalTags(tagNames, options) {
|
|
329
|
+
if (tagNames.length === 0) return;
|
|
330
|
+
await git([
|
|
331
|
+
"tag",
|
|
332
|
+
"-d",
|
|
333
|
+
...tagNames
|
|
334
|
+
], options);
|
|
335
|
+
}
|
|
289
336
|
function toGitCommandError(args, cwd, cause) {
|
|
290
337
|
const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
|
|
291
338
|
const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
|
|
@@ -603,6 +650,10 @@ function topologicalOrder(graph) {
|
|
|
603
650
|
if (pending.size > 0) throw new DependencyCycleError(findCycle(graph, new Set(pending.keys())));
|
|
604
651
|
return ordered;
|
|
605
652
|
}
|
|
653
|
+
/** The packages a release order names, in that order, for the stages that need each package's manifest rather than only its name. */
|
|
654
|
+
function orderedPackages(graph, order) {
|
|
655
|
+
return order.map((name) => mustGet(graph.packages, name, "package"));
|
|
656
|
+
}
|
|
606
657
|
/**
|
|
607
658
|
* Walks dependency edges between the packages Kahn's algorithm could not place, until it revisits one, so the error can name a concrete loop rather than just a set of packages. Every unplaced package is unplaced precisely because at least one of its own dependencies is too, so the walk always reaches a repeat.
|
|
608
659
|
*/
|
|
@@ -681,6 +732,8 @@ function matchTrailerLine(message, key) {
|
|
|
681
732
|
}
|
|
682
733
|
//#endregion
|
|
683
734
|
//#region src/plugins.ts
|
|
735
|
+
/** The one plugin a private package is kept off by default: see `resolveWorkspacePublishPlugins` for why a package that never reaches a registry does not get a public GitHub Release either. */
|
|
736
|
+
const GITHUB_RELEASE_PLUGIN = "@semantic-release/github";
|
|
684
737
|
/** semantic-release's own `getLastRelease` returns `{}` for a package with no prior tag -- not `undefined`, and not a fully-populated `LastRelease` -- contradicting the `gitHead: string` its own type declares. Narrows structurally rather than trusting that declared type, so a first-release context's `lastRelease` (correctly, at runtime) never claims a `gitHead` it does not have. */
|
|
685
738
|
function hasGitHead(lastRelease) {
|
|
686
739
|
return typeof lastRelease === "object" && lastRelease !== null && "gitHead" in lastRelease && typeof lastRelease.gitHead === "string";
|
|
@@ -807,13 +860,17 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
|
|
|
807
860
|
/**
|
|
808
861
|
* Resolves the publish plugin list of every package in the workspace: the workspace-wide list for each package, except where `packagePlugins` names a package, whose own list replaces it. Every list is held to the same rules as `resolvePublishPlugins` applies to a workspace-wide one, and a failure in an override names the package it belongs to.
|
|
809
862
|
*
|
|
863
|
+
* A package whose manifest sets `private: true` and which no `packagePlugins` entry names gets the workspace-wide list minus `@semantic-release/github`. A private package is never published, so a public GitHub Release for it advertises something nobody can install, and that release is not merely redundant: `@semantic-release/github` sets `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. A private package sitting at the end of the topological order (which is where a package that depends on the published ones necessarily sits) therefore takes the label on every run. Everything the private package actually needs from the run is untouched: its version bump, its tag, and the dependency cascade to its dependents all come from the other plugins and from the orchestrator itself.
|
|
864
|
+
*
|
|
865
|
+
* A `packagePlugins` entry naming a private package is taken exactly as written, `@semantic-release/github` included, so a workspace that does want a Release for a private package can still say so.
|
|
866
|
+
*
|
|
810
867
|
* A `packagePlugins` key that matches no package is rejected rather than ignored: a misspelt name would otherwise leave the package it meant on the workspace-wide list with nothing to say so, which for the intended use (keeping a package out of a step such as GitHub Release creation) is a silent wrong result.
|
|
811
868
|
*/
|
|
812
|
-
function resolveWorkspacePublishPlugins(
|
|
869
|
+
function resolveWorkspacePublishPlugins(packages, specs, workspaceRoot, options) {
|
|
813
870
|
const overrides = specs.packagePlugins === void 0 ? [] : Object.entries(specs.packagePlugins);
|
|
814
|
-
const known = new Set(
|
|
871
|
+
const known = new Set(packages.map((pkg) => pkg.name));
|
|
815
872
|
const unknown = overrides.map(([name]) => name).filter((name) => !known.has(name));
|
|
816
|
-
if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${
|
|
873
|
+
if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${[...known].join(", ")}.`);
|
|
817
874
|
const workspaceWide = resolvePublishPlugins(specs.plugins, workspaceRoot, options);
|
|
818
875
|
const resolvedOverrides = /* @__PURE__ */ new Map();
|
|
819
876
|
for (const [name, list] of overrides) try {
|
|
@@ -822,7 +879,9 @@ function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, opti
|
|
|
822
879
|
if (cause instanceof ReleaseConfigurationError) throw new ReleaseConfigurationError(`packagePlugins for "${name}": ${cause.message}`);
|
|
823
880
|
throw cause;
|
|
824
881
|
}
|
|
825
|
-
|
|
882
|
+
const privateSpecs = specs.plugins.filter((spec) => parsePublishPluginSpec(spec)[0] !== GITHUB_RELEASE_PLUGIN);
|
|
883
|
+
const forPrivatePackages = privateSpecs.length === specs.plugins.length ? workspaceWide : resolvePublishPlugins(privateSpecs, workspaceRoot, options);
|
|
884
|
+
return new Map(packages.map((pkg) => [pkg.name, resolvedOverrides.get(pkg.name) ?? (pkg.private ? forPrivatePackages : workspaceWide)]));
|
|
826
885
|
}
|
|
827
886
|
/**
|
|
828
887
|
* Resolves a plugin module name to an absolute file path, first from this tool's own module context (its peer dependencies, which every workspace installing the orchestrator must provide) and then from the workspace root (a workspace's own plugin dependencies, such as a custom changelog plugin). Both bases are named in the error when neither can resolve the name.
|
|
@@ -888,6 +947,27 @@ function formatTagForPackage(tagFormat, name) {
|
|
|
888
947
|
* 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.
|
|
889
948
|
*/
|
|
890
949
|
async function releaseWorkspaceSingleCommit(options) {
|
|
950
|
+
const log = options.log ?? console.log;
|
|
951
|
+
const maxAttempts = resolvePushAttempts(options.pushAttempts);
|
|
952
|
+
let lastLoss;
|
|
953
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
954
|
+
const result = await attemptSingleCommitRelease(options);
|
|
955
|
+
if (result.kind === "complete") return result.outcome;
|
|
956
|
+
if (result.kind === "pushed") {
|
|
957
|
+
await result.publish();
|
|
958
|
+
return result.outcome;
|
|
959
|
+
}
|
|
960
|
+
lastLoss = result.error;
|
|
961
|
+
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.`);
|
|
962
|
+
}
|
|
963
|
+
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}`);
|
|
964
|
+
}
|
|
965
|
+
function resolvePushAttempts(configured) {
|
|
966
|
+
if (configured === void 0) return 5;
|
|
967
|
+
if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
|
|
968
|
+
return configured;
|
|
969
|
+
}
|
|
970
|
+
async function attemptSingleCommitRelease(options) {
|
|
891
971
|
const root = (0, node_path.resolve)(options.root ?? process.cwd());
|
|
892
972
|
const log = options.log ?? console.log;
|
|
893
973
|
const dryRun = options.dryRun === true;
|
|
@@ -899,7 +979,7 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
899
979
|
validateDependencyRanges(graph);
|
|
900
980
|
const order = topologicalOrder(graph);
|
|
901
981
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
|
|
902
|
-
const resolvedPlugins = resolveWorkspacePublishPlugins(order, {
|
|
982
|
+
const resolvedPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
|
|
903
983
|
plugins: options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS,
|
|
904
984
|
packagePlugins: options.packagePlugins
|
|
905
985
|
}, workspace.root, {
|
|
@@ -971,8 +1051,11 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
971
1051
|
}
|
|
972
1052
|
}
|
|
973
1053
|
if (dryRun || planned.length === 0) return {
|
|
974
|
-
|
|
975
|
-
|
|
1054
|
+
kind: "complete",
|
|
1055
|
+
outcome: {
|
|
1056
|
+
order,
|
|
1057
|
+
packages: outcomes
|
|
1058
|
+
}
|
|
976
1059
|
};
|
|
977
1060
|
const branch = captured.branch;
|
|
978
1061
|
const repositoryUrl = captured.repositoryUrl;
|
|
@@ -1011,26 +1094,51 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
1011
1094
|
const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
|
|
1012
1095
|
const tagNames = planned.map((release) => release.gitTag);
|
|
1013
1096
|
for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
|
|
1014
|
-
await
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
}
|
|
1025
|
-
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1026
|
-
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1027
|
-
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1097
|
+
const branchName = await currentBranch({ cwd: repoRoot });
|
|
1098
|
+
try {
|
|
1099
|
+
await pushHeadAndTags(tagNames, { cwd: repoRoot });
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
if (!(error instanceof GitCommandError)) throw error;
|
|
1102
|
+
let remoteTip;
|
|
1103
|
+
try {
|
|
1104
|
+
remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
|
|
1105
|
+
} catch {
|
|
1106
|
+
throw error;
|
|
1028
1107
|
}
|
|
1029
|
-
|
|
1108
|
+
if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
|
|
1109
|
+
await deleteLocalTags(tagNames, { cwd: repoRoot });
|
|
1110
|
+
await resetHardTo(remoteTip, { cwd: repoRoot });
|
|
1111
|
+
return {
|
|
1112
|
+
kind: "lost",
|
|
1113
|
+
error,
|
|
1114
|
+
branch: branchName,
|
|
1115
|
+
remoteTip
|
|
1116
|
+
};
|
|
1030
1117
|
}
|
|
1118
|
+
log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
|
|
1031
1119
|
return {
|
|
1032
|
-
|
|
1033
|
-
|
|
1120
|
+
kind: "pushed",
|
|
1121
|
+
outcome: {
|
|
1122
|
+
order,
|
|
1123
|
+
packages: outcomes
|
|
1124
|
+
},
|
|
1125
|
+
publish: async () => {
|
|
1126
|
+
for (const release of planned) {
|
|
1127
|
+
const releases = [];
|
|
1128
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1129
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1130
|
+
if (plugin.publish) {
|
|
1131
|
+
const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1132
|
+
if (result !== false && result !== void 0) releases.push(result);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1136
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1137
|
+
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1138
|
+
}
|
|
1139
|
+
log(`${release.pkg.name}: published ${release.gitTag}`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1034
1142
|
};
|
|
1035
1143
|
}
|
|
1036
1144
|
/**
|
|
@@ -1176,7 +1284,7 @@ async function detachWorkspaceRelease(options) {
|
|
|
1176
1284
|
validateDependencyRanges(graph);
|
|
1177
1285
|
const order = topologicalOrder(graph);
|
|
1178
1286
|
log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
|
|
1179
|
-
const publishPlugins = resolveWorkspacePublishPlugins(order, {
|
|
1287
|
+
const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
|
|
1180
1288
|
plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
|
|
1181
1289
|
packagePlugins: options.packagePlugins
|
|
1182
1290
|
}, workspace.root, { requireGitPlugin: !dryRun });
|
|
@@ -1318,7 +1426,7 @@ async function releaseWorkspace(options = {}) {
|
|
|
1318
1426
|
validateDependencyRanges(graph);
|
|
1319
1427
|
const order = topologicalOrder(graph);
|
|
1320
1428
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
|
|
1321
|
-
const publishPlugins = resolveWorkspacePublishPlugins(order, {
|
|
1429
|
+
const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
|
|
1322
1430
|
plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
|
|
1323
1431
|
packagePlugins: options.packagePlugins
|
|
1324
1432
|
}, workspace.root, { requireGitPlugin: !dryRun });
|
|
@@ -1485,6 +1593,7 @@ exports.classifyDependencyRange = classifyDependencyRange;
|
|
|
1485
1593
|
exports.createScopedPlugins = createScopedPlugins;
|
|
1486
1594
|
exports.discoverWorkspace = discoverWorkspace;
|
|
1487
1595
|
exports.filterCommitsToDirectory = filterCommitsToDirectory;
|
|
1596
|
+
exports.orderedPackages = orderedPackages;
|
|
1488
1597
|
exports.packageName = packageName;
|
|
1489
1598
|
exports.readManifest = readManifest;
|
|
1490
1599
|
exports.releaseWorkspace = releaseWorkspace;
|
package/dist/index.d.cts
CHANGED
|
@@ -86,6 +86,8 @@ export declare function buildDependencyGraph(packages: readonly WorkspacePackage
|
|
|
86
86
|
* A cycle has no valid order at all, so it throws rather than picking one of the wrong answers. In a release context an arbitrary order is worse than a failure: it would publish a package whose sibling dependency range points at a version that does not exist yet.
|
|
87
87
|
*/
|
|
88
88
|
export declare function topologicalOrder(graph: DependencyGraph): readonly string[];
|
|
89
|
+
/** The packages a release order names, in that order, for the stages that need each package's manifest rather than only its name. */
|
|
90
|
+
export declare function orderedPackages(graph: DependencyGraph, order: readonly string[]): readonly WorkspacePackage[];
|
|
89
91
|
//#endregion
|
|
90
92
|
//#region src/version-range.d.ts
|
|
91
93
|
/**
|
|
@@ -149,6 +151,11 @@ interface DependencyBumpSource {
|
|
|
149
151
|
type PublishPluginSpec = string | readonly [string] | readonly [string, Record<string, unknown>];
|
|
150
152
|
/** Publish plugin lists keyed by package name. Each list replaces the workspace-wide list outright for that one package; it is not merged with it. */
|
|
151
153
|
type PackagePluginSpecs = Readonly<Record<string, readonly PublishPluginSpec[]>>;
|
|
154
|
+
/** All resolving a package's publish plugins needs to know about it: its name, and whether its manifest marks it private. `WorkspacePackage` satisfies this structurally. */
|
|
155
|
+
interface PublishPluginPackage {
|
|
156
|
+
readonly name: string;
|
|
157
|
+
readonly private: boolean;
|
|
158
|
+
}
|
|
152
159
|
/** The standard publish pipeline this orchestrator coordinates when a workspace configures none of its own. Every entry reuses the corresponding official plugin -- the orchestrator scopes and sequences them per package, it does not reimplement npm publishing, GitHub release creation, or changelog writing. */
|
|
153
160
|
export declare const DEFAULT_PUBLISH_PLUGINS: readonly PublishPluginSpec[];
|
|
154
161
|
/** The standard publish pipeline for `commitStrategy: 'single'`: the same as `DEFAULT_PUBLISH_PLUGINS` minus `@semantic-release/git`, which that mode never runs -- see `resolvePublishPlugins`'s `forbidGitPlugin` option for why it is rejected outright rather than merely unused. Single-commit mode does its own committing (one combined commit for every released package), so a `prepare`-step git plugin here would create the very per-package commits that mode exists to avoid. */
|
|
@@ -193,9 +200,13 @@ export declare function resolvePublishPlugins(specs: readonly PublishPluginSpec[
|
|
|
193
200
|
/**
|
|
194
201
|
* Resolves the publish plugin list of every package in the workspace: the workspace-wide list for each package, except where `packagePlugins` names a package, whose own list replaces it. Every list is held to the same rules as `resolvePublishPlugins` applies to a workspace-wide one, and a failure in an override names the package it belongs to.
|
|
195
202
|
*
|
|
203
|
+
* A package whose manifest sets `private: true` and which no `packagePlugins` entry names gets the workspace-wide list minus `@semantic-release/github`. A private package is never published, so a public GitHub Release for it advertises something nobody can install, and that release is not merely redundant: `@semantic-release/github` sets `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. A private package sitting at the end of the topological order (which is where a package that depends on the published ones necessarily sits) therefore takes the label on every run. Everything the private package actually needs from the run is untouched: its version bump, its tag, and the dependency cascade to its dependents all come from the other plugins and from the orchestrator itself.
|
|
204
|
+
*
|
|
205
|
+
* A `packagePlugins` entry naming a private package is taken exactly as written, `@semantic-release/github` included, so a workspace that does want a Release for a private package can still say so.
|
|
206
|
+
*
|
|
196
207
|
* A `packagePlugins` key that matches no package is rejected rather than ignored: a misspelt name would otherwise leave the package it meant on the workspace-wide list with nothing to say so, which for the intended use (keeping a package out of a step such as GitHub Release creation) is a silent wrong result.
|
|
197
208
|
*/
|
|
198
|
-
export declare function resolveWorkspacePublishPlugins(
|
|
209
|
+
export declare function resolveWorkspacePublishPlugins(packages: readonly PublishPluginPackage[], specs: {
|
|
199
210
|
readonly plugins: readonly PublishPluginSpec[];
|
|
200
211
|
readonly packagePlugins: PackagePluginSpecs | undefined;
|
|
201
212
|
}, workspaceRoot: string, options: {
|
|
@@ -246,7 +257,7 @@ interface ReleaseWorkspaceOptions {
|
|
|
246
257
|
readonly branches?: readonly BranchSpec[];
|
|
247
258
|
/** Publish-pipeline plugins (changelog, npm, GitHub, git), each scoped per package by semantic-release's own `cwd`. Defaults to the standard pipeline in DEFAULT_PUBLISH_PLUGINS for `commitStrategy: 'per-package'`, or SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS (the same list minus `@semantic-release/git`) for `commitStrategy: 'single'`. */
|
|
248
259
|
readonly plugins?: readonly PublishPluginSpec[];
|
|
249
|
-
/** Publish plugin lists for individual packages, keyed by package name, each replacing `plugins` (or its default) outright for that package. Every other package keeps the workspace-wide list
|
|
260
|
+
/** Publish plugin lists for individual packages, keyed by package name, each replacing `plugins` (or its default) outright for that package. Every other package keeps the workspace-wide list, except that a package marked `private` in its manifest drops `@semantic-release/github` from it on its own (see `resolveWorkspacePublishPlugins`), so the usual reason to reach for this is either the reverse of that default, a private package that does want a GitHub Release, or keeping a package out of some other step the rest need. Applies under every `commitStrategy` and to `gatePublish`. A name that is not a package in the workspace is rejected. */
|
|
250
261
|
readonly packagePlugins?: PackagePluginSpecs;
|
|
251
262
|
/** Options for the wrapped `@semantic-release/commit-analyzer`, applied per package after path filtering. */
|
|
252
263
|
readonly analyzeCommits?: Record<string, unknown>;
|
|
@@ -268,6 +279,14 @@ interface ReleaseWorkspaceOptions {
|
|
|
268
279
|
* and pins `...@<action>-v1`. Validated once per run by `validateTagFormat`.
|
|
269
280
|
*/
|
|
270
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;
|
|
271
290
|
}
|
|
272
291
|
/** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
|
|
273
292
|
interface AppliedDependencyBump extends DependencyBump {
|
|
@@ -334,4 +353,4 @@ export declare class GitCommandError extends WorkspaceReleaseError {
|
|
|
334
353
|
/** The workspace's git state does not support the release operation -- for example a detached HEAD, which names no branch that dependency-bump commits could be pushed to. */
|
|
335
354
|
export declare class WorkspaceStateError extends WorkspaceReleaseError {}
|
|
336
355
|
//#endregion
|
|
337
|
-
export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackagePluginSpecs, PackageReleaseOutcome, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
|
|
356
|
+
export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackagePluginSpecs, PackageReleaseOutcome, PublishPluginPackage, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
|
package/dist/index.d.ts
CHANGED
|
@@ -86,6 +86,8 @@ export declare function buildDependencyGraph(packages: readonly WorkspacePackage
|
|
|
86
86
|
* A cycle has no valid order at all, so it throws rather than picking one of the wrong answers. In a release context an arbitrary order is worse than a failure: it would publish a package whose sibling dependency range points at a version that does not exist yet.
|
|
87
87
|
*/
|
|
88
88
|
export declare function topologicalOrder(graph: DependencyGraph): readonly string[];
|
|
89
|
+
/** The packages a release order names, in that order, for the stages that need each package's manifest rather than only its name. */
|
|
90
|
+
export declare function orderedPackages(graph: DependencyGraph, order: readonly string[]): readonly WorkspacePackage[];
|
|
89
91
|
//#endregion
|
|
90
92
|
//#region src/version-range.d.ts
|
|
91
93
|
/**
|
|
@@ -149,6 +151,11 @@ interface DependencyBumpSource {
|
|
|
149
151
|
type PublishPluginSpec = string | readonly [string] | readonly [string, Record<string, unknown>];
|
|
150
152
|
/** Publish plugin lists keyed by package name. Each list replaces the workspace-wide list outright for that one package; it is not merged with it. */
|
|
151
153
|
type PackagePluginSpecs = Readonly<Record<string, readonly PublishPluginSpec[]>>;
|
|
154
|
+
/** All resolving a package's publish plugins needs to know about it: its name, and whether its manifest marks it private. `WorkspacePackage` satisfies this structurally. */
|
|
155
|
+
interface PublishPluginPackage {
|
|
156
|
+
readonly name: string;
|
|
157
|
+
readonly private: boolean;
|
|
158
|
+
}
|
|
152
159
|
/** The standard publish pipeline this orchestrator coordinates when a workspace configures none of its own. Every entry reuses the corresponding official plugin -- the orchestrator scopes and sequences them per package, it does not reimplement npm publishing, GitHub release creation, or changelog writing. */
|
|
153
160
|
export declare const DEFAULT_PUBLISH_PLUGINS: readonly PublishPluginSpec[];
|
|
154
161
|
/** The standard publish pipeline for `commitStrategy: 'single'`: the same as `DEFAULT_PUBLISH_PLUGINS` minus `@semantic-release/git`, which that mode never runs -- see `resolvePublishPlugins`'s `forbidGitPlugin` option for why it is rejected outright rather than merely unused. Single-commit mode does its own committing (one combined commit for every released package), so a `prepare`-step git plugin here would create the very per-package commits that mode exists to avoid. */
|
|
@@ -193,9 +200,13 @@ export declare function resolvePublishPlugins(specs: readonly PublishPluginSpec[
|
|
|
193
200
|
/**
|
|
194
201
|
* Resolves the publish plugin list of every package in the workspace: the workspace-wide list for each package, except where `packagePlugins` names a package, whose own list replaces it. Every list is held to the same rules as `resolvePublishPlugins` applies to a workspace-wide one, and a failure in an override names the package it belongs to.
|
|
195
202
|
*
|
|
203
|
+
* A package whose manifest sets `private: true` and which no `packagePlugins` entry names gets the workspace-wide list minus `@semantic-release/github`. A private package is never published, so a public GitHub Release for it advertises something nobody can install, and that release is not merely redundant: `@semantic-release/github` sets `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. A private package sitting at the end of the topological order (which is where a package that depends on the published ones necessarily sits) therefore takes the label on every run. Everything the private package actually needs from the run is untouched: its version bump, its tag, and the dependency cascade to its dependents all come from the other plugins and from the orchestrator itself.
|
|
204
|
+
*
|
|
205
|
+
* A `packagePlugins` entry naming a private package is taken exactly as written, `@semantic-release/github` included, so a workspace that does want a Release for a private package can still say so.
|
|
206
|
+
*
|
|
196
207
|
* A `packagePlugins` key that matches no package is rejected rather than ignored: a misspelt name would otherwise leave the package it meant on the workspace-wide list with nothing to say so, which for the intended use (keeping a package out of a step such as GitHub Release creation) is a silent wrong result.
|
|
197
208
|
*/
|
|
198
|
-
export declare function resolveWorkspacePublishPlugins(
|
|
209
|
+
export declare function resolveWorkspacePublishPlugins(packages: readonly PublishPluginPackage[], specs: {
|
|
199
210
|
readonly plugins: readonly PublishPluginSpec[];
|
|
200
211
|
readonly packagePlugins: PackagePluginSpecs | undefined;
|
|
201
212
|
}, workspaceRoot: string, options: {
|
|
@@ -246,7 +257,7 @@ interface ReleaseWorkspaceOptions {
|
|
|
246
257
|
readonly branches?: readonly BranchSpec[];
|
|
247
258
|
/** Publish-pipeline plugins (changelog, npm, GitHub, git), each scoped per package by semantic-release's own `cwd`. Defaults to the standard pipeline in DEFAULT_PUBLISH_PLUGINS for `commitStrategy: 'per-package'`, or SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS (the same list minus `@semantic-release/git`) for `commitStrategy: 'single'`. */
|
|
248
259
|
readonly plugins?: readonly PublishPluginSpec[];
|
|
249
|
-
/** Publish plugin lists for individual packages, keyed by package name, each replacing `plugins` (or its default) outright for that package. Every other package keeps the workspace-wide list
|
|
260
|
+
/** Publish plugin lists for individual packages, keyed by package name, each replacing `plugins` (or its default) outright for that package. Every other package keeps the workspace-wide list, except that a package marked `private` in its manifest drops `@semantic-release/github` from it on its own (see `resolveWorkspacePublishPlugins`), so the usual reason to reach for this is either the reverse of that default, a private package that does want a GitHub Release, or keeping a package out of some other step the rest need. Applies under every `commitStrategy` and to `gatePublish`. A name that is not a package in the workspace is rejected. */
|
|
250
261
|
readonly packagePlugins?: PackagePluginSpecs;
|
|
251
262
|
/** Options for the wrapped `@semantic-release/commit-analyzer`, applied per package after path filtering. */
|
|
252
263
|
readonly analyzeCommits?: Record<string, unknown>;
|
|
@@ -268,6 +279,14 @@ interface ReleaseWorkspaceOptions {
|
|
|
268
279
|
* and pins `...@<action>-v1`. Validated once per run by `validateTagFormat`.
|
|
269
280
|
*/
|
|
270
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;
|
|
271
290
|
}
|
|
272
291
|
/** One dependency-range change applied to a dependent package's manifest during the run, attached to the dependent's own outcome. */
|
|
273
292
|
interface AppliedDependencyBump extends DependencyBump {
|
|
@@ -334,4 +353,4 @@ export declare class GitCommandError extends WorkspaceReleaseError {
|
|
|
334
353
|
/** The workspace's git state does not support the release operation -- for example a detached HEAD, which names no branch that dependency-bump commits could be pushed to. */
|
|
335
354
|
export declare class WorkspaceStateError extends WorkspaceReleaseError {}
|
|
336
355
|
//#endregion
|
|
337
|
-
export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackagePluginSpecs, PackageReleaseOutcome, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
|
|
356
|
+
export type { AppliedDependencyBump, CommitStrategy, DependencyBump, DependencyBumpSource, DependencyField, DependencyGraph, DependencyRangeShape, DependencyRangeUpdate, DetachedPackageRelease, PackageManifest, PackagePluginSpecs, PackageReleaseOutcome, PublishPluginPackage, PublishPluginSpec, ReleaseWorkspaceOptions, ResolvedPublishPlugin, ResumeWorkspaceReleaseOptions, ScopedPlugins, Workspace, WorkspaceDependency, WorkspacePackage, WorkspaceReleaseOutcome };
|
package/dist/index.js
CHANGED
|
@@ -261,6 +261,53 @@ 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
|
+
/** Discards every commit and working-tree change the current attempt made, putting the checkout back on exactly `ref`. */
|
|
295
|
+
async function resetHardTo(ref, options) {
|
|
296
|
+
await git([
|
|
297
|
+
"reset",
|
|
298
|
+
"--hard",
|
|
299
|
+
ref
|
|
300
|
+
], options);
|
|
301
|
+
}
|
|
302
|
+
/** 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. */
|
|
303
|
+
async function deleteLocalTags(tagNames, options) {
|
|
304
|
+
if (tagNames.length === 0) return;
|
|
305
|
+
await git([
|
|
306
|
+
"tag",
|
|
307
|
+
"-d",
|
|
308
|
+
...tagNames
|
|
309
|
+
], options);
|
|
310
|
+
}
|
|
264
311
|
function toGitCommandError(args, cwd, cause) {
|
|
265
312
|
const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
|
|
266
313
|
const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
|
|
@@ -578,6 +625,10 @@ function topologicalOrder(graph) {
|
|
|
578
625
|
if (pending.size > 0) throw new DependencyCycleError(findCycle(graph, new Set(pending.keys())));
|
|
579
626
|
return ordered;
|
|
580
627
|
}
|
|
628
|
+
/** The packages a release order names, in that order, for the stages that need each package's manifest rather than only its name. */
|
|
629
|
+
function orderedPackages(graph, order) {
|
|
630
|
+
return order.map((name) => mustGet(graph.packages, name, "package"));
|
|
631
|
+
}
|
|
581
632
|
/**
|
|
582
633
|
* Walks dependency edges between the packages Kahn's algorithm could not place, until it revisits one, so the error can name a concrete loop rather than just a set of packages. Every unplaced package is unplaced precisely because at least one of its own dependencies is too, so the walk always reaches a repeat.
|
|
583
634
|
*/
|
|
@@ -656,6 +707,8 @@ function matchTrailerLine(message, key) {
|
|
|
656
707
|
}
|
|
657
708
|
//#endregion
|
|
658
709
|
//#region src/plugins.ts
|
|
710
|
+
/** The one plugin a private package is kept off by default: see `resolveWorkspacePublishPlugins` for why a package that never reaches a registry does not get a public GitHub Release either. */
|
|
711
|
+
const GITHUB_RELEASE_PLUGIN = "@semantic-release/github";
|
|
659
712
|
/** semantic-release's own `getLastRelease` returns `{}` for a package with no prior tag -- not `undefined`, and not a fully-populated `LastRelease` -- contradicting the `gitHead: string` its own type declares. Narrows structurally rather than trusting that declared type, so a first-release context's `lastRelease` (correctly, at runtime) never claims a `gitHead` it does not have. */
|
|
660
713
|
function hasGitHead(lastRelease) {
|
|
661
714
|
return typeof lastRelease === "object" && lastRelease !== null && "gitHead" in lastRelease && typeof lastRelease.gitHead === "string";
|
|
@@ -782,13 +835,17 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
|
|
|
782
835
|
/**
|
|
783
836
|
* Resolves the publish plugin list of every package in the workspace: the workspace-wide list for each package, except where `packagePlugins` names a package, whose own list replaces it. Every list is held to the same rules as `resolvePublishPlugins` applies to a workspace-wide one, and a failure in an override names the package it belongs to.
|
|
784
837
|
*
|
|
838
|
+
* A package whose manifest sets `private: true` and which no `packagePlugins` entry names gets the workspace-wide list minus `@semantic-release/github`. A private package is never published, so a public GitHub Release for it advertises something nobody can install, and that release is not merely redundant: `@semantic-release/github` sets `make_latest` from the release branch alone, with no option to opt out, so every release it creates claims the repository's Latest label and the last one created keeps it. A private package sitting at the end of the topological order (which is where a package that depends on the published ones necessarily sits) therefore takes the label on every run. Everything the private package actually needs from the run is untouched: its version bump, its tag, and the dependency cascade to its dependents all come from the other plugins and from the orchestrator itself.
|
|
839
|
+
*
|
|
840
|
+
* A `packagePlugins` entry naming a private package is taken exactly as written, `@semantic-release/github` included, so a workspace that does want a Release for a private package can still say so.
|
|
841
|
+
*
|
|
785
842
|
* A `packagePlugins` key that matches no package is rejected rather than ignored: a misspelt name would otherwise leave the package it meant on the workspace-wide list with nothing to say so, which for the intended use (keeping a package out of a step such as GitHub Release creation) is a silent wrong result.
|
|
786
843
|
*/
|
|
787
|
-
function resolveWorkspacePublishPlugins(
|
|
844
|
+
function resolveWorkspacePublishPlugins(packages, specs, workspaceRoot, options) {
|
|
788
845
|
const overrides = specs.packagePlugins === void 0 ? [] : Object.entries(specs.packagePlugins);
|
|
789
|
-
const known = new Set(
|
|
846
|
+
const known = new Set(packages.map((pkg) => pkg.name));
|
|
790
847
|
const unknown = overrides.map(([name]) => name).filter((name) => !known.has(name));
|
|
791
|
-
if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${
|
|
848
|
+
if (unknown.length > 0) throw new ReleaseConfigurationError(`packagePlugins names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${[...known].join(", ")}.`);
|
|
792
849
|
const workspaceWide = resolvePublishPlugins(specs.plugins, workspaceRoot, options);
|
|
793
850
|
const resolvedOverrides = /* @__PURE__ */ new Map();
|
|
794
851
|
for (const [name, list] of overrides) try {
|
|
@@ -797,7 +854,9 @@ function resolveWorkspacePublishPlugins(packageNames, specs, workspaceRoot, opti
|
|
|
797
854
|
if (cause instanceof ReleaseConfigurationError) throw new ReleaseConfigurationError(`packagePlugins for "${name}": ${cause.message}`);
|
|
798
855
|
throw cause;
|
|
799
856
|
}
|
|
800
|
-
|
|
857
|
+
const privateSpecs = specs.plugins.filter((spec) => parsePublishPluginSpec(spec)[0] !== GITHUB_RELEASE_PLUGIN);
|
|
858
|
+
const forPrivatePackages = privateSpecs.length === specs.plugins.length ? workspaceWide : resolvePublishPlugins(privateSpecs, workspaceRoot, options);
|
|
859
|
+
return new Map(packages.map((pkg) => [pkg.name, resolvedOverrides.get(pkg.name) ?? (pkg.private ? forPrivatePackages : workspaceWide)]));
|
|
801
860
|
}
|
|
802
861
|
/**
|
|
803
862
|
* Resolves a plugin module name to an absolute file path, first from this tool's own module context (its peer dependencies, which every workspace installing the orchestrator must provide) and then from the workspace root (a workspace's own plugin dependencies, such as a custom changelog plugin). Both bases are named in the error when neither can resolve the name.
|
|
@@ -863,6 +922,27 @@ function formatTagForPackage(tagFormat, name) {
|
|
|
863
922
|
* 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.
|
|
864
923
|
*/
|
|
865
924
|
async function releaseWorkspaceSingleCommit(options) {
|
|
925
|
+
const log = options.log ?? console.log;
|
|
926
|
+
const maxAttempts = resolvePushAttempts(options.pushAttempts);
|
|
927
|
+
let lastLoss;
|
|
928
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
929
|
+
const result = await attemptSingleCommitRelease(options);
|
|
930
|
+
if (result.kind === "complete") return result.outcome;
|
|
931
|
+
if (result.kind === "pushed") {
|
|
932
|
+
await result.publish();
|
|
933
|
+
return result.outcome;
|
|
934
|
+
}
|
|
935
|
+
lastLoss = result.error;
|
|
936
|
+
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.`);
|
|
937
|
+
}
|
|
938
|
+
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}`);
|
|
939
|
+
}
|
|
940
|
+
function resolvePushAttempts(configured) {
|
|
941
|
+
if (configured === void 0) return 5;
|
|
942
|
+
if (!Number.isInteger(configured) || configured < 1) throw new ReleaseConfigurationError(`"pushAttempts" must be a positive integer; received ${String(configured)}.`);
|
|
943
|
+
return configured;
|
|
944
|
+
}
|
|
945
|
+
async function attemptSingleCommitRelease(options) {
|
|
866
946
|
const root = resolve(options.root ?? process.cwd());
|
|
867
947
|
const log = options.log ?? console.log;
|
|
868
948
|
const dryRun = options.dryRun === true;
|
|
@@ -874,7 +954,7 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
874
954
|
validateDependencyRanges(graph);
|
|
875
955
|
const order = topologicalOrder(graph);
|
|
876
956
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
|
|
877
|
-
const resolvedPlugins = resolveWorkspacePublishPlugins(order, {
|
|
957
|
+
const resolvedPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
|
|
878
958
|
plugins: options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS,
|
|
879
959
|
packagePlugins: options.packagePlugins
|
|
880
960
|
}, workspace.root, {
|
|
@@ -946,8 +1026,11 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
946
1026
|
}
|
|
947
1027
|
}
|
|
948
1028
|
if (dryRun || planned.length === 0) return {
|
|
949
|
-
|
|
950
|
-
|
|
1029
|
+
kind: "complete",
|
|
1030
|
+
outcome: {
|
|
1031
|
+
order,
|
|
1032
|
+
packages: outcomes
|
|
1033
|
+
}
|
|
951
1034
|
};
|
|
952
1035
|
const branch = captured.branch;
|
|
953
1036
|
const repositoryUrl = captured.repositoryUrl;
|
|
@@ -986,26 +1069,51 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
986
1069
|
const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
|
|
987
1070
|
const tagNames = planned.map((release) => release.gitTag);
|
|
988
1071
|
for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
|
|
989
|
-
await
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
}
|
|
1000
|
-
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1001
|
-
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1002
|
-
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1072
|
+
const branchName = await currentBranch({ cwd: repoRoot });
|
|
1073
|
+
try {
|
|
1074
|
+
await pushHeadAndTags(tagNames, { cwd: repoRoot });
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
if (!(error instanceof GitCommandError)) throw error;
|
|
1077
|
+
let remoteTip;
|
|
1078
|
+
try {
|
|
1079
|
+
remoteTip = await fetchBranchTip(branchName, { cwd: repoRoot });
|
|
1080
|
+
} catch {
|
|
1081
|
+
throw error;
|
|
1003
1082
|
}
|
|
1004
|
-
|
|
1083
|
+
if (await isAncestor(remoteTip, "HEAD", { cwd: repoRoot })) throw error;
|
|
1084
|
+
await deleteLocalTags(tagNames, { cwd: repoRoot });
|
|
1085
|
+
await resetHardTo(remoteTip, { cwd: repoRoot });
|
|
1086
|
+
return {
|
|
1087
|
+
kind: "lost",
|
|
1088
|
+
error,
|
|
1089
|
+
branch: branchName,
|
|
1090
|
+
remoteTip
|
|
1091
|
+
};
|
|
1005
1092
|
}
|
|
1093
|
+
log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
|
|
1006
1094
|
return {
|
|
1007
|
-
|
|
1008
|
-
|
|
1095
|
+
kind: "pushed",
|
|
1096
|
+
outcome: {
|
|
1097
|
+
order,
|
|
1098
|
+
packages: outcomes
|
|
1099
|
+
},
|
|
1100
|
+
publish: async () => {
|
|
1101
|
+
for (const release of planned) {
|
|
1102
|
+
const releases = [];
|
|
1103
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1104
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1105
|
+
if (plugin.publish) {
|
|
1106
|
+
const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1107
|
+
if (result !== false && result !== void 0) releases.push(result);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
for (const [modulePath, pluginConfig] of mustGet(resolvedPlugins, release.pkg.name, "publish plugins")) {
|
|
1111
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
1112
|
+
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
1113
|
+
}
|
|
1114
|
+
log(`${release.pkg.name}: published ${release.gitTag}`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1009
1117
|
};
|
|
1010
1118
|
}
|
|
1011
1119
|
/**
|
|
@@ -1151,7 +1259,7 @@ async function detachWorkspaceRelease(options) {
|
|
|
1151
1259
|
validateDependencyRanges(graph);
|
|
1152
1260
|
const order = topologicalOrder(graph);
|
|
1153
1261
|
log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
|
|
1154
|
-
const publishPlugins = resolveWorkspacePublishPlugins(order, {
|
|
1262
|
+
const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
|
|
1155
1263
|
plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
|
|
1156
1264
|
packagePlugins: options.packagePlugins
|
|
1157
1265
|
}, workspace.root, { requireGitPlugin: !dryRun });
|
|
@@ -1293,7 +1401,7 @@ async function releaseWorkspace(options = {}) {
|
|
|
1293
1401
|
validateDependencyRanges(graph);
|
|
1294
1402
|
const order = topologicalOrder(graph);
|
|
1295
1403
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
|
|
1296
|
-
const publishPlugins = resolveWorkspacePublishPlugins(order, {
|
|
1404
|
+
const publishPlugins = resolveWorkspacePublishPlugins(orderedPackages(graph, order), {
|
|
1297
1405
|
plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
|
|
1298
1406
|
packagePlugins: options.packagePlugins
|
|
1299
1407
|
}, workspace.root, { requireGitPlugin: !dryRun });
|
|
@@ -1446,4 +1554,4 @@ async function bumpDependents(released, version, graph, options) {
|
|
|
1446
1554
|
return applied;
|
|
1447
1555
|
}
|
|
1448
1556
|
//#endregion
|
|
1449
|
-
export { DEFAULT_PUBLISH_PLUGINS, DependencyCycleError, GitCommandError, ReleaseConfigurationError, SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, UnsupportedDependencyRangeError, WorkspaceDiscoveryError, WorkspaceReleaseError, WorkspaceStateError, buildDependencyGraph, classifyDependencyRange, createScopedPlugins, discoverWorkspace, filterCommitsToDirectory, packageName, readManifest, releaseWorkspace, resolvePublishPlugins, resolveWorkspacePublishPlugins, resumeWorkspaceRelease, topologicalOrder, updateDependencyRange, writeDependencyRange };
|
|
1557
|
+
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