@exadev/semantic-release-workspace 1.3.5 → 1.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/cli.js +165 -67
- package/dist/index.cjs +163 -65
- package/dist/index.d.cts +27 -18
- package/dist/index.d.ts +27 -18
- package/dist/index.js +163 -65
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -4,7 +4,6 @@ import { dirname, relative, resolve, sep } from "node:path";
|
|
|
4
4
|
import { glob } from "tinyglobby";
|
|
5
5
|
import { parse } from "yaml";
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
7
|
import validateNpmPackageName from "validate-npm-package-name";
|
|
9
8
|
import { analyzeCommits } from "@semantic-release/commit-analyzer";
|
|
10
9
|
import { generateNotes } from "@semantic-release/release-notes-generator";
|
|
@@ -37,7 +36,7 @@ var DependencyCycleError = class extends WorkspaceReleaseError {
|
|
|
37
36
|
this.cycle = cycle;
|
|
38
37
|
}
|
|
39
38
|
};
|
|
40
|
-
/** A dependency
|
|
39
|
+
/** A dependency range this tool cannot release correctly: either a range on a workspace sibling that it cannot rewrite with confidence (rewriting it wrongly, or leaving it silently stale, produces a published manifest that disagrees with the repository), or a `workspace:`, `catalog:`, `link:`, or `file:` specifier in a publishable package's published dependencies, which `npm publish` ships verbatim and no consumer can install. The run stops instead. */
|
|
41
40
|
var UnsupportedDependencyRangeError = class extends WorkspaceReleaseError {};
|
|
42
41
|
/** The semantic-release options handed to the orchestrator cannot be scoped to a single package -- typically a publish plugin list that would leave a release commit or a cross-package manifest bump uncommitted. */
|
|
43
42
|
var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
|
|
@@ -45,7 +44,7 @@ var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
|
|
|
45
44
|
var GitCommandError = class extends WorkspaceReleaseError {
|
|
46
45
|
exitCode;
|
|
47
46
|
constructor(args, cwd, exitCode, detail) {
|
|
48
|
-
super(`git ${args.join(" ")} failed in ${cwd}${exitCode === void 0 ? "" : ` (exit ${exitCode})`}: ${detail}`);
|
|
47
|
+
super(`git ${args.join(" ")} failed in ${cwd}${exitCode === void 0 ? "" : ` (exit ${String(exitCode)})`}: ${detail}`);
|
|
49
48
|
this.exitCode = exitCode;
|
|
50
49
|
}
|
|
51
50
|
};
|
|
@@ -58,10 +57,30 @@ var PnpmCommandError = class extends WorkspaceReleaseError {
|
|
|
58
57
|
}
|
|
59
58
|
};
|
|
60
59
|
//#endregion
|
|
60
|
+
//#region src/exec-file.ts
|
|
61
|
+
/**
|
|
62
|
+
* 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.
|
|
63
|
+
*/
|
|
64
|
+
async function execFile$1(command, args, options) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
execFile(command, [...args], {
|
|
67
|
+
cwd: options.cwd,
|
|
68
|
+
env: options.env,
|
|
69
|
+
maxBuffer: options.maxBuffer
|
|
70
|
+
}, (error, stdout, stderr) => {
|
|
71
|
+
if (error) {
|
|
72
|
+
reject(error instanceof Error ? error : new Error(error.message));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
resolve({
|
|
76
|
+
stdout,
|
|
77
|
+
stderr
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
61
83
|
//#region src/git.ts
|
|
62
|
-
const execFileAsync$1 = promisify(execFile);
|
|
63
|
-
/** `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. */
|
|
64
|
-
const GIT_MAX_BUFFER_BYTES = 104857600;
|
|
65
84
|
/** Separates one commit's record in `git log --format` output. Chosen from the C0 control range so it can never appear in a hash or a file path. */
|
|
66
85
|
const COMMIT_RECORD_SEPARATOR = "";
|
|
67
86
|
/** The identity semantic-release's own core writes release commits under in CI when nothing else is configured (its COMMIT_NAME/COMMIT_EMAIL constants); dependency-bump commits use the same fallback so every commit a release run produces has a consistent author when the repository declares none. */
|
|
@@ -89,18 +108,22 @@ const GIT_REPOSITORY_DISCOVERY_ENV_KEYS = [
|
|
|
89
108
|
*/
|
|
90
109
|
function sanitizeGitEnv(env) {
|
|
91
110
|
const sanitized = { ...env };
|
|
92
|
-
for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS)
|
|
111
|
+
for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) Reflect.deleteProperty(sanitized, key);
|
|
93
112
|
return sanitized;
|
|
94
113
|
}
|
|
95
114
|
const SANITIZED_PROCESS_GIT_ENV = sanitizeGitEnv(process.env);
|
|
115
|
+
/** `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 -- hence the generous 100 MiB default, rather than execFile's own. */
|
|
116
|
+
async function execGit(args, cwd, maxBuffer = 104857600) {
|
|
117
|
+
const { stdout } = await execFile$1("git", args, {
|
|
118
|
+
cwd,
|
|
119
|
+
maxBuffer,
|
|
120
|
+
env: SANITIZED_PROCESS_GIT_ENV
|
|
121
|
+
});
|
|
122
|
+
return stdout;
|
|
123
|
+
}
|
|
96
124
|
async function git(args, options) {
|
|
97
125
|
try {
|
|
98
|
-
|
|
99
|
-
cwd: options.cwd,
|
|
100
|
-
maxBuffer: GIT_MAX_BUFFER_BYTES,
|
|
101
|
-
env: SANITIZED_PROCESS_GIT_ENV
|
|
102
|
-
});
|
|
103
|
-
return stdout;
|
|
126
|
+
return await execGit(args, options.cwd);
|
|
104
127
|
} catch (cause) {
|
|
105
128
|
throw toGitCommandError(args, options.cwd, cause);
|
|
106
129
|
}
|
|
@@ -214,9 +237,11 @@ async function workingTreeChanges(options) {
|
|
|
214
237
|
const paths = [];
|
|
215
238
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
216
239
|
const entry = tokens[index];
|
|
217
|
-
if (entry === void 0
|
|
240
|
+
if (entry === void 0) continue;
|
|
218
241
|
const statusCode = entry.slice(0, 2);
|
|
219
|
-
|
|
242
|
+
const path = entry.slice(3);
|
|
243
|
+
if (path === "") continue;
|
|
244
|
+
paths.push(path);
|
|
220
245
|
if (statusCode.includes("R") || statusCode.includes("C")) index += 1;
|
|
221
246
|
}
|
|
222
247
|
return paths;
|
|
@@ -274,6 +299,12 @@ const DEPENDENCY_FIELDS = [
|
|
|
274
299
|
"peerDependencies",
|
|
275
300
|
"optionalDependencies"
|
|
276
301
|
];
|
|
302
|
+
/** 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. */
|
|
303
|
+
const INSTALLED_DEPENDENCY_FIELDS = [
|
|
304
|
+
"dependencies",
|
|
305
|
+
"peerDependencies",
|
|
306
|
+
"optionalDependencies"
|
|
307
|
+
];
|
|
277
308
|
async function readManifest(path) {
|
|
278
309
|
const text = await readFile(path, "utf8");
|
|
279
310
|
const parsed = JSON.parse(text);
|
|
@@ -281,7 +312,7 @@ async function readManifest(path) {
|
|
|
281
312
|
const { name, version } = parsed;
|
|
282
313
|
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.`);
|
|
283
314
|
const validity = validateNpmPackageName(name);
|
|
284
|
-
if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${
|
|
315
|
+
if (!validity.validForOldPackages) throw new WorkspaceDiscoveryError(`${path} has an invalid "name" ("${name}"): ${validity.errors.join("; ")}`);
|
|
285
316
|
if (typeof version !== "string" || version.length === 0) throw new WorkspaceDiscoveryError(`${path} has no "version".`);
|
|
286
317
|
const dependencies = /* @__PURE__ */ new Map();
|
|
287
318
|
for (const field of DEPENDENCY_FIELDS) {
|
|
@@ -293,6 +324,7 @@ async function readManifest(path) {
|
|
|
293
324
|
return {
|
|
294
325
|
name,
|
|
295
326
|
version,
|
|
327
|
+
private: parsed.private === true,
|
|
296
328
|
dependencies
|
|
297
329
|
};
|
|
298
330
|
}
|
|
@@ -347,6 +379,7 @@ async function discoverWorkspace(root) {
|
|
|
347
379
|
packages.push({
|
|
348
380
|
name: manifest.name,
|
|
349
381
|
version: manifest.version,
|
|
382
|
+
private: manifest.private,
|
|
350
383
|
directory,
|
|
351
384
|
relativeDirectory,
|
|
352
385
|
repoRelativeDirectory,
|
|
@@ -395,7 +428,14 @@ function toPosix(path) {
|
|
|
395
428
|
const WORKSPACE_PROTOCOL = "workspace:";
|
|
396
429
|
const CATALOG_PROTOCOL = "catalog:";
|
|
397
430
|
const NPM_ALIAS_PROTOCOL = "npm:";
|
|
398
|
-
/**
|
|
431
|
+
/** 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. */
|
|
432
|
+
const UNPUBLISHABLE_SPECIFIER_PROTOCOLS = [
|
|
433
|
+
WORKSPACE_PROTOCOL,
|
|
434
|
+
CATALOG_PROTOCOL,
|
|
435
|
+
"link:",
|
|
436
|
+
"file:"
|
|
437
|
+
];
|
|
438
|
+
/** The `workspace:` suffixes that name no version, and so leave nothing in the manifest to rewrite. */
|
|
399
439
|
const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
|
|
400
440
|
"*",
|
|
401
441
|
"^",
|
|
@@ -410,6 +450,13 @@ const WILDCARD_RANGES = [
|
|
|
410
450
|
"latest"
|
|
411
451
|
];
|
|
412
452
|
/**
|
|
453
|
+
* 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.
|
|
454
|
+
*/
|
|
455
|
+
function unpublishableSpecifierProtocol(specifier) {
|
|
456
|
+
const trimmed = specifier.trim();
|
|
457
|
+
return UNPUBLISHABLE_SPECIFIER_PROTOCOLS.find((protocol) => trimmed.startsWith(protocol));
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
413
460
|
* 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.
|
|
414
461
|
*/
|
|
415
462
|
const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
|
|
@@ -431,7 +478,7 @@ function classifyDependencyRange(current) {
|
|
|
431
478
|
comparator: inner.comparator
|
|
432
479
|
};
|
|
433
480
|
}
|
|
434
|
-
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.
|
|
481
|
+
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.`);
|
|
435
482
|
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.`);
|
|
436
483
|
if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
|
|
437
484
|
const match = REWRITABLE_COMPARATOR.exec(range);
|
|
@@ -455,6 +502,27 @@ function updateDependencyRange(current, version) {
|
|
|
455
502
|
};
|
|
456
503
|
}
|
|
457
504
|
//#endregion
|
|
505
|
+
//#region src/publishable-dependencies.ts
|
|
506
|
+
/**
|
|
507
|
+
* Throws `UnsupportedDependencyRangeError` unless every publishable package's installed dependencies (`dependencies`, `peerDependencies`, `optionalDependencies`) are ranges a consumer's package manager can resolve.
|
|
508
|
+
*
|
|
509
|
+
* 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.
|
|
510
|
+
*
|
|
511
|
+
* 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.
|
|
512
|
+
*/
|
|
513
|
+
function assertPublishableDependencies(packages) {
|
|
514
|
+
const offences = [];
|
|
515
|
+
for (const pkg of packages) {
|
|
516
|
+
if (pkg.private) continue;
|
|
517
|
+
for (const field of INSTALLED_DEPENDENCY_FIELDS) {
|
|
518
|
+
const declared = pkg.dependencies.get(field);
|
|
519
|
+
if (declared === void 0) continue;
|
|
520
|
+
for (const [dependency, specifier] of declared) if (unpublishableSpecifierProtocol(specifier) !== void 0) offences.push(` ${pkg.name}: "${dependency}" in ${field} is declared as "${specifier}"`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
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.`);
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
458
526
|
//#region src/graph.ts
|
|
459
527
|
/**
|
|
460
528
|
* Builds the inter-package dependency graph from the manifests alone.
|
|
@@ -530,11 +598,15 @@ function firstUnplacedDependency(name, graph, unplaced) {
|
|
|
530
598
|
return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
|
|
531
599
|
}
|
|
532
600
|
/**
|
|
533
|
-
*
|
|
601
|
+
* Validates every dependency range in the workspace before anything releases, so a run stops before the first publish rather than after some sibling has already been published, tagged, committed, and pushed. Two independent checks, both static properties of the manifests:
|
|
602
|
+
*
|
|
603
|
+
* - Every publishable package's installed dependencies must be specifiers a consumer can install (see `assertPublishableDependencies`), whichever package they name.
|
|
604
|
+
* - Every workspace dependency edge's range must have a shape this tool can maintain (see `classifyDependencyRange`, which depends only on the range text and never on which version a sibling ends up releasing).
|
|
534
605
|
*
|
|
535
|
-
* Shared by
|
|
606
|
+
* Both throw `UnsupportedDependencyRangeError`. Shared by every release path (`release.ts`'s per-package loop, `single-commit-release.ts`'s analysis phase, and `gate-publish.ts`'s detach), which is why it lives alongside the graph it validates rather than inside any one of them.
|
|
536
607
|
*/
|
|
537
|
-
function
|
|
608
|
+
function validateDependencyRanges(graph) {
|
|
609
|
+
assertPublishableDependencies([...graph.packages.values()]);
|
|
538
610
|
for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
|
|
539
611
|
}
|
|
540
612
|
/**
|
|
@@ -584,6 +656,10 @@ function matchTrailerLine(message, key) {
|
|
|
584
656
|
}
|
|
585
657
|
//#endregion
|
|
586
658
|
//#region src/plugins.ts
|
|
659
|
+
/** semantic-release's own `getLastRelease` returns `{}` for a package with no prior tag -- not `undefined`, and not a fully-populated `LastRelease` -- contradicting the `gitHead: string` its own type declares. Narrows structurally rather than trusting that declared type, so a first-release context's `lastRelease` (correctly, at runtime) never claims a `gitHead` it does not have. */
|
|
660
|
+
function hasGitHead(lastRelease) {
|
|
661
|
+
return typeof lastRelease === "object" && lastRelease !== null && "gitHead" in lastRelease && typeof lastRelease.gitHead === "string";
|
|
662
|
+
}
|
|
587
663
|
/** 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. */
|
|
588
664
|
const DEFAULT_PUBLISH_PLUGINS = [
|
|
589
665
|
"@semantic-release/changelog",
|
|
@@ -594,7 +670,7 @@ const DEFAULT_PUBLISH_PLUGINS = [
|
|
|
594
670
|
message: "chore(release): ${nextRelease.gitTag} [skip ci]"
|
|
595
671
|
}]
|
|
596
672
|
];
|
|
597
|
-
/** The standard publish pipeline for `commitStrategy: 'single'`: the same as `DEFAULT_PUBLISH_PLUGINS` minus
|
|
673
|
+
/** 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. */
|
|
598
674
|
const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
|
|
599
675
|
"@semantic-release/changelog",
|
|
600
676
|
"@semantic-release/npm",
|
|
@@ -604,14 +680,14 @@ const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-r
|
|
|
604
680
|
/**
|
|
605
681
|
* Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
|
|
606
682
|
*
|
|
607
|
-
* Both apply the same path scoping before delegating to the real
|
|
683
|
+
* 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.
|
|
608
684
|
*
|
|
609
685
|
* 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.
|
|
610
686
|
*/
|
|
611
687
|
function createScopedPlugins(scope) {
|
|
612
688
|
let cached;
|
|
613
689
|
async function commitsForPackage(context) {
|
|
614
|
-
const from = context.lastRelease
|
|
690
|
+
const from = hasGitHead(context.lastRelease) ? context.lastRelease.gitHead : void 0;
|
|
615
691
|
if (cached === void 0) cached = {
|
|
616
692
|
from,
|
|
617
693
|
paths: changedPathsSince(from, { cwd: context.cwd })
|
|
@@ -631,10 +707,10 @@ function createScopedPlugins(scope) {
|
|
|
631
707
|
...context,
|
|
632
708
|
commits
|
|
633
709
|
});
|
|
634
|
-
if (type) return type;
|
|
710
|
+
if (typeof type === "string") return type;
|
|
635
711
|
const bumps = mergeDependencyBumps(scope.bumps.bumpsFor(scope.pkg.name), commits);
|
|
636
712
|
if (bumps.length === 0) return false;
|
|
637
|
-
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.`);
|
|
713
|
+
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.`);
|
|
638
714
|
return "patch";
|
|
639
715
|
},
|
|
640
716
|
async generateNotes(_pluginConfig, context) {
|
|
@@ -650,7 +726,7 @@ function createScopedPlugins(scope) {
|
|
|
650
726
|
"",
|
|
651
727
|
...bumps.map((bump) => describeDependencyBump(bump))
|
|
652
728
|
].join("\n");
|
|
653
|
-
return notes ? `${notes}\n\n${section}` : section;
|
|
729
|
+
return typeof notes === "string" ? `${notes}\n\n${section}` : section;
|
|
654
730
|
}
|
|
655
731
|
};
|
|
656
732
|
}
|
|
@@ -722,7 +798,6 @@ function parsePublishPluginSpec(spec) {
|
|
|
722
798
|
}
|
|
723
799
|
//#endregion
|
|
724
800
|
//#region src/pnpm.ts
|
|
725
|
-
const execFileAsync = promisify(execFile);
|
|
726
801
|
/**
|
|
727
802
|
* Regenerates `pnpm-lock.yaml` for the whole workspace from the manifests currently on disk, without touching `node_modules` or installing anything -- the same lockfile-refresh step a contributor runs by hand after editing a `package.json` dependency range.
|
|
728
803
|
*
|
|
@@ -730,7 +805,7 @@ const execFileAsync = promisify(execFile);
|
|
|
730
805
|
*/
|
|
731
806
|
async function regenerateLockfile(options) {
|
|
732
807
|
try {
|
|
733
|
-
await
|
|
808
|
+
await execFile$1("pnpm", ["install", "--lockfile-only"], { cwd: options.cwd });
|
|
734
809
|
} catch (cause) {
|
|
735
810
|
const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
|
|
736
811
|
const detail = stderr !== "" ? stderr : cause instanceof Error ? cause.message : String(cause);
|
|
@@ -745,8 +820,8 @@ async function regenerateLockfile(options) {
|
|
|
745
820
|
* Five phases, all inside one `releaseWorkspaceSingleCommit` call:
|
|
746
821
|
*
|
|
747
822
|
* 1. **Analyse** (this file's `analysePackage`): for every package, in topological order, run semantic-release with `dryRun: true` forced (regardless of the caller's own `dryRun` option) using the same path-scoped `analyzeCommits`/`generateNotes` wrapper `commitStrategy: 'per-package'` uses -- computing each package's next version and notes without writing, committing, tagging, or publishing anything. Cross-package dependency bumps are tracked purely in memory during this phase (`pendingBumps`), exactly as the per-package strategy tracks them for the span of one run; nothing is committed yet for a later run to recover from, because this strategy never leaves a partial commit for a crash to recover from in the first place -- either the whole combined commit lands, or nothing does.
|
|
748
|
-
* 2. **Verify** every released package's configured publish plugins' `verifyConditions` step (npm registry auth, GitHub token/repo access), before any file is written -- the same fail-fast-before-anything-releases discipline `
|
|
749
|
-
* 3. **Prepare**: for every released package, in topological order, apply any dependency-range bump its own manifest received (writing `package.json` directly, the same `writeDependencyRange` the per-package strategy uses), then run every configured publish plugin's own `prepare` step generically (whichever it defines --
|
|
823
|
+
* 2. **Verify** every released package's configured publish plugins' `verifyConditions` step (npm registry auth, GitHub token/repo access), before any file is written -- the same fail-fast-before-anything-releases discipline `validateDependencyRanges` already applies to dependency ranges.
|
|
824
|
+
* 3. **Prepare**: for every released package, in topological order, apply any dependency-range bump its own manifest received (writing `package.json` directly, the same `writeDependencyRange` the per-package strategy uses), then run every configured publish plugin's own `prepare` step generically (whichever it defines -- `@semantic-release/npm` bumps `package.json`'s version, `@semantic-release/changelog` writes `CHANGELOG.md`). `@semantic-release/git` is rejected outright from this mode's plugin list (see `resolvePublishPlugins`'s `forbidGitPlugin`), since its own `prepare` step would create exactly the per-package commit this mode exists to avoid. The lockfile is regenerated once at the end, not once per bump, since `pnpm install --lockfile-only` recomputes it from whatever is on disk regardless of how many manifests changed.
|
|
750
825
|
* 4. **Commit**: discover every file phase 3 touched via `git status` (rather than predicting filenames per plugin), make one commit, tag it once per released package (`name@version`, lightweight, matching semantic-release's own tag form), and push the commit and every tag together.
|
|
751
826
|
* 5. **Publish**: for every released package, in topological order, call each configured plugin's own `publish` step directly (not through semantic-release's top-level orchestrator -- see the note below), then `success`.
|
|
752
827
|
*
|
|
@@ -761,9 +836,9 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
761
836
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
|
|
762
837
|
await assertCleanWorkingTree({ cwd: repoRoot });
|
|
763
838
|
const graph = buildDependencyGraph(workspace.packages);
|
|
764
|
-
|
|
839
|
+
validateDependencyRanges(graph);
|
|
765
840
|
const order = topologicalOrder(graph);
|
|
766
|
-
log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
|
|
841
|
+
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
|
|
767
842
|
const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
|
|
768
843
|
requireGitPlugin: false,
|
|
769
844
|
forbidGitPlugin: true
|
|
@@ -789,19 +864,26 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
789
864
|
bumpsForThisPackage,
|
|
790
865
|
env,
|
|
791
866
|
branches: options.branches,
|
|
792
|
-
onCommitsResolved: (commits) =>
|
|
867
|
+
onCommitsResolved: (commits) => {
|
|
868
|
+
capturedCommits.set(name, commits);
|
|
869
|
+
},
|
|
793
870
|
onContextCaptured: (context) => {
|
|
794
871
|
captured.branch = context.branch;
|
|
795
872
|
captured.repositoryUrl = context.repositoryUrl;
|
|
796
873
|
}
|
|
797
874
|
});
|
|
798
|
-
outcomes.push({
|
|
875
|
+
outcomes.push(nextRelease === void 0 ? {
|
|
876
|
+
name,
|
|
877
|
+
directory: pkg.directory,
|
|
878
|
+
released: false,
|
|
879
|
+
dependencyBumps: bumpsForThisPackage
|
|
880
|
+
} : {
|
|
799
881
|
name,
|
|
800
882
|
directory: pkg.directory,
|
|
801
|
-
released:
|
|
802
|
-
version: nextRelease
|
|
803
|
-
gitTag: nextRelease
|
|
804
|
-
type: nextRelease
|
|
883
|
+
released: true,
|
|
884
|
+
version: nextRelease.version,
|
|
885
|
+
gitTag: nextRelease.gitTag,
|
|
886
|
+
type: nextRelease.type,
|
|
805
887
|
dependencyBumps: bumpsForThisPackage
|
|
806
888
|
});
|
|
807
889
|
if (nextRelease === void 0) {
|
|
@@ -829,7 +911,7 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
829
911
|
};
|
|
830
912
|
const branch = captured.branch;
|
|
831
913
|
const repositoryUrl = captured.repositoryUrl;
|
|
832
|
-
if (branch === void 0 || repositoryUrl === void 0) throw new ReleaseConfigurationError(`Internal error: ${packageName} analysed ${planned.length} package release(s) but never captured a branch/repositoryUrl from semantic-release's own context. This should be impossible when at least one package releases.`);
|
|
914
|
+
if (branch === void 0 || repositoryUrl === void 0) throw new ReleaseConfigurationError(`Internal error: ${packageName} analysed ${String(planned.length)} package release(s) but never captured a branch/repositoryUrl from semantic-release's own context. This should be impossible when at least one package releases.`);
|
|
833
915
|
const shared = {
|
|
834
916
|
env,
|
|
835
917
|
branch,
|
|
@@ -855,7 +937,7 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
855
937
|
}
|
|
856
938
|
if (anyRangeRewritten) await regenerateLockfile({ cwd: workspace.root });
|
|
857
939
|
const touchedPaths = await workingTreeChanges({ cwd: repoRoot });
|
|
858
|
-
if (touchedPaths.length === 0) throw new ReleaseConfigurationError(`${packageName}: analysis planned ${planned.length} release(s), but no files changed while preparing them. Every configured publish plugin's own "prepare" step (bumping package.json, writing CHANGELOG.md) produced nothing to commit -- check the plugin list includes something that writes the version, e.g. @semantic-release/npm.`);
|
|
940
|
+
if (touchedPaths.length === 0) throw new ReleaseConfigurationError(`${packageName}: analysis planned ${String(planned.length)} release(s), but no files changed while preparing them. Every configured publish plugin's own "prepare" step (bumping package.json, writing CHANGELOG.md) produced nothing to commit -- check the plugin list includes something that writes the version, e.g. @semantic-release/npm.`);
|
|
859
941
|
const identity = await resolveCommitIdentity({ cwd: repoRoot });
|
|
860
942
|
await commitFiles(touchedPaths, describeCombinedCommit(planned), {
|
|
861
943
|
cwd: repoRoot,
|
|
@@ -865,7 +947,7 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
865
947
|
const tagNames = planned.map((release) => release.gitTag);
|
|
866
948
|
for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
|
|
867
949
|
await pushHeadAndTags(tagNames, { cwd: repoRoot });
|
|
868
|
-
log(`${packageName}: committed ${commitSha} and pushed ${tagNames.length} tag(s): ${tagNames.join(", ")}`);
|
|
950
|
+
log(`${packageName}: committed ${commitSha} and pushed ${String(tagNames.length)} tag(s): ${tagNames.join(", ")}`);
|
|
869
951
|
for (const release of planned) {
|
|
870
952
|
const releases = [];
|
|
871
953
|
for (const [modulePath, pluginConfig] of resolvedPlugins) {
|
|
@@ -957,10 +1039,18 @@ function describeCombinedCommit(planned) {
|
|
|
957
1039
|
}
|
|
958
1040
|
function buildPluginContext(release, shared, capturedCommits, releases) {
|
|
959
1041
|
const logger = {
|
|
960
|
-
log: (...args) =>
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1042
|
+
log: (...args) => {
|
|
1043
|
+
shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`);
|
|
1044
|
+
},
|
|
1045
|
+
warn: (...args) => {
|
|
1046
|
+
shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`);
|
|
1047
|
+
},
|
|
1048
|
+
error: (...args) => {
|
|
1049
|
+
shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`);
|
|
1050
|
+
},
|
|
1051
|
+
success: (...args) => {
|
|
1052
|
+
shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`);
|
|
1053
|
+
}
|
|
964
1054
|
};
|
|
965
1055
|
return {
|
|
966
1056
|
cwd: release.pkg.directory,
|
|
@@ -1018,9 +1108,9 @@ async function detachWorkspaceRelease(options) {
|
|
|
1018
1108
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1019
1109
|
const workspace = await discoverWorkspace(root);
|
|
1020
1110
|
const graph = buildDependencyGraph(workspace.packages);
|
|
1021
|
-
|
|
1111
|
+
validateDependencyRanges(graph);
|
|
1022
1112
|
const order = topologicalOrder(graph);
|
|
1023
|
-
log(`${packageName}: ${order.length} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
|
|
1113
|
+
log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
|
|
1024
1114
|
const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
|
|
1025
1115
|
const analyzeCommitsConfig = options.analyzeCommits ?? {};
|
|
1026
1116
|
const generateNotesConfig = options.generateNotes ?? {};
|
|
@@ -1042,15 +1132,20 @@ async function detachWorkspaceRelease(options) {
|
|
|
1042
1132
|
});
|
|
1043
1133
|
return {
|
|
1044
1134
|
order,
|
|
1045
|
-
packages: entries.map((entry) =>
|
|
1135
|
+
packages: entries.map((entry) => entry.result === null ? {
|
|
1046
1136
|
name: entry.name,
|
|
1047
1137
|
directory: entry.directory,
|
|
1048
|
-
released:
|
|
1049
|
-
version: entry.result?.nextRelease.version,
|
|
1050
|
-
gitTag: entry.result?.nextRelease.gitTag,
|
|
1051
|
-
type: entry.result?.nextRelease.type,
|
|
1138
|
+
released: false,
|
|
1052
1139
|
dependencyBumps: entry.dependencyBumps
|
|
1053
|
-
}
|
|
1140
|
+
} : {
|
|
1141
|
+
name: entry.name,
|
|
1142
|
+
directory: entry.directory,
|
|
1143
|
+
released: true,
|
|
1144
|
+
version: entry.result.nextRelease.version,
|
|
1145
|
+
gitTag: entry.result.nextRelease.gitTag,
|
|
1146
|
+
type: entry.result.nextRelease.type,
|
|
1147
|
+
dependencyBumps: entry.dependencyBumps
|
|
1148
|
+
}),
|
|
1054
1149
|
detached: entries.map((entry) => ({
|
|
1055
1150
|
name: entry.name,
|
|
1056
1151
|
relativeDirectory: entry.relativeDirectory,
|
|
@@ -1090,6 +1185,7 @@ async function resumeWorkspaceRelease(options) {
|
|
|
1090
1185
|
const root = resolve(options.root ?? process.cwd());
|
|
1091
1186
|
const log = options.log ?? console.log;
|
|
1092
1187
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1188
|
+
assertPublishableDependencies(await Promise.all(options.detached.filter((entry) => entry.state !== null).map(async (entry) => readManifest(resolve(root, entry.relativeDirectory, "package.json")))));
|
|
1093
1189
|
const order = [];
|
|
1094
1190
|
const packages = [];
|
|
1095
1191
|
for (const entry of options.detached) {
|
|
@@ -1100,9 +1196,6 @@ async function resumeWorkspaceRelease(options) {
|
|
|
1100
1196
|
name: entry.name,
|
|
1101
1197
|
directory: resolve(root, entry.relativeDirectory),
|
|
1102
1198
|
released: false,
|
|
1103
|
-
version: void 0,
|
|
1104
|
-
gitTag: void 0,
|
|
1105
|
-
type: void 0,
|
|
1106
1199
|
dependencyBumps: entry.dependencyBumps
|
|
1107
1200
|
});
|
|
1108
1201
|
continue;
|
|
@@ -1118,7 +1211,7 @@ async function resumeWorkspaceRelease(options) {
|
|
|
1118
1211
|
} catch (cause) {
|
|
1119
1212
|
throw new WorkspaceReleaseError(`Resuming ${entry.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1120
1213
|
}
|
|
1121
|
-
log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${releases.length} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
|
|
1214
|
+
log(`${entry.name}: published ${entry.state.nextRelease.gitTag} (${String(releases.length)} publish plugin${releases.length === 1 ? "" : "s"} ran)`);
|
|
1122
1215
|
packages.push({
|
|
1123
1216
|
name: entry.name,
|
|
1124
1217
|
directory,
|
|
@@ -1153,9 +1246,9 @@ async function releaseWorkspace(options = {}) {
|
|
|
1153
1246
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1154
1247
|
const workspace = await discoverWorkspace(root);
|
|
1155
1248
|
const graph = buildDependencyGraph(workspace.packages);
|
|
1156
|
-
|
|
1249
|
+
validateDependencyRanges(graph);
|
|
1157
1250
|
const order = topologicalOrder(graph);
|
|
1158
|
-
log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")}`);
|
|
1251
|
+
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
|
|
1159
1252
|
const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
|
|
1160
1253
|
const analyzeCommitsConfig = options.analyzeCommits ?? {};
|
|
1161
1254
|
const generateNotesConfig = options.generateNotes ?? {};
|
|
@@ -1179,13 +1272,18 @@ async function releaseWorkspace(options = {}) {
|
|
|
1179
1272
|
};
|
|
1180
1273
|
})).map((entry) => {
|
|
1181
1274
|
const nextRelease = entry.result === false ? void 0 : entry.result.nextRelease;
|
|
1182
|
-
return {
|
|
1275
|
+
return nextRelease === void 0 ? {
|
|
1183
1276
|
name: entry.name,
|
|
1184
1277
|
directory: entry.directory,
|
|
1185
|
-
released:
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1278
|
+
released: false,
|
|
1279
|
+
dependencyBumps: entry.dependencyBumps
|
|
1280
|
+
} : {
|
|
1281
|
+
name: entry.name,
|
|
1282
|
+
directory: entry.directory,
|
|
1283
|
+
released: true,
|
|
1284
|
+
version: nextRelease.version,
|
|
1285
|
+
gitTag: nextRelease.gitTag,
|
|
1286
|
+
type: nextRelease.type,
|
|
1189
1287
|
dependencyBumps: entry.dependencyBumps
|
|
1190
1288
|
};
|
|
1191
1289
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exadev/semantic-release-workspace",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.7",
|
|
4
4
|
"description": "Independent per-package semantic-release orchestration for pnpm workspaces, without lockstep versioning.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"ci"
|
|
76
76
|
],
|
|
77
77
|
"license": "MIT",
|
|
78
|
-
"packageManager": "pnpm@
|
|
78
|
+
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
|
|
79
79
|
"dependencies": {
|
|
80
80
|
"@exadev/release-gate": "^1.0.0",
|
|
81
81
|
"commander": "^15.0.0",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"@commitlint/cli": "^21.2.2",
|
|
90
90
|
"@commitlint/config-conventional": "^21.2.2",
|
|
91
91
|
"@eslint/js": "^10.0.1",
|
|
92
|
-
"@exadev/eslint-config": "
|
|
92
|
+
"@exadev/eslint-config": "2.12.1",
|
|
93
93
|
"@semantic-release/changelog": "^7.0.0",
|
|
94
94
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
95
95
|
"@semantic-release/git": "^11.0.1",
|