@exadev/semantic-release-workspace 3.1.29 → 3.2.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 +28 -2
- package/dist/cli.js +334 -210
- package/dist/index.cjs +140 -21
- package/dist/index.d.cts +46 -3
- package/dist/index.d.ts +46 -3
- package/dist/index.js +140 -22
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,16 +5,16 @@ import { cosmiconfig } from "cosmiconfig";
|
|
|
5
5
|
import { Command, InvalidArgumentError } from "commander";
|
|
6
6
|
import { dirname, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { detachRelease, resumeRelease } from "@exadev/release-gate";
|
|
8
|
-
import { execFile } from "node:child_process";
|
|
9
8
|
import validateNpmPackageName from "validate-npm-package-name";
|
|
10
9
|
import { glob } from "tinyglobby";
|
|
11
10
|
import { parse } from "yaml";
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
12
|
import { analyzeCommits } from "@semantic-release/commit-analyzer";
|
|
13
13
|
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.1
|
|
17
|
+
var version = "3.2.1";
|
|
18
18
|
//#endregion
|
|
19
19
|
//#region src/errors.ts
|
|
20
20
|
/**
|
|
@@ -75,6 +75,187 @@ function formatTagForPackage(tagFormat, name) {
|
|
|
75
75
|
return tagFormat.replaceAll("${name}", name);
|
|
76
76
|
}
|
|
77
77
|
//#endregion
|
|
78
|
+
//#region src/json.ts
|
|
79
|
+
function isJsonObject(value) {
|
|
80
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
81
|
+
}
|
|
82
|
+
function isStringRecord(value) {
|
|
83
|
+
return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
84
|
+
}
|
|
85
|
+
function isStringArray(value) {
|
|
86
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Narrows `unknown` to `unknown[]`. `Array.isArray` alone narrows to `any[]`, whose elements flow as `any` into any later destructuring; going through this guard keeps the elements `unknown` so they must still be narrowed by hand.
|
|
90
|
+
*/
|
|
91
|
+
function isUnknownArray(value) {
|
|
92
|
+
return Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The indentation of the first indented line in a JSON document, so a rewritten manifest keeps the formatting the repository already uses instead of being reflowed to whatever `JSON.stringify` defaults to. A file with no indented line at all (`{}` on one line) has no evidence either way, in which case two spaces -- npm's own default when it writes a `package.json` -- is the closest thing to a neutral choice.
|
|
96
|
+
*/
|
|
97
|
+
const FIRST_INDENTED_LINE = /^[ \t]+(?=")/m;
|
|
98
|
+
const NPM_DEFAULT_INDENT = " ";
|
|
99
|
+
function detectIndent(text) {
|
|
100
|
+
const match = FIRST_INDENTED_LINE.exec(text);
|
|
101
|
+
return match === null ? NPM_DEFAULT_INDENT : match[0];
|
|
102
|
+
}
|
|
103
|
+
/** Serialises a JSON document back to text with the indentation and trailing-newline convention of the text it was read from, so rewriting one dependency range produces a one-line diff rather than a whole-file reformat. */
|
|
104
|
+
function stringifyJsonLike(value, originalText) {
|
|
105
|
+
const serialised = JSON.stringify(value, null, detectIndent(originalText));
|
|
106
|
+
return originalText.endsWith("\n") ? `${serialised}\n` : serialised;
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/manifest.ts
|
|
110
|
+
const DEPENDENCY_FIELDS = [
|
|
111
|
+
"dependencies",
|
|
112
|
+
"devDependencies",
|
|
113
|
+
"peerDependencies",
|
|
114
|
+
"optionalDependencies"
|
|
115
|
+
];
|
|
116
|
+
/** The fields whose entries a consumer's package manager resolves when it installs the published package. `devDependencies` is absent: it is published, but nothing ever installs it for a consumer. */
|
|
117
|
+
const INSTALLED_DEPENDENCY_FIELDS = [
|
|
118
|
+
"dependencies",
|
|
119
|
+
"peerDependencies",
|
|
120
|
+
"optionalDependencies"
|
|
121
|
+
];
|
|
122
|
+
async function readManifest(path) {
|
|
123
|
+
const text = await readFile(path, "utf8");
|
|
124
|
+
const parsed = JSON.parse(text);
|
|
125
|
+
if (!isJsonObject(parsed)) throw new WorkspaceDiscoveryError(`${path} does not contain a JSON object.`);
|
|
126
|
+
const { name, version } = parsed;
|
|
127
|
+
if (typeof name !== "string" || name.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "name". Every workspace package needs a name: releases are ordered, tagged, and matched to dependents by it.`);
|
|
128
|
+
const validity = validateNpmPackageName(name);
|
|
129
|
+
if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${validity.errors.join("; ")}`);
|
|
130
|
+
if (typeof version !== "string" || version.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "version".`);
|
|
131
|
+
const dependencies = /* @__PURE__ */ new Map();
|
|
132
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
133
|
+
const declared = parsed[field];
|
|
134
|
+
if (declared === void 0) continue;
|
|
135
|
+
if (!isStringRecord(declared)) throw new WorkspaceDiscoveryError(`${path} has a "${field}" field that is not an object of name-to-range strings.`);
|
|
136
|
+
dependencies.set(field, new Map(Object.entries(declared)));
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
name,
|
|
140
|
+
version,
|
|
141
|
+
private: parsed.private === true,
|
|
142
|
+
dependencies
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Rewrites one dependency range in a manifest on disk.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately re-reads the file rather than editing a copy held from discovery time: by the time a cross-package bump is applied, semantic-release's own `@semantic-release/npm` prepare step may already have rewritten `version` in this same file for an earlier package in the run. Writing back a manifest parsed before that would silently revert it.
|
|
149
|
+
*/
|
|
150
|
+
async function writeDependencyRange(path, field, dependency, range) {
|
|
151
|
+
const text = await readFile(path, "utf8");
|
|
152
|
+
const parsed = JSON.parse(text);
|
|
153
|
+
if (!isJsonObject(parsed)) throw new WorkspaceDiscoveryError(`${path} does not contain a JSON object.`);
|
|
154
|
+
const declared = parsed[field];
|
|
155
|
+
if (!isStringRecord(declared)) throw new WorkspaceDiscoveryError(`${path} no longer has a "${field}" object holding "${dependency}".`);
|
|
156
|
+
declared[dependency] = range;
|
|
157
|
+
await writeFile(path, stringifyJsonLike(parsed, text), "utf8");
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/version-range.ts
|
|
161
|
+
const WORKSPACE_PROTOCOL = "workspace:";
|
|
162
|
+
const CATALOG_PROTOCOL = "catalog:";
|
|
163
|
+
const NPM_ALIAS_PROTOCOL = "npm:";
|
|
164
|
+
/** Specifier protocols that only a workspace-aware package manager resolves locally. `npm publish` copies them into the registry as written, so a published package declaring one in an installed dependency field cannot be installed. */
|
|
165
|
+
const UNPUBLISHABLE_SPECIFIER_PROTOCOLS = [
|
|
166
|
+
WORKSPACE_PROTOCOL,
|
|
167
|
+
CATALOG_PROTOCOL,
|
|
168
|
+
"link:",
|
|
169
|
+
"file:"
|
|
170
|
+
];
|
|
171
|
+
/** The `workspace:` suffixes that name no version, and so leave nothing in the manifest to rewrite. */
|
|
172
|
+
const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
|
|
173
|
+
"*",
|
|
174
|
+
"^",
|
|
175
|
+
"~"
|
|
176
|
+
];
|
|
177
|
+
/** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
|
|
178
|
+
const WILDCARD_RANGES = [
|
|
179
|
+
"",
|
|
180
|
+
"*",
|
|
181
|
+
"x",
|
|
182
|
+
"X",
|
|
183
|
+
"latest"
|
|
184
|
+
];
|
|
185
|
+
/**
|
|
186
|
+
* The protocol of a specifier that `npm publish` would ship verbatim while no consumer's package manager can resolve it (`workspace:`, `catalog:`, `link:`, `file:`), or `undefined` for a specifier a consumer can install.
|
|
187
|
+
*/
|
|
188
|
+
function unpublishableSpecifierProtocol(specifier) {
|
|
189
|
+
const trimmed = specifier.trim();
|
|
190
|
+
return UNPUBLISHABLE_SPECIFIER_PROTOCOLS.find((protocol) => trimmed.startsWith(protocol));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* A single comparator whose version can be replaced in place without changing the comparator's intent. `<` and `<=` are deliberately absent: rewriting `<2.0.0` to `<1.4.0` narrows an upper bound to the very version being released, which is never what the author meant, so such a range is rejected rather than mangled.
|
|
194
|
+
*/
|
|
195
|
+
const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
|
|
196
|
+
/**
|
|
197
|
+
* Classifies a dependency range's shape, throwing `UnsupportedDependencyRangeError` for anything this tool cannot rewrite with confidence: a compound range (`>=1.0.0 <2.0.0`), a union (`1.x || 2.x`), a `catalog:` reference whose real version lives in `pnpm-workspace.yaml`, an `npm:` alias, a git or tarball URL. Guessing at those would either corrupt the range or silently leave it pointing at a version that no longer exists in the workspace, and a stale published range is exactly the divergence this tool exists to prevent.
|
|
198
|
+
*
|
|
199
|
+
* This never needs the version a sibling is releasing: every case above depends only on the shape of `current` itself, which is what lets `releaseWorkspace` validate every workspace dependency edge up front, before any package has published anything, rather than discovering an unsupported range only when the first dependency it names happens to release.
|
|
200
|
+
*/
|
|
201
|
+
function classifyDependencyRange(current) {
|
|
202
|
+
const range = current.trim();
|
|
203
|
+
if (range.startsWith(WORKSPACE_PROTOCOL)) {
|
|
204
|
+
const suffix = range.slice(10);
|
|
205
|
+
if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
|
|
206
|
+
const inner = classifyDependencyRange(suffix);
|
|
207
|
+
if (inner.kind !== "rewritable") throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only "workspace:*", "workspace:^", "workspace:~", and "workspace:" followed by a single concrete version range are supported.`);
|
|
208
|
+
return {
|
|
209
|
+
kind: "rewritable",
|
|
210
|
+
workspacePrefixed: true,
|
|
211
|
+
comparator: inner.comparator
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (range.startsWith(CATALOG_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": the version of a "catalog:" dependency lives in pnpm-workspace.yaml, not in the package manifest, so bumping it here would leave the catalog entry stale. Declare the sibling's version range directly (for example "^1.0.0") instead.`);
|
|
215
|
+
if (range.startsWith(NPM_ALIAS_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": an "npm:" alias points at a differently-named package, so the version released in this workspace is not necessarily the version this range refers to.`);
|
|
216
|
+
if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
|
|
217
|
+
const match = REWRITABLE_COMPARATOR.exec(range);
|
|
218
|
+
if (match === null) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only a single "^", "~", ">=", "=", or bare version comparator can be rewritten in place.`);
|
|
219
|
+
return {
|
|
220
|
+
kind: "rewritable",
|
|
221
|
+
workspacePrefixed: false,
|
|
222
|
+
comparator: match[1] ?? ""
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Computes what a dependency range on a workspace sibling becomes once that sibling releases `version`, by classifying the range's shape and then, for a rewritable shape, substituting `version` in place of the version it currently names.
|
|
227
|
+
*/
|
|
228
|
+
function updateDependencyRange(current, version) {
|
|
229
|
+
const shape = classifyDependencyRange(current);
|
|
230
|
+
if (shape.kind !== "rewritable") return shape;
|
|
231
|
+
const rewritten = `${shape.comparator}${version}`;
|
|
232
|
+
return {
|
|
233
|
+
kind: "rewritten",
|
|
234
|
+
range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/publishable-dependencies.ts
|
|
239
|
+
/**
|
|
240
|
+
* Throws `UnsupportedDependencyRangeError` unless every publishable package's installed dependencies (`dependencies`, `peerDependencies`, `optionalDependencies`) are ranges a consumer's package manager can resolve.
|
|
241
|
+
*
|
|
242
|
+
* Publishing goes through `@semantic-release/npm`, that is plain `npm publish`, which copies the manifest's specifiers into the registry unchanged. A `workspace:`, `catalog:`, `link:`, or `file:` specifier therefore reaches consumers as written and makes the package uninstallable, whichever sibling or external package it names and whether or not the tool would have rewritten it. `pnpm publish` would substitute some of them at pack time, but this tool never packs with pnpm, so accepting them would mean publishing a broken package with no warning.
|
|
243
|
+
*
|
|
244
|
+
* A `private` package never publishes and is exempt, and so is `devDependencies`, which no consumer installs. Every offending entry across every package is reported in one error, so a workspace with several of them is fixed in one pass rather than one failed run per entry.
|
|
245
|
+
*/
|
|
246
|
+
function assertPublishableDependencies(packages) {
|
|
247
|
+
const offences = [];
|
|
248
|
+
for (const pkg of packages) {
|
|
249
|
+
if (pkg.private) continue;
|
|
250
|
+
for (const field of INSTALLED_DEPENDENCY_FIELDS) {
|
|
251
|
+
const declared = pkg.dependencies.get(field);
|
|
252
|
+
if (declared === void 0) continue;
|
|
253
|
+
for (const [dependency, specifier] of declared) if (unpublishableSpecifierProtocol(specifier) !== void 0) offences.push(` ${pkg.name}: "${dependency}" in ${field} is declared as "${specifier}"`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (offences.length > 0) throw new UnsupportedDependencyRangeError(`Refusing to release: npm publish ships these dependency specifiers unchanged, so the published package could not be installed.\n${offences.join("\n")}\nDeclare a plain version range instead (for example "^1.2.3"). For a sibling in this workspace, keep "linkWorkspacePackages: true" in pnpm-workspace.yaml so pnpm still links it locally; this tool rewrites the range whenever the sibling releases. Packages marked "private" are never published and are exempt.`);
|
|
257
|
+
}
|
|
258
|
+
//#endregion
|
|
78
259
|
//#region src/exec-file.ts
|
|
79
260
|
/**
|
|
80
261
|
* A hand-written promise wrapper around `child_process.execFile`, in place of `util.promisify(execFile)`: `execFile` synchronously returns a `ChildProcess` in addition to invoking its callback, which trips `@typescript-eslint/strict-void-return` when the whole function is handed to `promisify` (a value-returning function used where a void-returning one is contextually expected there) -- exactly the void-return contravariance leniency that rule exists to catch, even though `tsc` itself accepts the pattern. Calling `execFile` directly with our own callback, whose own return type really is `void`, sidesteps the mismatch instead of suppressing it.
|
|
@@ -340,187 +521,6 @@ function toGitCommandError(args, cwd, cause) {
|
|
|
340
521
|
return new GitCommandError(args, cwd, exitCode, stderr !== "" ? stderr : cause instanceof Error ? cause.message : String(cause));
|
|
341
522
|
}
|
|
342
523
|
//#endregion
|
|
343
|
-
//#region src/json.ts
|
|
344
|
-
function isJsonObject(value) {
|
|
345
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
346
|
-
}
|
|
347
|
-
function isStringRecord(value) {
|
|
348
|
-
return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
349
|
-
}
|
|
350
|
-
function isStringArray(value) {
|
|
351
|
-
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
352
|
-
}
|
|
353
|
-
/**
|
|
354
|
-
* Narrows `unknown` to `unknown[]`. `Array.isArray` alone narrows to `any[]`, whose elements flow as `any` into any later destructuring; going through this guard keeps the elements `unknown` so they must still be narrowed by hand.
|
|
355
|
-
*/
|
|
356
|
-
function isUnknownArray(value) {
|
|
357
|
-
return Array.isArray(value);
|
|
358
|
-
}
|
|
359
|
-
/**
|
|
360
|
-
* The indentation of the first indented line in a JSON document, so a rewritten manifest keeps the formatting the repository already uses instead of being reflowed to whatever `JSON.stringify` defaults to. A file with no indented line at all (`{}` on one line) has no evidence either way, in which case two spaces -- npm's own default when it writes a `package.json` -- is the closest thing to a neutral choice.
|
|
361
|
-
*/
|
|
362
|
-
const FIRST_INDENTED_LINE = /^[ \t]+(?=")/m;
|
|
363
|
-
const NPM_DEFAULT_INDENT = " ";
|
|
364
|
-
function detectIndent(text) {
|
|
365
|
-
const match = FIRST_INDENTED_LINE.exec(text);
|
|
366
|
-
return match === null ? NPM_DEFAULT_INDENT : match[0];
|
|
367
|
-
}
|
|
368
|
-
/** Serialises a JSON document back to text with the indentation and trailing-newline convention of the text it was read from, so rewriting one dependency range produces a one-line diff rather than a whole-file reformat. */
|
|
369
|
-
function stringifyJsonLike(value, originalText) {
|
|
370
|
-
const serialised = JSON.stringify(value, null, detectIndent(originalText));
|
|
371
|
-
return originalText.endsWith("\n") ? `${serialised}\n` : serialised;
|
|
372
|
-
}
|
|
373
|
-
//#endregion
|
|
374
|
-
//#region src/manifest.ts
|
|
375
|
-
const DEPENDENCY_FIELDS = [
|
|
376
|
-
"dependencies",
|
|
377
|
-
"devDependencies",
|
|
378
|
-
"peerDependencies",
|
|
379
|
-
"optionalDependencies"
|
|
380
|
-
];
|
|
381
|
-
/** The fields whose entries a consumer's package manager resolves when it installs the published package. `devDependencies` is absent: it is published, but nothing ever installs it for a consumer. */
|
|
382
|
-
const INSTALLED_DEPENDENCY_FIELDS = [
|
|
383
|
-
"dependencies",
|
|
384
|
-
"peerDependencies",
|
|
385
|
-
"optionalDependencies"
|
|
386
|
-
];
|
|
387
|
-
async function readManifest(path) {
|
|
388
|
-
const text = await readFile(path, "utf8");
|
|
389
|
-
const parsed = JSON.parse(text);
|
|
390
|
-
if (!isJsonObject(parsed)) throw new WorkspaceDiscoveryError(`${path} does not contain a JSON object.`);
|
|
391
|
-
const { name, version } = parsed;
|
|
392
|
-
if (typeof name !== "string" || name.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "name". Every workspace package needs a name: releases are ordered, tagged, and matched to dependents by it.`);
|
|
393
|
-
const validity = validateNpmPackageName(name);
|
|
394
|
-
if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${validity.errors.join("; ")}`);
|
|
395
|
-
if (typeof version !== "string" || version.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "version".`);
|
|
396
|
-
const dependencies = /* @__PURE__ */ new Map();
|
|
397
|
-
for (const field of DEPENDENCY_FIELDS) {
|
|
398
|
-
const declared = parsed[field];
|
|
399
|
-
if (declared === void 0) continue;
|
|
400
|
-
if (!isStringRecord(declared)) throw new WorkspaceDiscoveryError(`${path} has a "${field}" field that is not an object of name-to-range strings.`);
|
|
401
|
-
dependencies.set(field, new Map(Object.entries(declared)));
|
|
402
|
-
}
|
|
403
|
-
return {
|
|
404
|
-
name,
|
|
405
|
-
version,
|
|
406
|
-
private: parsed.private === true,
|
|
407
|
-
dependencies
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
/**
|
|
411
|
-
* Rewrites one dependency range in a manifest on disk.
|
|
412
|
-
*
|
|
413
|
-
* Deliberately re-reads the file rather than editing a copy held from discovery time: by the time a cross-package bump is applied, semantic-release's own `@semantic-release/npm` prepare step may already have rewritten `version` in this same file for an earlier package in the run. Writing back a manifest parsed before that would silently revert it.
|
|
414
|
-
*/
|
|
415
|
-
async function writeDependencyRange(path, field, dependency, range) {
|
|
416
|
-
const text = await readFile(path, "utf8");
|
|
417
|
-
const parsed = JSON.parse(text);
|
|
418
|
-
if (!isJsonObject(parsed)) throw new WorkspaceDiscoveryError(`${path} does not contain a JSON object.`);
|
|
419
|
-
const declared = parsed[field];
|
|
420
|
-
if (!isStringRecord(declared)) throw new WorkspaceDiscoveryError(`${path} no longer has a "${field}" object holding "${dependency}".`);
|
|
421
|
-
declared[dependency] = range;
|
|
422
|
-
await writeFile(path, stringifyJsonLike(parsed, text), "utf8");
|
|
423
|
-
}
|
|
424
|
-
//#endregion
|
|
425
|
-
//#region src/version-range.ts
|
|
426
|
-
const WORKSPACE_PROTOCOL = "workspace:";
|
|
427
|
-
const CATALOG_PROTOCOL = "catalog:";
|
|
428
|
-
const NPM_ALIAS_PROTOCOL = "npm:";
|
|
429
|
-
/** Specifier protocols that only a workspace-aware package manager resolves locally. `npm publish` copies them into the registry as written, so a published package declaring one in an installed dependency field cannot be installed. */
|
|
430
|
-
const UNPUBLISHABLE_SPECIFIER_PROTOCOLS = [
|
|
431
|
-
WORKSPACE_PROTOCOL,
|
|
432
|
-
CATALOG_PROTOCOL,
|
|
433
|
-
"link:",
|
|
434
|
-
"file:"
|
|
435
|
-
];
|
|
436
|
-
/** The `workspace:` suffixes that name no version, and so leave nothing in the manifest to rewrite. */
|
|
437
|
-
const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
|
|
438
|
-
"*",
|
|
439
|
-
"^",
|
|
440
|
-
"~"
|
|
441
|
-
];
|
|
442
|
-
/** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
|
|
443
|
-
const WILDCARD_RANGES = [
|
|
444
|
-
"",
|
|
445
|
-
"*",
|
|
446
|
-
"x",
|
|
447
|
-
"X",
|
|
448
|
-
"latest"
|
|
449
|
-
];
|
|
450
|
-
/**
|
|
451
|
-
* The protocol of a specifier that `npm publish` would ship verbatim while no consumer's package manager can resolve it (`workspace:`, `catalog:`, `link:`, `file:`), or `undefined` for a specifier a consumer can install.
|
|
452
|
-
*/
|
|
453
|
-
function unpublishableSpecifierProtocol(specifier) {
|
|
454
|
-
const trimmed = specifier.trim();
|
|
455
|
-
return UNPUBLISHABLE_SPECIFIER_PROTOCOLS.find((protocol) => trimmed.startsWith(protocol));
|
|
456
|
-
}
|
|
457
|
-
/**
|
|
458
|
-
* A single comparator whose version can be replaced in place without changing the comparator's intent. `<` and `<=` are deliberately absent: rewriting `<2.0.0` to `<1.4.0` narrows an upper bound to the very version being released, which is never what the author meant, so such a range is rejected rather than mangled.
|
|
459
|
-
*/
|
|
460
|
-
const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
|
|
461
|
-
/**
|
|
462
|
-
* Classifies a dependency range's shape, throwing `UnsupportedDependencyRangeError` for anything this tool cannot rewrite with confidence: a compound range (`>=1.0.0 <2.0.0`), a union (`1.x || 2.x`), a `catalog:` reference whose real version lives in `pnpm-workspace.yaml`, an `npm:` alias, a git or tarball URL. Guessing at those would either corrupt the range or silently leave it pointing at a version that no longer exists in the workspace, and a stale published range is exactly the divergence this tool exists to prevent.
|
|
463
|
-
*
|
|
464
|
-
* This never needs the version a sibling is releasing: every case above depends only on the shape of `current` itself, which is what lets `releaseWorkspace` validate every workspace dependency edge up front, before any package has published anything, rather than discovering an unsupported range only when the first dependency it names happens to release.
|
|
465
|
-
*/
|
|
466
|
-
function classifyDependencyRange(current) {
|
|
467
|
-
const range = current.trim();
|
|
468
|
-
if (range.startsWith(WORKSPACE_PROTOCOL)) {
|
|
469
|
-
const suffix = range.slice(10);
|
|
470
|
-
if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
|
|
471
|
-
const inner = classifyDependencyRange(suffix);
|
|
472
|
-
if (inner.kind !== "rewritable") throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only "workspace:*", "workspace:^", "workspace:~", and "workspace:" followed by a single concrete version range are supported.`);
|
|
473
|
-
return {
|
|
474
|
-
kind: "rewritable",
|
|
475
|
-
workspacePrefixed: true,
|
|
476
|
-
comparator: inner.comparator
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
if (range.startsWith(CATALOG_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": the version of a "catalog:" dependency lives in pnpm-workspace.yaml, not in the package manifest, so bumping it here would leave the catalog entry stale. Declare the sibling's version range directly (for example "^1.0.0") instead.`);
|
|
480
|
-
if (range.startsWith(NPM_ALIAS_PROTOCOL)) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": an "npm:" alias points at a differently-named package, so the version released in this workspace is not necessarily the version this range refers to.`);
|
|
481
|
-
if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
|
|
482
|
-
const match = REWRITABLE_COMPARATOR.exec(range);
|
|
483
|
-
if (match === null) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only a single "^", "~", ">=", "=", or bare version comparator can be rewritten in place.`);
|
|
484
|
-
return {
|
|
485
|
-
kind: "rewritable",
|
|
486
|
-
workspacePrefixed: false,
|
|
487
|
-
comparator: match[1] ?? ""
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
/**
|
|
491
|
-
* Computes what a dependency range on a workspace sibling becomes once that sibling releases `version`, by classifying the range's shape and then, for a rewritable shape, substituting `version` in place of the version it currently names.
|
|
492
|
-
*/
|
|
493
|
-
function updateDependencyRange(current, version) {
|
|
494
|
-
const shape = classifyDependencyRange(current);
|
|
495
|
-
if (shape.kind !== "rewritable") return shape;
|
|
496
|
-
const rewritten = `${shape.comparator}${version}`;
|
|
497
|
-
return {
|
|
498
|
-
kind: "rewritten",
|
|
499
|
-
range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
|
|
500
|
-
};
|
|
501
|
-
}
|
|
502
|
-
//#endregion
|
|
503
|
-
//#region src/publishable-dependencies.ts
|
|
504
|
-
/**
|
|
505
|
-
* Throws `UnsupportedDependencyRangeError` unless every publishable package's installed dependencies (`dependencies`, `peerDependencies`, `optionalDependencies`) are ranges a consumer's package manager can resolve.
|
|
506
|
-
*
|
|
507
|
-
* Publishing goes through `@semantic-release/npm`, that is plain `npm publish`, which copies the manifest's specifiers into the registry unchanged. A `workspace:`, `catalog:`, `link:`, or `file:` specifier therefore reaches consumers as written and makes the package uninstallable, whichever sibling or external package it names and whether or not the tool would have rewritten it. `pnpm publish` would substitute some of them at pack time, but this tool never packs with pnpm, so accepting them would mean publishing a broken package with no warning.
|
|
508
|
-
*
|
|
509
|
-
* A `private` package never publishes and is exempt, and so is `devDependencies`, which no consumer installs. Every offending entry across every package is reported in one error, so a workspace with several of them is fixed in one pass rather than one failed run per entry.
|
|
510
|
-
*/
|
|
511
|
-
function assertPublishableDependencies(packages) {
|
|
512
|
-
const offences = [];
|
|
513
|
-
for (const pkg of packages) {
|
|
514
|
-
if (pkg.private) continue;
|
|
515
|
-
for (const field of INSTALLED_DEPENDENCY_FIELDS) {
|
|
516
|
-
const declared = pkg.dependencies.get(field);
|
|
517
|
-
if (declared === void 0) continue;
|
|
518
|
-
for (const [dependency, specifier] of declared) if (unpublishableSpecifierProtocol(specifier) !== void 0) offences.push(` ${pkg.name}: "${dependency}" in ${field} is declared as "${specifier}"`);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
if (offences.length > 0) throw new UnsupportedDependencyRangeError(`Refusing to release: npm publish ships these dependency specifiers unchanged, so the published package could not be installed.\n${offences.join("\n")}\nDeclare a plain version range instead (for example "^1.2.3"). For a sibling in this workspace, keep "linkWorkspacePackages: true" in pnpm-workspace.yaml so pnpm still links it locally; this tool rewrites the range whenever the sibling releases. Packages marked "private" are never published and are exempt.`);
|
|
522
|
-
}
|
|
523
|
-
//#endregion
|
|
524
524
|
//#region src/workspace.ts
|
|
525
525
|
/** The one filename pnpm recognises as a workspace definition. */
|
|
526
526
|
const WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
|
|
@@ -701,9 +701,68 @@ function mustGet(map, key, what) {
|
|
|
701
701
|
return value;
|
|
702
702
|
}
|
|
703
703
|
//#endregion
|
|
704
|
+
//#region src/bundling.ts
|
|
705
|
+
/**
|
|
706
|
+
* Validates `bundlingPackages` against the workspace and works out, for every package, which workspace dependencies it bundles.
|
|
707
|
+
*
|
|
708
|
+
* A package named in `bundlingPackages` declares that its build inlines every workspace sibling it lists in `devDependencies`, so consumers never install those siblings from the registry and their code reaches consumers only through this package. When such a sibling releases, this package's release type is at least the sibling's shipped release type (a `feat` in the sibling releases this package as a minor), instead of the patch an ordinary dependency bump forces. Edges in `dependencies`, `peerDependencies` and `optionalDependencies` are installed by consumers, so they keep the forced patch.
|
|
709
|
+
*
|
|
710
|
+
* A private package's own release type never changes: it keeps the patch a dependency bump forces, so its versions and tags are exactly what they would be without `bundlingPackages`. What it gains is a carried release type (see `BundledDependencies.carried`), which lets a bundling package that lists only the private package still see the release type of a private sibling the private package depends on.
|
|
711
|
+
*
|
|
712
|
+
* `bundlingPackages` names public workspace packages whose builds bundle their workspace `devDependencies`; `undefined` or an empty list leaves every package's release type as it was. Returns one entry per package in the graph. Throws `ReleaseConfigurationError` when a name is not a package in the workspace, or names a private package, which never publishes and so ships nothing to consumers.
|
|
713
|
+
*/
|
|
714
|
+
function resolveBundledDependencies(graph, bundlingPackages) {
|
|
715
|
+
const bundling = new Set(bundlingPackages);
|
|
716
|
+
const unknown = [...bundling].filter((name) => !graph.packages.has(name));
|
|
717
|
+
if (unknown.length > 0) throw new ReleaseConfigurationError(`bundlingPackages names ${unknown.map((name) => `"${name}"`).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a package in this workspace. Packages: ${[...graph.packages.keys()].join(", ")}.`);
|
|
718
|
+
const privateNames = [...bundling].filter((name) => mustGet(graph.packages, name, "package").private);
|
|
719
|
+
if (privateNames.length > 0) throw new ReleaseConfigurationError(`bundlingPackages names ${privateNames.map((name) => `"${name}"`).join(", ")}, which ${privateNames.length === 1 ? "is" : "are"} private. A private package is never published, so nothing it bundles reaches a consumer through it; list the public package whose build inlines it instead.`);
|
|
720
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
721
|
+
for (const [name, pkg] of graph.packages) {
|
|
722
|
+
const edges = mustGet(graph.dependencies, name, "dependency edges");
|
|
723
|
+
const releaseTypeFrom = new Set(edges.filter((edge) => bundling.has(name) && edge.field === "devDependencies").map((edge) => edge.dependency));
|
|
724
|
+
const carried = /* @__PURE__ */ new Set([...releaseTypeFrom, ...edges.filter((edge) => pkg.private && isPrivateRuntimeEdge(edge, graph)).map((edge) => edge.dependency)]);
|
|
725
|
+
resolved.set(name, {
|
|
726
|
+
releaseTypeFrom,
|
|
727
|
+
carried
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
return resolved;
|
|
731
|
+
}
|
|
732
|
+
function isPrivateRuntimeEdge(edge, graph) {
|
|
733
|
+
return edge.field !== "devDependencies" && mustGet(graph.packages, edge.dependency, "package").private;
|
|
734
|
+
}
|
|
735
|
+
//#endregion
|
|
704
736
|
//#region src/package-name.ts
|
|
705
737
|
const packageName = "@exadev/semantic-release-workspace";
|
|
706
738
|
//#endregion
|
|
739
|
+
//#region src/release-type.ts
|
|
740
|
+
/** Every release type semantic-release knows, highest first: the order `@semantic-release/commit-analyzer` itself ranks them in (its `lib/default-release-types.js`), so comparing two types here agrees with how the analyzer picks the highest type among a package's own commits. */
|
|
741
|
+
const RELEASE_TYPES_HIGHEST_FIRST = [
|
|
742
|
+
"major",
|
|
743
|
+
"premajor",
|
|
744
|
+
"minor",
|
|
745
|
+
"preminor",
|
|
746
|
+
"patch",
|
|
747
|
+
"prepatch",
|
|
748
|
+
"prerelease"
|
|
749
|
+
];
|
|
750
|
+
const KNOWN_RELEASE_TYPES = new Set(RELEASE_TYPES_HIGHEST_FIRST);
|
|
751
|
+
/** Whether `value` is one of semantic-release's release types. */
|
|
752
|
+
function isReleaseType(value) {
|
|
753
|
+
return typeof value === "string" && KNOWN_RELEASE_TYPES.has(value);
|
|
754
|
+
}
|
|
755
|
+
/** The highest of the given release types, ranked as `@semantic-release/commit-analyzer` ranks them, or `undefined` when `types` is empty. */
|
|
756
|
+
function highestReleaseType(types) {
|
|
757
|
+
let highest;
|
|
758
|
+
for (const type of types) if (highest === void 0 || RELEASE_TYPES_HIGHEST_FIRST.indexOf(type) < RELEASE_TYPES_HIGHEST_FIRST.indexOf(highest)) highest = type;
|
|
759
|
+
return highest;
|
|
760
|
+
}
|
|
761
|
+
/** `base`, raised to `other` when `other` is the higher release type; an `undefined` `other` leaves `base` as it is. */
|
|
762
|
+
function raiseReleaseType(base, other) {
|
|
763
|
+
return other === void 0 || RELEASE_TYPES_HIGHEST_FIRST.indexOf(base) <= RELEASE_TYPES_HIGHEST_FIRST.indexOf(other) ? base : other;
|
|
764
|
+
}
|
|
765
|
+
//#endregion
|
|
707
766
|
//#region src/dependency-bump-commit.ts
|
|
708
767
|
/**
|
|
709
768
|
* The commit message format for a cross-package dependency-range bump, shared between the code that writes it (`bumpDependents` in `release.ts`) and the code that reads it back (`createScopedPlugins` in `plugins.ts`).
|
|
@@ -713,26 +772,30 @@ const packageName = "@exadev/semantic-release-workspace";
|
|
|
713
772
|
const TRAILER_DEPENDENCY = "Bumped-Workspace-Dependency";
|
|
714
773
|
const TRAILER_VERSION = "Bumped-Workspace-Dependency-Version";
|
|
715
774
|
const TRAILER_RANGE = "Bumped-Workspace-Dependency-Range";
|
|
775
|
+
const TRAILER_RELEASE_TYPE = "Bumped-Workspace-Dependency-Release-Type";
|
|
716
776
|
/** Builds the full commit message (subject and trailer) for one dependency-range bump. */
|
|
717
777
|
function formatDependencyBumpMessage(info) {
|
|
718
778
|
return `${`chore(deps): bump ${info.dependency} to ${info.range} in ${info.dependent} [skip ci]`}\n\n${[
|
|
719
779
|
`${TRAILER_DEPENDENCY}: ${info.dependency}`,
|
|
720
780
|
`${TRAILER_VERSION}: ${info.version}`,
|
|
721
|
-
`${TRAILER_RANGE}: ${info.range}
|
|
781
|
+
`${TRAILER_RANGE}: ${info.range}`,
|
|
782
|
+
`${TRAILER_RELEASE_TYPE}: ${info.releaseType}`
|
|
722
783
|
].join("\n")}`;
|
|
723
784
|
}
|
|
724
785
|
/**
|
|
725
|
-
* Recovers the dependency bump a `formatDependencyBumpMessage` commit recorded, from its full git message (subject and body), or `undefined` if the message carries no such trailer.
|
|
786
|
+
* Recovers the dependency bump a `formatDependencyBumpMessage` commit recorded, from its full git message (subject and body), or `undefined` if the message carries no such trailer. The dependency, version and range lines must all be present for the commit to be treated as a bump commit at all -- a message missing even one is left alone rather than partially trusted. The release type line is optional, since bump commits written before it existed lack it; when it is absent or names no release type, `releaseType` is `undefined`.
|
|
726
787
|
*/
|
|
727
788
|
function parseDependencyBumpTrailer(message) {
|
|
728
789
|
const dependency = matchTrailerLine(message, TRAILER_DEPENDENCY);
|
|
729
790
|
const version = matchTrailerLine(message, TRAILER_VERSION);
|
|
730
791
|
const range = matchTrailerLine(message, TRAILER_RANGE);
|
|
731
792
|
if (dependency === void 0 || version === void 0 || range === void 0) return;
|
|
793
|
+
const releaseType = matchTrailerLine(message, TRAILER_RELEASE_TYPE);
|
|
732
794
|
return {
|
|
733
795
|
dependency,
|
|
734
796
|
version,
|
|
735
|
-
range
|
|
797
|
+
range,
|
|
798
|
+
releaseType: isReleaseType(releaseType) ? releaseType : void 0
|
|
736
799
|
};
|
|
737
800
|
}
|
|
738
801
|
function matchTrailerLine(message, key) {
|
|
@@ -770,10 +833,11 @@ const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-r
|
|
|
770
833
|
*
|
|
771
834
|
* Both apply the same path scoping before delegating to the real `@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator`: the commit list semantic-release already fetched for the release range is filtered down to commits whose `git log --name-only` file list intersects the package's own directory, and only the filtered list reaches the standard plugin. Conventional-commit parsing and changelog formatting stay entirely inside the standard plugins.
|
|
772
835
|
*
|
|
773
|
-
* The `analyzeCommits` wrapper carries
|
|
836
|
+
* The `analyzeCommits` wrapper carries two additions beyond filtering. When the standard analyzer finds no releasable commits but a workspace dependency range of the package's has changed, it returns 'patch' anyway: a dependent whose only change is a dependency bump still needs a release for that range to reach the registry. And when a bumped dependency is one the package bundles (`scope.bundled.releaseTypeFrom`), the package's release type is raised to at least that dependency's shipped release type, whether or not the package has releasable commits of its own, because the dependency's change reaches consumers only inside this package. "Has changed" is read from two sources, merged: bumps recorded in memory earlier in the current run (`scope.bumps`), and bumps recorded in the package's own filtered commit history via the trailer `dependency-bump-commit.ts` writes and reads -- the latter is what lets a run that starts after a previous run already committed and pushed the bump (a crash recovery, or simply a later run) reach the same decision, rather than depending on state that existed only inside the process that made the commit.
|
|
774
837
|
*/
|
|
775
838
|
function createScopedPlugins(scope) {
|
|
776
839
|
let cached;
|
|
840
|
+
let carriedReleaseType;
|
|
777
841
|
async function commitsForPackage(context) {
|
|
778
842
|
const from = hasGitHead(context.lastRelease) ? context.lastRelease.gitHead : void 0;
|
|
779
843
|
if (cached === void 0) cached = {
|
|
@@ -791,13 +855,26 @@ function createScopedPlugins(scope) {
|
|
|
791
855
|
return {
|
|
792
856
|
async analyzeCommits(_pluginConfig, context) {
|
|
793
857
|
const commits = await commitsForPackage(context);
|
|
794
|
-
const
|
|
858
|
+
const analysed = await analyzeCommits(scope.analyzeCommitsConfig, {
|
|
795
859
|
...context,
|
|
796
860
|
commits
|
|
797
861
|
});
|
|
798
|
-
if (typeof
|
|
862
|
+
if (typeof analysed === "string" && !isReleaseType(analysed)) throw new ReleaseConfigurationError(`@semantic-release/commit-analyzer returned "${analysed}" for ${scope.pkg.name}, which is not a semantic-release release type.`);
|
|
863
|
+
const own = typeof analysed === "string" ? analysed : void 0;
|
|
799
864
|
const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
|
|
865
|
+
carriedReleaseType = releaseTypeOfBumps(bumps.filter((bump) => scope.bundled?.carried.has(bump.dependency) === true));
|
|
866
|
+
const bundledBumps = bumps.filter((bump) => scope.bundled?.releaseTypeFrom.has(bump.dependency) === true);
|
|
867
|
+
const fromBundled = releaseTypeOfBumps(bundledBumps);
|
|
868
|
+
if (own !== void 0) {
|
|
869
|
+
const type = raiseReleaseType(own, fromBundled);
|
|
870
|
+
if (type !== own) context.logger.log(`${scope.pkg.name} bundles ${describeBundledBumps(bundledBumps)}; raising its ${own} release to ${type}.`);
|
|
871
|
+
return type;
|
|
872
|
+
}
|
|
800
873
|
if (bumps.length === 0) return false;
|
|
874
|
+
if (fromBundled !== void 0) {
|
|
875
|
+
context.logger.log(`No releasable commits under ${scope.pkg.relativeDirectory}, but it bundles ${describeBundledBumps(bundledBumps)}; releasing it as ${fromBundled}.`);
|
|
876
|
+
return fromBundled;
|
|
877
|
+
}
|
|
801
878
|
context.logger.log(`No releasable commits under ${scope.pkg.relativeDirectory}, but ${bumps.length === 1 ? "a workspace dependency range changed" : `${String(bumps.length)} workspace dependency ranges changed`}; forcing a patch release.`);
|
|
802
879
|
return "patch";
|
|
803
880
|
},
|
|
@@ -815,9 +892,19 @@ function createScopedPlugins(scope) {
|
|
|
815
892
|
...bumps.map((bump) => describeDependencyBump(bump))
|
|
816
893
|
].join("\n");
|
|
817
894
|
return typeof notes === "string" ? `${notes}\n\n${section}` : section;
|
|
895
|
+
},
|
|
896
|
+
shippedReleaseType(releaseType) {
|
|
897
|
+
return raiseReleaseType(releaseType, carriedReleaseType);
|
|
818
898
|
}
|
|
819
899
|
};
|
|
820
900
|
}
|
|
901
|
+
/** The highest release type among `bumps`, or `undefined` when there are none. A bump with no recorded type counts as the patch it always forced. */
|
|
902
|
+
function releaseTypeOfBumps(bumps) {
|
|
903
|
+
return highestReleaseType(bumps.map((bump) => bump.releaseType ?? "patch"));
|
|
904
|
+
}
|
|
905
|
+
function describeBundledBumps(bumps) {
|
|
906
|
+
return bumps.map((bump) => `${bump.dependency} (released ${bump.version}, ${bump.releaseType ?? "patch"})`).join(", ");
|
|
907
|
+
}
|
|
821
908
|
/**
|
|
822
909
|
* Combines the bumps recorded in memory earlier in the current run with bumps recovered from the package's own filtered commit history (a bump commit from this run, already visible because it touches the package's own directory, or one left over from a previous run), de-duplicated by dependency name. The in-memory entry wins on overlap: it carries the manifest field and dependent name a `resolved-at-publish` bump has no commit to recover from at all.
|
|
823
910
|
*/
|
|
@@ -1001,6 +1088,7 @@ async function attemptSingleCommitRelease(options) {
|
|
|
1001
1088
|
requireGitPlugin: false,
|
|
1002
1089
|
forbidGitPlugin: true
|
|
1003
1090
|
});
|
|
1091
|
+
const bundled = resolveBundledDependencies(graph, options.bundlingPackages);
|
|
1004
1092
|
const analyzeCommitsConfig = options.analyzeCommits ?? {};
|
|
1005
1093
|
const generateNotesConfig = options.generateNotes ?? {};
|
|
1006
1094
|
const tagFormat = validateTagFormat(options.tagFormat ?? "${name}@${version}");
|
|
@@ -1022,6 +1110,7 @@ async function attemptSingleCommitRelease(options) {
|
|
|
1022
1110
|
analyzeCommitsConfig,
|
|
1023
1111
|
generateNotesConfig,
|
|
1024
1112
|
bumpsForThisPackage,
|
|
1113
|
+
bundled: mustGet(bundled, name, "bundled dependencies"),
|
|
1025
1114
|
env,
|
|
1026
1115
|
branches: options.branches,
|
|
1027
1116
|
onCommitsResolved: (commits) => {
|
|
@@ -1059,7 +1148,10 @@ async function attemptSingleCommitRelease(options) {
|
|
|
1059
1148
|
notes: nextRelease.notes,
|
|
1060
1149
|
bumps: bumpsForThisPackage
|
|
1061
1150
|
});
|
|
1062
|
-
for (const bump of planDependentBumps(pkg,
|
|
1151
|
+
for (const bump of planDependentBumps(pkg, {
|
|
1152
|
+
version: nextRelease.version,
|
|
1153
|
+
releaseType: nextRelease.shippedReleaseType
|
|
1154
|
+
}, graph)) {
|
|
1063
1155
|
const forDependent = pendingBumps.get(bump.dependent) ?? [];
|
|
1064
1156
|
forDependent.push(bump);
|
|
1065
1157
|
pendingBumps.set(bump.dependent, forDependent);
|
|
@@ -1165,6 +1257,7 @@ async function analysePackage(pkg, options) {
|
|
|
1165
1257
|
analyzeCommitsConfig: options.analyzeCommitsConfig,
|
|
1166
1258
|
generateNotesConfig: options.generateNotesConfig,
|
|
1167
1259
|
bumps: { bumpsFor: () => options.bumpsForThisPackage },
|
|
1260
|
+
bundled: options.bundled,
|
|
1168
1261
|
onCommitsResolved: options.onCommitsResolved
|
|
1169
1262
|
});
|
|
1170
1263
|
const semanticReleaseOptions = {
|
|
@@ -1190,13 +1283,15 @@ async function analysePackage(pkg, options) {
|
|
|
1190
1283
|
if (result.nextRelease.notes === void 0) throw new ReleaseConfigurationError(`Internal error: ${pkg.name} released with no notes computed.`);
|
|
1191
1284
|
return {
|
|
1192
1285
|
type: result.nextRelease.type,
|
|
1286
|
+
shippedReleaseType: scoped.shippedReleaseType(result.nextRelease.type),
|
|
1193
1287
|
version: result.nextRelease.version,
|
|
1194
1288
|
gitTag: result.nextRelease.gitTag,
|
|
1195
1289
|
notes: result.nextRelease.notes
|
|
1196
1290
|
};
|
|
1197
1291
|
}
|
|
1198
1292
|
/** Pure classification of what a released package's version does to each dependent's declared range -- the same logic `bumpDependents` in `release.ts` applies, minus the file write/commit/push: `commitStrategy: 'single'` defers every write to phase 3, applying the same classification once analysis has finished for the whole run. */
|
|
1199
|
-
function planDependentBumps(released,
|
|
1293
|
+
function planDependentBumps(released, release, graph) {
|
|
1294
|
+
const { version, releaseType } = release;
|
|
1200
1295
|
const applied = [];
|
|
1201
1296
|
const dependents = graph.dependents.get(released.name);
|
|
1202
1297
|
if (dependents === void 0) return applied;
|
|
@@ -1209,7 +1304,8 @@ function planDependentBumps(released, version, graph) {
|
|
|
1209
1304
|
field: edge.field,
|
|
1210
1305
|
version,
|
|
1211
1306
|
range: update.kind === "rewritten" ? update.range : edge.range,
|
|
1212
|
-
kind: update.kind
|
|
1307
|
+
kind: update.kind,
|
|
1308
|
+
releaseType
|
|
1213
1309
|
});
|
|
1214
1310
|
}
|
|
1215
1311
|
return applied;
|
|
@@ -1310,17 +1406,19 @@ async function releaseWorkspace(options = {}) {
|
|
|
1310
1406
|
plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
|
|
1311
1407
|
packagePlugins: options.packagePlugins
|
|
1312
1408
|
}, workspace.root, { requireGitPlugin: !dryRun });
|
|
1409
|
+
const bundled = resolveBundledDependencies(graph, options.bundlingPackages);
|
|
1313
1410
|
const analyzeCommitsConfig = options.analyzeCommits ?? {};
|
|
1314
1411
|
const generateNotesConfig = options.generateNotes ?? {};
|
|
1315
1412
|
return {
|
|
1316
1413
|
order,
|
|
1317
1414
|
packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
|
|
1318
|
-
const result = await runPackageRelease(pkg, {
|
|
1415
|
+
const { result, releaseType } = await runPackageRelease(pkg, {
|
|
1319
1416
|
tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
|
|
1320
1417
|
publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
|
|
1321
1418
|
analyzeCommitsConfig,
|
|
1322
1419
|
generateNotesConfig,
|
|
1323
1420
|
bumpsForThisPackage,
|
|
1421
|
+
bundled: mustGet(bundled, pkg.name, "bundled dependencies"),
|
|
1324
1422
|
dryRun,
|
|
1325
1423
|
env,
|
|
1326
1424
|
branches: options.branches
|
|
@@ -1329,6 +1427,7 @@ async function releaseWorkspace(options = {}) {
|
|
|
1329
1427
|
return {
|
|
1330
1428
|
released: nextRelease !== void 0,
|
|
1331
1429
|
version: nextRelease?.version,
|
|
1430
|
+
releaseType,
|
|
1332
1431
|
result
|
|
1333
1432
|
};
|
|
1334
1433
|
})).map((entry) => {
|
|
@@ -1351,7 +1450,7 @@ async function releaseWorkspace(options = {}) {
|
|
|
1351
1450
|
};
|
|
1352
1451
|
}
|
|
1353
1452
|
/**
|
|
1354
|
-
* The per-package release loop shared by `releaseWorkspace` (normal per-package releases) and `detachWorkspaceRelease` (gated tag-only releases): iterate the topological `order`, call the caller's own `releaseOne` for each package, record which dependency-range bumps were already applied to it, and -- when it released -- bump every workspace dependent's manifest before that dependent's own turn (see `bumpDependents`'s doc comment for why this happens immediately rather than being staged). The two call sites differ only in how they call semantic-release (`semanticRelease(...)` vs `detachRelease(...)`) and how they read "did it release,
|
|
1453
|
+
* The per-package release loop shared by `releaseWorkspace` (normal per-package releases) and `detachWorkspaceRelease` (gated tag-only releases): iterate the topological `order`, call the caller's own `releaseOne` for each package, record which dependency-range bumps were already applied to it, and -- when it released -- bump every workspace dependent's manifest before that dependent's own turn (see `bumpDependents`'s doc comment for why this happens immediately rather than being staged). The two call sites differ only in how they call semantic-release (`semanticRelease(...)` vs `detachRelease(...)`) and how they read "did it release, at what version and with which shipped release type" off the result, all captured by `releaseOne`.
|
|
1355
1454
|
*/
|
|
1356
1455
|
async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne) {
|
|
1357
1456
|
const pendingBumps = /* @__PURE__ */ new Map();
|
|
@@ -1361,7 +1460,7 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
|
|
|
1361
1460
|
const pkg = mustGet(graph.packages, name, "package");
|
|
1362
1461
|
const bumpsForThisPackage = pendingBumps.get(name) ?? [];
|
|
1363
1462
|
log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
|
|
1364
|
-
const { released, version, result } = dryRun ? await releaseOne(pkg, bumpsForThisPackage) : await releasePackageAcrossBranchMoves(name, branchTracker(workspace, log), async () => await releaseOne(pkg, bumpsForThisPackage), log);
|
|
1463
|
+
const { released, version, releaseType, result } = dryRun ? await releaseOne(pkg, bumpsForThisPackage) : await releasePackageAcrossBranchMoves(name, branchTracker(workspace, log), async () => await releaseOne(pkg, bumpsForThisPackage), log);
|
|
1365
1464
|
entries.push({
|
|
1366
1465
|
name,
|
|
1367
1466
|
directory: pkg.directory,
|
|
@@ -1370,13 +1469,16 @@ async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne)
|
|
|
1370
1469
|
dependencyBumps: bumpsForThisPackage
|
|
1371
1470
|
});
|
|
1372
1471
|
pendingBumps.delete(name);
|
|
1373
|
-
if (!released || version === void 0) {
|
|
1472
|
+
if (!released || version === void 0 || releaseType === void 0) {
|
|
1374
1473
|
log(`${name}: no release`);
|
|
1375
1474
|
continue;
|
|
1376
1475
|
}
|
|
1377
1476
|
log(`${name}: released ${version}`);
|
|
1378
1477
|
identity ??= await resolveCommitIdentity({ cwd: workspace.root });
|
|
1379
|
-
const bumps = await bumpDependents(pkg,
|
|
1478
|
+
const bumps = await bumpDependents(pkg, {
|
|
1479
|
+
version,
|
|
1480
|
+
releaseType
|
|
1481
|
+
}, graph, {
|
|
1380
1482
|
workspace,
|
|
1381
1483
|
dryRun,
|
|
1382
1484
|
identity,
|
|
@@ -1395,7 +1497,8 @@ async function runPackageRelease(pkg, options) {
|
|
|
1395
1497
|
pkg,
|
|
1396
1498
|
analyzeCommitsConfig: options.analyzeCommitsConfig,
|
|
1397
1499
|
generateNotesConfig: options.generateNotesConfig,
|
|
1398
|
-
bumps: { bumpsFor: () => options.bumpsForThisPackage }
|
|
1500
|
+
bumps: { bumpsFor: () => options.bumpsForThisPackage },
|
|
1501
|
+
bundled: options.bundled
|
|
1399
1502
|
});
|
|
1400
1503
|
const semanticReleaseOptions = {
|
|
1401
1504
|
tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
|
|
@@ -1406,10 +1509,14 @@ async function runPackageRelease(pkg, options) {
|
|
|
1406
1509
|
if (options.dryRun) semanticReleaseOptions.dryRun = true;
|
|
1407
1510
|
if (options.branches !== void 0) semanticReleaseOptions.branches = options.branches;
|
|
1408
1511
|
try {
|
|
1409
|
-
|
|
1512
|
+
const result = await semanticRelease(semanticReleaseOptions, {
|
|
1410
1513
|
cwd: pkg.directory,
|
|
1411
1514
|
env: { ...options.env }
|
|
1412
1515
|
});
|
|
1516
|
+
return {
|
|
1517
|
+
result,
|
|
1518
|
+
releaseType: result === false ? void 0 : scoped.shippedReleaseType(result.nextRelease.type)
|
|
1519
|
+
};
|
|
1413
1520
|
} catch (cause) {
|
|
1414
1521
|
throw new WorkspaceReleaseError(`Release of ${pkg.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1415
1522
|
}
|
|
@@ -1421,10 +1528,11 @@ const LOCKFILE_FILENAME = "pnpm-lock.yaml";
|
|
|
1421
1528
|
*
|
|
1422
1529
|
* The bump is committed (and pushed) right away rather than staged, because a dependent's semantic-release run analyses git history, not the working tree: an uncommitted bump would be invisible to its commit analysis, and would be stranded uncommitted if the dependent then released nothing -- a manifest that names versions the registry has never seen. Committing immediately means the dependent's own run sees the bump commit (it touches only the dependent's directory, so it passes that dependent's path filter), the forced-patch logic in the scoped analyzer covers the case where that bump is the dependent's only change, and a run interrupted partway leaves the remote describing exactly what was published. Pushing immediately mirrors what semantic-release itself does with release commits. The lockfile is regenerated (`pnpm install --lockfile-only`) and committed alongside the manifest for the same reason: a manifest bump committed without it leaves `pnpm-lock.yaml` naming the old range, which `pnpm install --frozen-lockfile` (what CI runs) then rejects.
|
|
1423
1530
|
*/
|
|
1424
|
-
async function bumpDependents(released,
|
|
1531
|
+
async function bumpDependents(released, release, graph, options) {
|
|
1425
1532
|
const applied = [];
|
|
1426
1533
|
const dependents = graph.dependents.get(released.name);
|
|
1427
1534
|
if (dependents === void 0) return applied;
|
|
1535
|
+
const { version, releaseType } = release;
|
|
1428
1536
|
for (const edge of dependents) {
|
|
1429
1537
|
const update = updateDependencyRange(edge.range, version);
|
|
1430
1538
|
if (update.kind === "wildcard") continue;
|
|
@@ -1437,6 +1545,7 @@ async function bumpDependents(released, version, graph, options) {
|
|
|
1437
1545
|
dependency: released.name,
|
|
1438
1546
|
version,
|
|
1439
1547
|
range: update.range,
|
|
1548
|
+
releaseType,
|
|
1440
1549
|
dependent: edge.dependent
|
|
1441
1550
|
});
|
|
1442
1551
|
await commitFiles([`${dependent.relativeDirectory}/package.json`, LOCKFILE_FILENAME], message, {
|
|
@@ -1453,7 +1562,8 @@ async function bumpDependents(released, version, graph, options) {
|
|
|
1453
1562
|
field: edge.field,
|
|
1454
1563
|
version,
|
|
1455
1564
|
range: update.kind === "rewritten" ? update.range : edge.range,
|
|
1456
|
-
kind: update.kind
|
|
1565
|
+
kind: update.kind,
|
|
1566
|
+
releaseType
|
|
1457
1567
|
});
|
|
1458
1568
|
}
|
|
1459
1569
|
return applied;
|
|
@@ -1499,14 +1609,16 @@ async function detachWorkspaceRelease(options) {
|
|
|
1499
1609
|
plugins: options.plugins ?? DEFAULT_PUBLISH_PLUGINS,
|
|
1500
1610
|
packagePlugins: options.packagePlugins
|
|
1501
1611
|
}, workspace.root, { requireGitPlugin: !dryRun });
|
|
1612
|
+
const bundled = resolveBundledDependencies(graph, options.bundlingPackages);
|
|
1502
1613
|
const analyzeCommitsConfig = options.analyzeCommits ?? {};
|
|
1503
1614
|
const generateNotesConfig = options.generateNotes ?? {};
|
|
1504
1615
|
const entries = await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
|
|
1505
|
-
const state = await runPackageDetach(pkg, {
|
|
1616
|
+
const { state, releaseType } = await runPackageDetach(pkg, {
|
|
1506
1617
|
publishPlugins: mustGet(publishPlugins, pkg.name, "publish plugins"),
|
|
1507
1618
|
analyzeCommitsConfig,
|
|
1508
1619
|
generateNotesConfig,
|
|
1509
1620
|
bumpsForThisPackage,
|
|
1621
|
+
bundled: mustGet(bundled, pkg.name, "bundled dependencies"),
|
|
1510
1622
|
tagFormat: validateTagFormat(options.tagFormat ?? "${name}@${version}"),
|
|
1511
1623
|
dryRun,
|
|
1512
1624
|
env,
|
|
@@ -1515,6 +1627,7 @@ async function detachWorkspaceRelease(options) {
|
|
|
1515
1627
|
return {
|
|
1516
1628
|
released: state !== null,
|
|
1517
1629
|
version: state?.nextRelease.version,
|
|
1630
|
+
releaseType,
|
|
1518
1631
|
result: state
|
|
1519
1632
|
};
|
|
1520
1633
|
});
|
|
@@ -1547,7 +1660,8 @@ async function runPackageDetach(pkg, options) {
|
|
|
1547
1660
|
pkg,
|
|
1548
1661
|
analyzeCommitsConfig: options.analyzeCommitsConfig,
|
|
1549
1662
|
generateNotesConfig: options.generateNotesConfig,
|
|
1550
|
-
bumps: { bumpsFor: () => options.bumpsForThisPackage }
|
|
1663
|
+
bumps: { bumpsFor: () => options.bumpsForThisPackage },
|
|
1664
|
+
bundled: options.bundled
|
|
1551
1665
|
});
|
|
1552
1666
|
const cliOptions = {
|
|
1553
1667
|
tagFormat: formatTagForPackage(options.tagFormat, pkg.name),
|
|
@@ -1558,10 +1672,14 @@ async function runPackageDetach(pkg, options) {
|
|
|
1558
1672
|
if (options.dryRun) cliOptions.dryRun = true;
|
|
1559
1673
|
if (options.branches !== void 0) cliOptions.branches = options.branches;
|
|
1560
1674
|
try {
|
|
1561
|
-
|
|
1675
|
+
const state = await detachRelease(cliOptions, {
|
|
1562
1676
|
cwd: pkg.directory,
|
|
1563
1677
|
env: { ...options.env }
|
|
1564
1678
|
});
|
|
1679
|
+
return {
|
|
1680
|
+
state,
|
|
1681
|
+
releaseType: state === null ? void 0 : scoped.shippedReleaseType(state.nextRelease.type)
|
|
1682
|
+
};
|
|
1565
1683
|
} catch (cause) {
|
|
1566
1684
|
throw new WorkspaceReleaseError(`Detaching ${pkg.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1567
1685
|
}
|
|
@@ -1638,7 +1756,8 @@ const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
|
1638
1756
|
"generateNotes",
|
|
1639
1757
|
"commitStrategy",
|
|
1640
1758
|
"gatePublish",
|
|
1641
|
-
"tagFormat"
|
|
1759
|
+
"tagFormat",
|
|
1760
|
+
"bundlingPackages"
|
|
1642
1761
|
]);
|
|
1643
1762
|
const COMMIT_STRATEGIES = /* @__PURE__ */ new Set(["per-package", "single"]);
|
|
1644
1763
|
function isCommitStrategy(value) {
|
|
@@ -1663,7 +1782,8 @@ function createProgram() {
|
|
|
1663
1782
|
release.option("--tag-format <template>", "template for each package's release tag, with ${name} and ${version} placeholders; must contain ${version} (default: ${name}@${version})", parseTagFormat);
|
|
1664
1783
|
release.option("--gate-publish", "tag and push each due package via @exadev/release-gate, but defer publishing -- requires --gate-state-file, and cannot be combined with --commit-strategy single");
|
|
1665
1784
|
release.option("--gate-state-file <path>", "with --gate-publish: where to write the state a later \"resume\" run needs to finish publishing");
|
|
1666
|
-
release.option("--
|
|
1785
|
+
release.option("--bundling-package <name>", "a public package whose build bundles its workspace devDependencies, so it releases as at least their release type rather than a forced patch; repeat for several packages", collectRepeated, []);
|
|
1786
|
+
release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, tagFormat, gatePublish, bundlingPackages); explicit flags win");
|
|
1667
1787
|
release.action(runRelease);
|
|
1668
1788
|
const resume = program.command("resume");
|
|
1669
1789
|
resume.description("Finish publishing every package a --gate-publish release tagged and pushed but did not publish.");
|
|
@@ -1699,7 +1819,8 @@ const NO_CONFIG_FILE = {
|
|
|
1699
1819
|
generateNotes: void 0,
|
|
1700
1820
|
commitStrategy: void 0,
|
|
1701
1821
|
tagFormat: void 0,
|
|
1702
|
-
gatePublish: void 0
|
|
1822
|
+
gatePublish: void 0,
|
|
1823
|
+
bundlingPackages: void 0
|
|
1703
1824
|
};
|
|
1704
1825
|
async function runRelease(flags) {
|
|
1705
1826
|
const file = flags.config === void 0 ? NO_CONFIG_FILE : await readReleaseConfigFile(flags.config);
|
|
@@ -1715,7 +1836,8 @@ async function runRelease(flags) {
|
|
|
1715
1836
|
generateNotes: flags.generateNotes === void 0 ? file.generateNotes : parseJsonObjectFlag(flags.generateNotes, "--generate-notes"),
|
|
1716
1837
|
commitStrategy: flags.commitStrategy ?? file.commitStrategy,
|
|
1717
1838
|
tagFormat: flags.tagFormat ?? file.tagFormat,
|
|
1718
|
-
gatePublish
|
|
1839
|
+
gatePublish,
|
|
1840
|
+
bundlingPackages: flags.bundlingPackage.length > 0 ? flags.bundlingPackage : file.bundlingPackages
|
|
1719
1841
|
});
|
|
1720
1842
|
for (const pkg of outcome.packages) console.log(describeOutcome(pkg));
|
|
1721
1843
|
if (gatePublish) {
|
|
@@ -1776,7 +1898,7 @@ async function readReleaseConfigFile(path) {
|
|
|
1776
1898
|
const parsed = await readConfigFile(path);
|
|
1777
1899
|
if (!isJsonObject(parsed)) throw new InvalidArgumentError(`--config file ${path} must contain a JSON object`);
|
|
1778
1900
|
for (const key of Object.keys(parsed)) if (!CONFIG_OPTION_KEYS.has(key)) throw new InvalidArgumentError(`--config file ${path} has an unknown option "${key}"; recognised options: ${[...CONFIG_OPTION_KEYS].join(", ")}`);
|
|
1779
|
-
const { dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, tagFormat, gatePublish } = parsed;
|
|
1901
|
+
const { dryRun, branches, plugins, packagePlugins, analyzeCommits, generateNotes, commitStrategy, tagFormat, gatePublish, bundlingPackages } = parsed;
|
|
1780
1902
|
if (dryRun !== void 0 && typeof dryRun !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "dryRun" must be a boolean`);
|
|
1781
1903
|
if (branches !== void 0 && !isStringArray(branches)) throw new InvalidArgumentError(`--config file ${path}: "branches" must be an array of branch name strings`);
|
|
1782
1904
|
if (plugins !== void 0 && !Array.isArray(plugins)) throw new InvalidArgumentError(`--config file ${path}: "plugins" must be an array`);
|
|
@@ -1786,6 +1908,7 @@ async function readReleaseConfigFile(path) {
|
|
|
1786
1908
|
if (commitStrategy !== void 0 && (typeof commitStrategy !== "string" || !isCommitStrategy(commitStrategy))) throw new InvalidArgumentError(`--config file ${path}: "commitStrategy" must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
|
|
1787
1909
|
if (tagFormat !== void 0 && typeof tagFormat !== "string") throw new InvalidArgumentError(`--config file ${path}: "tagFormat" must be a string`);
|
|
1788
1910
|
if (gatePublish !== void 0 && typeof gatePublish !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "gatePublish" must be a boolean`);
|
|
1911
|
+
if (bundlingPackages !== void 0 && !isStringArray(bundlingPackages)) throw new InvalidArgumentError(`--config file ${path}: "bundlingPackages" must be an array of package name strings`);
|
|
1789
1912
|
return {
|
|
1790
1913
|
dryRun,
|
|
1791
1914
|
branches,
|
|
@@ -1795,7 +1918,8 @@ async function readReleaseConfigFile(path) {
|
|
|
1795
1918
|
generateNotes,
|
|
1796
1919
|
commitStrategy,
|
|
1797
1920
|
tagFormat: tagFormat === void 0 ? void 0 : validateTagFormatOption(tagFormat, (message) => `--config file ${path}: ${message}`),
|
|
1798
|
-
gatePublish
|
|
1921
|
+
gatePublish,
|
|
1922
|
+
bundlingPackages
|
|
1799
1923
|
};
|
|
1800
1924
|
}
|
|
1801
1925
|
function parseConfigFilePackagePlugins(packagePlugins, path) {
|