-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
Copy pathversionUtils.ts
542 lines (419 loc) Β· 19.7 KB
/
versionUtils.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
import {AllDependencies, miscUtils, hashUtils, Workspace, structUtils, Project, Manifest, IdentHash, Report, MessageName, WorkspaceResolver} from '@yarnpkg/core';
import {PortablePath, npath, ppath, xfs} from '@yarnpkg/fslib';
import {parseSyml, stringifySyml} from '@yarnpkg/parsers';
import {gitUtils} from '@yarnpkg/plugin-git';
import {UsageError} from 'clipanion';
import omit from 'lodash/omit';
import semver from 'semver';
// Basically we only support auto-upgrading the ranges that are very simple (^x.y.z, ~x.y.z, >=x.y.z, and of course x.y.z)
const SUPPORTED_UPGRADE_REGEXP = /^(>=|[~^]|)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/;
export enum Decision {
UNDECIDED = `undecided`,
DECLINE = `decline`,
MAJOR = `major`,
MINOR = `minor`,
PATCH = `patch`,
PRERELEASE = `prerelease`,
}
export type IncrementDecision = Exclude<Decision, Decision.UNDECIDED | Decision.DECLINE>;
export type Releases = Map<Workspace, string>;
export function validateReleaseDecision(decision: unknown): string {
const semverDecision = semver.valid(decision as string);
if (semverDecision)
return semverDecision;
return miscUtils.validateEnum(omit(Decision, `UNDECIDED`), decision as string);
}
export type VersionFile = {
project: Project;
changedFiles: Set<PortablePath>;
changedWorkspaces: Set<Workspace>;
releaseRoots: Set<Workspace>;
releases: Releases;
saveAll: () => Promise<void>;
} & ({
root: PortablePath;
baseHash: string;
baseTitle: string;
} | {
root: null;
baseHash: null;
baseTitle: null;
});
export async function resolveVersionFiles(project: Project, {prerelease = null}: {prerelease?: string | null} = {}) {
let candidateReleases = new Map<Workspace, string>();
const deferredVersionFolder = project.configuration.get(`deferredVersionFolder`);
if (!xfs.existsSync(deferredVersionFolder))
return candidateReleases;
const deferredVersionFiles = await xfs.readdirPromise(deferredVersionFolder);
for (const entry of deferredVersionFiles) {
if (!entry.endsWith(`.yml`))
continue;
const versionPath = ppath.join(deferredVersionFolder, entry);
const versionContent = await xfs.readFilePromise(versionPath, `utf8`);
const versionData = parseSyml(versionContent);
for (const [identStr, decision] of Object.entries(versionData.releases || {})) {
if (decision === Decision.DECLINE)
continue;
const ident = structUtils.parseIdent(identStr);
const workspace = project.tryWorkspaceByIdent(ident);
if (workspace === null)
throw new Error(`Assertion failed: Expected a release definition file to only reference existing workspaces (${ppath.basename(versionPath)} references ${identStr})`);
if (workspace.manifest.version === null)
throw new Error(`Assertion failed: Expected the workspace to have a version (${structUtils.prettyLocator(project.configuration, workspace.anchoredLocator)})`);
// If there's a `stableVersion` field, then we assume that `version`
// contains a prerelease version and that we need to base the version
// bump relative to the latest stable instead.
const baseVersion = workspace.manifest.raw.stableVersion ?? workspace.manifest.version;
const candidateRelease = candidateReleases.get(workspace);
const suggestedRelease = applyStrategy(baseVersion, validateReleaseDecision(decision));
if (suggestedRelease === null)
throw new Error(`Assertion failed: Expected ${baseVersion} to support being bumped via strategy ${decision}`);
const bestRelease = typeof candidateRelease !== `undefined`
? semver.gt(suggestedRelease, candidateRelease) ? suggestedRelease : candidateRelease
: suggestedRelease;
candidateReleases.set(workspace, bestRelease);
}
}
if (prerelease) {
candidateReleases = new Map([...candidateReleases].map(([workspace, release]) => {
return [workspace, applyPrerelease(release, {current: workspace.manifest.version!, prerelease})];
}));
}
return candidateReleases;
}
export async function clearVersionFiles(project: Project) {
const deferredVersionFolder = project.configuration.get(`deferredVersionFolder`);
if (!xfs.existsSync(deferredVersionFolder))
return;
await xfs.removePromise(deferredVersionFolder);
}
export async function updateVersionFiles(project: Project, workspaces: Array<Workspace>) {
const workspaceSet = new Set(workspaces);
const deferredVersionFolder = project.configuration.get(`deferredVersionFolder`);
if (!xfs.existsSync(deferredVersionFolder))
return;
const deferredVersionFiles = await xfs.readdirPromise(deferredVersionFolder);
for (const entry of deferredVersionFiles) {
if (!entry.endsWith(`.yml`))
continue;
const versionPath = ppath.join(deferredVersionFolder, entry);
const versionContent = await xfs.readFilePromise(versionPath, `utf8`);
const versionData = parseSyml(versionContent);
const releases = versionData?.releases;
if (!releases)
continue;
for (const locatorStr of Object.keys(releases)) {
const ident = structUtils.parseIdent(locatorStr);
const workspace = project.tryWorkspaceByIdent(ident);
if (workspace === null || workspaceSet.has(workspace)) {
delete versionData.releases[locatorStr];
}
}
if (Object.keys(versionData.releases).length > 0) {
await xfs.changeFilePromise(versionPath, stringifySyml(
new stringifySyml.PreserveOrdering(
versionData,
),
));
} else {
await xfs.unlinkPromise(versionPath);
}
}
}
export async function openVersionFile(project: Project, opts: {allowEmpty: true}): Promise<VersionFile>;
export async function openVersionFile(project: Project, opts?: {allowEmpty?: boolean}): Promise<VersionFile | null>;
export async function openVersionFile(project: Project, {allowEmpty = false}: {allowEmpty?: boolean} = {}) {
const configuration = project.configuration;
if (configuration.projectCwd === null)
throw new UsageError(`This command can only be run from within a Yarn project`);
const root = await gitUtils.fetchRoot(configuration.projectCwd);
const base = root !== null
? await gitUtils.fetchBase(root, {baseRefs: configuration.get(`changesetBaseRefs`)})
: null;
const changedFiles = root !== null
? await gitUtils.fetchChangedFiles(root, {base: base!.hash, project})
: [];
const deferredVersionFolder = configuration.get(`deferredVersionFolder`);
const versionFiles = changedFiles.filter(p => ppath.contains(deferredVersionFolder, p) !== null);
if (versionFiles.length > 1)
throw new UsageError(`Your current branch contains multiple versioning files; this isn't supported:\n- ${versionFiles.map(file => npath.fromPortablePath(file)).join(`\n- `)}`);
const changedWorkspaces: Set<Workspace> = new Set(miscUtils.mapAndFilter(changedFiles, file => {
const workspace = project.tryWorkspaceByFilePath(file);
if (workspace === null)
return miscUtils.mapAndFilter.skip;
return workspace;
}));
if (versionFiles.length === 0 && changedWorkspaces.size === 0 && !allowEmpty)
return null;
const versionPath = versionFiles.length === 1
? versionFiles[0]
: ppath.join(deferredVersionFolder, `${hashUtils.makeHash(Math.random().toString()).slice(0, 8)}.yml`);
const versionContent = xfs.existsSync(versionPath)
? await xfs.readFilePromise(versionPath, `utf8`)
: `{}`;
const versionData = parseSyml(versionContent);
const releaseStore: Releases = new Map();
for (const identStr of versionData.declined || []) {
const ident = structUtils.parseIdent(identStr);
const workspace = project.getWorkspaceByIdent(ident);
releaseStore.set(workspace, Decision.DECLINE);
}
for (const [identStr, decision] of Object.entries(versionData.releases || {})) {
const ident = structUtils.parseIdent(identStr);
const workspace = project.getWorkspaceByIdent(ident);
releaseStore.set(workspace, validateReleaseDecision(decision));
}
return {
project,
root,
baseHash: base !== null
? base.hash
: null,
baseTitle: base !== null
? base.title
: null,
changedFiles: new Set(changedFiles),
changedWorkspaces,
releaseRoots: new Set([...changedWorkspaces].filter(workspace => workspace.manifest.version !== null)),
releases: releaseStore,
async saveAll() {
const releases: {[key: string]: string} = {};
const declined: Array<string> = [];
const undecided: Array<string> = [];
for (const workspace of project.workspaces) {
// Let's assume that packages without versions don't need to see their version increased
if (workspace.manifest.version === null)
continue;
const identStr = structUtils.stringifyIdent(workspace.anchoredLocator);
const decision = releaseStore.get(workspace);
if (decision === Decision.DECLINE) {
declined.push(identStr);
} else if (typeof decision !== `undefined`) {
releases[identStr] = validateReleaseDecision(decision);
} else if (changedWorkspaces.has(workspace)) {
undecided.push(identStr);
}
}
await xfs.mkdirPromise(ppath.dirname(versionPath), {recursive: true});
await xfs.changeFilePromise(versionPath, stringifySyml(
new stringifySyml.PreserveOrdering({
releases: Object.keys(releases).length > 0 ? releases : undefined,
declined: declined.length > 0 ? declined : undefined,
undecided: undecided.length > 0 ? undecided : undefined,
}),
));
},
} as VersionFile;
}
export function requireMoreDecisions(versionFile: VersionFile) {
if (getUndecidedWorkspaces(versionFile).size > 0)
return true;
if (getUndecidedDependentWorkspaces(versionFile).length > 0)
return true;
return false;
}
export function getUndecidedWorkspaces(versionFile: VersionFile) {
const undecided = new Set<Workspace>();
for (const workspace of versionFile.changedWorkspaces) {
// Let's assume that packages without versions don't need to see their version increased
if (workspace.manifest.version === null)
continue;
if (versionFile.releases.has(workspace))
continue;
undecided.add(workspace);
}
return undecided;
}
export function getUndecidedDependentWorkspaces(versionFile: Pick<VersionFile, 'project' | 'releases'>, {include = new Set()}: {include?: Set<Workspace>} = {}) {
const undecided = [];
const bumpedWorkspaces = new Map(miscUtils.mapAndFilter([...versionFile.releases], ([workspace, decision]) => {
if (decision === Decision.DECLINE)
return miscUtils.mapAndFilter.skip;
return [workspace.anchoredLocator.locatorHash, workspace];
}));
const declinedWorkspaces = new Map(miscUtils.mapAndFilter([...versionFile.releases], ([workspace, decision]) => {
if (decision !== Decision.DECLINE)
return miscUtils.mapAndFilter.skip;
return [workspace.anchoredLocator.locatorHash, workspace];
}));
// Then we check which workspaces depend on packages that will be released again but have no release strategies themselves
for (const workspace of versionFile.project.workspaces) {
// We allow to overrule the following check because the interactive mode wants to keep displaying the previously-undecided packages even after they have been decided
if (!include.has(workspace)) {
// We don't need to run the check for packages that have already been decided
if (declinedWorkspaces.has(workspace.anchoredLocator.locatorHash))
continue;
if (bumpedWorkspaces.has(workspace.anchoredLocator.locatorHash)) {
continue;
}
}
// Let's assume that packages without versions don't need to see their version increased
if (workspace.manifest.version === null)
continue;
for (const dependencyType of Manifest.hardDependencies) {
for (const descriptor of workspace.manifest.getForScope(dependencyType).values()) {
const matchingWorkspace = versionFile.project.tryWorkspaceByDescriptor(descriptor);
if (matchingWorkspace === null)
continue;
// We only care about workspaces, and we only care about workspaces that will be bumped
if (bumpedWorkspaces.has(matchingWorkspace.anchoredLocator.locatorHash)) {
// Quick note: we don't want to check whether the workspace pointer
// by `resolution` is private, because while it doesn't makes sense
// to bump a private package because its dependencies changed, the
// opposite isn't true: a (public) package might need to be bumped
// because one of its dev dependencies is a (private) package whose
// behavior sensibly changed.
undecided.push([workspace, matchingWorkspace]);
}
}
}
}
return undecided;
}
export function suggestStrategy(from: string, to: string) {
const cleaned = semver.clean(to);
for (const strategy of Object.values(Decision))
if (strategy !== Decision.UNDECIDED && strategy !== Decision.DECLINE)
if (semver.inc(from, strategy) === cleaned)
return strategy;
return null;
}
export function applyStrategy(version: string | null, strategy: string) {
if (semver.valid(strategy))
return strategy;
if (version === null)
throw new UsageError(`Cannot apply the release strategy "${strategy}" unless the workspace already has a valid version`);
if (!semver.valid(version))
throw new UsageError(`Cannot apply the release strategy "${strategy}" on a non-semver version (${version})`);
const nextVersion = semver.inc(version, strategy as any);
if (nextVersion === null)
throw new UsageError(`Cannot apply the release strategy "${strategy}" on the specified version (${version})`);
return nextVersion;
}
export function applyReleases(project: Project, newVersions: Map<Workspace, string>, {report}: {report: Report}) {
const allDependents: Map<Workspace, Array<[
Workspace,
AllDependencies,
IdentHash,
]>> = new Map();
// First we compute the reverse map to figure out which workspace is
// depended upon by which other.
//
// Note that we need to do this before applying the new versions,
// otherwise the `findWorkspacesByDescriptor` calls won't be able to
// resolve the workspaces anymore (because the workspace versions will
// have changed and won't match the outdated dependencies).
for (const dependent of project.workspaces) {
for (const set of Manifest.allDependencies) {
for (const descriptor of dependent.manifest[set].values()) {
const workspace = project.tryWorkspaceByDescriptor(descriptor);
if (workspace === null)
continue;
// We only care about workspaces that depend on a workspace that will
// receive a fresh update
if (!newVersions.has(workspace))
continue;
const dependents = miscUtils.getArrayWithDefault(allDependents, workspace);
dependents.push([dependent, set, descriptor.identHash]);
}
}
}
// Now that we know which workspaces depend on which others, we can
// proceed to update everything at once using our accumulated knowledge.
for (const [workspace, newVersion] of newVersions) {
const oldVersion = workspace.manifest.version;
workspace.manifest.version = newVersion;
if (semver.prerelease(newVersion) === null)
delete workspace.manifest.raw.stableVersion;
else if (!workspace.manifest.raw.stableVersion)
workspace.manifest.raw.stableVersion = oldVersion;
const identString = workspace.manifest.name !== null
? structUtils.stringifyIdent(workspace.manifest.name)
: null;
report.reportInfo(MessageName.UNNAMED, `${structUtils.prettyLocator(project.configuration, workspace.anchoredLocator)}: Bumped to ${newVersion}`);
report.reportJson({cwd: npath.fromPortablePath(workspace.cwd), ident: identString, oldVersion, newVersion});
const dependents = allDependents.get(workspace);
if (typeof dependents === `undefined`)
continue;
for (const [dependent, set, identHash] of dependents) {
const descriptor = dependent.manifest[set].get(identHash);
if (typeof descriptor === `undefined`)
throw new Error(`Assertion failed: The dependency should have existed`);
let range = descriptor.range;
let useWorkspaceProtocol = false;
if (range.startsWith(WorkspaceResolver.protocol)) {
range = range.slice(WorkspaceResolver.protocol.length);
useWorkspaceProtocol = true;
// Workspaces referenced through their path never get upgraded ("workspace:packages/yarnpkg-core")
if (range === workspace.relativeCwd) {
continue;
}
}
// We can only auto-upgrade the basic semver ranges (we can't auto-upgrade ">=1.0.0 <2.0.0", for example)
const parsed = range.match(SUPPORTED_UPGRADE_REGEXP);
if (!parsed) {
report.reportWarning(MessageName.UNNAMED, `Couldn't auto-upgrade range ${range} (in ${structUtils.prettyLocator(project.configuration, dependent.anchoredLocator)})`);
continue;
}
let newRange = `${parsed[1]}${newVersion}`;
if (useWorkspaceProtocol)
newRange = `${WorkspaceResolver.protocol}${newRange}`;
const newDescriptor = structUtils.makeDescriptor(descriptor, newRange);
dependent.manifest[set].set(identHash, newDescriptor);
}
}
}
const placeholders: Map<string, {
extract: (parts: Array<string | number>) => [string | number, Array<string | number>] | null;
generate: (previous?: number) => string;
}> = new Map([
[`%n`, {
extract: parts => {
if (parts.length >= 1) {
return [parts[0], parts.slice(1)];
} else {
return null;
}
},
generate: (previous = 0) => {
return `${previous + 1}`;
},
}],
]);
export function applyPrerelease(version: string, {current, prerelease}: {current: string, prerelease: string}) {
const currentVersion = new semver.SemVer(current);
let currentPreParts = currentVersion.prerelease.slice();
const nextPreParts = [];
currentVersion.prerelease = [];
// If the version we have in mind has nothing in common with the one we want,
// we don't want to reuse its prerelease identifiers (1.0.0-rc.5 -> 1.1.0->rc.1)
if (currentVersion.format() !== version)
currentPreParts.length = 0;
let patternMatched = true;
const patternParts = prerelease.split(/\./g);
for (const part of patternParts) {
const placeholder = placeholders.get(part);
if (typeof placeholder === `undefined`) {
nextPreParts.push(part);
if (currentPreParts[0] === part) {
currentPreParts.shift();
} else {
patternMatched = false;
}
} else {
const res = patternMatched
? placeholder.extract(currentPreParts)
: null;
if (res !== null && typeof res[0] === `number`) {
nextPreParts.push(placeholder.generate(res[0]));
currentPreParts = res[1];
} else {
nextPreParts.push(placeholder.generate());
patternMatched = false;
}
}
}
if (currentVersion.prerelease)
currentVersion.prerelease = [];
return `${version}-${nextPreParts.join(`.`)}`;
}