@exadev/semantic-release-workspace 1.1.7 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/dist/cli.js +444 -74
- package/dist/index.cjs +429 -72
- package/dist/index.d.cts +17 -3
- package/dist/index.d.ts +17 -3
- package/dist/index.js +429 -73
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -12,8 +12,9 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
12
12
|
import validateNpmPackageName from "validate-npm-package-name";
|
|
13
13
|
import { glob } from "tinyglobby";
|
|
14
14
|
import { parse } from "yaml";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
15
16
|
//#region package.json
|
|
16
|
-
var version = "1.1
|
|
17
|
+
var version = "1.2.1";
|
|
17
18
|
//#endregion
|
|
18
19
|
//#region src/errors.ts
|
|
19
20
|
/**
|
|
@@ -141,11 +142,36 @@ const BOT_IDENTITY = {
|
|
|
141
142
|
name: "semantic-release-bot",
|
|
142
143
|
email: "semantic-release-bot@martynus.net"
|
|
143
144
|
};
|
|
145
|
+
/**
|
|
146
|
+
* The environment variables git itself sets when it invokes a hook (`pre-push`, `pre-commit`, and friends), which then leak into every child process a hook spawns unless a child explicitly clears them: `GIT_DIR` (and, in a worktree checkout, its value points at that worktree's own git-dir under the main repository's `.git/worktrees/<name>`), `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_PREFIX`, `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_COMMON_DIR`, and `GIT_NAMESPACE`. Every one of these overrides git's normal cwd-based repository discovery, so if any of them survive into a `git()` call this package makes with an explicit `cwd` (most obviously `git init` on a throwaway temporary directory in the test suite's own fixtures), the command silently targets whatever repository the *outer* invocation was hooked from instead of the directory this package actually asked for -- observed directly as `git init` in a fixture's temp directory failing to lock the real repository's own `.git/config`, because `GIT_DIR` inherited from this package's own `pre-push` hook pointed straight back at it. `GIT_EXEC_PATH`, `GIT_EDITOR`, and networking/auth-related `GIT_*` variables (SSH, proxy, credential helpers) are deliberately left alone: they do not affect which repository a command targets, only how it behaves once it has found one.
|
|
147
|
+
*/
|
|
148
|
+
const GIT_REPOSITORY_DISCOVERY_ENV_KEYS = [
|
|
149
|
+
"GIT_DIR",
|
|
150
|
+
"GIT_WORK_TREE",
|
|
151
|
+
"GIT_INDEX_FILE",
|
|
152
|
+
"GIT_PREFIX",
|
|
153
|
+
"GIT_OBJECT_DIRECTORY",
|
|
154
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
155
|
+
"GIT_COMMON_DIR",
|
|
156
|
+
"GIT_NAMESPACE"
|
|
157
|
+
];
|
|
158
|
+
/**
|
|
159
|
+
* A copy of the given environment with every repository-discovery-affecting `GIT_*` key removed -- see `GIT_REPOSITORY_DISCOVERY_ENV_KEYS`.
|
|
160
|
+
*
|
|
161
|
+
* Exported (not just used internally by `git()`) because this package hands an environment to two different kinds of subprocess: its own `git()` calls (sanitized unconditionally below, from `process.env`, since those never take a caller-supplied environment) and semantic-release's own programmatic API, which spawns its *own* internal git subprocesses (tag, push, verifyAuth) using whatever `env` this package passes it -- release.ts and single-commit-release.ts both call this on the environment they construct for that, so a `GIT_DIR` inherited from an outer hook cannot leak into semantic-release's internal git calls either, not just into this package's own.
|
|
162
|
+
*/
|
|
163
|
+
function sanitizeGitEnv(env) {
|
|
164
|
+
const sanitized = { ...env };
|
|
165
|
+
for (const key of GIT_REPOSITORY_DISCOVERY_ENV_KEYS) delete sanitized[key];
|
|
166
|
+
return sanitized;
|
|
167
|
+
}
|
|
168
|
+
const SANITIZED_PROCESS_GIT_ENV = sanitizeGitEnv(process.env);
|
|
144
169
|
async function git(args, options) {
|
|
145
170
|
try {
|
|
146
171
|
const { stdout } = await execFileAsync$1("git", [...args], {
|
|
147
172
|
cwd: options.cwd,
|
|
148
|
-
maxBuffer: GIT_MAX_BUFFER_BYTES
|
|
173
|
+
maxBuffer: GIT_MAX_BUFFER_BYTES,
|
|
174
|
+
env: SANITIZED_PROCESS_GIT_ENV
|
|
149
175
|
});
|
|
150
176
|
return stdout;
|
|
151
177
|
} catch (cause) {
|
|
@@ -238,6 +264,50 @@ async function pushHead(options) {
|
|
|
238
264
|
`HEAD:${await currentBranch(options)}`
|
|
239
265
|
], options);
|
|
240
266
|
}
|
|
267
|
+
/** Creates a lightweight tag (`git tag <name> <ref>`, no annotation) at the given commit -- the same form semantic-release's own core creates its release tags with (see its `lib/git.js`), so a tag this tool creates directly is indistinguishable from one semantic-release would have made itself. */
|
|
268
|
+
async function createTag(name, ref, options) {
|
|
269
|
+
await git([
|
|
270
|
+
"tag",
|
|
271
|
+
name,
|
|
272
|
+
ref
|
|
273
|
+
], options);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Lists every path with a working-tree or index change (modified, added, deleted, untracked), repository-root-relative, via `git status --porcelain=v1 -z`. `commitStrategy: 'single'` uses this rather than predicting which files each configured prepare plugin touched (a version bump, a changelog write, a dependency-range rewrite, a regenerated lockfile) by name: asking git what actually changed is correct regardless of which prepare plugins are configured or how they name their own output files.
|
|
277
|
+
*
|
|
278
|
+
* `-z` NUL-terminates every field so a path containing a space or newline cannot be misread as two paths; a rename or copy (status codes `R`/`C`) carries two NUL-terminated fields (the new path first, then the origin path), so it consumes two tokens instead of one.
|
|
279
|
+
*/
|
|
280
|
+
async function workingTreeChanges(options) {
|
|
281
|
+
const tokens = (await git([
|
|
282
|
+
"status",
|
|
283
|
+
"--porcelain=v1",
|
|
284
|
+
"--untracked-files=all",
|
|
285
|
+
"-z"
|
|
286
|
+
], options)).split("\0").filter((token) => token !== "");
|
|
287
|
+
const paths = [];
|
|
288
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
289
|
+
const entry = tokens[index];
|
|
290
|
+
if (entry === void 0 || entry.length < 4) continue;
|
|
291
|
+
const statusCode = entry.slice(0, 2);
|
|
292
|
+
paths.push(entry.slice(3));
|
|
293
|
+
if (statusCode.includes("R") || statusCode.includes("C")) index += 1;
|
|
294
|
+
}
|
|
295
|
+
return paths;
|
|
296
|
+
}
|
|
297
|
+
/** Fails loudly if the working tree is not clean, rather than silently folding pre-existing, unrelated dirty state into a release commit. `commitStrategy: 'single'` calls this before it starts, since it relies on `workingTreeChanges` to discover exactly what its own run touches. */
|
|
298
|
+
async function assertCleanWorkingTree(options) {
|
|
299
|
+
const changes = await workingTreeChanges(options);
|
|
300
|
+
if (changes.length > 0) throw new WorkspaceStateError(`The working tree in ${options.cwd} is not clean: ${changes.join(", ")}. commitStrategy "single" discovers what it touched via "git status", so it requires a clean tree to start from; commit, stash elsewhere, or discard these changes first.`);
|
|
301
|
+
}
|
|
302
|
+
/** Pushes the current branch's head and a set of tags to origin in one push, so a combined release commit and every tag pointing at it land on the remote as a single atomic-looking update rather than as separate pushes an interrupted run could split across. */
|
|
303
|
+
async function pushHeadAndTags(tagNames, options) {
|
|
304
|
+
await git([
|
|
305
|
+
"push",
|
|
306
|
+
"origin",
|
|
307
|
+
`HEAD:${await currentBranch(options)}`,
|
|
308
|
+
...tagNames
|
|
309
|
+
], options);
|
|
310
|
+
}
|
|
241
311
|
function toGitCommandError(args, cwd, cause) {
|
|
242
312
|
const exitCode = cause instanceof Error && "code" in cause && typeof cause.code === "number" ? cause.code : void 0;
|
|
243
313
|
const stderr = cause instanceof Error && "stderr" in cause && typeof cause.stderr === "string" ? cause.stderr.trim() : "";
|
|
@@ -255,6 +325,12 @@ const DEFAULT_PUBLISH_PLUGINS = [
|
|
|
255
325
|
message: "chore(release): ${nextRelease.gitTag} [skip ci]"
|
|
256
326
|
}]
|
|
257
327
|
];
|
|
328
|
+
/** 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. */
|
|
329
|
+
const SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS = [
|
|
330
|
+
"@semantic-release/changelog",
|
|
331
|
+
"@semantic-release/npm",
|
|
332
|
+
"@semantic-release/github"
|
|
333
|
+
];
|
|
258
334
|
const STEP_PLUGINS_THE_ORCHESTRATOR_OWNS = /* @__PURE__ */ new Set(["@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator"]);
|
|
259
335
|
/**
|
|
260
336
|
* Builds the per-package `analyzeCommits` and `generateNotes` functions handed to semantic-release as inline plugins.
|
|
@@ -275,7 +351,9 @@ function createScopedPlugins(scope) {
|
|
|
275
351
|
from,
|
|
276
352
|
paths: changedPathsSince(from, { cwd: context.cwd })
|
|
277
353
|
};
|
|
278
|
-
|
|
354
|
+
const commits = filterCommitsToDirectory(context.commits, await cached.paths, scope.pkg.repoRelativeDirectory);
|
|
355
|
+
scope.onCommitsResolved?.(commits);
|
|
356
|
+
return commits;
|
|
279
357
|
}
|
|
280
358
|
return {
|
|
281
359
|
async analyzeCommits(_pluginConfig, context) {
|
|
@@ -346,7 +424,10 @@ function resolvePublishPlugins(specs, workspaceRoot, options) {
|
|
|
346
424
|
for (const spec of specs) {
|
|
347
425
|
const [name, config] = parsePublishPluginSpec(spec);
|
|
348
426
|
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.`);
|
|
349
|
-
if (name === "@semantic-release/git")
|
|
427
|
+
if (name === "@semantic-release/git") {
|
|
428
|
+
hasGitPlugin = true;
|
|
429
|
+
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.`);
|
|
430
|
+
}
|
|
350
431
|
const entry = [resolvePluginModule(name, requireFromTool, requireFromWorkspace), config];
|
|
351
432
|
resolved.push(entry);
|
|
352
433
|
}
|
|
@@ -415,6 +496,70 @@ async function writeDependencyRange(path, field, dependency, range) {
|
|
|
415
496
|
await writeFile(path, stringifyJsonLike(parsed, text), "utf8");
|
|
416
497
|
}
|
|
417
498
|
//#endregion
|
|
499
|
+
//#region src/version-range.ts
|
|
500
|
+
const WORKSPACE_PROTOCOL = "workspace:";
|
|
501
|
+
const CATALOG_PROTOCOL = "catalog:";
|
|
502
|
+
const NPM_ALIAS_PROTOCOL = "npm:";
|
|
503
|
+
/** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
|
|
504
|
+
const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
|
|
505
|
+
"*",
|
|
506
|
+
"^",
|
|
507
|
+
"~"
|
|
508
|
+
];
|
|
509
|
+
/** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
|
|
510
|
+
const WILDCARD_RANGES = [
|
|
511
|
+
"",
|
|
512
|
+
"*",
|
|
513
|
+
"x",
|
|
514
|
+
"X",
|
|
515
|
+
"latest"
|
|
516
|
+
];
|
|
517
|
+
/**
|
|
518
|
+
* 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.
|
|
519
|
+
*/
|
|
520
|
+
const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
|
|
521
|
+
/**
|
|
522
|
+
* Classifies a dependency range's shape, throwing `UnsupportedDependencyRangeError` for anything this tool cannot rewrite with confidence: a compound range (`>=1.0.0 <2.0.0`), a union (`1.x || 2.x`), a `catalog:` reference whose real version lives in `pnpm-workspace.yaml`, an `npm:` alias, a git or tarball URL. Guessing at those would either corrupt the range or silently leave it pointing at a version that no longer exists in the workspace, and a stale published range is exactly the divergence this tool exists to prevent.
|
|
523
|
+
*
|
|
524
|
+
* This never needs the version a sibling is releasing: every case above depends only on the shape of `current` itself, which is what lets `releaseWorkspace` validate every workspace dependency edge up front, before any package has published anything, rather than discovering an unsupported range only when the first dependency it names happens to release.
|
|
525
|
+
*/
|
|
526
|
+
function classifyDependencyRange(current) {
|
|
527
|
+
const range = current.trim();
|
|
528
|
+
if (range.startsWith(WORKSPACE_PROTOCOL)) {
|
|
529
|
+
const suffix = range.slice(10);
|
|
530
|
+
if (PUBLISH_RESOLVED_WORKSPACE_SUFFIXES.includes(suffix)) return { kind: "resolved-at-publish" };
|
|
531
|
+
const inner = classifyDependencyRange(suffix);
|
|
532
|
+
if (inner.kind !== "rewritable") throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only "workspace:*", "workspace:^", "workspace:~", and "workspace:" followed by a single concrete version range are supported.`);
|
|
533
|
+
return {
|
|
534
|
+
kind: "rewritable",
|
|
535
|
+
workspacePrefixed: true,
|
|
536
|
+
comparator: inner.comparator
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
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. Depend on the sibling directly (for example "workspace:^") instead.`);
|
|
540
|
+
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.`);
|
|
541
|
+
if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
|
|
542
|
+
const match = REWRITABLE_COMPARATOR.exec(range);
|
|
543
|
+
if (match === null) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only a single "^", "~", ">=", "=", or bare version comparator can be rewritten in place.`);
|
|
544
|
+
return {
|
|
545
|
+
kind: "rewritable",
|
|
546
|
+
workspacePrefixed: false,
|
|
547
|
+
comparator: match[1] ?? ""
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Computes what a dependency range on a workspace sibling becomes once that sibling releases `version`, by classifying the range's shape and then, for a rewritable shape, substituting `version` in place of the version it currently names.
|
|
552
|
+
*/
|
|
553
|
+
function updateDependencyRange(current, version) {
|
|
554
|
+
const shape = classifyDependencyRange(current);
|
|
555
|
+
if (shape.kind !== "rewritable") return shape;
|
|
556
|
+
const rewritten = `${shape.comparator}${version}`;
|
|
557
|
+
return {
|
|
558
|
+
kind: "rewritten",
|
|
559
|
+
range: shape.workspacePrefixed ? `${WORKSPACE_PROTOCOL}${rewritten}` : rewritten
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
//#endregion
|
|
418
563
|
//#region src/workspace.ts
|
|
419
564
|
/** The one filename pnpm recognises as a workspace definition. */
|
|
420
565
|
const WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
|
|
@@ -569,6 +714,22 @@ function firstUnplacedDependency(name, graph, unplaced) {
|
|
|
569
714
|
if (name === void 0) return [...unplaced].sort()[0];
|
|
570
715
|
return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
|
|
571
716
|
}
|
|
717
|
+
/**
|
|
718
|
+
* Checks every workspace dependency edge's range shape before anything releases, so an `UnsupportedDependencyRangeError` stops a run before the first publish rather than after some sibling has already been published, tagged, committed, and pushed. The shape a range supports depends only on the range text itself (see `classifyDependencyRange`), never on which version a sibling ends up releasing, so this can run once up front for the whole graph instead of only being discovered edge by edge as each dependency happens to release.
|
|
719
|
+
*
|
|
720
|
+
* Shared by both commit strategies (`release.ts`'s per-package loop and `single-commit-release.ts`'s analysis phase), which is why it lives alongside the graph it validates rather than inside either strategy's own module.
|
|
721
|
+
*/
|
|
722
|
+
function validateDependencyRangeShapes(graph) {
|
|
723
|
+
for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Looks up a value the caller already knows must be present -- a package or edge the topological order or graph just produced -- throwing rather than returning `undefined` if it somehow is not. An internal-consistency guard, not a user-facing validation, so both commit strategies share it rather than each carrying its own copy.
|
|
727
|
+
*/
|
|
728
|
+
function mustGet(map, key, what) {
|
|
729
|
+
const value = map.get(key);
|
|
730
|
+
if (value === void 0) throw new WorkspaceReleaseError(`Internal error: ${what} "${key}" disappeared from the dependency graph mid-run.`);
|
|
731
|
+
return value;
|
|
732
|
+
}
|
|
572
733
|
//#endregion
|
|
573
734
|
//#region src/pnpm.ts
|
|
574
735
|
const execFileAsync = promisify(execFile);
|
|
@@ -587,69 +748,274 @@ async function regenerateLockfile(options) {
|
|
|
587
748
|
}
|
|
588
749
|
}
|
|
589
750
|
//#endregion
|
|
590
|
-
//#region src/
|
|
591
|
-
const WORKSPACE_PROTOCOL = "workspace:";
|
|
592
|
-
const CATALOG_PROTOCOL = "catalog:";
|
|
593
|
-
const NPM_ALIAS_PROTOCOL = "npm:";
|
|
594
|
-
/** The `workspace:` suffixes pnpm resolves against the sibling's version at pack time rather than against anything written in the manifest. */
|
|
595
|
-
const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
|
|
596
|
-
"*",
|
|
597
|
-
"^",
|
|
598
|
-
"~"
|
|
599
|
-
];
|
|
600
|
-
/** Ranges that pin nothing, so a sibling's new version cannot change what they mean. */
|
|
601
|
-
const WILDCARD_RANGES = [
|
|
602
|
-
"",
|
|
603
|
-
"*",
|
|
604
|
-
"x",
|
|
605
|
-
"X",
|
|
606
|
-
"latest"
|
|
607
|
-
];
|
|
608
|
-
/**
|
|
609
|
-
* 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.
|
|
610
|
-
*/
|
|
611
|
-
const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
|
|
751
|
+
//#region src/single-commit-release.ts
|
|
612
752
|
/**
|
|
613
|
-
*
|
|
753
|
+
* `commitStrategy: 'single'`: one combined commit for the whole run instead of one commit per package release plus one per dependency bump.
|
|
614
754
|
*
|
|
615
|
-
*
|
|
755
|
+
* Five phases, all inside one `releaseWorkspaceSingleCommit` call:
|
|
756
|
+
*
|
|
757
|
+
* 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.
|
|
758
|
+
* 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 `validateDependencyRangeShapes` already applies to dependency ranges.
|
|
759
|
+
* 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.
|
|
760
|
+
* 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.
|
|
761
|
+
* 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`.
|
|
762
|
+
*
|
|
763
|
+
* Why publish is not just another semantic-release() call: semantic-release's own `run()` unconditionally derives `lastRelease` from the newest tag already on the branch matching `tagFormat`, and this mode has, by the time phase 5 runs, already created and pushed that exact tag itself. A second real `semanticRelease()` call would see its own just-created tag as the already-published release and compute the wrong next version from it. Phase 5 instead calls each resolved plugin module's own exported `verifyConditions`/`publish`/`success` functions directly, with a hand-built context -- the same public per-plugin API surface semantic-release's own core calls internally, just invoked without going through the parts of `run()` that assume a not-yet-tagged repository.
|
|
616
764
|
*/
|
|
617
|
-
function
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
765
|
+
async function releaseWorkspaceSingleCommit(options) {
|
|
766
|
+
const root = resolve(options.root ?? process.cwd());
|
|
767
|
+
const log = options.log ?? console.log;
|
|
768
|
+
const dryRun = options.dryRun === true;
|
|
769
|
+
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
770
|
+
const workspace = await discoverWorkspace(root);
|
|
771
|
+
const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
|
|
772
|
+
await assertCleanWorkingTree({ cwd: repoRoot });
|
|
773
|
+
const graph = buildDependencyGraph(workspace.packages);
|
|
774
|
+
validateDependencyRangeShapes(graph);
|
|
775
|
+
const order = topologicalOrder(graph);
|
|
776
|
+
log(`${packageName}: ${order.length} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
|
|
777
|
+
const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
|
|
778
|
+
requireGitPlugin: false,
|
|
779
|
+
forbidGitPlugin: true
|
|
780
|
+
});
|
|
781
|
+
const analyzeCommitsConfig = options.analyzeCommits ?? {};
|
|
782
|
+
const generateNotesConfig = options.generateNotes ?? {};
|
|
783
|
+
const capturedCommits = /* @__PURE__ */ new Map();
|
|
784
|
+
const captured = {
|
|
785
|
+
branch: void 0,
|
|
786
|
+
repositoryUrl: void 0
|
|
787
|
+
};
|
|
788
|
+
const pendingBumps = /* @__PURE__ */ new Map();
|
|
789
|
+
const outcomes = [];
|
|
790
|
+
const planned = [];
|
|
791
|
+
for (const name of order) {
|
|
792
|
+
const pkg = mustGet(graph.packages, name, "package");
|
|
793
|
+
const bumpsForThisPackage = pendingBumps.get(name) ?? [];
|
|
794
|
+
pendingBumps.delete(name);
|
|
795
|
+
const nextRelease = await analysePackage(pkg, {
|
|
796
|
+
resolvedPlugins,
|
|
797
|
+
analyzeCommitsConfig,
|
|
798
|
+
generateNotesConfig,
|
|
799
|
+
bumpsForThisPackage,
|
|
800
|
+
env,
|
|
801
|
+
branches: options.branches,
|
|
802
|
+
onCommitsResolved: (commits) => capturedCommits.set(name, commits),
|
|
803
|
+
onContextCaptured: (context) => {
|
|
804
|
+
captured.branch = context.branch;
|
|
805
|
+
captured.repositoryUrl = context.repositoryUrl;
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
outcomes.push({
|
|
809
|
+
name,
|
|
810
|
+
directory: pkg.directory,
|
|
811
|
+
released: nextRelease !== void 0,
|
|
812
|
+
version: nextRelease?.version,
|
|
813
|
+
gitTag: nextRelease?.gitTag,
|
|
814
|
+
type: nextRelease?.type,
|
|
815
|
+
dependencyBumps: bumpsForThisPackage
|
|
816
|
+
});
|
|
817
|
+
if (nextRelease === void 0) {
|
|
818
|
+
log(`${name}: no release`);
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
log(`${name}: would release ${nextRelease.gitTag}`);
|
|
822
|
+
planned.push({
|
|
823
|
+
pkg,
|
|
824
|
+
type: nextRelease.type,
|
|
825
|
+
version: nextRelease.version,
|
|
826
|
+
gitTag: nextRelease.gitTag,
|
|
827
|
+
notes: nextRelease.notes,
|
|
828
|
+
bumps: bumpsForThisPackage
|
|
829
|
+
});
|
|
830
|
+
for (const bump of planDependentBumps(pkg, nextRelease.version, graph)) {
|
|
831
|
+
const forDependent = pendingBumps.get(bump.dependent) ?? [];
|
|
832
|
+
forDependent.push(bump);
|
|
833
|
+
pendingBumps.set(bump.dependent, forDependent);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (dryRun || planned.length === 0) return {
|
|
837
|
+
order,
|
|
838
|
+
packages: outcomes
|
|
839
|
+
};
|
|
840
|
+
const branch = captured.branch;
|
|
841
|
+
const repositoryUrl = captured.repositoryUrl;
|
|
842
|
+
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.`);
|
|
843
|
+
const shared = {
|
|
844
|
+
env,
|
|
845
|
+
branch,
|
|
846
|
+
repositoryUrl,
|
|
847
|
+
log
|
|
848
|
+
};
|
|
849
|
+
const moduleCache = /* @__PURE__ */ new Map();
|
|
850
|
+
for (const release of planned) for (const [modulePath, pluginConfig] of resolvedPlugins) {
|
|
851
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
852
|
+
if (plugin.verifyConditions) await plugin.verifyConditions(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
|
|
853
|
+
}
|
|
854
|
+
let anyRangeRewritten = false;
|
|
855
|
+
for (const release of planned) {
|
|
856
|
+
for (const bump of release.bumps) {
|
|
857
|
+
if (bump.kind !== "rewritten") continue;
|
|
858
|
+
await writeDependencyRange(release.pkg.manifestPath, bump.field, bump.dependency, bump.range);
|
|
859
|
+
anyRangeRewritten = true;
|
|
860
|
+
}
|
|
861
|
+
for (const [modulePath, pluginConfig] of resolvedPlugins) {
|
|
862
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
863
|
+
if (plugin.prepare) await plugin.prepare(pluginConfig, buildPluginContext(release, shared, capturedCommits, []));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (anyRangeRewritten) await regenerateLockfile({ cwd: workspace.root });
|
|
867
|
+
const touchedPaths = await workingTreeChanges({ cwd: repoRoot });
|
|
868
|
+
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.`);
|
|
869
|
+
const identity = await resolveCommitIdentity({ cwd: repoRoot });
|
|
870
|
+
await commitFiles(touchedPaths, describeCombinedCommit(planned), {
|
|
871
|
+
cwd: repoRoot,
|
|
872
|
+
identity
|
|
873
|
+
});
|
|
874
|
+
const commitSha = (await git(["rev-parse", "HEAD"], { cwd: repoRoot })).trim();
|
|
875
|
+
const tagNames = planned.map((release) => release.gitTag);
|
|
876
|
+
for (const tagName of tagNames) await createTag(tagName, commitSha, { cwd: repoRoot });
|
|
877
|
+
await pushHeadAndTags(tagNames, { cwd: repoRoot });
|
|
878
|
+
log(`${packageName}: committed ${commitSha} and pushed ${tagNames.length} tag(s): ${tagNames.join(", ")}`);
|
|
879
|
+
for (const release of planned) {
|
|
880
|
+
const releases = [];
|
|
881
|
+
for (const [modulePath, pluginConfig] of resolvedPlugins) {
|
|
882
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
883
|
+
if (plugin.publish) {
|
|
884
|
+
const result = await plugin.publish(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
885
|
+
if (result !== false && result !== void 0) releases.push(result);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
for (const [modulePath, pluginConfig] of resolvedPlugins) {
|
|
889
|
+
const plugin = await loadReleasePlugin(modulePath, moduleCache);
|
|
890
|
+
if (plugin.success) await plugin.success(pluginConfig, buildPluginContext(release, shared, capturedCommits, releases));
|
|
891
|
+
}
|
|
892
|
+
log(`${release.pkg.name}: published ${release.gitTag}`);
|
|
629
893
|
}
|
|
630
|
-
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. Depend on the sibling directly (for example "workspace:^") instead.`);
|
|
631
|
-
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.`);
|
|
632
|
-
if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
|
|
633
|
-
const match = REWRITABLE_COMPARATOR.exec(range);
|
|
634
|
-
if (match === null) throw new UnsupportedDependencyRangeError(`Cannot bump the workspace dependency range "${current}": only a single "^", "~", ">=", "=", or bare version comparator can be rewritten in place.`);
|
|
635
894
|
return {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
comparator: match[1] ?? ""
|
|
895
|
+
order,
|
|
896
|
+
packages: outcomes
|
|
639
897
|
};
|
|
640
898
|
}
|
|
641
899
|
/**
|
|
642
|
-
*
|
|
900
|
+
* Runs one package's analysis with semantic-release's own real `dryRun: true` mode (which still fully computes `nextRelease.version`/`notes` and the branch/repository context; only the plugin steps whose own definition opts out of dry runs -- prepare, publish, addChannel, success, fail -- are skipped), using the exact same path-scoped `analyzeCommits`/`generateNotes` wrapper `commitStrategy: 'per-package'` uses. `dryRun: true` is forced here regardless of the caller's own `dryRun` option: this is always how phase 1 computes what *would* release, whether or not the run goes on to actually commit it.
|
|
643
901
|
*/
|
|
644
|
-
function
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
902
|
+
async function analysePackage(pkg, options) {
|
|
903
|
+
const scoped = createScopedPlugins({
|
|
904
|
+
pkg,
|
|
905
|
+
analyzeCommitsConfig: options.analyzeCommitsConfig,
|
|
906
|
+
generateNotesConfig: options.generateNotesConfig,
|
|
907
|
+
bumps: { bumpsFor: () => options.bumpsForThisPackage },
|
|
908
|
+
onCommitsResolved: options.onCommitsResolved
|
|
909
|
+
});
|
|
910
|
+
const semanticReleaseOptions = {
|
|
911
|
+
tagFormat: `${pkg.name}@\${version}`,
|
|
912
|
+
plugins: options.resolvedPlugins,
|
|
913
|
+
dryRun: true,
|
|
914
|
+
async analyzeCommits(pluginConfig, context) {
|
|
915
|
+
if (context.options.repositoryUrl === void 0) throw new ReleaseConfigurationError("Internal error: semantic-release did not resolve a repository URL before analyzeCommits ran.");
|
|
916
|
+
options.onContextCaptured({
|
|
917
|
+
branch: context.branch,
|
|
918
|
+
repositoryUrl: context.options.repositoryUrl
|
|
919
|
+
});
|
|
920
|
+
return scoped.analyzeCommits(pluginConfig, context);
|
|
921
|
+
},
|
|
922
|
+
generateNotes: scoped.generateNotes
|
|
923
|
+
};
|
|
924
|
+
if (options.branches !== void 0) semanticReleaseOptions.branches = options.branches;
|
|
925
|
+
const result = await semanticRelease(semanticReleaseOptions, {
|
|
926
|
+
cwd: pkg.directory,
|
|
927
|
+
env: { ...options.env }
|
|
928
|
+
});
|
|
929
|
+
if (result === false) return;
|
|
930
|
+
if (result.nextRelease.notes === void 0) throw new ReleaseConfigurationError(`Internal error: ${pkg.name} released with no notes computed.`);
|
|
648
931
|
return {
|
|
649
|
-
|
|
650
|
-
|
|
932
|
+
type: result.nextRelease.type,
|
|
933
|
+
version: result.nextRelease.version,
|
|
934
|
+
gitTag: result.nextRelease.gitTag,
|
|
935
|
+
notes: result.nextRelease.notes
|
|
651
936
|
};
|
|
652
937
|
}
|
|
938
|
+
/** Pure classification of what a released package's version does to each dependent's declared range -- the same logic `bumpDependents` in `release.ts` applies, minus the file write/commit/push: `commitStrategy: 'single'` defers every write to phase 3, applying the same classification once analysis has finished for the whole run. */
|
|
939
|
+
function planDependentBumps(released, version, graph) {
|
|
940
|
+
const applied = [];
|
|
941
|
+
const dependents = graph.dependents.get(released.name);
|
|
942
|
+
if (dependents === void 0) return applied;
|
|
943
|
+
for (const edge of dependents) {
|
|
944
|
+
const update = updateDependencyRange(edge.range, version);
|
|
945
|
+
if (update.kind === "wildcard") continue;
|
|
946
|
+
applied.push({
|
|
947
|
+
dependent: edge.dependent,
|
|
948
|
+
dependency: released.name,
|
|
949
|
+
field: edge.field,
|
|
950
|
+
version,
|
|
951
|
+
range: update.kind === "rewritten" ? update.range : edge.range,
|
|
952
|
+
kind: update.kind
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
return applied;
|
|
956
|
+
}
|
|
957
|
+
function describeCombinedCommit(planned) {
|
|
958
|
+
return [
|
|
959
|
+
"chore(release): batch release [skip ci]",
|
|
960
|
+
"",
|
|
961
|
+
...planned.map((release) => {
|
|
962
|
+
const bumpDescriptions = release.bumps.map((bump) => `${bump.dependency} to ${bump.range}`);
|
|
963
|
+
const bumpSuffix = bumpDescriptions.length === 0 ? "" : ` (dependenc${bumpDescriptions.length === 1 ? "y" : "ies"} bumped: ${bumpDescriptions.join(", ")})`;
|
|
964
|
+
return `- ${release.gitTag} (${release.type})${bumpSuffix}`;
|
|
965
|
+
})
|
|
966
|
+
].join("\n");
|
|
967
|
+
}
|
|
968
|
+
function buildPluginContext(release, shared, capturedCommits, releases) {
|
|
969
|
+
const logger = {
|
|
970
|
+
log: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`),
|
|
971
|
+
warn: (...args) => shared.log(`[${release.pkg.name}] warn: ${args.map(String).join(" ")}`),
|
|
972
|
+
error: (...args) => shared.log(`[${release.pkg.name}] error: ${args.map(String).join(" ")}`),
|
|
973
|
+
success: (...args) => shared.log(`[${release.pkg.name}] ${args.map(String).join(" ")}`)
|
|
974
|
+
};
|
|
975
|
+
return {
|
|
976
|
+
cwd: release.pkg.directory,
|
|
977
|
+
env: { ...shared.env },
|
|
978
|
+
stdout: process.stdout,
|
|
979
|
+
stderr: process.stderr,
|
|
980
|
+
logger,
|
|
981
|
+
options: { repositoryUrl: shared.repositoryUrl },
|
|
982
|
+
branch: shared.branch,
|
|
983
|
+
commits: capturedCommits.get(release.pkg.name) ?? [],
|
|
984
|
+
releases,
|
|
985
|
+
nextRelease: {
|
|
986
|
+
type: release.type,
|
|
987
|
+
version: release.version,
|
|
988
|
+
gitTag: release.gitTag,
|
|
989
|
+
name: release.gitTag,
|
|
990
|
+
notes: release.notes,
|
|
991
|
+
channel: channelOrNull(shared.branch.channel)
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
/** Matches semantic-release's own core exactly (`context.branch.channel || null` in its `index.js`): a branch's `channel` is `string | false | undefined`, and any falsy value (including `false` and an empty string) means "the default channel", represented here as `null`. Written as explicit comparisons rather than `||`/`??` so the falsy-to-null collapse (deliberately including `false` and `''`, which `??` alone would not fold) reads as intentional rather than as a fallback for `undefined`/`null` alone. */
|
|
996
|
+
function channelOrNull(channel) {
|
|
997
|
+
return channel === void 0 || channel === false || channel === "" ? null : channel;
|
|
998
|
+
}
|
|
999
|
+
function isReleaseLifecycleFnOrUndefined(value) {
|
|
1000
|
+
return value === void 0 || typeof value === "function";
|
|
1001
|
+
}
|
|
1002
|
+
function isReleasePluginModule(value) {
|
|
1003
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1004
|
+
if ("verifyConditions" in value && !isReleaseLifecycleFnOrUndefined(value.verifyConditions)) return false;
|
|
1005
|
+
if ("prepare" in value && !isReleaseLifecycleFnOrUndefined(value.prepare)) return false;
|
|
1006
|
+
if ("publish" in value && !isReleaseLifecycleFnOrUndefined(value.publish)) return false;
|
|
1007
|
+
if ("success" in value && !isReleaseLifecycleFnOrUndefined(value.success)) return false;
|
|
1008
|
+
return true;
|
|
1009
|
+
}
|
|
1010
|
+
/** Loads a publish plugin module directly (bypassing semantic-release's own step-pipeline machinery, since phase 5 cannot go through it -- see this file's own top-of-file note), caching by resolved path so a plugin shared by several packages is imported once per run rather than once per package per phase. */
|
|
1011
|
+
async function loadReleasePlugin(absolutePath, cache) {
|
|
1012
|
+
const cached = cache.get(absolutePath);
|
|
1013
|
+
if (cached !== void 0) return cached;
|
|
1014
|
+
const loaded = await import(pathToFileURL(absolutePath).href);
|
|
1015
|
+
if (!isReleasePluginModule(loaded)) throw new ReleaseConfigurationError(`The publish plugin resolved to ${absolutePath} does not export a recognised semantic-release plugin interface (verifyConditions/prepare/publish/success functions), so commitStrategy "single" cannot call its lifecycle steps directly.`);
|
|
1016
|
+
cache.set(absolutePath, loaded);
|
|
1017
|
+
return loaded;
|
|
1018
|
+
}
|
|
653
1019
|
//#endregion
|
|
654
1020
|
//#region src/release.ts
|
|
655
1021
|
/**
|
|
@@ -658,10 +1024,11 @@ function updateDependencyRange(current, version) {
|
|
|
658
1024
|
* 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.
|
|
659
1025
|
*/
|
|
660
1026
|
async function releaseWorkspace(options = {}) {
|
|
1027
|
+
if ((options.commitStrategy ?? "per-package") === "single") return releaseWorkspaceSingleCommit(options);
|
|
661
1028
|
const root = resolve(options.root ?? process.cwd());
|
|
662
1029
|
const log = options.log ?? console.log;
|
|
663
1030
|
const dryRun = options.dryRun === true;
|
|
664
|
-
const env = options.env ?? process.env;
|
|
1031
|
+
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
665
1032
|
const workspace = await discoverWorkspace(root);
|
|
666
1033
|
const graph = buildDependencyGraph(workspace.packages);
|
|
667
1034
|
validateDependencyRangeShapes(graph);
|
|
@@ -720,12 +1087,6 @@ async function releaseWorkspace(options = {}) {
|
|
|
720
1087
|
packages: outcomes
|
|
721
1088
|
};
|
|
722
1089
|
}
|
|
723
|
-
/**
|
|
724
|
-
* Checks every workspace dependency edge's range shape before anything releases, so an `UnsupportedDependencyRangeError` stops the run before the first publish rather than after some sibling has already been published, tagged, committed, and pushed. The shape a range supports depends only on the range text itself (see `classifyDependencyRange`), never on which version a sibling ends up releasing, so this can run once up front for the whole graph instead of only being discovered edge by edge as each dependency happens to release.
|
|
725
|
-
*/
|
|
726
|
-
function validateDependencyRangeShapes(graph) {
|
|
727
|
-
for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
|
|
728
|
-
}
|
|
729
1090
|
async function runPackageRelease(pkg, options) {
|
|
730
1091
|
const scoped = createScopedPlugins({
|
|
731
1092
|
pkg,
|
|
@@ -794,11 +1155,6 @@ async function bumpDependents(released, version, graph, options) {
|
|
|
794
1155
|
}
|
|
795
1156
|
return applied;
|
|
796
1157
|
}
|
|
797
|
-
function mustGet(map, key, what) {
|
|
798
|
-
const value = map.get(key);
|
|
799
|
-
if (value === void 0) throw new WorkspaceReleaseError(`Internal error: ${what} "${key}" disappeared from the dependency graph mid-run.`);
|
|
800
|
-
return value;
|
|
801
|
-
}
|
|
802
1158
|
//#endregion
|
|
803
1159
|
//#region src/cli.ts
|
|
804
1160
|
const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -806,8 +1162,13 @@ const CONFIG_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
|
806
1162
|
"branches",
|
|
807
1163
|
"plugins",
|
|
808
1164
|
"analyzeCommits",
|
|
809
|
-
"generateNotes"
|
|
1165
|
+
"generateNotes",
|
|
1166
|
+
"commitStrategy"
|
|
810
1167
|
]);
|
|
1168
|
+
const COMMIT_STRATEGIES = /* @__PURE__ */ new Set(["per-package", "single"]);
|
|
1169
|
+
function isCommitStrategy(value) {
|
|
1170
|
+
return COMMIT_STRATEGIES.has(value);
|
|
1171
|
+
}
|
|
811
1172
|
/**
|
|
812
1173
|
* Builds the commander program but never parses argv or exits the process itself in construction, so the command tree stays testable in isolation. `release` is the orchestration entry point: discover the workspace, order it topologically, and run semantic-release per package.
|
|
813
1174
|
*/
|
|
@@ -823,16 +1184,22 @@ function createProgram() {
|
|
|
823
1184
|
release.option("--plugin <spec>", "publish-pipeline plugin, repeatable: a module name, or a JSON array of [name, config]; defaults to the standard changelog/npm/github/git pipeline", collectRepeated, []);
|
|
824
1185
|
release.option("--analyze-commits <json>", "options for the wrapped @semantic-release/commit-analyzer, as a JSON object");
|
|
825
1186
|
release.option("--generate-notes <json>", "options for the wrapped @semantic-release/release-notes-generator, as a JSON object");
|
|
826
|
-
release.option("--
|
|
1187
|
+
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);
|
|
1188
|
+
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); explicit flags win");
|
|
827
1189
|
release.action(runRelease);
|
|
828
1190
|
return program;
|
|
829
1191
|
}
|
|
1192
|
+
function parseCommitStrategy(value) {
|
|
1193
|
+
if (!isCommitStrategy(value)) throw new InvalidArgumentError(`--commit-strategy must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
|
|
1194
|
+
return value;
|
|
1195
|
+
}
|
|
830
1196
|
const NO_CONFIG_FILE = {
|
|
831
1197
|
dryRun: void 0,
|
|
832
1198
|
branches: void 0,
|
|
833
1199
|
plugins: void 0,
|
|
834
1200
|
analyzeCommits: void 0,
|
|
835
|
-
generateNotes: void 0
|
|
1201
|
+
generateNotes: void 0,
|
|
1202
|
+
commitStrategy: void 0
|
|
836
1203
|
};
|
|
837
1204
|
async function runRelease(flags) {
|
|
838
1205
|
const file = flags.config === void 0 ? NO_CONFIG_FILE : readReleaseConfigFile(flags.config);
|
|
@@ -840,9 +1207,10 @@ async function runRelease(flags) {
|
|
|
840
1207
|
root: flags.root,
|
|
841
1208
|
dryRun: flags.dryRun ?? (file.dryRun === true ? true : void 0),
|
|
842
1209
|
branches: flags.branches.length > 0 ? flags.branches : file.branches,
|
|
843
|
-
plugins: flags.plugin.length > 0 ? flags.plugin.map((spec) => parsePluginSpec(spec)) : file.plugins
|
|
1210
|
+
plugins: flags.plugin.length > 0 ? flags.plugin.map((spec) => parsePluginSpec(spec)) : file.plugins,
|
|
844
1211
|
analyzeCommits: flags.analyzeCommits === void 0 ? file.analyzeCommits : parseJsonObjectFlag(flags.analyzeCommits, "--analyze-commits"),
|
|
845
|
-
generateNotes: flags.generateNotes === void 0 ? file.generateNotes : parseJsonObjectFlag(flags.generateNotes, "--generate-notes")
|
|
1212
|
+
generateNotes: flags.generateNotes === void 0 ? file.generateNotes : parseJsonObjectFlag(flags.generateNotes, "--generate-notes"),
|
|
1213
|
+
commitStrategy: flags.commitStrategy ?? file.commitStrategy
|
|
846
1214
|
});
|
|
847
1215
|
for (const pkg of outcome.packages) console.log(describeOutcome(pkg));
|
|
848
1216
|
}
|
|
@@ -888,18 +1256,20 @@ function readReleaseConfigFile(path) {
|
|
|
888
1256
|
const parsed = readConfigFile(path);
|
|
889
1257
|
if (!isJsonObject(parsed)) throw new InvalidArgumentError(`--config file ${path} must contain a JSON object`);
|
|
890
1258
|
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(", ")}`);
|
|
891
|
-
const { dryRun, branches, plugins, analyzeCommits, generateNotes } = parsed;
|
|
1259
|
+
const { dryRun, branches, plugins, analyzeCommits, generateNotes, commitStrategy } = parsed;
|
|
892
1260
|
if (dryRun !== void 0 && typeof dryRun !== "boolean") throw new InvalidArgumentError(`--config file ${path}: "dryRun" must be a boolean`);
|
|
893
1261
|
if (branches !== void 0 && !isStringArray(branches)) throw new InvalidArgumentError(`--config file ${path}: "branches" must be an array of branch name strings`);
|
|
894
1262
|
if (plugins !== void 0 && !Array.isArray(plugins)) throw new InvalidArgumentError(`--config file ${path}: "plugins" must be an array`);
|
|
895
1263
|
if (analyzeCommits !== void 0 && !isJsonObject(analyzeCommits)) throw new InvalidArgumentError(`--config file ${path}: "analyzeCommits" must be an object`);
|
|
896
1264
|
if (generateNotes !== void 0 && !isJsonObject(generateNotes)) throw new InvalidArgumentError(`--config file ${path}: "generateNotes" must be an object`);
|
|
1265
|
+
if (commitStrategy !== void 0 && (typeof commitStrategy !== "string" || !isCommitStrategy(commitStrategy))) throw new InvalidArgumentError(`--config file ${path}: "commitStrategy" must be one of: ${[...COMMIT_STRATEGIES].join(", ")}`);
|
|
897
1266
|
return {
|
|
898
1267
|
dryRun,
|
|
899
1268
|
branches,
|
|
900
1269
|
plugins: plugins === void 0 ? void 0 : plugins.map((spec) => parseConfigFilePlugin(spec, path)),
|
|
901
1270
|
analyzeCommits,
|
|
902
|
-
generateNotes
|
|
1271
|
+
generateNotes,
|
|
1272
|
+
commitStrategy
|
|
903
1273
|
};
|
|
904
1274
|
}
|
|
905
1275
|
function parseConfigFilePlugin(spec, path) {
|