-
-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathpackage.ts
394 lines (367 loc) · 13 KB
/
package.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
import fs, { WriteStream } from 'fs';
import path from 'path';
import { format } from 'util';
import { parseChangelog, updateChangelog } from '@metamask/auto-changelog';
import { format as formatPrettier } from 'prettier/standalone';
import * as markdown from 'prettier/plugins/markdown';
import { WriteStreamLike, readFile, writeFile, writeJsonFile } from './fs.js';
import { isErrorWithCode } from './misc-utils.js';
import {
readPackageManifest,
UnvalidatedPackageManifest,
ValidatedPackageManifest,
} from './package-manifest.js';
import { Project } from './project.js';
import { PackageReleasePlan } from './release-plan.js';
import { hasChangesInDirectorySinceGitTag } from './repo.js';
import { SemVer } from './semver.js';
const MANIFEST_FILE_NAME = 'package.json';
const CHANGELOG_FILE_NAME = 'CHANGELOG.md';
/**
* Information about a package within a project.
*
* @property directoryPath - The path to the directory where the package is
* located.
* @property manifestPath - The path to the manifest file.
* @property manifest - The data extracted from the manifest.
* @property changelogPath - The path to the changelog file (which may or may
* not exist).
*/
export type Package = {
directoryPath: string;
manifestPath: string;
unvalidatedManifest: UnvalidatedPackageManifest;
validatedManifest: ValidatedPackageManifest;
changelogPath: string;
hasChangesSinceLatestRelease: boolean;
};
/**
* Generates the possible Git tag name for the root package of a monorepo. The
* only tag name in use at this time is "v" + the package version.
*
* @param packageVersion - The version of the package.
* @returns An array of possible release tag names.
*/
function generateMonorepoRootPackageReleaseTagName(packageVersion: string) {
return `v${packageVersion}`;
}
/**
* Generates a possible Git tag name for the workspace package of a monorepo.
* Accounts for changes to `action-publish-release`, which going forward will
* generate tags for workspace packages in `[email protected]`.
*
* @param packageName - The name of the package.
* @param packageVersion - The version of the package.
* @returns An array of possible release tag names.
*/
function generateMonorepoWorkspacePackageReleaseTagName(
packageName: string,
packageVersion: string,
) {
return `${packageName}@${packageVersion}`;
}
/**
* Collects information about the root package of a monorepo.
*
* @param args - The arguments to this function.
* @param args.packageDirectoryPath - The path to a package within a project.
* @param args.projectDirectoryPath - The path to the project directory.
* @param args.projectTagNames - The tag names across the whole project.
* @returns Information about the package.
*/
export async function readMonorepoRootPackage({
packageDirectoryPath,
projectDirectoryPath,
projectTagNames,
}: {
packageDirectoryPath: string;
projectDirectoryPath: string;
projectTagNames: string[];
}): Promise<Package> {
const manifestPath = path.join(packageDirectoryPath, MANIFEST_FILE_NAME);
const changelogPath = path.join(packageDirectoryPath, CHANGELOG_FILE_NAME);
const { unvalidated: unvalidatedManifest, validated: validatedManifest } =
await readPackageManifest(manifestPath);
const expectedTagNameForLatestRelease =
generateMonorepoRootPackageReleaseTagName(
validatedManifest.version.toString(),
);
const matchingTagNameForLatestRelease = projectTagNames.find(
(tagName) => tagName === expectedTagNameForLatestRelease,
);
if (
projectTagNames.length > 0 &&
matchingTagNameForLatestRelease === undefined
) {
throw new Error(
format(
'The package %s has no Git tag for its current version %s (expected %s), so this tool is unable to determine whether it should be included in this release. You will need to create a tag for this package in order to proceed.',
validatedManifest.name,
validatedManifest.version,
`"${expectedTagNameForLatestRelease}"`,
),
);
}
const hasChangesSinceLatestRelease =
matchingTagNameForLatestRelease === undefined
? true
: await hasChangesInDirectorySinceGitTag(
projectDirectoryPath,
packageDirectoryPath,
expectedTagNameForLatestRelease,
);
return {
directoryPath: packageDirectoryPath,
manifestPath,
validatedManifest,
unvalidatedManifest,
changelogPath,
hasChangesSinceLatestRelease,
};
}
/**
* Collects information about a workspace package within a monorepo.
*
* @param args - The arguments to this function.
* @param args.packageDirectoryPath - The path to a package within a project.
* @param args.rootPackageName - The name of the root package within the
* monorepo to which this package belongs.
* @param args.rootPackageVersion - The version of the root package within the
* monorepo to which this package belongs.
* @param args.projectDirectoryPath - The path to the project directory.
* @param args.projectTagNames - The tag names across the whole project.
* @param args.stderr - A stream that can be used to write to standard error.
* @returns Information about the package.
*/
export async function readMonorepoWorkspacePackage({
packageDirectoryPath,
rootPackageName,
rootPackageVersion,
projectDirectoryPath,
projectTagNames,
stderr,
}: {
packageDirectoryPath: string;
rootPackageName: string;
rootPackageVersion: SemVer;
projectDirectoryPath: string;
projectTagNames: string[];
stderr: WriteStreamLike;
}): Promise<Package> {
const manifestPath = path.join(packageDirectoryPath, MANIFEST_FILE_NAME);
const changelogPath = path.join(packageDirectoryPath, CHANGELOG_FILE_NAME);
const { unvalidated: unvalidatedManifest, validated: validatedManifest } =
await readPackageManifest(manifestPath);
const expectedTagNameForWorkspacePackageLatestRelease =
generateMonorepoWorkspacePackageReleaseTagName(
validatedManifest.name,
validatedManifest.version.toString(),
);
const expectedTagNameForRootPackageLatestRelease =
generateMonorepoRootPackageReleaseTagName(rootPackageVersion.toString());
const matchingTagNameForWorkspacePackageLatestRelease = projectTagNames.find(
(tagName) => tagName === expectedTagNameForWorkspacePackageLatestRelease,
);
const matchingTagNameForRootPackageLatestRelease = projectTagNames.find(
(tagName) => tagName === expectedTagNameForRootPackageLatestRelease,
);
const matchingTagNameForLatestRelease =
matchingTagNameForWorkspacePackageLatestRelease ??
matchingTagNameForRootPackageLatestRelease;
if (
projectTagNames.length > 0 &&
matchingTagNameForLatestRelease === undefined
) {
throw new Error(
format(
'The current release of workspace package %s, %s, has no corresponding Git tag %s, and the current release of root package %s, %s, has no tag %s. Hence, this tool is unable to know whether the workspace package changed and should be included in this release. You will need to create tags for both of these packages in order to proceed.',
validatedManifest.name,
validatedManifest.version,
`"${expectedTagNameForWorkspacePackageLatestRelease}"`,
rootPackageName,
rootPackageVersion,
`"${expectedTagNameForRootPackageLatestRelease}"`,
),
);
}
if (
matchingTagNameForWorkspacePackageLatestRelease === undefined &&
matchingTagNameForRootPackageLatestRelease !== undefined
) {
stderr.write(
format(
'WARNING: Could not determine changes for workspace package %s version %s based on Git tag %s; using tag for root package %s version %s, %s, instead.\n',
validatedManifest.name,
validatedManifest.version,
`"${expectedTagNameForWorkspacePackageLatestRelease}"`,
rootPackageName,
rootPackageVersion,
`"${expectedTagNameForRootPackageLatestRelease}"`,
),
);
}
const hasChangesSinceLatestRelease =
matchingTagNameForLatestRelease === undefined
? true
: await hasChangesInDirectorySinceGitTag(
projectDirectoryPath,
packageDirectoryPath,
matchingTagNameForLatestRelease,
);
return {
directoryPath: packageDirectoryPath,
manifestPath,
validatedManifest,
unvalidatedManifest,
changelogPath,
hasChangesSinceLatestRelease,
};
}
/**
* Migrate all unreleased changes to a release section.
*
* Changes are migrated in their existing categories, and placed above any
* pre-existing changes in that category.
*
* @param args - The arguments.
* @param args.project - The project.
* @param args.package - A particular package in the project.
* @param args.version - The release version to migrate unreleased changes to.
* @param args.stderr - A stream that can be used to write to standard error.
* @returns The result of writing to the changelog.
*/
export async function migrateUnreleasedChangelogChangesToRelease({
project: { repositoryUrl },
package: pkg,
version,
stderr,
}: {
project: Pick<Project, 'directoryPath' | 'repositoryUrl'>;
package: Package;
version: string;
stderr: Pick<WriteStream, 'write'>;
}): Promise<void> {
let changelogContent;
try {
changelogContent = await readFile(pkg.changelogPath);
} catch (error) {
if (isErrorWithCode(error) && error.code === 'ENOENT') {
stderr.write(
`${pkg.validatedManifest.name} does not seem to have a changelog. Skipping.\n`,
);
return;
}
throw error;
}
const changelog = parseChangelog({
changelogContent,
repoUrl: repositoryUrl,
tagPrefix: `${pkg.validatedManifest.name}@`,
formatter: formatChangelog,
});
changelog.addRelease({ version });
changelog.migrateUnreleasedChangesToRelease(version);
await writeFile(pkg.changelogPath, await changelog.toString());
}
/**
* Format the given changelog using Prettier. This is extracted into a separate
* function for coverage purposes.
*
* @param changelog - The changelog to format.
* @returns The formatted changelog.
*/
export async function formatChangelog(changelog: string) {
return await formatPrettier(changelog, {
parser: 'markdown',
plugins: [markdown],
});
}
/**
* Updates the changelog file of the given package using
* `@metamask/auto-changelog`. Assumes that the changelog file is located at the
* package root directory and named "CHANGELOG.md".
*
* @param args - The arguments.
* @param args.project - The project.
* @param args.package - A particular package in the project.
* @param args.stderr - A stream that can be used to write to standard error.
* @param args.fetchRemote - Whether to synchronize local tags with remote.
* @returns The result of writing to the changelog.
*/
export async function updatePackageChangelog({
project: { repositoryUrl },
package: pkg,
stderr,
fetchRemote,
}: {
project: Pick<Project, 'directoryPath' | 'repositoryUrl'>;
package: Package;
stderr: Pick<WriteStream, 'write'>;
fetchRemote?: boolean | undefined;
}): Promise<void> {
let changelogContent;
try {
changelogContent = await readFile(pkg.changelogPath);
} catch (error) {
if (isErrorWithCode(error) && error.code === 'ENOENT') {
stderr.write(
`${pkg.validatedManifest.name} does not seem to have a changelog. Skipping.\n`,
);
return;
}
throw error;
}
const newChangelogContent = await updateChangelog({
changelogContent,
// Setting `isReleaseCandidate` to false because `updateChangelog` requires a
// specific version number when this flag is true, and the package release version
// is not determined at this stage of the process.
isReleaseCandidate: false,
projectRootDirectory: pkg.directoryPath,
repoUrl: repositoryUrl,
tagPrefixes: [`${pkg.validatedManifest.name}@`, 'v'],
formatter: formatChangelog,
fetchRemote,
});
if (newChangelogContent) {
await writeFile(pkg.changelogPath, newChangelogContent);
} else {
stderr.write(
`Changelog for ${pkg.validatedManifest.name} was not updated as there were no updates to make.`,
);
}
}
/**
* Updates the package by replacing the `version` field in the manifest
* according to the one in the given release plan. Also updates the
* changelog by migrating changes in the Unreleased section to the section
* representing the new version.
*
* @param args - The project.
* @param args.project - The project.
* @param args.packageReleasePlan - The release plan for a particular package in the
* project.
* @param args.stderr - A stream that can be used to write to standard error.
* Defaults to /dev/null.
*/
export async function updatePackage({
project,
packageReleasePlan,
stderr = fs.createWriteStream('/dev/null'),
}: {
project: Pick<Project, 'directoryPath' | 'repositoryUrl'>;
packageReleasePlan: PackageReleasePlan;
stderr?: Pick<WriteStream, 'write'>;
}): Promise<void> {
const { package: pkg, newVersion } = packageReleasePlan;
await writeJsonFile(pkg.manifestPath, {
...pkg.unvalidatedManifest,
version: newVersion,
});
await migrateUnreleasedChangelogChangesToRelease({
project,
package: pkg,
stderr,
version: newVersion,
});
}