@pnpm/releasing.versioning 1100.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +22 -0
- package/README.md +24 -0
- package/lib/applyReleasePlan.d.ts +35 -0
- package/lib/applyReleasePlan.js +115 -0
- package/lib/assembleReleasePlan.d.ts +91 -0
- package/lib/assembleReleasePlan.js +699 -0
- package/lib/changelog.d.ts +12 -0
- package/lib/changelog.js +84 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +7 -0
- package/lib/intents.d.ts +17 -0
- package/lib/intents.js +76 -0
- package/lib/ledger.d.ts +50 -0
- package/lib/ledger.js +130 -0
- package/lib/pendingChangelog.d.ts +26 -0
- package/lib/pendingChangelog.js +84 -0
- package/package.json +56 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { PnpmError } from '@pnpm/error';
|
|
3
|
+
import { WorkspaceSpec } from '@pnpm/workspace.spec-parser';
|
|
4
|
+
import { compare, diff, inc, prerelease as parsePrerelease, satisfies, valid, validRange } from 'semver';
|
|
5
|
+
import { buildConsumptionIndex, normalizeProjectDir } from './ledger.js';
|
|
6
|
+
const BUMP_ORDER = { patch: 1, minor: 2, major: 3 };
|
|
7
|
+
const PROPAGATED_DEP_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies'];
|
|
8
|
+
/**
|
|
9
|
+
* Whether a package reference is a workspace-relative directory path rather
|
|
10
|
+
* than a package name — the additive extension to the changesets format,
|
|
11
|
+
* needed only when workspace projects share a published name.
|
|
12
|
+
*/
|
|
13
|
+
export function isDirRef(ref) {
|
|
14
|
+
return ref.startsWith('./');
|
|
15
|
+
}
|
|
16
|
+
export function indexProjectRefs(projects, workspaceDir) {
|
|
17
|
+
const dirs = new Set();
|
|
18
|
+
const dirsByName = new Map();
|
|
19
|
+
for (const project of projects) {
|
|
20
|
+
const dir = toProjectDir(workspaceDir, project.rootDir);
|
|
21
|
+
dirs.add(dir);
|
|
22
|
+
const name = project.manifest.name;
|
|
23
|
+
if (name == null)
|
|
24
|
+
continue;
|
|
25
|
+
let named = dirsByName.get(name);
|
|
26
|
+
if (named == null) {
|
|
27
|
+
named = [];
|
|
28
|
+
dirsByName.set(name, named);
|
|
29
|
+
}
|
|
30
|
+
named.push(dir);
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
refToDirs: (ref) => {
|
|
34
|
+
if (isDirRef(ref)) {
|
|
35
|
+
const dir = normalizeProjectDir(ref);
|
|
36
|
+
return dirs.has(dir) ? [dir] : [];
|
|
37
|
+
}
|
|
38
|
+
return dirsByName.get(ref) ?? [];
|
|
39
|
+
},
|
|
40
|
+
nameToDirs: (name) => dirsByName.get(name) ?? [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** The workspace-relative directory of a project, in canonical spelling. */
|
|
44
|
+
export function toProjectDir(workspaceDir, rootDir) {
|
|
45
|
+
return normalizeProjectDir(path.relative(workspaceDir, rootDir));
|
|
46
|
+
}
|
|
47
|
+
export function assembleReleasePlan(opts) {
|
|
48
|
+
const refs = indexProjectRefs(opts.projects, opts.workspaceDir);
|
|
49
|
+
const participants = collectParticipants(opts.projects, refs, opts);
|
|
50
|
+
const lanesByDir = resolveLanes(refs, participants, opts.versioning);
|
|
51
|
+
const fixedGroups = resolveFixedGroups(refs, participants, opts.versioning);
|
|
52
|
+
validateFixedGroupLanes(fixedGroups, lanesByDir, opts.versioning);
|
|
53
|
+
const epics = resolveEpics(refs, participants, opts.versioning);
|
|
54
|
+
validateEpics(epics, fixedGroups);
|
|
55
|
+
const intentBumps = resolveIntents(opts.intents, refs, participants);
|
|
56
|
+
if (opts.enforceWorkspaceProtocol) {
|
|
57
|
+
assertInternalDepsUseWorkspaceProtocol(participants);
|
|
58
|
+
}
|
|
59
|
+
const consumptionOf = buildConsumptionIndex(opts.ledger, refs.nameToDirs);
|
|
60
|
+
const ctx = { participants, lanesByDir, fixedGroups, epics, intentBumps, consumptionOf, opts };
|
|
61
|
+
let selection = opts.filter;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const plan = assemble(ctx, selection);
|
|
64
|
+
if (selection == null)
|
|
65
|
+
return plan;
|
|
66
|
+
const expanded = new Set(selection);
|
|
67
|
+
for (const release of plan.releases) {
|
|
68
|
+
expanded.add(release.dir);
|
|
69
|
+
}
|
|
70
|
+
if (expanded.size === selection.size)
|
|
71
|
+
return plan;
|
|
72
|
+
selection = expanded;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function assemble(ctx, selection) {
|
|
76
|
+
const { participants, lanesByDir, fixedGroups, epics, opts } = ctx;
|
|
77
|
+
const pendingByDir = collectPendingIntents(ctx);
|
|
78
|
+
const laneConsumedByDir = collectLaneConsumedIntents(ctx);
|
|
79
|
+
const state = new Map();
|
|
80
|
+
const bumpAtLeast = (dir, bumpType, cause) => {
|
|
81
|
+
const existing = state.get(dir);
|
|
82
|
+
if (existing == null) {
|
|
83
|
+
state.set(dir, { bumpType, causes: new Set([cause]), dependencyUpdates: new Map() });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
existing.causes.add(cause);
|
|
87
|
+
if (BUMP_ORDER[bumpType] > BUMP_ORDER[existing.bumpType]) {
|
|
88
|
+
existing.bumpType = bumpType;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
};
|
|
93
|
+
const intentBumpFor = (intent, dir) => ctx.intentBumps.get(intent.id)?.get(dir);
|
|
94
|
+
for (const [dir, pending] of pendingByDir.entries()) {
|
|
95
|
+
if (selection != null && !selection.has(dir))
|
|
96
|
+
continue;
|
|
97
|
+
const direct = maxBumpType(pending.map((intent) => intentBumpFor(intent, dir)));
|
|
98
|
+
if (direct != null) {
|
|
99
|
+
bumpAtLeast(dir, direct, 'intent');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// A package that left its lane releases the accumulated stable
|
|
103
|
+
// version even when no new intents are pending.
|
|
104
|
+
for (const [dir, laneConsumed] of laneConsumedByDir.entries()) {
|
|
105
|
+
if (selection != null && !selection.has(dir))
|
|
106
|
+
continue;
|
|
107
|
+
if (lanesByDir.has(dir) || laneConsumed.length === 0)
|
|
108
|
+
continue;
|
|
109
|
+
const graduated = maxBumpType(laneConsumed.map((intent) => intentBumpFor(intent, dir)));
|
|
110
|
+
if (graduated != null) {
|
|
111
|
+
bumpAtLeast(dir, graduated, 'intent');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const cumulativeBump = (dir, planned) => {
|
|
115
|
+
const laneConsumed = laneConsumedByDir.get(dir) ?? [];
|
|
116
|
+
return maxBumpType([planned, ...laneConsumed.map((intent) => intentBumpFor(intent, dir))]) ?? planned;
|
|
117
|
+
};
|
|
118
|
+
const newVersions = new Map();
|
|
119
|
+
const computeVersions = () => {
|
|
120
|
+
newVersions.clear();
|
|
121
|
+
for (const [dir, pkgState] of state.entries()) {
|
|
122
|
+
const participant = participants.get(dir);
|
|
123
|
+
newVersions.set(dir, computeNewVersion(participant, pkgState.bumpType, {
|
|
124
|
+
laneTag: lanesByDir.get(dir),
|
|
125
|
+
cumulativeBump: cumulativeBump(dir, pkgState.bumpType),
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
applyFixedGroupVersions({ participants, state, newVersions, cumulativeBump, fixedGroups, lanesByDir });
|
|
129
|
+
applyEpicBandVersions({ participants, state, newVersions, epics, lanesByDir });
|
|
130
|
+
};
|
|
131
|
+
for (let changed = true; changed;) {
|
|
132
|
+
changed = false;
|
|
133
|
+
computeVersions();
|
|
134
|
+
for (const dependent of participants.values()) {
|
|
135
|
+
for (const dep of dependent.internalDeps) {
|
|
136
|
+
const target = participants.get(dep.targetDir);
|
|
137
|
+
const targetNewVersion = newVersions.get(dep.targetDir);
|
|
138
|
+
if (target == null || targetNewVersion == null)
|
|
139
|
+
continue;
|
|
140
|
+
const materializedRange = materializeWorkspaceRange(dep.spec, target.currentVersion);
|
|
141
|
+
if (materializedRange == null || satisfies(targetNewVersion, materializedRange))
|
|
142
|
+
continue;
|
|
143
|
+
if (bumpAtLeast(dependent.dir, 'patch', 'dependencies')) {
|
|
144
|
+
changed = true;
|
|
145
|
+
}
|
|
146
|
+
state.get(dependent.dir).dependencyUpdates.set(dep.targetName, targetNewVersion);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const group of fixedGroups) {
|
|
150
|
+
const groupBump = maxBumpType(group.map((dir) => state.get(dir)?.bumpType));
|
|
151
|
+
if (groupBump == null)
|
|
152
|
+
continue;
|
|
153
|
+
for (const dir of group) {
|
|
154
|
+
if (bumpAtLeast(dir, groupBump, 'fixed')) {
|
|
155
|
+
changed = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// When the lead crosses to a new stable major, every member re-bases to
|
|
160
|
+
// the band floor. Seed a release for each so the override in
|
|
161
|
+
// applyEpicBandVersions has a version to replace and dependents propagate.
|
|
162
|
+
for (const epic of epics) {
|
|
163
|
+
if (epicRebaseFloor(epic, participants, newVersions) == null)
|
|
164
|
+
continue;
|
|
165
|
+
for (const memberDir of epic.memberDirs) {
|
|
166
|
+
if (bumpAtLeast(memberDir, 'major', 'epic')) {
|
|
167
|
+
changed = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
computeVersions();
|
|
173
|
+
const releases = [];
|
|
174
|
+
for (const [dir, pkgState] of state.entries()) {
|
|
175
|
+
const participant = participants.get(dir);
|
|
176
|
+
const consumedForChangelog = [
|
|
177
|
+
...(pendingByDir.get(dir) ?? []),
|
|
178
|
+
...(lanesByDir.has(dir) ? [] : laneConsumedByDir.get(dir) ?? []),
|
|
179
|
+
];
|
|
180
|
+
releases.push({
|
|
181
|
+
name: participant.name,
|
|
182
|
+
dir,
|
|
183
|
+
rootDir: participant.rootDir,
|
|
184
|
+
currentVersion: participant.currentVersion,
|
|
185
|
+
newVersion: opts.snapshotSuffix != null ? `0.0.0-${opts.snapshotSuffix}` : newVersions.get(dir),
|
|
186
|
+
bumpType: pkgState.bumpType,
|
|
187
|
+
intents: consumedForChangelog,
|
|
188
|
+
dependencyUpdates: Array.from(pkgState.dependencyUpdates.entries())
|
|
189
|
+
.map(([depName, newVersion]) => ({ name: depName, newVersion }))
|
|
190
|
+
.sort((left, right) => left.name.localeCompare(right.name)),
|
|
191
|
+
causes: Array.from(pkgState.causes).sort(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
releases.sort((left, right) => left.name.localeCompare(right.name) || left.dir.localeCompare(right.dir));
|
|
195
|
+
assertNoDuplicateReleaseIdentity(releases);
|
|
196
|
+
if (opts.snapshotSuffix == null) {
|
|
197
|
+
enforceEpicBands(epics, participants, newVersions);
|
|
198
|
+
enforceMaxBump(releases, opts.versioning);
|
|
199
|
+
}
|
|
200
|
+
return { releases };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* A published `package@version` identifies exactly one artifact, so two
|
|
204
|
+
* projects that share a name cannot both release the same version — the
|
|
205
|
+
* registry would reject the second publish, and the name-keyed ledger entry
|
|
206
|
+
* would collide. Caught here, before any manifest is written, naming both
|
|
207
|
+
* directories.
|
|
208
|
+
*/
|
|
209
|
+
function assertNoDuplicateReleaseIdentity(releases) {
|
|
210
|
+
const byIdentity = new Map();
|
|
211
|
+
for (const release of releases) {
|
|
212
|
+
const identity = `${release.name}@${release.newVersion}`;
|
|
213
|
+
const other = byIdentity.get(identity);
|
|
214
|
+
if (other != null) {
|
|
215
|
+
throw new PnpmError('VERSIONING_DUPLICATE_RELEASE', `Two projects both release ${identity}: ./${other} and ./${release.dir}. ` +
|
|
216
|
+
'A package name and version identify one published artifact, so same-named projects must release on different version lines (e.g. different lanes or majors).');
|
|
217
|
+
}
|
|
218
|
+
byIdentity.set(identity, release.dir);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function collectParticipants(projects, refs, opts) {
|
|
222
|
+
const ignoredDirs = new Set();
|
|
223
|
+
for (const ref of opts.versioning?.ignore ?? []) {
|
|
224
|
+
for (const dir of resolveConfigRef(refs, ref, 'versioning.ignore')) {
|
|
225
|
+
ignoredDirs.add(dir);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const participants = new Map();
|
|
229
|
+
for (const project of projects) {
|
|
230
|
+
const { name, version } = project.manifest;
|
|
231
|
+
const dir = toProjectDir(opts.workspaceDir, project.rootDir);
|
|
232
|
+
// What cannot release is excluded automatically: unnamed and versionless
|
|
233
|
+
// (private) packages, packages with non-semver placeholder versions, and
|
|
234
|
+
// the explicitly frozen ones.
|
|
235
|
+
if (name == null || version == null || valid(version) == null || ignoredDirs.has(dir))
|
|
236
|
+
continue;
|
|
237
|
+
participants.set(dir, {
|
|
238
|
+
name,
|
|
239
|
+
dir,
|
|
240
|
+
rootDir: project.rootDir,
|
|
241
|
+
currentVersion: version,
|
|
242
|
+
manifest: project.manifest,
|
|
243
|
+
internalDeps: [],
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
for (const participant of participants.values()) {
|
|
247
|
+
for (const fieldName of PROPAGATED_DEP_FIELDS) {
|
|
248
|
+
for (const [alias, spec] of Object.entries(participant.manifest[fieldName] ?? {})) {
|
|
249
|
+
const targetName = internalDepTargetName(alias, spec, refs);
|
|
250
|
+
if (targetName == null)
|
|
251
|
+
continue;
|
|
252
|
+
const targetDirs = refs.nameToDirs(targetName).filter((dir) => participants.has(dir));
|
|
253
|
+
if (targetDirs.length === 0)
|
|
254
|
+
continue;
|
|
255
|
+
// A workspace: range naming an ambiguous package cannot be linked at
|
|
256
|
+
// install time, so the release engine never legitimately sees one.
|
|
257
|
+
if (targetDirs.length > 1) {
|
|
258
|
+
throw new PnpmError('VERSIONING_AMBIGUOUS_PACKAGE', `Package ${participant.name} (./${participant.dir}) depends on ${targetName}, which matches multiple workspace projects: ${targetDirs.map((dir) => `./${dir}`).join(', ')}`);
|
|
259
|
+
}
|
|
260
|
+
participant.internalDeps.push({ targetDir: targetDirs[0], targetName, fieldName, alias, spec });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return participants;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Decides whether a dependency entry points at a workspace package. Aliased
|
|
268
|
+
* specs targeting somewhere else (`npm:`, `file:`, git URLs, …) are external
|
|
269
|
+
* even when the alias collides with a workspace package name; a plain semver
|
|
270
|
+
* range or `catalog:` entry on a workspace name is internal — it is exactly
|
|
271
|
+
* the declaration the workspace-protocol check must reject.
|
|
272
|
+
*/
|
|
273
|
+
function internalDepTargetName(alias, spec, refs) {
|
|
274
|
+
if (spec.startsWith('workspace:')) {
|
|
275
|
+
const targetName = WorkspaceSpec.parse(spec)?.alias ?? alias;
|
|
276
|
+
return refs.nameToDirs(targetName).length > 0 ? targetName : null;
|
|
277
|
+
}
|
|
278
|
+
if (refs.nameToDirs(alias).length === 0)
|
|
279
|
+
return null;
|
|
280
|
+
if (spec.startsWith('catalog:') || validRange(spec) != null)
|
|
281
|
+
return alias;
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Resolves a package reference from `versioning` configuration. An unknown
|
|
286
|
+
* reference is skipped — configuration may outlive a removed project — but an
|
|
287
|
+
* ambiguous name is an error: it cannot be attributed, and silence here is
|
|
288
|
+
* exactly the name-keying flaw this engine exists to fix.
|
|
289
|
+
*/
|
|
290
|
+
function resolveConfigRef(refs, ref, settingName) {
|
|
291
|
+
const dirs = refs.refToDirs(ref);
|
|
292
|
+
if (dirs.length > 1) {
|
|
293
|
+
throw new PnpmError('VERSIONING_AMBIGUOUS_PACKAGE', `${settingName} references ${ref}, which matches multiple workspace projects: ${dirs.map((dir) => `./${dir}`).join(', ')}. Reference the project by directory instead.`);
|
|
294
|
+
}
|
|
295
|
+
return dirs;
|
|
296
|
+
}
|
|
297
|
+
function resolveLanes(refs, participants, versioning) {
|
|
298
|
+
const lanesByDir = new Map();
|
|
299
|
+
for (const [ref, lane] of Object.entries(versioning?.lanes ?? {})) {
|
|
300
|
+
if (lane.toLowerCase() === 'main') {
|
|
301
|
+
throw new PnpmError('VERSIONING_INVALID_LANE_NAME', `versioning.lanes assigns ${ref} to the "${lane}" lane, but "main" is the reserved default lane. Remove the entry instead.`);
|
|
302
|
+
}
|
|
303
|
+
for (const dir of resolveConfigRef(refs, ref, 'versioning.lanes')) {
|
|
304
|
+
if (participants.has(dir)) {
|
|
305
|
+
lanesByDir.set(dir, lane);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return lanesByDir;
|
|
310
|
+
}
|
|
311
|
+
function resolveFixedGroups(refs, participants, versioning) {
|
|
312
|
+
return (versioning?.fixed ?? []).map((group) => group
|
|
313
|
+
.flatMap((ref) => resolveConfigRef(refs, ref, 'versioning.fixed'))
|
|
314
|
+
.filter((dir) => participants.has(dir)));
|
|
315
|
+
}
|
|
316
|
+
function validateFixedGroupLanes(fixedGroups, lanesByDir, versioning) {
|
|
317
|
+
for (const [index, group] of fixedGroups.entries()) {
|
|
318
|
+
const tags = new Set(group.map((dir) => lanesByDir.get(dir)));
|
|
319
|
+
if (tags.size > 1) {
|
|
320
|
+
throw new PnpmError('VERSIONING_CONFLICTING_CONFIG', `The fixed group [${(versioning?.fixed ?? [])[index].join(', ')}] mixes packages on different lanes. A fixed group must move between lanes together.`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Resolves each configured epic to its lead directory and the set of member
|
|
326
|
+
* directories its selectors match. The lead — a single named package with a
|
|
327
|
+
* semver version — is excluded from its own membership; a selector matching
|
|
328
|
+
* it is a no-op. Membership selectors match name globs, `./`-prefixed
|
|
329
|
+
* directory globs, and `!`-prefixed negations.
|
|
330
|
+
*/
|
|
331
|
+
function resolveEpics(refs, participants, versioning) {
|
|
332
|
+
return (versioning?.epics ?? []).map((epic) => {
|
|
333
|
+
const leadDir = resolveConfigRef(refs, epic.lead, 'versioning.epics lead')[0];
|
|
334
|
+
if (leadDir == null || !participants.has(leadDir)) {
|
|
335
|
+
throw new PnpmError('VERSIONING_EPIC_UNKNOWN_LEAD', `versioning.epics lead "${epic.lead}" is not a releasable workspace project (it must be a named package with a semver version).`);
|
|
336
|
+
}
|
|
337
|
+
const selectors = epic.packages.map(compileEpicSelector);
|
|
338
|
+
const memberDirs = new Set();
|
|
339
|
+
for (const participant of participants.values()) {
|
|
340
|
+
if (participant.dir === leadDir)
|
|
341
|
+
continue;
|
|
342
|
+
if (matchesEpicSelectors(selectors, participant.dir, participant.name)) {
|
|
343
|
+
memberDirs.add(participant.dir);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { leadRef: epic.lead, leadDir, memberDirs };
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
function compileEpicSelector(selector) {
|
|
350
|
+
const negated = selector.startsWith('!');
|
|
351
|
+
const body = negated ? selector.slice(1) : selector;
|
|
352
|
+
const onDir = isDirRef(body);
|
|
353
|
+
return { negated, onDir, match: wildcardMatch(onDir ? normalizeProjectDir(body) : body) };
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Whether a project is an epic member under pnpm's order-dependent selector
|
|
357
|
+
* rule: each matching selector overrides the previous verdict, so the last one
|
|
358
|
+
* to match decides — a positive include or a `!` negation — mirroring
|
|
359
|
+
* `@pnpm/config.matcher`, where a later include can re-include a package an
|
|
360
|
+
* earlier negation excluded.
|
|
361
|
+
*/
|
|
362
|
+
function matchesEpicSelectors(selectors, dir, name) {
|
|
363
|
+
let included = false;
|
|
364
|
+
for (const selector of selectors) {
|
|
365
|
+
if (selector.match(selector.onDir ? dir : name)) {
|
|
366
|
+
included = !selector.negated;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return included;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Compiles a selector where `*` matches any run of characters and every other
|
|
373
|
+
* character is literal, mirroring `@pnpm/config.matcher`'s wildcard semantics
|
|
374
|
+
* so epic membership globs behave like pnpm's other package selectors.
|
|
375
|
+
*/
|
|
376
|
+
function wildcardMatch(pattern) {
|
|
377
|
+
if (pattern === '*')
|
|
378
|
+
return () => true;
|
|
379
|
+
let source = '^';
|
|
380
|
+
for (const character of pattern) {
|
|
381
|
+
source += character === '*' ? '.*' : character.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
382
|
+
}
|
|
383
|
+
source += '$';
|
|
384
|
+
const regexp = new RegExp(source);
|
|
385
|
+
return (input) => regexp.test(input);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Rejects epic configurations that cannot be attributed unambiguously: a
|
|
389
|
+
* package matched by two epics, and a fixed group that straddles an epic
|
|
390
|
+
* boundary (a group must sit entirely inside or entirely outside an epic, so
|
|
391
|
+
* its members never disagree on whether they are band-constrained).
|
|
392
|
+
*/
|
|
393
|
+
function validateEpics(epics, fixedGroups) {
|
|
394
|
+
const epicOfMember = new Map();
|
|
395
|
+
for (const epic of epics) {
|
|
396
|
+
for (const memberDir of epic.memberDirs) {
|
|
397
|
+
const other = epicOfMember.get(memberDir);
|
|
398
|
+
if (other != null && other !== epic.leadRef) {
|
|
399
|
+
throw new PnpmError('VERSIONING_EPIC_OVERLAP', `Package ./${memberDir} is matched by two epics (leads "${other}" and "${epic.leadRef}"). A package can belong to at most one epic.`);
|
|
400
|
+
}
|
|
401
|
+
epicOfMember.set(memberDir, epic.leadRef);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
for (const epic of epics) {
|
|
405
|
+
for (const group of fixedGroups) {
|
|
406
|
+
if (!group.some((dir) => epic.memberDirs.has(dir)))
|
|
407
|
+
continue;
|
|
408
|
+
const outsiders = group.filter((dir) => !epic.memberDirs.has(dir));
|
|
409
|
+
if (outsiders.length > 0) {
|
|
410
|
+
throw new PnpmError('VERSIONING_EPIC_FIXED_GROUP_CONFLICT', `A fixed group straddles the epic led by "${epic.leadRef}": it mixes epic members with outside package(s) ${outsiders.map((dir) => `./${dir}`).join(', ')}. A fixed group must sit entirely inside or entirely outside an epic.`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Resolves every intent's package references to participant directories,
|
|
417
|
+
* validating along the way: unknown references and names matching several
|
|
418
|
+
* projects are hard errors, and a release can only be demanded from a
|
|
419
|
+
* participant — otherwise the intent could never be consumed and the file
|
|
420
|
+
* would linger forever. A `none` decline is fine for any workspace package.
|
|
421
|
+
*/
|
|
422
|
+
function resolveIntents(intents, refs, participants) {
|
|
423
|
+
const intentBumps = new Map();
|
|
424
|
+
for (const intent of intents) {
|
|
425
|
+
const byDir = new Map();
|
|
426
|
+
for (const [ref, bumpType] of Object.entries(intent.releases)) {
|
|
427
|
+
const dirs = refs.refToDirs(ref);
|
|
428
|
+
if (dirs.length === 0) {
|
|
429
|
+
throw new PnpmError('VERSIONING_UNKNOWN_PACKAGE', `Change intent file ${intent.filePath} names ${ref}, which is not a package in this workspace`);
|
|
430
|
+
}
|
|
431
|
+
if (dirs.length > 1) {
|
|
432
|
+
throw new PnpmError('VERSIONING_AMBIGUOUS_PACKAGE', `Change intent file ${intent.filePath} names ${ref}, which matches multiple workspace projects: ${dirs.map((dir) => `./${dir}`).join(', ')}. ` +
|
|
433
|
+
'Reference the project by directory instead, e.g. "./' + dirs[0] + '": ' + bumpType);
|
|
434
|
+
}
|
|
435
|
+
const dir = dirs[0];
|
|
436
|
+
if (bumpType !== 'none' && !participants.has(dir)) {
|
|
437
|
+
throw new PnpmError('VERSIONING_UNRELEASABLE_PACKAGE', `Change intent file ${intent.filePath} requests a ${bumpType} release of ${ref}, which cannot release ` +
|
|
438
|
+
'(it is listed in versioning.ignore, has no version field, or has a non-semver version). ' +
|
|
439
|
+
'Remove the entry or change it to "none".');
|
|
440
|
+
}
|
|
441
|
+
const existing = byDir.get(dir);
|
|
442
|
+
if (existing == null || (bumpType !== 'none' && BUMP_ORDER[bumpType] > (existing === 'none' ? 0 : BUMP_ORDER[existing]))) {
|
|
443
|
+
byDir.set(dir, bumpType);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
intentBumps.set(intent.id, byDir);
|
|
447
|
+
}
|
|
448
|
+
return intentBumps;
|
|
449
|
+
}
|
|
450
|
+
function assertInternalDepsUseWorkspaceProtocol(participants) {
|
|
451
|
+
for (const participant of participants.values()) {
|
|
452
|
+
for (const dep of participant.internalDeps) {
|
|
453
|
+
if (!dep.spec.startsWith('workspace:')) {
|
|
454
|
+
throw new PnpmError('VERSIONING_INTERNAL_RANGE', `Package ${participant.name} declares the internal dependency ${dep.alias} in ${dep.fieldName} as "${dep.spec}". ` +
|
|
455
|
+
'Internal dependencies must use the workspace: protocol so that dependency ranges never need rewriting at release time.');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function collectPendingIntents(ctx) {
|
|
461
|
+
const pending = new Map();
|
|
462
|
+
for (const dir of ctx.participants.keys()) {
|
|
463
|
+
const consumed = ctx.consumptionOf(dir);
|
|
464
|
+
const pkgIntents = ctx.opts.intents.filter((intent) => {
|
|
465
|
+
const bump = ctx.intentBumps.get(intent.id)?.get(dir);
|
|
466
|
+
return bump != null && bump !== 'none' && !consumed.allIds.has(intent.id);
|
|
467
|
+
});
|
|
468
|
+
if (pkgIntents.length > 0) {
|
|
469
|
+
pending.set(dir, pkgIntents);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return pending;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Intents already consumed by prereleases of a package that has not graduated
|
|
476
|
+
* to a stable version yet. They participate in the cumulative bump computation
|
|
477
|
+
* of the package's lane and compose the stable changelog section at
|
|
478
|
+
* graduation.
|
|
479
|
+
*/
|
|
480
|
+
function collectLaneConsumedIntents(ctx) {
|
|
481
|
+
const laneConsumed = new Map();
|
|
482
|
+
for (const dir of ctx.participants.keys()) {
|
|
483
|
+
const consumed = ctx.consumptionOf(dir);
|
|
484
|
+
if (consumed.prereleaseOnlyIds.size === 0)
|
|
485
|
+
continue;
|
|
486
|
+
const pkgIntents = ctx.opts.intents.filter((intent) => {
|
|
487
|
+
const bump = ctx.intentBumps.get(intent.id)?.get(dir);
|
|
488
|
+
return bump != null && bump !== 'none' && consumed.prereleaseOnlyIds.has(intent.id);
|
|
489
|
+
});
|
|
490
|
+
if (pkgIntents.length > 0) {
|
|
491
|
+
laneConsumed.set(dir, pkgIntents);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return laneConsumed;
|
|
495
|
+
}
|
|
496
|
+
function maxBumpType(types) {
|
|
497
|
+
let result = null;
|
|
498
|
+
for (const type of types) {
|
|
499
|
+
if (type !== 'patch' && type !== 'minor' && type !== 'major')
|
|
500
|
+
continue;
|
|
501
|
+
if (result == null || BUMP_ORDER[type] > BUMP_ORDER[result]) {
|
|
502
|
+
result = type;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return result;
|
|
506
|
+
}
|
|
507
|
+
function computeNewVersion(participant, bumpType, opts) {
|
|
508
|
+
const current = participant.currentVersion;
|
|
509
|
+
if (opts.laneTag == null) {
|
|
510
|
+
if (parsePrerelease(current) == null) {
|
|
511
|
+
return inc(current, bumpType);
|
|
512
|
+
}
|
|
513
|
+
// Graduation: the accumulated stable version the lane was
|
|
514
|
+
// building toward.
|
|
515
|
+
return escalateStableTarget(stablePart(current), opts.cumulativeBump);
|
|
516
|
+
}
|
|
517
|
+
const target = parsePrerelease(current) == null
|
|
518
|
+
? inc(current, opts.cumulativeBump)
|
|
519
|
+
: escalateStableTarget(stablePart(current), opts.cumulativeBump);
|
|
520
|
+
return `${target}-${opts.laneTag}.${nextPrereleaseNumber(current, target, opts.laneTag)}`;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Re-derives the stable version a lane is building toward when the
|
|
524
|
+
* cumulative bump escalates. The invariant: the stable part of the current
|
|
525
|
+
* prerelease already reflects the previous cumulative bump applied to the
|
|
526
|
+
* version the line started from, so only an escalation changes it.
|
|
527
|
+
*/
|
|
528
|
+
function escalateStableTarget(target, cumulativeBump) {
|
|
529
|
+
const [major, minor, patch] = target.split('.').map(Number);
|
|
530
|
+
switch (cumulativeBump) {
|
|
531
|
+
case 'major':
|
|
532
|
+
return minor === 0 && patch === 0 ? target : `${major + 1}.0.0`;
|
|
533
|
+
case 'minor':
|
|
534
|
+
return patch === 0 ? target : `${major}.${minor + 1}.0`;
|
|
535
|
+
case 'patch':
|
|
536
|
+
return target;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function stablePart(version) {
|
|
540
|
+
return version.split('-')[0];
|
|
541
|
+
}
|
|
542
|
+
function nextPrereleaseNumber(current, target, laneTag) {
|
|
543
|
+
const currentPrerelease = parsePrerelease(current);
|
|
544
|
+
if (currentPrerelease == null)
|
|
545
|
+
return 0;
|
|
546
|
+
const [currentTag, currentN] = currentPrerelease;
|
|
547
|
+
// semver parses an all-digit prerelease identifier as a number, so the tag
|
|
548
|
+
// comparison must not be strict about the type.
|
|
549
|
+
if (stablePart(current) !== target || String(currentTag) !== laneTag || typeof currentN !== 'number')
|
|
550
|
+
return 0;
|
|
551
|
+
return currentN + 1;
|
|
552
|
+
}
|
|
553
|
+
function applyFixedGroupVersions({ participants, state, newVersions, cumulativeBump, fixedGroups, lanesByDir }) {
|
|
554
|
+
for (const group of fixedGroups) {
|
|
555
|
+
const bumpedMembers = group.filter((dir) => state.has(dir));
|
|
556
|
+
if (bumpedMembers.length === 0)
|
|
557
|
+
continue;
|
|
558
|
+
const groupBump = maxBumpType(bumpedMembers.map((dir) => cumulativeBump(dir, state.get(dir).bumpType)));
|
|
559
|
+
const highestCurrent = group
|
|
560
|
+
.map((dir) => participants.get(dir).currentVersion)
|
|
561
|
+
.sort(compare)
|
|
562
|
+
.at(-1);
|
|
563
|
+
const target = parsePrerelease(highestCurrent) == null
|
|
564
|
+
? inc(highestCurrent, groupBump)
|
|
565
|
+
: escalateStableTarget(stablePart(highestCurrent), groupBump);
|
|
566
|
+
const laneTag = lanesByDir.get(group[0]);
|
|
567
|
+
let sharedVersion = target;
|
|
568
|
+
if (laneTag != null) {
|
|
569
|
+
const nextN = Math.max(...group.map((dir) => nextPrereleaseNumber(participants.get(dir).currentVersion, target, laneTag)));
|
|
570
|
+
sharedVersion = `${target}-${laneTag}.${nextN}`;
|
|
571
|
+
}
|
|
572
|
+
for (const dir of group) {
|
|
573
|
+
if (state.has(dir)) {
|
|
574
|
+
newVersions.set(dir, sharedVersion);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* The band floor (`newMajor × 100`) an epic re-bases its members to, or null
|
|
581
|
+
* when no re-base is due. A re-base fires only when the lead releases to a
|
|
582
|
+
* new, higher *stable* major in this plan; a prerelease lead version (the lead
|
|
583
|
+
* on a lane) defers the re-base until its stable release.
|
|
584
|
+
*/
|
|
585
|
+
function epicRebaseFloor(epic, participants, newVersions) {
|
|
586
|
+
const lead = participants.get(epic.leadDir);
|
|
587
|
+
const newLeadVersion = newVersions.get(epic.leadDir);
|
|
588
|
+
if (lead == null || newLeadVersion == null || parsePrerelease(newLeadVersion) != null)
|
|
589
|
+
return null;
|
|
590
|
+
const newMajor = Number(newLeadVersion.split('.')[0]);
|
|
591
|
+
const currentMajor = Number(lead.currentVersion.split('.')[0]);
|
|
592
|
+
return newMajor > currentMajor ? newMajor * 100 : null;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Overrides the computed version of every bumped epic member with the band
|
|
596
|
+
* floor when its lead crosses to a new stable major. A member on a lane
|
|
597
|
+
* re-bases to a prerelease of the floor; every other member to `floor.0.0`.
|
|
598
|
+
*/
|
|
599
|
+
function applyEpicBandVersions({ participants, state, newVersions, epics, lanesByDir }) {
|
|
600
|
+
for (const epic of epics) {
|
|
601
|
+
const floor = epicRebaseFloor(epic, participants, newVersions);
|
|
602
|
+
if (floor == null)
|
|
603
|
+
continue;
|
|
604
|
+
const target = `${floor}.0.0`;
|
|
605
|
+
for (const memberDir of epic.memberDirs) {
|
|
606
|
+
if (!state.has(memberDir))
|
|
607
|
+
continue;
|
|
608
|
+
const laneTag = lanesByDir.get(memberDir);
|
|
609
|
+
newVersions.set(memberDir, laneTag == null
|
|
610
|
+
? target
|
|
611
|
+
: `${target}-${laneTag}.${nextPrereleaseNumber(participants.get(memberDir).currentVersion, target, laneTag)}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* The band of member majors an epic permits: `[leadMajor×100, leadMajor×100+99]`,
|
|
617
|
+
* where `leadMajor` is the major the plan establishes for the lead — its
|
|
618
|
+
* re-based major when the lead crosses to a new stable major, otherwise the
|
|
619
|
+
* lead's current major (a prerelease lead does not open the next band).
|
|
620
|
+
*/
|
|
621
|
+
function epicBandMajor(epic, participants, newVersions) {
|
|
622
|
+
const floor = epicRebaseFloor(epic, participants, newVersions);
|
|
623
|
+
return floor != null ? floor / 100 : Number(participants.get(epic.leadDir).currentVersion.split('.')[0]);
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Enforces that every released member's new major stays inside its epic's band.
|
|
627
|
+
* The re-base already keeps members in band when the lead moves; this guards
|
|
628
|
+
* the other direction — an ordinary `major` intent that would carry a member
|
|
629
|
+
* over the band ceiling (`1199.x` → `1200.0.0` while the lead is still on 11)
|
|
630
|
+
* is rejected rather than silently landing the member in the next band.
|
|
631
|
+
*/
|
|
632
|
+
function enforceEpicBands(epics, participants, newVersions) {
|
|
633
|
+
for (const epic of epics) {
|
|
634
|
+
const bandMajor = epicBandMajor(epic, participants, newVersions);
|
|
635
|
+
const low = bandMajor * 100;
|
|
636
|
+
const high = low + 99;
|
|
637
|
+
for (const memberDir of epic.memberDirs) {
|
|
638
|
+
const memberVersion = newVersions.get(memberDir);
|
|
639
|
+
if (memberVersion == null)
|
|
640
|
+
continue;
|
|
641
|
+
const memberMajor = Number(memberVersion.split('.')[0]);
|
|
642
|
+
if (memberMajor < low || memberMajor > high) {
|
|
643
|
+
throw new PnpmError('VERSIONING_EPIC_OUT_OF_BAND', `The release plan takes ${participants.get(memberDir).name} to ${memberVersion}, whose major ${memberMajor} is outside the band ${low}-${high} of the epic led by "${epic.leadRef}" (major ${bandMajor}). ` +
|
|
644
|
+
(memberMajor > high
|
|
645
|
+
? 'The band is exhausted - the lead must advance to a new major to open the next band.'
|
|
646
|
+
: 'Re-base the member into the band, or remove it from the epic.'));
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* The range that pnpm materializes for a workspace: spec at pack time, given
|
|
653
|
+
* the dependency's version at the dependent's previous release. Dependent
|
|
654
|
+
* propagation republishes the dependent whenever the dependency's new version
|
|
655
|
+
* falls outside this range.
|
|
656
|
+
*/
|
|
657
|
+
export function materializeWorkspaceRange(spec, depCurrentVersion) {
|
|
658
|
+
const parsed = WorkspaceSpec.parse(spec);
|
|
659
|
+
if (parsed == null)
|
|
660
|
+
return null;
|
|
661
|
+
switch (parsed.version) {
|
|
662
|
+
case '^':
|
|
663
|
+
return `^${depCurrentVersion}`;
|
|
664
|
+
case '~':
|
|
665
|
+
return `~${depCurrentVersion}`;
|
|
666
|
+
case '*':
|
|
667
|
+
case '':
|
|
668
|
+
return depCurrentVersion;
|
|
669
|
+
default:
|
|
670
|
+
return parsed.version;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function enforceMaxBump(releases, versioning) {
|
|
674
|
+
const maxBump = versioning?.maxBump;
|
|
675
|
+
if (maxBump == null)
|
|
676
|
+
return;
|
|
677
|
+
for (const release of releases) {
|
|
678
|
+
const effectiveBump = effectiveBumpClass(release);
|
|
679
|
+
if (BUMP_ORDER[effectiveBump] <= BUMP_ORDER[maxBump])
|
|
680
|
+
continue;
|
|
681
|
+
const intentFiles = release.intents
|
|
682
|
+
.filter((intent) => Object.values(intent.releases).includes(effectiveBump))
|
|
683
|
+
.map((intent) => intent.filePath);
|
|
684
|
+
const raisedBy = intentFiles.length > 0 ? `intent file(s) ${intentFiles.join(', ')}` : `constraint chain: ${release.causes.join(', ')}`;
|
|
685
|
+
throw new PnpmError('VERSIONING_MAX_BUMP_EXCEEDED', `The release plan bumps ${release.name} by ${effectiveBump}, but versioning.maxBump caps releases from this branch at ${maxBump}. Raised by ${raisedBy}.`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* The bump class a release actually applies. Fixed-group version sharing and
|
|
690
|
+
* lane escalation can move a version further than the package's own
|
|
691
|
+
* declared or propagated bump, so the cap compares against the real distance
|
|
692
|
+
* between the current and the new version as well.
|
|
693
|
+
*/
|
|
694
|
+
function effectiveBumpClass(release) {
|
|
695
|
+
const diffClass = diff(release.currentVersion, release.newVersion);
|
|
696
|
+
const normalized = diffClass?.replace(/^pre(?!release)/, '');
|
|
697
|
+
return maxBumpType([release.bumpType, normalized ?? undefined]) ?? release.bumpType;
|
|
698
|
+
}
|
|
699
|
+
//# sourceMappingURL=assembleReleasePlan.js.map
|