Skip to content

Commit 1f7db33

Browse files
authored
feat(build-cli): New vnext generate:changelog command (#24899)
Adds a replacement vnext command for `generate:changelog`. The new command is based on the BuildProject infrastructure as all vnext commands are. The primary change in this command is that git is no longer used to revert changes. All changes are just made locally and must be committed manually. The main thing we were reverting was a version bump, which is trivial to reset using the `setVersion` function in build-infra. I also moved the `canonicalizeChangeset` function to a shared location and call it from both the old and new commands. Once we feel good about the new command, we can remove the old one, replace it with the one in vnext, and add a deprecated alias for the now-removed vnext command. A lot of the approach here is informed by changesets/changesets#595, which is basically what we'd like. Ideally we could ask changesets to generate changelogs for a version that we provide (without also bumping versions), but that doesn't exist at the moment.
1 parent 45f8a0d commit 1f7db33

File tree

9 files changed

+1047
-57
lines changed

9 files changed

+1047
-57
lines changed

build-tools/packages/build-cli/docs/vnext.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,20 @@
44
Vnext commands are new implementations of standard flub commands using new infrastructure.
55

66
* [`flub vnext check latestVersions`](#flub-vnext-check-latestversions)
7+
* [`flub vnext generate changelog`](#flub-vnext-generate-changelog)
78

89
## `flub vnext check latestVersions`
910

1011
Determines if an input version matches a latest minor release version. Intended to be used in the Fluid Framework CI pipeline only.
1112

1213
```
1314
USAGE
14-
$ flub vnext check latestVersions --releaseGroup <value> --version <value> [-v | --quiet]
15+
$ flub vnext check latestVersions -g <value> --version <value> [-v | --quiet]
1516
1617
FLAGS
17-
--releaseGroup=<value> (required) The name of a release group.
18-
--version=<value> (required) The version to check. When running in CI, this value corresponds to the pipeline
19-
trigger branch.
18+
-g, --releaseGroup=<value> (required) The name of a release group.
19+
--version=<value> (required) The version to check. When running in CI, this value corresponds to the
20+
pipeline trigger branch.
2021
2122
LOGGING FLAGS
2223
-v, --verbose Enable verbose logging.
@@ -31,3 +32,34 @@ DESCRIPTION
3132
```
3233

3334
_See code: [src/commands/vnext/check/latestVersions.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/commands/vnext/check/latestVersions.ts)_
35+
36+
## `flub vnext generate changelog`
37+
38+
Generate a changelog for packages based on changesets. Note that this process deletes the changeset files!
39+
40+
```
41+
USAGE
42+
$ flub vnext generate changelog -g <value> [-v | --quiet] [--version <value>]
43+
44+
FLAGS
45+
-g, --releaseGroup=<value> (required) The name of a release group.
46+
--version=<value> The version for which to generate the changelog. If this is not provided, the version of
47+
the package according to package.json will be used.
48+
49+
LOGGING FLAGS
50+
-v, --verbose Enable verbose logging.
51+
--quiet Disable all logging.
52+
53+
DESCRIPTION
54+
Generate a changelog for packages based on changesets. Note that this process deletes the changeset files!
55+
56+
ALIASES
57+
$ flub vnext generate changelogs
58+
59+
EXAMPLES
60+
Generate changelogs for the client release group.
61+
62+
$ flub vnext generate changelog --releaseGroup client
63+
```
64+
65+
_See code: [src/commands/vnext/generate/changelog.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/commands/vnext/generate/changelog.ts)_

build-tools/packages/build-cli/src/commands/generate/changelog.ts

Lines changed: 6 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55

66
import { readFile, writeFile } from "node:fs/promises";
7-
import path from "node:path";
87
import {
98
type VersionBumpType,
109
bumpVersionScheme,
@@ -17,7 +16,9 @@ import { inc } from "semver";
1716
import { CleanOptions } from "simple-git";
1817

1918
import { checkFlags, releaseGroupFlag, semverFlag } from "../../flags.js";
20-
import { BaseCommand, DEFAULT_CHANGESET_PATH, loadChangesets } from "../../library/index.js";
19+
// eslint-disable-next-line import/no-internal-modules
20+
import { canonicalizeChangesets } from "../../library/changesets.js";
21+
import { BaseCommand } from "../../library/index.js";
2122
import { isReleaseGroup } from "../../releaseGroups.js";
2223

2324
async function replaceInFile(
@@ -54,11 +55,8 @@ export default class GenerateChangeLogCommand extends BaseCommand<
5455
},
5556
];
5657

57-
private bumpType?: VersionBumpType;
58-
59-
private async processPackage(pkg: Package): Promise<void> {
58+
private async processPackage(pkg: Package, bumpType: VersionBumpType): Promise<void> {
6059
const { directory, version: pkgVersion } = pkg;
61-
const bumpType = this.bumpType ?? "patch";
6260

6361
// This is the version that the changesets tooling calculates by default. It does a bump of the highest semver type
6462
// in the changesets on the current version. We search for that version in the generated changelog and replace it
@@ -83,49 +81,6 @@ export default class GenerateChangeLogCommand extends BaseCommand<
8381
);
8482
}
8583

86-
/**
87-
* Removes any custom metadata from all changesets and writes the resulting changes back to the source files. This
88-
* metadata needs to be removed prior to running `changeset version` from the \@changesets/cli package. If it is not,
89-
* then the custom metadata is interpreted as change metadata and the changeset tools fail.
90-
*
91-
* For more information about the custom metadata we use in our changesets, see
92-
* https://github.com/microsoft/FluidFramework/wiki/Changesets#custom-metadata
93-
*
94-
* **Note that this is a lossy action!** The metadata is completely removed. Changesets are typically in source
95-
* control so changes can usually be reverted.
96-
*/
97-
private async canonicalizeChangesets(releaseGroupRootDir: string): Promise<void> {
98-
const changesetDir = path.join(releaseGroupRootDir, DEFAULT_CHANGESET_PATH);
99-
const changesets = await loadChangesets(changesetDir, this.logger);
100-
101-
// Determine the highest bump type and save it for later - it determines the changesets-calculated version.
102-
const bumpTypes: Set<VersionBumpType> = new Set();
103-
for (const changeset of changesets) {
104-
for (const bumpType of changeset.changeTypes) {
105-
bumpTypes.add(bumpType);
106-
}
107-
}
108-
this.bumpType = bumpTypes.has("major")
109-
? "major"
110-
: bumpTypes.has("minor")
111-
? "minor"
112-
: "patch";
113-
114-
const toWrite: Promise<void>[] = [];
115-
for (const changeset of changesets) {
116-
// Filter out properties that start with __
117-
const metadata = Object.entries(changeset.metadata)
118-
.filter(([key]) => !key.startsWith("__"))
119-
.map(([packageName, bump]) => {
120-
return `"${packageName}": ${bump}`;
121-
});
122-
const output = `---\n${metadata.join("\n")}\n---\n\n${changeset.summary}\n\n${changeset.body}\n`;
123-
this.info(`Writing canonical changeset: ${changeset.sourceFile}`);
124-
toWrite.push(writeFile(changeset.sourceFile, output));
125-
}
126-
await Promise.all(toWrite);
127-
}
128-
12984
public async run(): Promise<void> {
13085
const context = await this.getContext();
13186

@@ -147,7 +102,7 @@ export default class GenerateChangeLogCommand extends BaseCommand<
147102

148103
// Strips additional custom metadata from the source files before we call `changeset version`,
149104
// because the changeset tools - like @changesets/cli - only work on canonical changesets.
150-
await this.canonicalizeChangesets(releaseGroupRoot);
105+
const bumpType = await canonicalizeChangesets(releaseGroupRoot, this.logger);
151106

152107
// The `changeset version` command applies the changesets to the changelogs
153108
ux.action.start("Running `changeset version`");
@@ -179,7 +134,7 @@ export default class GenerateChangeLogCommand extends BaseCommand<
179134
ux.action.start("Processing changelog updates");
180135
const processPromises: Promise<void>[] = [];
181136
for (const pkg of packagesToCheck) {
182-
processPromises.push(this.processPackage(pkg));
137+
processPromises.push(this.processPackage(pkg, bumpType));
183138
}
184139
const results = await Promise.allSettled(processPromises);
185140
const failures = results.filter((p) => p.status === "rejected");
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*!
2+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3+
* Licensed under the MIT License.
4+
*/
5+
6+
import { ux } from "@oclif/core";
7+
import { command as execCommand } from "execa";
8+
import { parse } from "semver";
9+
10+
import { setVersion } from "@fluid-tools/build-infrastructure";
11+
import { releaseGroupNameFlag, semverFlag } from "../../../flags.js";
12+
// eslint-disable-next-line import/no-internal-modules
13+
import { updateChangelogs } from "../../../library/changelogs.js";
14+
// eslint-disable-next-line import/no-internal-modules
15+
import { canonicalizeChangesets } from "../../../library/changesets.js";
16+
import { BaseCommandWithBuildProject } from "../../../library/index.js";
17+
18+
/**
19+
* Generate a changelog for packages based on changesets. Note that this process deletes the changeset files!
20+
*
21+
* The reason we use a search/replace approach to update the version strings in the changelogs is largely because of
22+
* https://github.com/changesets/changesets/issues/595. What we would like to do is generate the changelogs without
23+
* doing version bumping, but that feature does not exist in the changeset tools.
24+
*/
25+
export default class GenerateChangeLogCommand extends BaseCommandWithBuildProject<
26+
typeof GenerateChangeLogCommand
27+
> {
28+
static readonly description =
29+
"Generate a changelog for packages based on changesets. Note that this process deletes the changeset files!";
30+
31+
static readonly aliases = ["vnext:generate:changelogs"];
32+
33+
static readonly flags = {
34+
releaseGroup: releaseGroupNameFlag({ required: true }),
35+
version: semverFlag({
36+
description:
37+
"The version for which to generate the changelog. If this is not provided, the version of the package according to package.json will be used.",
38+
}),
39+
...BaseCommandWithBuildProject.flags,
40+
} as const;
41+
42+
static readonly examples = [
43+
{
44+
description: "Generate changelogs for the client release group.",
45+
command: "<%= config.bin %> <%= command.id %> --releaseGroup client",
46+
},
47+
];
48+
49+
public async run(): Promise<void> {
50+
const buildProject = this.getBuildProject();
51+
52+
const { releaseGroup: releaseGroupName, version: versionOverride } = this.flags;
53+
54+
const releaseGroup = buildProject.releaseGroups.get(releaseGroupName);
55+
if (releaseGroup === undefined) {
56+
this.error(`Can't find release group named '${releaseGroupName}'`, { exit: 1 });
57+
}
58+
59+
const releaseGroupRoot = releaseGroup.workspace.directory;
60+
const releaseGroupVersion = parse(releaseGroup.version);
61+
if (releaseGroupVersion === null) {
62+
this.error(`Version isn't a valid semver string: '${releaseGroup.version}'`, {
63+
exit: 1,
64+
});
65+
}
66+
67+
// Strips additional custom metadata from the source files before we call `changeset version`,
68+
// because the changeset tools - like @changesets/cli - only work on canonical changesets.
69+
const bumpType = await canonicalizeChangesets(releaseGroupRoot, this.logger);
70+
71+
// The `changeset version` command applies the changesets to the changelogs
72+
ux.action.start("Running `changeset version`");
73+
await execCommand("pnpm exec changeset version", { cwd: releaseGroupRoot });
74+
ux.action.stop();
75+
76+
const packagesToCheck = releaseGroup.packages;
77+
78+
// restore the package versions that were changed by `changeset version`
79+
await setVersion(packagesToCheck, releaseGroupVersion);
80+
81+
// Extract the version string from the SemVer object if provided
82+
const versionString = versionOverride?.version;
83+
84+
// Calls processPackage on all packages.
85+
ux.action.start("Processing changelog updates");
86+
const processPromises: Promise<void>[] = [];
87+
for (const pkg of packagesToCheck) {
88+
processPromises.push(updateChangelogs(pkg, bumpType, versionString));
89+
}
90+
const results = await Promise.allSettled(processPromises);
91+
const failures = results.filter((p) => p.status === "rejected");
92+
if (failures.length > 0) {
93+
this.error(
94+
`Error processing packages; failure reasons:\n${failures
95+
.map((p) => (p as PromiseRejectedResult).reason as string)
96+
.join(", ")}`,
97+
{ exit: 1 },
98+
);
99+
}
100+
ux.action.stop();
101+
102+
this.log("Commit and open a PR!");
103+
}
104+
}

build-tools/packages/build-cli/src/flags.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ export const releaseGroupFlag = Flags.custom<ReleaseGroup>({
5555
* A re-usable CLI flag to parse release group names.
5656
*/
5757
export const releaseGroupNameFlag = Flags.custom<ReleaseGroupName>({
58-
required: true,
5958
description: "The name of a release group.",
59+
char: "g",
6060
parse: async (input) => {
6161
return input as ReleaseGroupName;
6262
},
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*!
2+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3+
* Licensed under the MIT License.
4+
*/
5+
6+
import { readFile, writeFile } from "node:fs/promises";
7+
import type { IPackage } from "@fluid-tools/build-infrastructure";
8+
import {
9+
type VersionBumpType,
10+
bumpVersionScheme,
11+
isInternalVersionScheme,
12+
} from "@fluid-tools/version-tools";
13+
import { inc } from "semver";
14+
15+
/**
16+
* Escapes special regex characters in a string to make it safe for use in a RegExp.
17+
*/
18+
function escapeRegex(str: string): string {
19+
return str.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
20+
}
21+
22+
/**
23+
* Replaces all occurrences of a search string with a replacement string in a file.
24+
* The search string is treated as a literal string, not a regex pattern.
25+
*
26+
* @param search - The literal string to search for (will be escaped for regex safety)
27+
* @param replace - The string to replace matches with
28+
* @param filePath - The path to the file to modify
29+
* @throws Error if the file cannot be read or written
30+
*/
31+
async function replaceInFile(
32+
search: string,
33+
replace: string,
34+
filePath: string,
35+
): Promise<void> {
36+
try {
37+
const content = await readFile(filePath, "utf8");
38+
const escapedSearch = escapeRegex(search);
39+
const newContent = content.replace(new RegExp(escapedSearch, "g"), replace);
40+
await writeFile(filePath, newContent, "utf8");
41+
} catch (error) {
42+
throw new Error(
43+
`Failed to replace "${search}" with "${replace}" in file ${filePath}: ${error}`,
44+
);
45+
}
46+
}
47+
48+
export async function updateChangelogs(
49+
pkg: IPackage,
50+
bumpType: VersionBumpType,
51+
version?: string,
52+
): Promise<void> {
53+
if (pkg.isReleaseGroupRoot || pkg.isWorkspaceRoot) {
54+
// No changelog for root packages.
55+
return;
56+
}
57+
const { directory, version: pkgVersion } = pkg;
58+
59+
// This is the version that the changesets tooling calculates by default. It does a bump of the highest semver type
60+
// in the changesets on the current version. We search for that version in the generated changelog and replace it
61+
// with the one that we want.
62+
const changesetsCalculatedVersion = isInternalVersionScheme(pkgVersion)
63+
? bumpVersionScheme(pkgVersion, bumpType, "internal")
64+
: inc(pkgVersion, bumpType);
65+
const versionToUse = version ?? pkgVersion;
66+
67+
// Replace the changeset version with the correct version.
68+
await replaceInFile(
69+
`## ${changesetsCalculatedVersion}\n`,
70+
`## ${versionToUse}\n`,
71+
`${directory}/CHANGELOG.md`,
72+
);
73+
74+
// For changelogs that had no changesets applied to them, add in a 'dependency updates only' section.
75+
await replaceInFile(
76+
`## ${versionToUse}\n\n## `,
77+
`## ${versionToUse}\n\nDependency updates only.\n\n## `,
78+
`${directory}/CHANGELOG.md`,
79+
);
80+
}

0 commit comments

Comments
 (0)