@exadev/semantic-release-workspace 1.3.6 → 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 +59 -11
- package/dist/index.cjs +58 -10
- package/dist/index.d.cts +9 -5
- package/dist/index.d.ts +9 -5
- package/dist/index.js +58 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,20 +37,22 @@ Why commit immediately, rather than the alternatives:
|
|
|
37
37
|
|
|
38
38
|
Because every dependent sits downstream in topological order, its own run always sees the bump commit: the commit touches only the dependent's directory, so it passes that dependent's path filter and participates in its analysis.
|
|
39
39
|
|
|
40
|
-
**A package whose only change is dependency bumps still gets a patch release — deliberately.**
|
|
40
|
+
**A package whose only change is dependency bumps still gets a patch release — deliberately.** A range rewritten on disk (`^1.0.0` → `^1.1.0`) changes the dependent's *published* dependency range, so the dependent must be republished for the change to reach consumers. A range that names no version, such as a bare `workspace:^` in a private package, has nothing to rewrite, but the dependent still releases, so a package always follows a sibling it depends on. This is not left to chance: the wrapped `analyzeCommits` returns `patch` whenever the standard analyzer found nothing but this run bumped one of the package's dependency ranges, so the behaviour does not depend on how the workspace's own analyzer config happens to classify `chore(deps)` commits (many presets release nothing for `chore`). Release notes gain a `### Dependencies` section listing the bumps, so the release is self-explaining rather than empty.
|
|
41
41
|
|
|
42
42
|
Dependency-range handling, in full:
|
|
43
43
|
|
|
44
44
|
| Range in the dependent's manifest | What happens |
|
|
45
45
|
| --- | --- |
|
|
46
|
-
| `^1.0.0`, `~1.0.0`, `>=1.0.0`, `=1.0.0`, `1.0.0
|
|
47
|
-
| `workspace
|
|
46
|
+
| `^1.0.0`, `~1.0.0`, `>=1.0.0`, `=1.0.0`, `1.0.0` | Rewritten in place, preserving the comparator (`^1.0.0` → `^1.1.0`); `pnpm-lock.yaml` is regenerated to match, and both are committed and pushed together; dependent gets at least a patch release |
|
|
47
|
+
| `workspace:^1.0.0` and the other anchored `workspace:` forms, in `devDependencies` or a private package | Rewritten in place with the `workspace:` prefix kept (`workspace:^1.0.0` → `workspace:^1.1.0`), committed and pushed as above; dependent gets at least a patch release |
|
|
48
|
+
| `workspace:*`, `workspace:^`, `workspace:~`, in `devDependencies` or a private package | No manifest edit, since the range names no version; the dependent still gets a patch release |
|
|
49
|
+
| Any `workspace:`, `catalog:`, `link:`, or `file:` specifier in the `dependencies`, `peerDependencies`, or `optionalDependencies` of a package that is not private, whichever package it names | The run stops with `UnsupportedDependencyRangeError`: `npm publish` ships the specifier unchanged, so the published package could not be installed |
|
|
48
50
|
| `*`, `x`, `latest` | Nothing to update and the published range is unaffected — no bump, no forced release |
|
|
49
51
|
| Compound ranges (`>=1.0.0 <2.0.0`), unions (`1.x \|\| 2.x`), `<`/`<=` bounds, `catalog:`, `npm:` aliases, git/tarball URLs | The run stops with `UnsupportedDependencyRangeError` — rewriting any of these wrongly, or leaving them silently stale, both produce a published manifest that disagrees with the repository, so neither is attempted |
|
|
50
52
|
|
|
51
|
-
Every
|
|
53
|
+
Every range above is validated before the release loop starts, not just when the dependency it names happens to release: both the shape of each workspace dependency edge and the specifiers in each publishable package's installed dependency fields are static properties of the manifests, knowable at discovery time, so `UnsupportedDependencyRangeError` stops the run before the first package publishes, rather than after some upstream sibling has already been published, tagged, committed, and pushed. Every offending entry is listed in one error. `resume` checks the manifests again before it publishes anything.
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
Publishing goes through `@semantic-release/npm`, which is plain `npm publish`: it ships a manifest's specifiers unchanged and never substitutes pnpm's protocols the way `pnpm publish` does. A publishable package therefore declares a concrete version range for each workspace sibling, which the orchestrator keeps up to date for you, and keeps `linkWorkspacePackages: true` in `pnpm-workspace.yaml` so pnpm still links the sibling locally instead of fetching it from the registry. `workspace:` ranges remain usable in private packages, which never publish, and in `devDependencies`, which no consumer installs.
|
|
54
56
|
|
|
55
57
|
## Commit strategies
|
|
56
58
|
|
|
@@ -149,7 +151,7 @@ The core technique is the same one multi-semantic-release proved in production:
|
|
|
149
151
|
- **In-process delegation, not CLI wrapping.** semantic-release is invoked through its programmatic API with inline plugin functions, so the wrappers delegate to the real @semantic-release/commit-analyzer and @semantic-release/release-notes-generator running in the same process. (The analysed plugins are ESM named exports here, resolved as peers of this package — no plugin re-implementation anywhere.)
|
|
150
152
|
- **Bump-only dependents always release.** multi-semantic-release rewrites dependency ranges in the working tree without committing them, so a dependent whose only change is a dependency update can go unreleased until some other commit triggers it. Here the bump is committed before the dependent's turn and a patch release is forced deterministically (see the timing section above).
|
|
151
153
|
- **Loud failures by design.** A dependency cycle, an unsupported dependency range, a publish pipeline without @semantic-release/git (which would leave released manifests uncommitted), an unresolvable plugin, a duplicate package name — each stops the run with a specific error rather than degrading silently. There is deliberately no "skip this package and carry on" path: a partially-consistent set of publishes is worse than none.
|
|
152
|
-
- **
|
|
154
|
+
- **Only installable specifiers reach the registry.** `workspace:`, `catalog:`, `link:`, and `file:` specifiers in a publishable package's installed dependency fields are rejected instead of being published as written. In private packages and `devDependencies`, `workspace:*`/`workspace:^`/`workspace:~` are understood as naming no version (bump the release, not the manifest text), and `catalog:` and `npm:` aliases on a workspace sibling are rejected with an explanation instead of being mangled.
|
|
153
155
|
- **Workspace-agnostic discovery.** Everything comes from `pnpm-workspace.yaml` and the manifests its globs match; pointing the orchestrator at any pnpm workspace is the entire configuration.
|
|
154
156
|
|
|
155
157
|
Out of scope, on purpose: parallelising independent branches of the dependency graph (packages release sequentially in topological order for correctness first — a real future optimisation, not attempted here), and any Changesets-style explicit-changeset mode, which is a different paradigm rather than a missing feature.
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { generateNotes } from "@semantic-release/release-notes-generator";
|
|
|
14
14
|
import semanticRelease from "semantic-release";
|
|
15
15
|
import { pathToFileURL } from "node:url";
|
|
16
16
|
//#region package.json
|
|
17
|
-
var version = "1.3.
|
|
17
|
+
var version = "1.3.7";
|
|
18
18
|
//#endregion
|
|
19
19
|
//#region src/errors.ts
|
|
20
20
|
/**
|
|
@@ -39,7 +39,7 @@ var DependencyCycleError = class extends WorkspaceReleaseError {
|
|
|
39
39
|
this.cycle = cycle;
|
|
40
40
|
}
|
|
41
41
|
};
|
|
42
|
-
/** A dependency
|
|
42
|
+
/** 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. */
|
|
43
43
|
var UnsupportedDependencyRangeError = class extends WorkspaceReleaseError {};
|
|
44
44
|
/** 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. */
|
|
45
45
|
var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
|
|
@@ -308,6 +308,12 @@ const DEPENDENCY_FIELDS = [
|
|
|
308
308
|
"peerDependencies",
|
|
309
309
|
"optionalDependencies"
|
|
310
310
|
];
|
|
311
|
+
/** 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. */
|
|
312
|
+
const INSTALLED_DEPENDENCY_FIELDS = [
|
|
313
|
+
"dependencies",
|
|
314
|
+
"peerDependencies",
|
|
315
|
+
"optionalDependencies"
|
|
316
|
+
];
|
|
311
317
|
async function readManifest(path) {
|
|
312
318
|
const text = await readFile(path, "utf8");
|
|
313
319
|
const parsed = JSON.parse(text);
|
|
@@ -327,6 +333,7 @@ async function readManifest(path) {
|
|
|
327
333
|
return {
|
|
328
334
|
name,
|
|
329
335
|
version,
|
|
336
|
+
private: parsed.private === true,
|
|
330
337
|
dependencies
|
|
331
338
|
};
|
|
332
339
|
}
|
|
@@ -349,7 +356,14 @@ async function writeDependencyRange(path, field, dependency, range) {
|
|
|
349
356
|
const WORKSPACE_PROTOCOL = "workspace:";
|
|
350
357
|
const CATALOG_PROTOCOL = "catalog:";
|
|
351
358
|
const NPM_ALIAS_PROTOCOL = "npm:";
|
|
352
|
-
/**
|
|
359
|
+
/** 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. */
|
|
360
|
+
const UNPUBLISHABLE_SPECIFIER_PROTOCOLS = [
|
|
361
|
+
WORKSPACE_PROTOCOL,
|
|
362
|
+
CATALOG_PROTOCOL,
|
|
363
|
+
"link:",
|
|
364
|
+
"file:"
|
|
365
|
+
];
|
|
366
|
+
/** The `workspace:` suffixes that name no version, and so leave nothing in the manifest to rewrite. */
|
|
353
367
|
const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
|
|
354
368
|
"*",
|
|
355
369
|
"^",
|
|
@@ -364,6 +378,13 @@ const WILDCARD_RANGES = [
|
|
|
364
378
|
"latest"
|
|
365
379
|
];
|
|
366
380
|
/**
|
|
381
|
+
* 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.
|
|
382
|
+
*/
|
|
383
|
+
function unpublishableSpecifierProtocol(specifier) {
|
|
384
|
+
const trimmed = specifier.trim();
|
|
385
|
+
return UNPUBLISHABLE_SPECIFIER_PROTOCOLS.find((protocol) => trimmed.startsWith(protocol));
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
367
388
|
* 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.
|
|
368
389
|
*/
|
|
369
390
|
const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
|
|
@@ -385,7 +406,7 @@ function classifyDependencyRange(current) {
|
|
|
385
406
|
comparator: inner.comparator
|
|
386
407
|
};
|
|
387
408
|
}
|
|
388
|
-
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.
|
|
409
|
+
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.`);
|
|
389
410
|
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.`);
|
|
390
411
|
if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
|
|
391
412
|
const match = REWRITABLE_COMPARATOR.exec(range);
|
|
@@ -409,6 +430,27 @@ function updateDependencyRange(current, version) {
|
|
|
409
430
|
};
|
|
410
431
|
}
|
|
411
432
|
//#endregion
|
|
433
|
+
//#region src/publishable-dependencies.ts
|
|
434
|
+
/**
|
|
435
|
+
* Throws `UnsupportedDependencyRangeError` unless every publishable package's installed dependencies (`dependencies`, `peerDependencies`, `optionalDependencies`) are ranges a consumer's package manager can resolve.
|
|
436
|
+
*
|
|
437
|
+
* 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.
|
|
438
|
+
*
|
|
439
|
+
* 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.
|
|
440
|
+
*/
|
|
441
|
+
function assertPublishableDependencies(packages) {
|
|
442
|
+
const offences = [];
|
|
443
|
+
for (const pkg of packages) {
|
|
444
|
+
if (pkg.private) continue;
|
|
445
|
+
for (const field of INSTALLED_DEPENDENCY_FIELDS) {
|
|
446
|
+
const declared = pkg.dependencies.get(field);
|
|
447
|
+
if (declared === void 0) continue;
|
|
448
|
+
for (const [dependency, specifier] of declared) if (unpublishableSpecifierProtocol(specifier) !== void 0) offences.push(` ${pkg.name}: "${dependency}" in ${field} is declared as "${specifier}"`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
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.`);
|
|
452
|
+
}
|
|
453
|
+
//#endregion
|
|
412
454
|
//#region src/workspace.ts
|
|
413
455
|
/** The one filename pnpm recognises as a workspace definition. */
|
|
414
456
|
const WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
|
|
@@ -445,6 +487,7 @@ async function discoverWorkspace(root) {
|
|
|
445
487
|
packages.push({
|
|
446
488
|
name: manifest.name,
|
|
447
489
|
version: manifest.version,
|
|
490
|
+
private: manifest.private,
|
|
448
491
|
directory,
|
|
449
492
|
relativeDirectory,
|
|
450
493
|
repoRelativeDirectory,
|
|
@@ -564,11 +607,15 @@ function firstUnplacedDependency(name, graph, unplaced) {
|
|
|
564
607
|
return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
|
|
565
608
|
}
|
|
566
609
|
/**
|
|
567
|
-
*
|
|
610
|
+
* 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:
|
|
611
|
+
*
|
|
612
|
+
* - Every publishable package's installed dependencies must be specifiers a consumer can install (see `assertPublishableDependencies`), whichever package they name.
|
|
613
|
+
* - 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).
|
|
568
614
|
*
|
|
569
|
-
* Shared by
|
|
615
|
+
* 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.
|
|
570
616
|
*/
|
|
571
|
-
function
|
|
617
|
+
function validateDependencyRanges(graph) {
|
|
618
|
+
assertPublishableDependencies([...graph.packages.values()]);
|
|
572
619
|
for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
|
|
573
620
|
}
|
|
574
621
|
/**
|
|
@@ -785,7 +832,7 @@ async function regenerateLockfile(options) {
|
|
|
785
832
|
* Five phases, all inside one `releaseWorkspaceSingleCommit` call:
|
|
786
833
|
*
|
|
787
834
|
* 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.
|
|
788
|
-
* 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 `
|
|
835
|
+
* 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.
|
|
789
836
|
* 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.
|
|
790
837
|
* 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.
|
|
791
838
|
* 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`.
|
|
@@ -801,7 +848,7 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
801
848
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
|
|
802
849
|
await assertCleanWorkingTree({ cwd: repoRoot });
|
|
803
850
|
const graph = buildDependencyGraph(workspace.packages);
|
|
804
|
-
|
|
851
|
+
validateDependencyRanges(graph);
|
|
805
852
|
const order = topologicalOrder(graph);
|
|
806
853
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
|
|
807
854
|
const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
|
|
@@ -1080,7 +1127,7 @@ async function releaseWorkspace(options = {}) {
|
|
|
1080
1127
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1081
1128
|
const workspace = await discoverWorkspace(root);
|
|
1082
1129
|
const graph = buildDependencyGraph(workspace.packages);
|
|
1083
|
-
|
|
1130
|
+
validateDependencyRanges(graph);
|
|
1084
1131
|
const order = topologicalOrder(graph);
|
|
1085
1132
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
|
|
1086
1133
|
const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
|
|
@@ -1243,7 +1290,7 @@ async function detachWorkspaceRelease(options) {
|
|
|
1243
1290
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1244
1291
|
const workspace = await discoverWorkspace(root);
|
|
1245
1292
|
const graph = buildDependencyGraph(workspace.packages);
|
|
1246
|
-
|
|
1293
|
+
validateDependencyRanges(graph);
|
|
1247
1294
|
const order = topologicalOrder(graph);
|
|
1248
1295
|
log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
|
|
1249
1296
|
const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
|
|
@@ -1332,6 +1379,7 @@ async function resumeWorkspaceRelease(options) {
|
|
|
1332
1379
|
const root = resolve(options.root ?? process.cwd());
|
|
1333
1380
|
const log = options.log ?? console.log;
|
|
1334
1381
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1382
|
+
assertPublishableDependencies(await Promise.all(options.detached.filter((entry) => entry.state !== null).map(async (entry) => readManifest(resolve(root, entry.relativeDirectory, "package.json")))));
|
|
1335
1383
|
const order = [];
|
|
1336
1384
|
const packages = [];
|
|
1337
1385
|
for (const entry of options.detached) {
|
package/dist/index.cjs
CHANGED
|
@@ -61,7 +61,7 @@ var DependencyCycleError = class extends WorkspaceReleaseError {
|
|
|
61
61
|
this.cycle = cycle;
|
|
62
62
|
}
|
|
63
63
|
};
|
|
64
|
-
/** A dependency
|
|
64
|
+
/** 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. */
|
|
65
65
|
var UnsupportedDependencyRangeError = class extends WorkspaceReleaseError {};
|
|
66
66
|
/** 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. */
|
|
67
67
|
var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
|
|
@@ -324,6 +324,12 @@ const DEPENDENCY_FIELDS = [
|
|
|
324
324
|
"peerDependencies",
|
|
325
325
|
"optionalDependencies"
|
|
326
326
|
];
|
|
327
|
+
/** 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. */
|
|
328
|
+
const INSTALLED_DEPENDENCY_FIELDS = [
|
|
329
|
+
"dependencies",
|
|
330
|
+
"peerDependencies",
|
|
331
|
+
"optionalDependencies"
|
|
332
|
+
];
|
|
327
333
|
async function readManifest(path) {
|
|
328
334
|
const text = await (0, node_fs_promises.readFile)(path, "utf8");
|
|
329
335
|
const parsed = JSON.parse(text);
|
|
@@ -343,6 +349,7 @@ async function readManifest(path) {
|
|
|
343
349
|
return {
|
|
344
350
|
name,
|
|
345
351
|
version,
|
|
352
|
+
private: parsed.private === true,
|
|
346
353
|
dependencies
|
|
347
354
|
};
|
|
348
355
|
}
|
|
@@ -397,6 +404,7 @@ async function discoverWorkspace(root) {
|
|
|
397
404
|
packages.push({
|
|
398
405
|
name: manifest.name,
|
|
399
406
|
version: manifest.version,
|
|
407
|
+
private: manifest.private,
|
|
400
408
|
directory,
|
|
401
409
|
relativeDirectory,
|
|
402
410
|
repoRelativeDirectory,
|
|
@@ -445,7 +453,14 @@ function toPosix(path) {
|
|
|
445
453
|
const WORKSPACE_PROTOCOL = "workspace:";
|
|
446
454
|
const CATALOG_PROTOCOL = "catalog:";
|
|
447
455
|
const NPM_ALIAS_PROTOCOL = "npm:";
|
|
448
|
-
/**
|
|
456
|
+
/** 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. */
|
|
457
|
+
const UNPUBLISHABLE_SPECIFIER_PROTOCOLS = [
|
|
458
|
+
WORKSPACE_PROTOCOL,
|
|
459
|
+
CATALOG_PROTOCOL,
|
|
460
|
+
"link:",
|
|
461
|
+
"file:"
|
|
462
|
+
];
|
|
463
|
+
/** The `workspace:` suffixes that name no version, and so leave nothing in the manifest to rewrite. */
|
|
449
464
|
const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
|
|
450
465
|
"*",
|
|
451
466
|
"^",
|
|
@@ -460,6 +475,13 @@ const WILDCARD_RANGES = [
|
|
|
460
475
|
"latest"
|
|
461
476
|
];
|
|
462
477
|
/**
|
|
478
|
+
* 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.
|
|
479
|
+
*/
|
|
480
|
+
function unpublishableSpecifierProtocol(specifier) {
|
|
481
|
+
const trimmed = specifier.trim();
|
|
482
|
+
return UNPUBLISHABLE_SPECIFIER_PROTOCOLS.find((protocol) => trimmed.startsWith(protocol));
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
463
485
|
* 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.
|
|
464
486
|
*/
|
|
465
487
|
const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
|
|
@@ -481,7 +503,7 @@ function classifyDependencyRange(current) {
|
|
|
481
503
|
comparator: inner.comparator
|
|
482
504
|
};
|
|
483
505
|
}
|
|
484
|
-
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.
|
|
506
|
+
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.`);
|
|
485
507
|
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.`);
|
|
486
508
|
if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
|
|
487
509
|
const match = REWRITABLE_COMPARATOR.exec(range);
|
|
@@ -505,6 +527,27 @@ function updateDependencyRange(current, version) {
|
|
|
505
527
|
};
|
|
506
528
|
}
|
|
507
529
|
//#endregion
|
|
530
|
+
//#region src/publishable-dependencies.ts
|
|
531
|
+
/**
|
|
532
|
+
* Throws `UnsupportedDependencyRangeError` unless every publishable package's installed dependencies (`dependencies`, `peerDependencies`, `optionalDependencies`) are ranges a consumer's package manager can resolve.
|
|
533
|
+
*
|
|
534
|
+
* 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.
|
|
535
|
+
*
|
|
536
|
+
* 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.
|
|
537
|
+
*/
|
|
538
|
+
function assertPublishableDependencies(packages) {
|
|
539
|
+
const offences = [];
|
|
540
|
+
for (const pkg of packages) {
|
|
541
|
+
if (pkg.private) continue;
|
|
542
|
+
for (const field of INSTALLED_DEPENDENCY_FIELDS) {
|
|
543
|
+
const declared = pkg.dependencies.get(field);
|
|
544
|
+
if (declared === void 0) continue;
|
|
545
|
+
for (const [dependency, specifier] of declared) if (unpublishableSpecifierProtocol(specifier) !== void 0) offences.push(` ${pkg.name}: "${dependency}" in ${field} is declared as "${specifier}"`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
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.`);
|
|
549
|
+
}
|
|
550
|
+
//#endregion
|
|
508
551
|
//#region src/graph.ts
|
|
509
552
|
/**
|
|
510
553
|
* Builds the inter-package dependency graph from the manifests alone.
|
|
@@ -580,11 +623,15 @@ function firstUnplacedDependency(name, graph, unplaced) {
|
|
|
580
623
|
return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
|
|
581
624
|
}
|
|
582
625
|
/**
|
|
583
|
-
*
|
|
626
|
+
* 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:
|
|
627
|
+
*
|
|
628
|
+
* - Every publishable package's installed dependencies must be specifiers a consumer can install (see `assertPublishableDependencies`), whichever package they name.
|
|
629
|
+
* - 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).
|
|
584
630
|
*
|
|
585
|
-
* Shared by
|
|
631
|
+
* 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.
|
|
586
632
|
*/
|
|
587
|
-
function
|
|
633
|
+
function validateDependencyRanges(graph) {
|
|
634
|
+
assertPublishableDependencies([...graph.packages.values()]);
|
|
588
635
|
for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
|
|
589
636
|
}
|
|
590
637
|
/**
|
|
@@ -798,7 +845,7 @@ async function regenerateLockfile(options) {
|
|
|
798
845
|
* Five phases, all inside one `releaseWorkspaceSingleCommit` call:
|
|
799
846
|
*
|
|
800
847
|
* 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.
|
|
801
|
-
* 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 `
|
|
848
|
+
* 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.
|
|
802
849
|
* 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.
|
|
803
850
|
* 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.
|
|
804
851
|
* 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`.
|
|
@@ -814,7 +861,7 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
814
861
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
|
|
815
862
|
await assertCleanWorkingTree({ cwd: repoRoot });
|
|
816
863
|
const graph = buildDependencyGraph(workspace.packages);
|
|
817
|
-
|
|
864
|
+
validateDependencyRanges(graph);
|
|
818
865
|
const order = topologicalOrder(graph);
|
|
819
866
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
|
|
820
867
|
const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
|
|
@@ -1086,7 +1133,7 @@ async function detachWorkspaceRelease(options) {
|
|
|
1086
1133
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1087
1134
|
const workspace = await discoverWorkspace(root);
|
|
1088
1135
|
const graph = buildDependencyGraph(workspace.packages);
|
|
1089
|
-
|
|
1136
|
+
validateDependencyRanges(graph);
|
|
1090
1137
|
const order = topologicalOrder(graph);
|
|
1091
1138
|
log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
|
|
1092
1139
|
const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
|
|
@@ -1163,6 +1210,7 @@ async function resumeWorkspaceRelease(options) {
|
|
|
1163
1210
|
const root = (0, node_path.resolve)(options.root ?? process.cwd());
|
|
1164
1211
|
const log = options.log ?? console.log;
|
|
1165
1212
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1213
|
+
assertPublishableDependencies(await Promise.all(options.detached.filter((entry) => entry.state !== null).map(async (entry) => readManifest((0, node_path.resolve)(root, entry.relativeDirectory, "package.json")))));
|
|
1166
1214
|
const order = [];
|
|
1167
1215
|
const packages = [];
|
|
1168
1216
|
for (const entry of options.detached) {
|
|
@@ -1223,7 +1271,7 @@ async function releaseWorkspace(options = {}) {
|
|
|
1223
1271
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1224
1272
|
const workspace = await discoverWorkspace(root);
|
|
1225
1273
|
const graph = buildDependencyGraph(workspace.packages);
|
|
1226
|
-
|
|
1274
|
+
validateDependencyRanges(graph);
|
|
1227
1275
|
const order = topologicalOrder(graph);
|
|
1228
1276
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
|
|
1229
1277
|
const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
|
package/dist/index.d.cts
CHANGED
|
@@ -7,12 +7,14 @@ export declare const packageName = "@exadev/semantic-release-workspace";
|
|
|
7
7
|
/**
|
|
8
8
|
* The manifest fields that can name a workspace sibling.
|
|
9
9
|
*
|
|
10
|
-
* All four contribute edges to the release order: whatever field a dependency sits in, the sibling has to have released before the dependent's manifest can name its new version. All four also contribute to the decision to release a dependent (see `releaseWorkspace`)
|
|
10
|
+
* All four contribute edges to the release order: whatever field a dependency sits in, the sibling has to have released before the dependent's manifest can name its new version. All four also contribute to the decision to release a dependent (see `releaseWorkspace`).
|
|
11
11
|
*/
|
|
12
12
|
type DependencyField = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';
|
|
13
13
|
interface PackageManifest {
|
|
14
14
|
readonly name: string;
|
|
15
15
|
readonly version: string;
|
|
16
|
+
/** Whether the manifest sets `private` to the boolean `true`, the one value `@semantic-release/npm` treats as "never publish this package". */
|
|
17
|
+
readonly private: boolean;
|
|
16
18
|
/** Only the fields actually present in the file, each mapping dependency name to its declared range. */
|
|
17
19
|
readonly dependencies: ReadonlyMap<DependencyField, ReadonlyMap<string, string>>;
|
|
18
20
|
}
|
|
@@ -28,6 +30,8 @@ export declare function writeDependencyRange(path: string, field: DependencyFiel
|
|
|
28
30
|
interface WorkspacePackage {
|
|
29
31
|
readonly name: string;
|
|
30
32
|
readonly version: string;
|
|
33
|
+
/** Whether the package is marked `private`, which means it is never published to npm. */
|
|
34
|
+
readonly private: boolean;
|
|
31
35
|
/** Absolute path to the package directory. */
|
|
32
36
|
readonly directory: string;
|
|
33
37
|
/** Path relative to the workspace root, always POSIX-separated. Used for filesystem and git-pathspec purposes scoped to the workspace itself (for example `git add` run with the workspace root as `cwd`) -- never for matching against `git log` output, which `git` always reports relative to the repository's toplevel, not to whatever `cwd` a command happened to run from. Compare `repoRelativeDirectory` for that. */
|
|
@@ -87,7 +91,7 @@ export declare function topologicalOrder(graph: DependencyGraph): readonly strin
|
|
|
87
91
|
/**
|
|
88
92
|
* What happens to one dependency range when the sibling it points at releases a new version.
|
|
89
93
|
*
|
|
90
|
-
* The distinction between `rewritten` and `resolved-at-publish` matters for the manifest, not for the release decision: both
|
|
94
|
+
* The distinction between `rewritten` and `resolved-at-publish` matters for the manifest, not for the release decision: both make the dependent release in its own right, so it always follows a sibling it depends on. Only `wildcard` leaves the dependent entirely unaffected.
|
|
91
95
|
*/
|
|
92
96
|
type DependencyRangeUpdate =
|
|
93
97
|
/** The range names a concrete version that has to be rewritten in the manifest. */
|
|
@@ -95,7 +99,7 @@ type DependencyRangeUpdate =
|
|
|
95
99
|
readonly kind: 'rewritten';
|
|
96
100
|
readonly range: string;
|
|
97
101
|
} |
|
|
98
|
-
/** A bare `workspace:*`, `workspace:^`, or `workspace:~` range:
|
|
102
|
+
/** A bare `workspace:*`, `workspace:^`, or `workspace:~` range: it names no version, so the manifest on disk needs no edit. Only a private package or a `devDependencies` entry can carry one and still be released (see `assertPublishableDependencies`), because `npm publish` ships the specifier unchanged. */
|
|
99
103
|
{
|
|
100
104
|
readonly kind: 'resolved-at-publish';
|
|
101
105
|
} |
|
|
@@ -128,7 +132,7 @@ export declare function updateDependencyRange(current: string, version: string):
|
|
|
128
132
|
//#endregion
|
|
129
133
|
//#region src/plugins.d.ts
|
|
130
134
|
/**
|
|
131
|
-
* One workspace dependency range that changed because its package released a new version during this run. `rewritten` means the dependent's manifest was edited on disk; `resolved-at-publish` means a `workspace:^`-style range whose on-disk text is unchanged
|
|
135
|
+
* One workspace dependency range that changed because its package released a new version during this run. `rewritten` means the dependent's manifest was edited on disk; `resolved-at-publish` means a bare `workspace:^`-style range whose on-disk text is unchanged. Both count towards the dependent's release.
|
|
132
136
|
*/
|
|
133
137
|
interface DependencyBump {
|
|
134
138
|
readonly dependency: string;
|
|
@@ -294,7 +298,7 @@ export declare class DependencyCycleError extends WorkspaceReleaseError {
|
|
|
294
298
|
readonly cycle: readonly string[];
|
|
295
299
|
constructor(cycle: readonly string[]);
|
|
296
300
|
}
|
|
297
|
-
/** A dependency
|
|
301
|
+
/** 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. */
|
|
298
302
|
export declare class UnsupportedDependencyRangeError extends WorkspaceReleaseError {}
|
|
299
303
|
/** 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. */
|
|
300
304
|
export declare class ReleaseConfigurationError extends WorkspaceReleaseError {}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,12 +7,14 @@ export declare const packageName = "@exadev/semantic-release-workspace";
|
|
|
7
7
|
/**
|
|
8
8
|
* The manifest fields that can name a workspace sibling.
|
|
9
9
|
*
|
|
10
|
-
* All four contribute edges to the release order: whatever field a dependency sits in, the sibling has to have released before the dependent's manifest can name its new version. All four also contribute to the decision to release a dependent (see `releaseWorkspace`)
|
|
10
|
+
* All four contribute edges to the release order: whatever field a dependency sits in, the sibling has to have released before the dependent's manifest can name its new version. All four also contribute to the decision to release a dependent (see `releaseWorkspace`).
|
|
11
11
|
*/
|
|
12
12
|
type DependencyField = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';
|
|
13
13
|
interface PackageManifest {
|
|
14
14
|
readonly name: string;
|
|
15
15
|
readonly version: string;
|
|
16
|
+
/** Whether the manifest sets `private` to the boolean `true`, the one value `@semantic-release/npm` treats as "never publish this package". */
|
|
17
|
+
readonly private: boolean;
|
|
16
18
|
/** Only the fields actually present in the file, each mapping dependency name to its declared range. */
|
|
17
19
|
readonly dependencies: ReadonlyMap<DependencyField, ReadonlyMap<string, string>>;
|
|
18
20
|
}
|
|
@@ -28,6 +30,8 @@ export declare function writeDependencyRange(path: string, field: DependencyFiel
|
|
|
28
30
|
interface WorkspacePackage {
|
|
29
31
|
readonly name: string;
|
|
30
32
|
readonly version: string;
|
|
33
|
+
/** Whether the package is marked `private`, which means it is never published to npm. */
|
|
34
|
+
readonly private: boolean;
|
|
31
35
|
/** Absolute path to the package directory. */
|
|
32
36
|
readonly directory: string;
|
|
33
37
|
/** Path relative to the workspace root, always POSIX-separated. Used for filesystem and git-pathspec purposes scoped to the workspace itself (for example `git add` run with the workspace root as `cwd`) -- never for matching against `git log` output, which `git` always reports relative to the repository's toplevel, not to whatever `cwd` a command happened to run from. Compare `repoRelativeDirectory` for that. */
|
|
@@ -87,7 +91,7 @@ export declare function topologicalOrder(graph: DependencyGraph): readonly strin
|
|
|
87
91
|
/**
|
|
88
92
|
* What happens to one dependency range when the sibling it points at releases a new version.
|
|
89
93
|
*
|
|
90
|
-
* The distinction between `rewritten` and `resolved-at-publish` matters for the manifest, not for the release decision: both
|
|
94
|
+
* The distinction between `rewritten` and `resolved-at-publish` matters for the manifest, not for the release decision: both make the dependent release in its own right, so it always follows a sibling it depends on. Only `wildcard` leaves the dependent entirely unaffected.
|
|
91
95
|
*/
|
|
92
96
|
type DependencyRangeUpdate =
|
|
93
97
|
/** The range names a concrete version that has to be rewritten in the manifest. */
|
|
@@ -95,7 +99,7 @@ type DependencyRangeUpdate =
|
|
|
95
99
|
readonly kind: 'rewritten';
|
|
96
100
|
readonly range: string;
|
|
97
101
|
} |
|
|
98
|
-
/** A bare `workspace:*`, `workspace:^`, or `workspace:~` range:
|
|
102
|
+
/** A bare `workspace:*`, `workspace:^`, or `workspace:~` range: it names no version, so the manifest on disk needs no edit. Only a private package or a `devDependencies` entry can carry one and still be released (see `assertPublishableDependencies`), because `npm publish` ships the specifier unchanged. */
|
|
99
103
|
{
|
|
100
104
|
readonly kind: 'resolved-at-publish';
|
|
101
105
|
} |
|
|
@@ -128,7 +132,7 @@ export declare function updateDependencyRange(current: string, version: string):
|
|
|
128
132
|
//#endregion
|
|
129
133
|
//#region src/plugins.d.ts
|
|
130
134
|
/**
|
|
131
|
-
* One workspace dependency range that changed because its package released a new version during this run. `rewritten` means the dependent's manifest was edited on disk; `resolved-at-publish` means a `workspace:^`-style range whose on-disk text is unchanged
|
|
135
|
+
* One workspace dependency range that changed because its package released a new version during this run. `rewritten` means the dependent's manifest was edited on disk; `resolved-at-publish` means a bare `workspace:^`-style range whose on-disk text is unchanged. Both count towards the dependent's release.
|
|
132
136
|
*/
|
|
133
137
|
interface DependencyBump {
|
|
134
138
|
readonly dependency: string;
|
|
@@ -294,7 +298,7 @@ export declare class DependencyCycleError extends WorkspaceReleaseError {
|
|
|
294
298
|
readonly cycle: readonly string[];
|
|
295
299
|
constructor(cycle: readonly string[]);
|
|
296
300
|
}
|
|
297
|
-
/** A dependency
|
|
301
|
+
/** 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. */
|
|
298
302
|
export declare class UnsupportedDependencyRangeError extends WorkspaceReleaseError {}
|
|
299
303
|
/** 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. */
|
|
300
304
|
export declare class ReleaseConfigurationError extends WorkspaceReleaseError {}
|
package/dist/index.js
CHANGED
|
@@ -36,7 +36,7 @@ var DependencyCycleError = class extends WorkspaceReleaseError {
|
|
|
36
36
|
this.cycle = cycle;
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
|
-
/** 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. */
|
|
40
40
|
var UnsupportedDependencyRangeError = class extends WorkspaceReleaseError {};
|
|
41
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. */
|
|
42
42
|
var ReleaseConfigurationError = class extends WorkspaceReleaseError {};
|
|
@@ -299,6 +299,12 @@ const DEPENDENCY_FIELDS = [
|
|
|
299
299
|
"peerDependencies",
|
|
300
300
|
"optionalDependencies"
|
|
301
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
|
+
];
|
|
302
308
|
async function readManifest(path) {
|
|
303
309
|
const text = await readFile(path, "utf8");
|
|
304
310
|
const parsed = JSON.parse(text);
|
|
@@ -318,6 +324,7 @@ async function readManifest(path) {
|
|
|
318
324
|
return {
|
|
319
325
|
name,
|
|
320
326
|
version,
|
|
327
|
+
private: parsed.private === true,
|
|
321
328
|
dependencies
|
|
322
329
|
};
|
|
323
330
|
}
|
|
@@ -372,6 +379,7 @@ async function discoverWorkspace(root) {
|
|
|
372
379
|
packages.push({
|
|
373
380
|
name: manifest.name,
|
|
374
381
|
version: manifest.version,
|
|
382
|
+
private: manifest.private,
|
|
375
383
|
directory,
|
|
376
384
|
relativeDirectory,
|
|
377
385
|
repoRelativeDirectory,
|
|
@@ -420,7 +428,14 @@ function toPosix(path) {
|
|
|
420
428
|
const WORKSPACE_PROTOCOL = "workspace:";
|
|
421
429
|
const CATALOG_PROTOCOL = "catalog:";
|
|
422
430
|
const NPM_ALIAS_PROTOCOL = "npm:";
|
|
423
|
-
/**
|
|
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. */
|
|
424
439
|
const PUBLISH_RESOLVED_WORKSPACE_SUFFIXES = [
|
|
425
440
|
"*",
|
|
426
441
|
"^",
|
|
@@ -435,6 +450,13 @@ const WILDCARD_RANGES = [
|
|
|
435
450
|
"latest"
|
|
436
451
|
];
|
|
437
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
|
+
/**
|
|
438
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.
|
|
439
461
|
*/
|
|
440
462
|
const REWRITABLE_COMPARATOR = /^(\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?)$/;
|
|
@@ -456,7 +478,7 @@ function classifyDependencyRange(current) {
|
|
|
456
478
|
comparator: inner.comparator
|
|
457
479
|
};
|
|
458
480
|
}
|
|
459
|
-
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.`);
|
|
460
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.`);
|
|
461
483
|
if (WILDCARD_RANGES.includes(range)) return { kind: "wildcard" };
|
|
462
484
|
const match = REWRITABLE_COMPARATOR.exec(range);
|
|
@@ -480,6 +502,27 @@ function updateDependencyRange(current, version) {
|
|
|
480
502
|
};
|
|
481
503
|
}
|
|
482
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
|
|
483
526
|
//#region src/graph.ts
|
|
484
527
|
/**
|
|
485
528
|
* Builds the inter-package dependency graph from the manifests alone.
|
|
@@ -555,11 +598,15 @@ function firstUnplacedDependency(name, graph, unplaced) {
|
|
|
555
598
|
return (graph.dependencies.get(name) ?? []).map((edge) => edge.dependency).filter((dependency) => unplaced.has(dependency)).sort()[0];
|
|
556
599
|
}
|
|
557
600
|
/**
|
|
558
|
-
*
|
|
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).
|
|
559
605
|
*
|
|
560
|
-
* 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.
|
|
561
607
|
*/
|
|
562
|
-
function
|
|
608
|
+
function validateDependencyRanges(graph) {
|
|
609
|
+
assertPublishableDependencies([...graph.packages.values()]);
|
|
563
610
|
for (const edges of graph.dependencies.values()) for (const edge of edges) classifyDependencyRange(edge.range);
|
|
564
611
|
}
|
|
565
612
|
/**
|
|
@@ -773,7 +820,7 @@ async function regenerateLockfile(options) {
|
|
|
773
820
|
* Five phases, all inside one `releaseWorkspaceSingleCommit` call:
|
|
774
821
|
*
|
|
775
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.
|
|
776
|
-
* 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 `
|
|
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.
|
|
777
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.
|
|
778
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.
|
|
779
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`.
|
|
@@ -789,7 +836,7 @@ async function releaseWorkspaceSingleCommit(options) {
|
|
|
789
836
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], { cwd: workspace.root })).trim();
|
|
790
837
|
await assertCleanWorkingTree({ cwd: repoRoot });
|
|
791
838
|
const graph = buildDependencyGraph(workspace.packages);
|
|
792
|
-
|
|
839
|
+
validateDependencyRanges(graph);
|
|
793
840
|
const order = topologicalOrder(graph);
|
|
794
841
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")} (commitStrategy: single)`);
|
|
795
842
|
const resolvedPlugins = resolvePublishPlugins(options.plugins ?? SINGLE_COMMIT_DEFAULT_PUBLISH_PLUGINS, workspace.root, {
|
|
@@ -1061,7 +1108,7 @@ async function detachWorkspaceRelease(options) {
|
|
|
1061
1108
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1062
1109
|
const workspace = await discoverWorkspace(root);
|
|
1063
1110
|
const graph = buildDependencyGraph(workspace.packages);
|
|
1064
|
-
|
|
1111
|
+
validateDependencyRanges(graph);
|
|
1065
1112
|
const order = topologicalOrder(graph);
|
|
1066
1113
|
log(`${packageName}: ${String(order.length)} packages in release order (gated -- tag only, publish deferred): ${order.join(" -> ")}`);
|
|
1067
1114
|
const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
|
|
@@ -1138,6 +1185,7 @@ async function resumeWorkspaceRelease(options) {
|
|
|
1138
1185
|
const root = resolve(options.root ?? process.cwd());
|
|
1139
1186
|
const log = options.log ?? console.log;
|
|
1140
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")))));
|
|
1141
1189
|
const order = [];
|
|
1142
1190
|
const packages = [];
|
|
1143
1191
|
for (const entry of options.detached) {
|
|
@@ -1198,7 +1246,7 @@ async function releaseWorkspace(options = {}) {
|
|
|
1198
1246
|
const env = sanitizeGitEnv(options.env ?? process.env);
|
|
1199
1247
|
const workspace = await discoverWorkspace(root);
|
|
1200
1248
|
const graph = buildDependencyGraph(workspace.packages);
|
|
1201
|
-
|
|
1249
|
+
validateDependencyRanges(graph);
|
|
1202
1250
|
const order = topologicalOrder(graph);
|
|
1203
1251
|
log(`${packageName}: ${String(order.length)} packages in release order: ${order.join(" -> ")}`);
|
|
1204
1252
|
const publishPlugins = resolvePublishPlugins(options.plugins ?? DEFAULT_PUBLISH_PLUGINS, workspace.root, { requireGitPlugin: !dryRun });
|
package/package.json
CHANGED