@exadev/semantic-release-workspace 1.2.4 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -1
- package/dist/cli.js +428 -232
- package/dist/index.cjs +179 -23
- package/dist/index.d.cts +32 -1
- package/dist/index.d.ts +32 -1
- package/dist/index.js +179 -24
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
4
|
import { cosmiconfigSync } from "cosmiconfig";
|
|
4
5
|
import { Command, InvalidArgumentError } from "commander";
|
|
5
6
|
import { dirname, relative, resolve, sep } from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
import { generateNotes } from "@semantic-release/release-notes-generator";
|
|
7
|
+
import { detachRelease, resumeRelease } from "@exadev/release-gate";
|
|
8
8
|
import { execFile } from "node:child_process";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
-
import semanticRelease from "semantic-release";
|
|
11
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
12
10
|
import validateNpmPackageName from "validate-npm-package-name";
|
|
13
11
|
import { glob } from "tinyglobby";
|
|
14
12
|
import { parse } from "yaml";
|
|
13
|
+
import { analyzeCommits } from "@semantic-release/commit-analyzer";
|
|
14
|
+
import { generateNotes } from "@semantic-release/release-notes-generator";
|
|
15
|
+
import semanticRelease from "semantic-release";
|
|
15
16
|
import { pathToFileURL } from "node:url";
|
|
16
17
|
//#region package.json
|
|
17
|
-
var version = "1.
|
|
18
|
+
var version = "1.3.0";
|
|
18
19
|
//#endregion
|
|
19
20
|
//#region src/errors.ts
|
|
20
21
|
/**
|
|
@@ -60,77 +61,6 @@ var PnpmCommandError = class extends WorkspaceReleaseError {
|
|
|
60
61
|
}
|
|
61
62
|
};
|
|
62
63
|
//#endregion
|
|
63
|
-
//#region src/json.ts
|
|
64
|
-
function isJsonObject(value) {
|
|
65
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
-
}
|
|
67
|
-
function isStringRecord(value) {
|
|
68
|
-
return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
69
|
-
}
|
|
70
|
-
function isStringArray(value) {
|
|
71
|
-
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* 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.
|
|
75
|
-
*/
|
|
76
|
-
function isUnknownArray(value) {
|
|
77
|
-
return Array.isArray(value);
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* 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.
|
|
81
|
-
*/
|
|
82
|
-
const FIRST_INDENTED_LINE = /^[ \t]+(?=")/m;
|
|
83
|
-
const NPM_DEFAULT_INDENT = " ";
|
|
84
|
-
function detectIndent(text) {
|
|
85
|
-
const match = FIRST_INDENTED_LINE.exec(text);
|
|
86
|
-
return match === null ? NPM_DEFAULT_INDENT : match[0];
|
|
87
|
-
}
|
|
88
|
-
/** 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. */
|
|
89
|
-
function stringifyJsonLike(value, originalText) {
|
|
90
|
-
const serialised = JSON.stringify(value, null, detectIndent(originalText));
|
|
91
|
-
return originalText.endsWith("\n") ? `${serialised}\n` : serialised;
|
|
92
|
-
}
|
|
93
|
-
//#endregion
|
|
94
|
-
//#region src/package-name.ts
|
|
95
|
-
const packageName = "@exadev/semantic-release-workspace";
|
|
96
|
-
//#endregion
|
|
97
|
-
//#region src/dependency-bump-commit.ts
|
|
98
|
-
/**
|
|
99
|
-
* 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`).
|
|
100
|
-
*
|
|
101
|
-
* The subject line alone (`chore(deps): bump @scope/a to ^1.1.0 in @scope/b [skip ci]`) is for humans reading `git log`. Recovering the forced-patch decision from a commit already sitting in history -- rather than only from the in-memory record a single run builds as it goes -- needs a machine-parseable form as well, because a run that starts after a previous run already committed and pushed the bump (whether that previous run crashed immediately afterwards, or simply finished days ago) has no in-memory record at all: the only place the fact "this dependency range changed because a sibling released" is stated is the repository itself. The trailer below is that statement.
|
|
102
|
-
*/
|
|
103
|
-
const TRAILER_DEPENDENCY = "Bumped-Workspace-Dependency";
|
|
104
|
-
const TRAILER_VERSION = "Bumped-Workspace-Dependency-Version";
|
|
105
|
-
const TRAILER_RANGE = "Bumped-Workspace-Dependency-Range";
|
|
106
|
-
/** Builds the full commit message (subject and trailer) for one dependency-range bump. */
|
|
107
|
-
function formatDependencyBumpMessage(info) {
|
|
108
|
-
return `${`chore(deps): bump ${info.dependency} to ${info.range} in ${info.dependent} [skip ci]`}\n\n${[
|
|
109
|
-
`${TRAILER_DEPENDENCY}: ${info.dependency}`,
|
|
110
|
-
`${TRAILER_VERSION}: ${info.version}`,
|
|
111
|
-
`${TRAILER_RANGE}: ${info.range}`
|
|
112
|
-
].join("\n")}`;
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* 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. All three lines must 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.
|
|
116
|
-
*/
|
|
117
|
-
function parseDependencyBumpTrailer(message) {
|
|
118
|
-
const dependency = matchTrailerLine(message, TRAILER_DEPENDENCY);
|
|
119
|
-
const version = matchTrailerLine(message, TRAILER_VERSION);
|
|
120
|
-
const range = matchTrailerLine(message, TRAILER_RANGE);
|
|
121
|
-
if (dependency === void 0 || version === void 0 || range === void 0) return;
|
|
122
|
-
return {
|
|
123
|
-
dependency,
|
|
124
|
-
version,
|
|
125
|
-
range
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
function matchTrailerLine(message, key) {
|
|
129
|
-
const prefix = `${key}: `;
|
|
130
|
-
const line = message.split("\n").find((candidate) => candidate.startsWith(prefix));
|
|
131
|
-
return line === void 0 ? void 0 : line.slice(prefix.length).trim();
|
|
132
|
-
}
|
|
133
|
-
//#endregion
|
|
134
64
|
//#region src/git.ts
|
|
135
65
|
const execFileAsync$1 = promisify(execFile);
|
|
136
66
|
/** `git log --name-only` over everything since a package's last release tag can legitimately produce tens of megabytes of path output on a long-lived monorepo, well past execFile's default buffer, failing on exactly the big workspaces this tool exists for. */
|
|
@@ -315,142 +245,35 @@ function toGitCommandError(args, cwd, cause) {
|
|
|
315
245
|
return new GitCommandError(args, cwd, exitCode, stderr !== "" ? stderr : cause instanceof Error ? cause.message : String(cause));
|
|
316
246
|
}
|
|
317
247
|
//#endregion
|
|
318
|
-
//#region src/
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
"@semantic-release/changelog",
|
|
322
|
-
"@semantic-release/npm",
|
|
323
|
-
"@semantic-release/github",
|
|
324
|
-
["@semantic-release/git", {
|
|
325
|
-
assets: ["CHANGELOG.md", "package.json"],
|
|
326
|
-
message: "chore(release): ${nextRelease.gitTag} [skip ci]"
|
|
327
|
-
}]
|
|
328
|
-
];
|
|
329
|
-
/** 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. */
|
|
330
|
-
const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
|
|
331
|
-
"@semantic-release/changelog",
|
|
332
|
-
"@semantic-release/npm",
|
|
333
|
-
"@semantic-release/github"
|
|
334
|
-
];
|
|
335
|
-
const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator"]);
|
|
336
|
-
/**
|
|
337
|
-
* Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
|
|
338
|
-
*
|
|
339
|
-
* 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.
|
|
340
|
-
*
|
|
341
|
-
* The `analyzeCommits` wrapper carries one addition 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. "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.
|
|
342
|
-
*/
|
|
343
|
-
function createScopedPlugins(scope) {
|
|
344
|
-
let cached;
|
|
345
|
-
async function commitsForPackage(context) {
|
|
346
|
-
const from = context.lastRelease?.gitHead ?? void 0;
|
|
347
|
-
if (cached === void 0) cached = {
|
|
348
|
-
from,
|
|
349
|
-
paths: changedPathsSince(from, { cwd: context.cwd })
|
|
350
|
-
};
|
|
351
|
-
else if (cached.from !== from) cached = {
|
|
352
|
-
from,
|
|
353
|
-
paths: changedPathsSince(from, { cwd: context.cwd })
|
|
354
|
-
};
|
|
355
|
-
const commits = filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
|
|
356
|
-
scope.onCommitsResolved?.(commits);
|
|
357
|
-
return commits;
|
|
358
|
-
}
|
|
359
|
-
return {
|
|
360
|
-
async analyzeCommits(_pluginConfig, context) {
|
|
361
|
-
const commits = await commitsForPackage(context);
|
|
362
|
-
const type = await analyzeCommits(scope.analyzeCommitsConfig, {
|
|
363
|
-
...context,
|
|
364
|
-
commits
|
|
365
|
-
});
|
|
366
|
-
if (type) return type;
|
|
367
|
-
const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
|
|
368
|
-
if (bumps.length === 0) return false;
|
|
369
|
-
context.logger.log(`No releasable commits under ${scope.pkg.relativeDirectory}, but ${bumps.length === 1 ? "a workspace dependency range changed" : `${bumps.length} workspace dependency ranges changed`}; forcing a patch release.`);
|
|
370
|
-
return "patch";
|
|
371
|
-
},
|
|
372
|
-
async generateNotes(_pluginConfig, context) {
|
|
373
|
-
const commits = await commitsForPackage(context);
|
|
374
|
-
const notes = await generateNotes(scope.generateNotesConfig, {
|
|
375
|
-
...context,
|
|
376
|
-
commits
|
|
377
|
-
});
|
|
378
|
-
const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
|
|
379
|
-
if (bumps.length === 0) return notes;
|
|
380
|
-
const section = [
|
|
381
|
-
"### Dependencies",
|
|
382
|
-
"",
|
|
383
|
-
...bumps.map((bump) => describeDependencyBump(bump))
|
|
384
|
-
].join("\n");
|
|
385
|
-
return notes ? `${notes}\n\n${section}` : section;
|
|
386
|
-
}
|
|
387
|
-
};
|
|
248
|
+
//#region src/json.ts
|
|
249
|
+
function isJsonObject(value) {
|
|
250
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
388
251
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
*/
|
|
392
|
-
function mergeDependencyBumps(runtimeBumps, commits) {
|
|
393
|
-
const byDependency = /* @__PURE__ */ new Map();
|
|
394
|
-
for (const commit of commits) {
|
|
395
|
-
const parsed = parseDependencyBumpTrailer(commit.message);
|
|
396
|
-
if (parsed !== void 0) byDependency.set(parsed.dependency, {
|
|
397
|
-
...parsed,
|
|
398
|
-
kind: "rewritten"
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
for (const bump of runtimeBumps) byDependency.set(bump.dependency, bump);
|
|
402
|
-
return [...byDependency.values()];
|
|
252
|
+
function isStringRecord(value) {
|
|
253
|
+
return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
403
254
|
}
|
|
404
|
-
function
|
|
405
|
-
return
|
|
255
|
+
function isStringArray(value) {
|
|
256
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
406
257
|
}
|
|
407
258
|
/**
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* A commit missing from the changed-paths map is kept rather than dropped: it is inside the package's release range (semantic-release put it there), so a failure to parse its file list must not silently swallow a release. Absent evidence errs towards publishing, which is the visible direction for a release tool.
|
|
259
|
+
* 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.
|
|
411
260
|
*/
|
|
412
|
-
function
|
|
413
|
-
|
|
414
|
-
return commits.filter((commit) => {
|
|
415
|
-
const paths = changedPaths.get(commit.hash);
|
|
416
|
-
if (paths === void 0) return true;
|
|
417
|
-
return [...paths].some((path) => path === directory || path.startsWith(prefix));
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
function resolvePublishPlugins(specs, workspaceRoot, options) {
|
|
421
|
-
const requireFromTool = createRequire(import.meta.url);
|
|
422
|
-
const requireFromWorkspace = createRequire(resolve(workspaceRoot, "package.json"));
|
|
423
|
-
const resolved = [];
|
|
424
|
-
let hasGitPlugin = false;
|
|
425
|
-
for (const spec of specs) {
|
|
426
|
-
const [name, config] = parsePublishPluginSpec(spec);
|
|
427
|
-
if (STEP_PLUGINS_THE_ORCHESTRATOR_OWNS.has(name)) throw new ReleaseConfigurationError(`"${name}" is listed as a publish plugin, but ${packageName} always provides the ${name === "@semantic-release/commit-analyzer" ? "analyzeCommits" : "generateNotes"} step itself, wrapped around that plugin. Passing it here would make its configuration a silent no-op; set that configuration on the orchestrator's analyzeCommits/generateNotes options instead.`);
|
|
428
|
-
if (name === "@semantic-release/git") {
|
|
429
|
-
hasGitPlugin = true;
|
|
430
|
-
if (options.forbidGitPlugin === true) throw new ReleaseConfigurationError(`"@semantic-release/git" is listed as a publish plugin, but commitStrategy "single" does its own committing -- one combined commit for every released package, tagged once every package has been analysed -- rather than letting each package's own release commit itself. Remove @semantic-release/git from the plugin list; its version bump and changelog write still happen (via its sibling prepare plugins), just folded into the combined commit instead of made on their own.`);
|
|
431
|
-
}
|
|
432
|
-
const entry = [resolvePluginModule(name, requireFromTool, requireFromWorkspace), config];
|
|
433
|
-
resolved.push(entry);
|
|
434
|
-
}
|
|
435
|
-
if (options.requireGitPlugin && !hasGitPlugin) throw new ReleaseConfigurationError(`The publish plugin list does not include @semantic-release/git. Without it, nothing commits each released package's manifest and changelog back to the branch, so the repository would drift out of agreement with the published versions -- the exact divergence this tool exists to prevent. (Dry runs are exempt.)`);
|
|
436
|
-
return resolved;
|
|
261
|
+
function isUnknownArray(value) {
|
|
262
|
+
return Array.isArray(value);
|
|
437
263
|
}
|
|
438
264
|
/**
|
|
439
|
-
*
|
|
265
|
+
* 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.
|
|
440
266
|
*/
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
attempts.push(`${label}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
447
|
-
}
|
|
448
|
-
throw new ReleaseConfigurationError(`Cannot resolve the publish plugin "${name}". Tried resolving it from ${attempts.join("; and from ")}.`);
|
|
267
|
+
const FIRST_INDENTED_LINE = /^[ \t]+(?=")/m;
|
|
268
|
+
const NPM_DEFAULT_INDENT = " ";
|
|
269
|
+
function detectIndent(text) {
|
|
270
|
+
const match = FIRST_INDENTED_LINE.exec(text);
|
|
271
|
+
return match === null ? NPM_DEFAULT_INDENT : match[0];
|
|
449
272
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
return
|
|
273
|
+
/** 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. */
|
|
274
|
+
function stringifyJsonLike(value, originalText) {
|
|
275
|
+
const serialised = JSON.stringify(value, null, detectIndent(originalText));
|
|
276
|
+
return originalText.endsWith("\n") ? `${serialised}\n` : serialised;
|
|
454
277
|
}
|
|
455
278
|
//#endregion
|
|
456
279
|
//#region src/manifest.ts
|
|
@@ -732,6 +555,184 @@ function mustGet(map, key, what) {
|
|
|
732
555
|
return value;
|
|
733
556
|
}
|
|
734
557
|
//#endregion
|
|
558
|
+
//#region src/package-name.ts
|
|
559
|
+
const packageName = "@exadev/semantic-release-workspace";
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/dependency-bump-commit.ts
|
|
562
|
+
/**
|
|
563
|
+
* 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`).
|
|
564
|
+
*
|
|
565
|
+
* The subject line alone (`chore(deps): bump @scope/a to ^1.1.0 in @scope/b [skip ci]`) is for humans reading `git log`. Recovering the forced-patch decision from a commit already sitting in history -- rather than only from the in-memory record a single run builds as it goes -- needs a machine-parseable form as well, because a run that starts after a previous run already committed and pushed the bump (whether that previous run crashed immediately afterwards, or simply finished days ago) has no in-memory record at all: the only place the fact "this dependency range changed because a sibling released" is stated is the repository itself. The trailer below is that statement.
|
|
566
|
+
*/
|
|
567
|
+
const TRAILER_DEPENDENCY = "Bumped-Workspace-Dependency";
|
|
568
|
+
const TRAILER_VERSION = "Bumped-Workspace-Dependency-Version";
|
|
569
|
+
const TRAILER_RANGE = "Bumped-Workspace-Dependency-Range";
|
|
570
|
+
/** Builds the full commit message (subject and trailer) for one dependency-range bump. */
|
|
571
|
+
function formatDependencyBumpMessage(info) {
|
|
572
|
+
return `${`chore(deps): bump ${info.dependency} to ${info.range} in ${info.dependent} [skip ci]`}\n\n${[
|
|
573
|
+
`${TRAILER_DEPENDENCY}: ${info.dependency}`,
|
|
574
|
+
`${TRAILER_VERSION}: ${info.version}`,
|
|
575
|
+
`${TRAILER_RANGE}: ${info.range}`
|
|
576
|
+
].join("\n")}`;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* 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. All three lines must 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.
|
|
580
|
+
*/
|
|
581
|
+
function parseDependencyBumpTrailer(message) {
|
|
582
|
+
const dependency = matchTrailerLine(message, TRAILER_DEPENDENCY);
|
|
583
|
+
const version = matchTrailerLine(message, TRAILER_VERSION);
|
|
584
|
+
const range = matchTrailerLine(message, TRAILER_RANGE);
|
|
585
|
+
if (dependency === void 0 || version === void 0 || range === void 0) return;
|
|
586
|
+
return {
|
|
587
|
+
dependency,
|
|
588
|
+
version,
|
|
589
|
+
range
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function matchTrailerLine(message, key) {
|
|
593
|
+
const prefix = `${key}: `;
|
|
594
|
+
const line = message.split("\n").find((candidate) => candidate.startsWith(prefix));
|
|
595
|
+
return line === void 0 ? void 0 : line.slice(prefix.length).trim();
|
|
596
|
+
}
|
|
597
|
+
//#endregion
|
|
598
|
+
//#region src/plugins.ts
|
|
599
|
+
/** 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. */
|
|
600
|
+
const DEFAULT_PUBLISH_PLUGINS = [
|
|
601
|
+
"@semantic-release/changelog",
|
|
602
|
+
"@semantic-release/npm",
|
|
603
|
+
"@semantic-release/github",
|
|
604
|
+
["@semantic-release/git", {
|
|
605
|
+
assets: ["CHANGELOG.md", "package.json"],
|
|
606
|
+
message: "chore(release): ${nextRelease.gitTag} [skip ci]"
|
|
607
|
+
}]
|
|
608
|
+
];
|
|
609
|
+
/** 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. */
|
|
610
|
+
const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
|
|
611
|
+
"@semantic-release/changelog",
|
|
612
|
+
"@semantic-release/npm",
|
|
613
|
+
"@semantic-release/github"
|
|
614
|
+
];
|
|
615
|
+
const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator"]);
|
|
616
|
+
/**
|
|
617
|
+
* Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
|
|
618
|
+
*
|
|
619
|
+
* 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.
|
|
620
|
+
*
|
|
621
|
+
* The `analyzeCommits` wrapper carries one addition 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. "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.
|
|
622
|
+
*/
|
|
623
|
+
function createScopedPlugins(scope) {
|
|
624
|
+
let cached;
|
|
625
|
+
async function commitsForPackage(context) {
|
|
626
|
+
const from = context.lastRelease?.gitHead ?? void 0;
|
|
627
|
+
if (cached === void 0) cached = {
|
|
628
|
+
from,
|
|
629
|
+
paths: changedPathsSince(from, { cwd: context.cwd })
|
|
630
|
+
};
|
|
631
|
+
else if (cached.from !== from) cached = {
|
|
632
|
+
from,
|
|
633
|
+
paths: changedPathsSince(from, { cwd: context.cwd })
|
|
634
|
+
};
|
|
635
|
+
const commits = filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
|
|
636
|
+
scope.onCommitsResolved?.(commits);
|
|
637
|
+
return commits;
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
async analyzeCommits(_pluginConfig, context) {
|
|
641
|
+
const commits = await commitsForPackage(context);
|
|
642
|
+
const type = await analyzeCommits(scope.analyzeCommitsConfig, {
|
|
643
|
+
...context,
|
|
644
|
+
commits
|
|
645
|
+
});
|
|
646
|
+
if (type) return type;
|
|
647
|
+
const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
|
|
648
|
+
if (bumps.length === 0) return false;
|
|
649
|
+
context.logger.log(`No releasable commits under ${scope.pkg.relativeDirectory}, but ${bumps.length === 1 ? "a workspace dependency range changed" : `${bumps.length} workspace dependency ranges changed`}; forcing a patch release.`);
|
|
650
|
+
return "patch";
|
|
651
|
+
},
|
|
652
|
+
async generateNotes(_pluginConfig, context) {
|
|
653
|
+
const commits = await commitsForPackage(context);
|
|
654
|
+
const notes = await generateNotes(scope.generateNotesConfig, {
|
|
655
|
+
...context,
|
|
656
|
+
commits
|
|
657
|
+
});
|
|
658
|
+
const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
|
|
659
|
+
if (bumps.length === 0) return notes;
|
|
660
|
+
const section = [
|
|
661
|
+
"### Dependencies",
|
|
662
|
+
"",
|
|
663
|
+
...bumps.map((bump) => describeDependencyBump(bump))
|
|
664
|
+
].join("\n");
|
|
665
|
+
return notes ? `${notes}\n\n${section}` : section;
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* 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.
|
|
671
|
+
*/
|
|
672
|
+
function mergeDependencyBumps(runtimeBumps, commits) {
|
|
673
|
+
const byDependency = /* @__PURE__ */ new Map();
|
|
674
|
+
for (const commit of commits) {
|
|
675
|
+
const parsed = parseDependencyBumpTrailer(commit.message);
|
|
676
|
+
if (parsed !== void 0) byDependency.set(parsed.dependency, {
|
|
677
|
+
...parsed,
|
|
678
|
+
kind: "rewritten"
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
for (const bump of runtimeBumps) byDependency.set(bump.dependency, bump);
|
|
682
|
+
return [...byDependency.values()];
|
|
683
|
+
}
|
|
684
|
+
function describeDependencyBump(bump) {
|
|
685
|
+
return bump.kind === "rewritten" ? `- Updated ${bump.dependency} to ${bump.range}` : `- Updated ${bump.dependency} to ${bump.version} (declared as \`${bump.range}\`, resolved by pnpm at publish time)`;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Keeps a commit for the package when any path it changed lies under the package's directory. The trailing-slash prefix comparison stops `packages/a` from matching `packages/abc/x`.
|
|
689
|
+
*
|
|
690
|
+
* A commit missing from the changed-paths map is kept rather than dropped: it is inside the package's release range (semantic-release put it there), so a failure to parse its file list must not silently swallow a release. Absent evidence errs towards publishing, which is the visible direction for a release tool.
|
|
691
|
+
*/
|
|
692
|
+
function filterCommitsToDirectory(commits, changedPaths, directory) {
|
|
693
|
+
const prefix = `${directory}/`;
|
|
694
|
+
return commits.filter((commit) => {
|
|
695
|
+
const paths = changedPaths.get(commit.hash);
|
|
696
|
+
if (paths === void 0) return true;
|
|
697
|
+
return [...paths].some((path) => path === directory || path.startsWith(prefix));
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
function resolvePublishPlugins(specs, workspaceRoot, options) {
|
|
701
|
+
const requireFromTool = createRequire(import.meta.url);
|
|
702
|
+
const requireFromWorkspace = createRequire(resolve(workspaceRoot, "package.json"));
|
|
703
|
+
const resolved = [];
|
|
704
|
+
let hasGitPlugin = false;
|
|
705
|
+
for (const spec of specs) {
|
|
706
|
+
const [name, config] = parsePublishPluginSpec(spec);
|
|
707
|
+
if (STEP_PLUGINS_THE_ORCHESTRATOR_OWNS.has(name)) throw new ReleaseConfigurationError(`"${name}" is listed as a publish plugin, but ${packageName} always provides the ${name === "@semantic-release/commit-analyzer" ? "analyzeCommits" : "generateNotes"} step itself, wrapped around that plugin. Passing it here would make its configuration a silent no-op; set that configuration on the orchestrator's analyzeCommits/generateNotes options instead.`);
|
|
708
|
+
if (name === "@semantic-release/git") {
|
|
709
|
+
hasGitPlugin = true;
|
|
710
|
+
if (options.forbidGitPlugin === true) throw new ReleaseConfigurationError(`"@semantic-release/git" is listed as a publish plugin, but commitStrategy "single" does its own committing -- one combined commit for every released package, tagged once every package has been analysed -- rather than letting each package's own release commit itself. Remove @semantic-release/git from the plugin list; its version bump and changelog write still happen (via its sibling prepare plugins), just folded into the combined commit instead of made on their own.`);
|
|
711
|
+
}
|
|
712
|
+
const entry = [resolvePluginModule(name, requireFromTool, requireFromWorkspace), config];
|
|
713
|
+
resolved.push(entry);
|
|
714
|
+
}
|
|
715
|
+
if (options.requireGitPlugin && !hasGitPlugin) throw new ReleaseConfigurationError(`The publish plugin list does not include @semantic-release/git. Without it, nothing commits each released package's manifest and changelog back to the branch, so the repository would drift out of agreement with the published versions -- the exact divergence this tool exists to prevent. (Dry runs are exempt.)`);
|
|
716
|
+
return resolved;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* 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.
|
|
720
|
+
*/
|
|
721
|
+
function resolvePluginModule(name, requireFromTool, requireFromWorkspace) {
|
|
722
|
+
const attempts = [];
|
|
723
|
+
for (const [label, requirer] of [["this tool", requireFromTool], ["the workspace root", requireFromWorkspace]]) try {
|
|
724
|
+
return requirer.resolve(name);
|
|
725
|
+
} catch (cause) {
|
|
726
|
+
attempts.push(`${label}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
727
|
+
}
|
|
728
|
+
throw new ReleaseConfigurationError(`Cannot resolve the publish plugin "${name}". Tried resolving it from ${attempts.join("; and from ")}.`);
|
|
729
|
+
}
|
|
730
|
+
function parsePublishPluginSpec(spec) {
|
|
731
|
+
if (typeof spec === "string") return [spec, {}];
|
|
732
|
+
const [name, config] = spec;
|
|
733
|
+
return [name, config ?? {}];
|
|
734
|
+
}
|
|
735
|
+
//#endregion
|
|
735
736
|
//#region src/pnpm.ts
|
|
736
737
|
const execFileAsync = promisify(execFile);
|
|
737
738
|
/**
|
|
@@ -1025,6 +1026,10 @@ async function loadReleasePlugin(absolutePath, cache) {
|
|
|
1025
1026
|
* For each package, in topological order: run semantic-release's programmatic API with `cwd` scoped to the package directory, a `name@version` tag format to keep each package's tags distinct in the one shared tag namespace, and inline `analyzeCommits`/`generateNotes` plugins that filter the release range's commits down to the package's own directory before delegating to the standard plugins. When a package releases, every workspace package that depends on it and has not run yet gets its dependency range rewritten in its manifest and committed immediately -- before its own turn, so its commit analysis and its published manifest both see the new range.
|
|
1026
1027
|
*/
|
|
1027
1028
|
async function releaseWorkspace(options = {}) {
|
|
1029
|
+
if (options.gatePublish === true) {
|
|
1030
|
+
if ((options.commitStrategy ?? "per-package") === "single") throw new ReleaseConfigurationError("gatePublish is not supported together with commitStrategy: \"single\" -- single-commit-release.ts's tag/publish machinery is entirely bespoke and never goes through semantic-release's own run(), so it has no insertion point for release-gate's detach/resume primitives.");
|
|
1031
|
+
return detachWorkspaceRelease(options);
|
|
1032
|
+
}
|
|
1028
1033
|
if ((options.commitStrategy ?? "per-package") === "single") return releaseWorkspaceSingleCommit(options);
|
|
1029
1034
|
const root = resolve(options.root ?? process.cwd());
|
|
1030
1035
|
const log = options.log ?? console.log;
|
|
@@ -1038,40 +1043,65 @@ async function releaseWorkspace(options = {}) {
|
|
|
1038
1043
|
const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
|
|
1039
1044
|
const analyzeCommitsConfig = options.analyzeCommits ?? {};
|
|
1040
1045
|
const generateNotesConfig = options.generateNotes ?? {};
|
|
1046
|
+
return {
|
|
1047
|
+
order,
|
|
1048
|
+
packages: (await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
|
|
1049
|
+
const result = await runPackageRelease(pkg, {
|
|
1050
|
+
publishPlugins,
|
|
1051
|
+
analyzeCommitsConfig,
|
|
1052
|
+
generateNotesConfig,
|
|
1053
|
+
bumpsForThisPackage,
|
|
1054
|
+
dryRun,
|
|
1055
|
+
env,
|
|
1056
|
+
branches: options.branches
|
|
1057
|
+
});
|
|
1058
|
+
const nextRelease = result === false ? void 0 : result.nextRelease;
|
|
1059
|
+
return {
|
|
1060
|
+
released: nextRelease !== void 0,
|
|
1061
|
+
version: nextRelease?.version,
|
|
1062
|
+
result
|
|
1063
|
+
};
|
|
1064
|
+
})).map((entry) => {
|
|
1065
|
+
const nextRelease = entry.result === false ? void 0 : entry.result.nextRelease;
|
|
1066
|
+
return {
|
|
1067
|
+
name: entry.name,
|
|
1068
|
+
directory: entry.directory,
|
|
1069
|
+
released: nextRelease !== void 0,
|
|
1070
|
+
version: nextRelease?.version,
|
|
1071
|
+
gitTag: nextRelease?.gitTag,
|
|
1072
|
+
type: nextRelease?.type,
|
|
1073
|
+
dependencyBumps: entry.dependencyBumps
|
|
1074
|
+
};
|
|
1075
|
+
})
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* 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, and what version" off the result, both captured by `releaseOne`.
|
|
1080
|
+
*/
|
|
1081
|
+
async function runReleaseLoop(graph, order, workspace, dryRun, log, releaseOne) {
|
|
1041
1082
|
const pendingBumps = /* @__PURE__ */ new Map();
|
|
1042
1083
|
let identity;
|
|
1043
|
-
const
|
|
1084
|
+
const entries = [];
|
|
1044
1085
|
for (const name of order) {
|
|
1045
1086
|
const pkg = mustGet(graph.packages, name, "package");
|
|
1046
1087
|
const bumpsForThisPackage = pendingBumps.get(name) ?? [];
|
|
1047
1088
|
log(`Releasing ${name} from ${pkg.relativeDirectory}${bumpsForThisPackage.length > 0 ? ` (dependency ranges already bumped: ${bumpsForThisPackage.map((bump) => bump.dependency).join(", ")})` : ""}`);
|
|
1048
|
-
const result = await
|
|
1049
|
-
|
|
1050
|
-
analyzeCommitsConfig,
|
|
1051
|
-
generateNotesConfig,
|
|
1052
|
-
bumpsForThisPackage,
|
|
1053
|
-
dryRun,
|
|
1054
|
-
env,
|
|
1055
|
-
branches: options.branches
|
|
1056
|
-
});
|
|
1057
|
-
const nextRelease = result === false ? void 0 : result.nextRelease;
|
|
1058
|
-
outcomes.push({
|
|
1089
|
+
const { released, version, result } = await releaseOne(pkg, bumpsForThisPackage);
|
|
1090
|
+
entries.push({
|
|
1059
1091
|
name,
|
|
1060
1092
|
directory: pkg.directory,
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
gitTag: nextRelease?.gitTag,
|
|
1064
|
-
type: nextRelease?.type,
|
|
1093
|
+
relativeDirectory: pkg.relativeDirectory,
|
|
1094
|
+
result,
|
|
1065
1095
|
dependencyBumps: bumpsForThisPackage
|
|
1066
1096
|
});
|
|
1067
1097
|
pendingBumps.delete(name);
|
|
1068
|
-
if (
|
|
1098
|
+
if (!released || version === void 0) {
|
|
1069
1099
|
log(`${name}: no release`);
|
|
1070
1100
|
continue;
|
|
1071
1101
|
}
|
|
1072
|
-
log(`${name}: released ${
|
|
1102
|
+
log(`${name}: released ${version}`);
|
|
1073
1103
|
identity ??= await resolveCommitIdentity({ cwd: workspace.root });
|
|
1074
|
-
const bumps = await bumpDependents(pkg,
|
|
1104
|
+
const bumps = await bumpDependents(pkg, version, graph, {
|
|
1075
1105
|
workspace,
|
|
1076
1106
|
dryRun,
|
|
1077
1107
|
identity,
|
|
@@ -1083,10 +1113,7 @@ async function releaseWorkspace(options = {}) {
|
|
|
1083
1113
|
pendingBumps.set(bump.dependent, forDependent);
|
|
1084
1114
|
}
|
|
1085
1115
|
}
|
|
1086
|
-
return
|
|
1087
|
-
order,
|
|
1088
|
-
packages: outcomes
|
|
1089
|
-
};
|
|
1116
|
+
return entries;
|
|
1090
1117
|
}
|
|
1091
1118
|
async function runPackageRelease(pkg, options) {
|
|
1092
1119
|
const scoped = createScopedPlugins({
|
|
@@ -1157,6 +1184,146 @@ async function bumpDependents(released, version, graph, options) {
|
|
|
1157
1184
|
return applied;
|
|
1158
1185
|
}
|
|
1159
1186
|
//#endregion
|
|
1187
|
+
//#region src/gate-publish.ts
|
|
1188
|
+
/**
|
|
1189
|
+
* The `gatePublish: true` half of `releaseWorkspace`: tags and pushes every due package via `@exadev/release-gate`'s `detachRelease`, but never publishes. Built on the same `runReleaseLoop` the normal per-package path uses, so dependency-range bumps between packages happen identically either way -- only the release call itself (`detachRelease` instead of `semanticRelease`) and how "did it release" gets read differ.
|
|
1190
|
+
*/
|
|
1191
|
+
async function detachWorkspaceRelease(options) {
|
|
1192
|
+
const root = resolve(options.root ?? process.cwd());
|
|
1193
|
+
const log = options.log ?? console.log;
|
|
1194
|
+
const dryRun = options.dryRun === true;
|
|
1195
|
+
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1196
|
+
const workspace = await discoverWorkspace(root);
|
|
1197
|
+
const graph = buildDependencyGraph(workspace.packages);
|
|
1198
|
+
validateDependencyRangeShapes(graph);
|
|
1199
|
+
const order = topologicalOrder(graph);
|
|
1200
|
+
log(`${packageName}: ${order.length} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
|
|
1201
|
+
const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
|
|
1202
|
+
const analyzeCommitsConfig = options.analyzeCommits ?? {};
|
|
1203
|
+
const generateNotesConfig = options.generateNotes ?? {};
|
|
1204
|
+
const entries = await runReleaseLoop(graph, order, workspace, dryRun, log, async (pkg, bumpsForThisPackage) => {
|
|
1205
|
+
const state = await runPackageDetach(pkg, {
|
|
1206
|
+
publishPlugins,
|
|
1207
|
+
analyzeCommitsConfig,
|
|
1208
|
+
generateNotesConfig,
|
|
1209
|
+
bumpsForThisPackage,
|
|
1210
|
+
dryRun,
|
|
1211
|
+
env,
|
|
1212
|
+
branches: options.branches
|
|
1213
|
+
});
|
|
1214
|
+
return {
|
|
1215
|
+
released: state !== null,
|
|
1216
|
+
version: state?.nextRelease.version,
|
|
1217
|
+
result: state
|
|
1218
|
+
};
|
|
1219
|
+
});
|
|
1220
|
+
return {
|
|
1221
|
+
order,
|
|
1222
|
+
packages: entries.map((entry) => ({
|
|
1223
|
+
name: entry.name,
|
|
1224
|
+
directory: entry.directory,
|
|
1225
|
+
released: entry.result !== null,
|
|
1226
|
+
version: entry.result?.nextRelease.version,
|
|
1227
|
+
gitTag: entry.result?.nextRelease.gitTag,
|
|
1228
|
+
type: entry.result?.nextRelease.type,
|
|
1229
|
+
dependencyBumps: entry.dependencyBumps
|
|
1230
|
+
})),
|
|
1231
|
+
detached: entries.map((entry) => ({
|
|
1232
|
+
name: entry.name,
|
|
1233
|
+
relativeDirectory: entry.relativeDirectory,
|
|
1234
|
+
state: entry.result,
|
|
1235
|
+
dependencyBumps: entry.dependencyBumps
|
|
1236
|
+
}))
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
async function runPackageDetach(pkg, options) {
|
|
1240
|
+
const scoped = createScopedPlugins({
|
|
1241
|
+
pkg,
|
|
1242
|
+
analyzeCommitsConfig: options.analyzeCommitsConfig,
|
|
1243
|
+
generateNotesConfig: options.generateNotesConfig,
|
|
1244
|
+
bumps: { bumpsFor: () => options.bumpsForThisPackage }
|
|
1245
|
+
});
|
|
1246
|
+
const cliOptions = {
|
|
1247
|
+
tagFormat: `${pkg.name}@\${version}`,
|
|
1248
|
+
plugins: options.publishPlugins,
|
|
1249
|
+
analyzeCommits: scoped.analyzeCommits,
|
|
1250
|
+
generateNotes: scoped.generateNotes
|
|
1251
|
+
};
|
|
1252
|
+
if (options.dryRun) cliOptions.dryRun = true;
|
|
1253
|
+
if (options.branches !== void 0) cliOptions.branches = options.branches;
|
|
1254
|
+
try {
|
|
1255
|
+
return await detachRelease(cliOptions, {
|
|
1256
|
+
cwd: pkg.directory,
|
|
1257
|
+
env: { ...options.env }
|
|
1258
|
+
});
|
|
1259
|
+
} catch (cause) {
|
|
1260
|
+
throw new WorkspaceReleaseError(`Detaching ${pkg.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Runtime guard for a `DetachedPackageRelease[]` read back off disk (the CLI's `resume` subcommand reads `WorkspaceReleaseOutcome.detached` from a JSON file `release --gate-publish` wrote) -- `JSON.parse` returns `any`, so this is the boundary that turns it back into something safe to pass to `resumeWorkspaceRelease`. Only checks the outer shape (`name`, `relativeDirectory`, `dependencyBumps`, and that `state` is either `null` or an object); `state`'s own inner shape is validated by `@exadev/release-gate`'s `resumeRelease` itself, once per entry, which is where a genuinely malformed state fails loudly with a specific error rather than here.
|
|
1265
|
+
*/
|
|
1266
|
+
function isDetachedPackageReleaseArray(value) {
|
|
1267
|
+
if (!isUnknownArray(value)) return false;
|
|
1268
|
+
return value.every((entry) => {
|
|
1269
|
+
if (!isJsonObject(entry)) return false;
|
|
1270
|
+
if (typeof entry.name !== "string" || typeof entry.relativeDirectory !== "string") return false;
|
|
1271
|
+
if (entry.state !== null && !isJsonObject(entry.state)) return false;
|
|
1272
|
+
return isUnknownArray(entry.dependencyBumps);
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Finishes every package a `gatePublish: true` `releaseWorkspace` run tagged and pushed but did not publish. Resumes each `detached` entry in the array's own order -- not re-derived from the workspace's dependency graph, since a resume pass may run in a separate process or checkout, where re-deriving order should only ever matter for directory lookup, not sequencing.
|
|
1277
|
+
*/
|
|
1278
|
+
async function resumeWorkspaceRelease(options) {
|
|
1279
|
+
const root = resolve(options.root ?? process.cwd());
|
|
1280
|
+
const log = options.log ?? console.log;
|
|
1281
|
+
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1282
|
+
const order = [];
|
|
1283
|
+
const packages = [];
|
|
1284
|
+
for (const entry of options.detached) {
|
|
1285
|
+
order.push(entry.name);
|
|
1286
|
+
if (entry.state === null) {
|
|
1287
|
+
log(`${entry.name}: no release to resume`);
|
|
1288
|
+
packages.push({
|
|
1289
|
+
name: entry.name,
|
|
1290
|
+
directory: resolve(root, entry.relativeDirectory),
|
|
1291
|
+
released: false,
|
|
1292
|
+
version: void 0,
|
|
1293
|
+
gitTag: void 0,
|
|
1294
|
+
type: void 0,
|
|
1295
|
+
dependencyBumps: entry.dependencyBumps
|
|
1296
|
+
});
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const directory = resolve(root, entry.relativeDirectory);
|
|
1300
|
+
log(`Resuming ${entry.name} from ${entry.relativeDirectory}`);
|
|
1301
|
+
let releases;
|
|
1302
|
+
try {
|
|
1303
|
+
releases = await resumeRelease(entry.state, {
|
|
1304
|
+
cwd: directory,
|
|
1305
|
+
env: { ...env }
|
|
1306
|
+
});
|
|
1307
|
+
} catch (cause) {
|
|
1308
|
+
throw new WorkspaceReleaseError(`Resuming ${entry.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1309
|
+
}
|
|
1310
|
+
log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${releases.length} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
|
|
1311
|
+
packages.push({
|
|
1312
|
+
name: entry.name,
|
|
1313
|
+
directory,
|
|
1314
|
+
released: true,
|
|
1315
|
+
version: entry.state.nextRelease.version,
|
|
1316
|
+
gitTag: entry.state.nextRelease.gitTag,
|
|
1317
|
+
type: entry.state.nextRelease.type,
|
|
1318
|
+
dependencyBumps: entry.dependencyBumps
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
return {
|
|
1322
|
+
order,
|
|
1323
|
+
packages
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
//#endregion
|
|
1160
1327
|
//#region src/cli.ts
|
|
1161
1328
|
const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
1162
1329
|
"dryRun",
|
|
@@ -1164,7 +1331,8 @@ const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
|
1164
1331
|
"plugins",
|
|
1165
1332
|
"analyzeCommits",
|
|
1166
1333
|
"generateNotes",
|
|
1167
|
-
"commitStrategy"
|
|
1334
|
+
"commitStrategy",
|
|
1335
|
+
"gatePublish"
|
|
1168
1336
|
]);
|
|
1169
1337
|
const COMMIT_STRATEGIES = /* @__PURE__ */ new Set(["per-package", "single"]);
|
|
1170
1338
|
function isCommitStrategy(value) {
|
|
@@ -1186,8 +1354,15 @@ function createProgram() {
|
|
|
1186
1354
|
release.option("--analyze-commits <json>", "options for the wrapped @semantic-release/commit-analyzer, as a JSON object");
|
|
1187
1355
|
release.option("--generate-notes <json>", "options for the wrapped @semantic-release/release-notes-generator, as a JSON object");
|
|
1188
1356
|
release.option("--commit-strategy <mode>", "how the run commits its released changes: \"per-package\" (default; today's behaviour, one commit per release plus one per dependency bump) or \"single\" (one combined commit for the whole run, tagged once per released package)", parseCommitStrategy);
|
|
1189
|
-
release.option("--
|
|
1357
|
+
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");
|
|
1358
|
+
release.option("--gate-state-file <path>", "with --gate-publish: where to write the state a later \"resume\" run needs to finish publishing");
|
|
1359
|
+
release.option("--config <file>", "config file (.json, .yaml, .yml, .js, .cjs, or .ts) providing any of the release options (dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy, gatePublish); explicit flags win");
|
|
1190
1360
|
release.action(runRelease);
|
|
1361
|
+
const resume = program.command("resume");
|
|
1362
|
+
resume.description("Finish publishing every package a --gate-publish release tagged and pushed but did not publish.");
|
|
1363
|
+
resume.option("--root <directory>", "workspace root holding pnpm-workspace.yaml (this checkout, which may differ from the one that ran release --gate-publish)", process.cwd());
|
|
1364
|
+
resume.requiredOption("--gate-state-file <path>", "the state file a \"release --gate-publish\" run wrote");
|
|
1365
|
+
resume.action(runResume);
|
|
1191
1366
|
return program;
|
|
1192
1367
|
}
|
|
1193
1368
|
function parseCommitStrategy(value) {
|
|
@@ -1200,10 +1375,13 @@ const NO_CONFIG_FILE = {
|
|
|
1200
1375
|
plugins: void 0,
|
|
1201
1376
|
analyzeCommits: void 0,
|
|
1202
1377
|
generateNotes: void 0,
|
|
1203
|
-
commitStrategy: void 0
|
|
1378
|
+
commitStrategy: void 0,
|
|
1379
|
+
gatePublish: void 0
|
|
1204
1380
|
};
|
|
1205
1381
|
async function runRelease(flags) {
|
|
1206
1382
|
const file = flags.config === void 0 ? NO_CONFIG_FILE : readReleaseConfigFile(flags.config);
|
|
1383
|
+
const gatePublish = flags.gatePublish ?? file.gatePublish ?? false;
|
|
1384
|
+
if (gatePublish && flags.gateStateFile === void 0) throw new InvalidArgumentError("--gate-publish requires --gate-state-file, since that is where the state a later \"resume\" run needs gets written");
|
|
1207
1385
|
const outcome = await releaseWorkspace({
|
|
1208
1386
|
root: flags.root,
|
|
1209
1387
|
dryRun: flags.dryRun ?? (file.dryRun === true ? true : void 0),
|
|
@@ -1211,7 +1389,23 @@ async function runRelease(flags) {
|
|
|
1211
1389
|
plugins: flags.plugin.length > 0 ? flags.plugin.map((spec) => parsePluginSpec(spec)) : file.plugins,
|
|
1212
1390
|
analyzeCommits: flags.analyzeCommits === void 0 ? file.analyzeCommits : parseJsonObjectFlag(flags.analyzeCommits, "--analyze-commits"),
|
|
1213
1391
|
generateNotes: flags.generateNotes === void 0 ? file.generateNotes : parseJsonObjectFlag(flags.generateNotes, "--generate-notes"),
|
|
1214
|
-
commitStrategy: flags.commitStrategy ?? file.commitStrategy
|
|
1392
|
+
commitStrategy: flags.commitStrategy ?? file.commitStrategy,
|
|
1393
|
+
gatePublish
|
|
1394
|
+
});
|
|
1395
|
+
for (const pkg of outcome.packages) console.log(describeOutcome(pkg));
|
|
1396
|
+
if (gatePublish) {
|
|
1397
|
+
if (flags.gateStateFile === void 0) throw new WorkspaceReleaseError("gatePublish was true but no --gate-state-file was resolved -- this should be unreachable.");
|
|
1398
|
+
await writeFile(flags.gateStateFile, JSON.stringify(outcome.detached ?? [], null, 2));
|
|
1399
|
+
console.log(`${packageName}: wrote gate state for ${(outcome.detached ?? []).length} package(s) to ${flags.gateStateFile}`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
async function runResume(flags) {
|
|
1403
|
+
const raw = await readFile(flags.gateStateFile, "utf8");
|
|
1404
|
+
const parsed = JSON.parse(raw);
|
|
1405
|
+
if (!isDetachedPackageReleaseArray(parsed)) throw new InvalidArgumentError(`--gate-state-file ${flags.gateStateFile} does not contain a valid gate state array`);
|
|
1406
|
+
const outcome = await resumeWorkspaceRelease({
|
|
1407
|
+
root: flags.root,
|
|
1408
|
+
detached: parsed
|
|
1215
1409
|
});
|
|
1216
1410
|
for (const pkg of outcome.packages) console.log(describeOutcome(pkg));
|
|
1217
1411
|
}
|
|
@@ -1257,20 +1451,22 @@ function readReleaseConfigFile(path) {
|
|
|
1257
1451
|
const parsed = readConfigFile(path);
|
|
1258
1452
|
if (!isJsonObject(parsed)) throw new InvalidArgumentError(`--config file ${path} must contain a JSON object`);
|
|
1259
1453
|
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(", ")}`);
|
|
1260
|
-
const { dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy } = parsed;
|
|
1454
|
+
const { dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy, gatePublish } = parsed;
|
|
1261
1455
|
if (dryRun !== void 0 && typeof dryRun !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "dryRun" must be a boolean`);
|
|
1262
1456
|
if (branches !== void 0 && !isStringArray(branches)) throw new InvalidArgumentError(`--config file ${path}: "branches" must be an array of branch name strings`);
|
|
1263
1457
|
if (plugins !== void 0 && !Array.isArray(plugins)) throw new InvalidArgumentError(`--config file ${path}: "plugins" must be an array`);
|
|
1264
1458
|
if (analyzeCommits !== void 0 && !isJsonObject(analyzeCommits)) throw new InvalidArgumentError(`--config file ${path}: "analyzeCommits" must be an object`);
|
|
1265
1459
|
if (generateNotes !== void 0 && !isJsonObject(generateNotes)) throw new InvalidArgumentError(`--config file ${path}: "generateNotes" must be an object`);
|
|
1266
1460
|
if (commitStrategy !== void 0 && (typeof commitStrategy !== "string" || !isCommitStrategy(commitStrategy))) throw new InvalidArgumentError(`--config file ${path}: "commitStrategy" must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
|
|
1461
|
+
if (gatePublish !== void 0 && typeof gatePublish !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "gatePublish" must be a boolean`);
|
|
1267
1462
|
return {
|
|
1268
1463
|
dryRun,
|
|
1269
1464
|
branches,
|
|
1270
1465
|
plugins: plugins === void 0 ? void 0 : plugins.map((spec) => parseConfigFilePlugin(spec, path)),
|
|
1271
1466
|
analyzeCommits,
|
|
1272
1467
|
generateNotes,
|
|
1273
|
-
commitStrategy
|
|
1468
|
+
commitStrategy,
|
|
1469
|
+
gatePublish
|
|
1274
1470
|
};
|
|
1275
1471
|
}
|
|
1276
1472
|
function parseConfigFilePlugin(spec, path) {
|