Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Add \"strictChangefileValidation\" experiment and \"--verify-all\" flag for \"rush change\". When the experiment is enabled, \"rush change --verify\" and \"rush change --verify-all\" will report errors if change files reference nonexistent projects or target non-main projects in a lockstepped version policy.",
"type": "none"
}
],
"packageName": "@microsoft/rush",
"email": "iclanton@users.noreply.github.com"
}
1 change: 1 addition & 0 deletions common/reviews/api/rush-lib.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ export interface IExperimentsJson {
omitImportersFromPreventManualShrinkwrapChanges?: boolean;
printEventHooksOutputToConsole?: boolean;
rushAlerts?: boolean;
strictChangefileValidation?: boolean;
useIPCScriptsInWatchMode?: boolean;
usePnpmFrozenLockfileForRushInstall?: boolean;
usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,5 +125,12 @@
* macOS to store extended attributes on filesystems that don't support them, and should generally not
* be included in the shared build cache.
*/
/*[LINE "HYPOTHETICAL"]*/ "omitAppleDoubleFilesFromBuildCache": true
/*[LINE "HYPOTHETICAL"]*/ "omitAppleDoubleFilesFromBuildCache": true,

/**
* If true, "rush change --verify" will report errors if change files reference projects that do not
* exist in the Rush configuration, or if change files target a project that belongs to a lockstepped
* version policy but is not the policy's main project.
*/
/*[LINE "HYPOTHETICAL"]*/ "strictChangefileValidation": true
}
8 changes: 8 additions & 0 deletions libraries/rush-lib/src/api/ExperimentsConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ export interface IExperimentsJson {
* be included in the shared build cache.
*/
omitAppleDoubleFilesFromBuildCache?: boolean;

/**
* If true, `rush change --verify` will perform additional validation of change files. Specifically,
* it will report errors if change files reference projects that do not exist in the Rush configuration,
* or if change files target a project that belongs to a lockstepped version policy but is not the
* policy's main project.
*/
strictChangefileValidation?: boolean;
}

const _EXPERIMENTS_JSON_SCHEMA: JsonSchema = JsonSchema.fromLoadedObject(schemaJson);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ Object {
"required": false,
"shortName": "-v",
},
Object {
"description": "Validate all change files in the repository, not just those added in the current branch. Reports errors for change files that reference nonexistent projects or target non-main projects in a lockstepped version policy. Requires the \\"strictChangefileValidation\\" experiment to be enabled.",
"environmentVariable": undefined,
"kind": "Flag",
"longName": "--verify-all",
"required": false,
"shortName": undefined,
},
Object {
"description": "Skips fetching the baseline branch before running \\"git diff\\" to detect changes.",
"environmentVariable": undefined,
Expand Down
181 changes: 143 additions & 38 deletions libraries/rush-lib/src/cli/actions/ChangeAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import type {
CommandLineStringParameter,
CommandLineChoiceParameter
} from '@rushstack/ts-command-line';
import { FileSystem, AlreadyReportedError } from '@rushstack/node-core-library';
import { FileSystem, JsonFile, AlreadyReportedError } from '@rushstack/node-core-library';
import { Colorize } from '@rushstack/terminal';
import { getRepoRoot } from '@rushstack/package-deps-hash';

import type { RushConfigurationProject } from '../../api/RushConfigurationProject';
import type { IRushConfigurationJson } from '../../api/RushConfiguration';
import { type IChangeFile, type IChangeInfo, ChangeType } from '../../api/ChangeManagement';
import { ChangeFile } from '../../api/ChangeFile';
import { BaseRushAction } from './BaseRushAction';
Expand All @@ -38,6 +39,7 @@ const BULK_BUMP_TYPE_LONG_NAME: string = '--bump-type';
export class ChangeAction extends BaseRushAction {
private readonly _git: Git;
private readonly _verifyParameter: CommandLineFlagParameter;
private readonly _verifyAllParameter: CommandLineFlagParameter;
private readonly _noFetchParameter: CommandLineFlagParameter;
private readonly _targetBranchParameter: CommandLineStringParameter;
private readonly _changeEmailParameter: CommandLineStringParameter;
Expand Down Expand Up @@ -98,6 +100,14 @@ export class ChangeAction extends BaseRushAction {
description: 'Verify the change file has been generated and that it is a valid JSON file'
});

this._verifyAllParameter = this.defineFlagParameter({
parameterLongName: '--verify-all',
description:
'Validate all change files in the repository, not just those added in the current branch. ' +
'Reports errors for change files that reference nonexistent projects or target non-main projects ' +
'in a lockstepped version policy. Requires the "strictChangefileValidation" experiment to be enabled.'
});

this._noFetchParameter = this.defineFlagParameter({
parameterLongName: '--no-fetch',
description: 'Skips fetching the baseline branch before running "git diff" to detect changes.'
Expand Down Expand Up @@ -162,29 +172,63 @@ export class ChangeAction extends BaseRushAction {
}

public async runAsync(): Promise<void> {
if (this._verifyAllParameter.value) {
const incompatibleParameters: (
| CommandLineFlagParameter
| CommandLineStringParameter
| CommandLineChoiceParameter
)[] = [
this._verifyParameter,
this._bulkChangeParameter,
this._bulkChangeMessageParameter,
this._bulkChangeBumpTypeParameter,
this._overwriteFlagParameter,
this._commitChangesFlagParameter
];
const errors: string[] = incompatibleParameters
.filter((parameter) => parameter.value)
.map(
(parameter) =>
`The ${parameter.longName} parameter cannot be provided with the ` +
`${this._verifyAllParameter.longName} parameter`
);
if (errors.length > 0) {
errors.forEach((error) => {
this.terminal.writeErrorLine(error);
});
throw new AlreadyReportedError();
}

await this._validateAllChangeFilesAsync();
return;
}

const targetBranch: string = await this._getTargetBranchAsync();
// eslint-disable-next-line no-console
console.log(`The target branch is ${targetBranch}`);
this.terminal.writeLine(`The target branch is ${targetBranch}`);

if (this._verifyParameter.value) {
const errors: string[] = [
const incompatibleParameters: (
| CommandLineFlagParameter
| CommandLineStringParameter
| CommandLineChoiceParameter
)[] = [
this._bulkChangeParameter,
this._bulkChangeMessageParameter,
this._bulkChangeBumpTypeParameter,
this._overwriteFlagParameter,
this._commitChangesFlagParameter
]
];
const errors: string[] = incompatibleParameters
.map((parameter) => {
return parameter.value
? `The {${this._bulkChangeParameter.longName} parameter cannot be provided with the ` +
? `The ${parameter.longName} parameter cannot be provided with the ` +
`${this._verifyParameter.longName} parameter`
: '';
})
.filter((error) => error !== '');
if (errors.length > 0) {
errors.forEach((error) => {
// eslint-disable-next-line no-console
console.error(error);
this.terminal.writeErrorLine(error);
});
throw new AlreadyReportedError();
}
Expand Down Expand Up @@ -261,8 +305,7 @@ export class ChangeAction extends BaseRushAction {

if (errors.length > 0) {
for (const error of errors) {
// eslint-disable-next-line no-console
console.error(error);
this.terminal.writeErrorLine(error);
}

throw new AlreadyReportedError();
Expand All @@ -276,7 +319,8 @@ export class ChangeAction extends BaseRushAction {
interactiveMode = true;

const existingChangeComments: Map<string, string[]> = ChangeFiles.getChangeComments(
await this._getChangeFilesAsync()
this.terminal,
await this._getChangeFilesSinceBaseBranchAsync()
);
changeFileData = await this._promptForChangeFileDataAsync(
promptModule,
Expand Down Expand Up @@ -338,9 +382,31 @@ export class ChangeAction extends BaseRushAction {
}

private async _verifyAsync(): Promise<void> {
const changedPackages: string[] = await this._getChangedProjectNamesAsync();
if (changedPackages.length > 0) {
await this._validateChangeFileAsync(changedPackages);
const changedProjectNames: string[] = await this._getChangedProjectNamesAsync();
const strictValidation: boolean | undefined =
this.rushConfiguration.experimentsConfiguration.configuration.strictChangefileValidation;

// When strict validation is enabled, validate ALL change files to catch references to
// deleted or nonexistent projects. Otherwise, only validate change files added on this branch.
const changeFilesInstance: ChangeFiles = new ChangeFiles(this.rushConfiguration);
let filesToValidate: string[];
if (strictValidation) {
filesToValidate = await changeFilesInstance.getAllChangeFilesAsync();
} else {
filesToValidate = await this._getChangeFilesSinceBaseBranchAsync();
}

if (changedProjectNames.length > 0 || filesToValidate.length > 0) {
const deletedProjectNames: Set<string> | undefined = strictValidation
? await this._getDeletedProjectNamesAsync()
: undefined;

await changeFilesInstance.validateAsync({
terminal: this.terminal,
filesToValidate,
changedProjectNames,
deletedProjectNames
});
} else {
this._logNoChangeFileRequired();
}
Expand Down Expand Up @@ -385,12 +451,61 @@ export class ChangeAction extends BaseRushAction {
return Array.from(changedProjectNames);
}

private async _validateChangeFileAsync(changedPackages: string[]): Promise<void> {
const files: string[] = await this._getChangeFilesAsync();
ChangeFiles.validate(files, changedPackages, this.rushConfiguration);
private async _validateAllChangeFilesAsync(): Promise<void> {
if (!this.rushConfiguration.experimentsConfiguration.configuration.strictChangefileValidation) {
throw new Error(
`The ${this._verifyAllParameter.longName} parameter requires the ` +
'"strictChangefileValidation" experiment to be enabled.'
);
}

const changeFiles: ChangeFiles = new ChangeFiles(this.rushConfiguration);
const allChangeFiles: string[] = await changeFiles.getAllChangeFilesAsync();
const deletedProjectNames: Set<string> = await this._getDeletedProjectNamesAsync();
await changeFiles.validateAsync({
terminal: this.terminal,
filesToValidate: allChangeFiles,
changedProjectNames: [],
deletedProjectNames
});
}

/**
* Compares the current rush.json project list against the target branch to find
* projects that were removed.
*/
private async _getDeletedProjectNamesAsync(): Promise<Set<string>> {
const repoRoot: string = getRepoRoot(this.rushConfiguration.rushJsonFolder);
const targetBranch: string = await this._getTargetBranchAsync();
const mergeBase: string = await this._git.getMergeBaseAsync(targetBranch, this.terminal);

let oldRushJsonContent: string;
try {
const rushJsonRelativePath: string = path.relative(repoRoot, this.rushConfiguration.rushJsonFile);
oldRushJsonContent = await this._git.getBlobContentAsync({
blobSpec: `${mergeBase}:${rushJsonRelativePath}`,
repositoryRoot: repoRoot
});
} catch {
// If rush.json didn't exist on the target branch, no projects were deleted
return new Set();
}

const oldRushJson: IRushConfigurationJson = JsonFile.parseString(oldRushJsonContent);
const currentProjectsByName: ReadonlyMap<string, RushConfigurationProject> =
this.rushConfiguration.projectsByName;

const deletedProjectNames: Set<string> = new Set<string>();
for (const { packageName } of oldRushJson.projects) {
if (!currentProjectsByName.has(packageName)) {
deletedProjectNames.add(packageName);
}
}

return deletedProjectNames;
}

private async _getChangeFilesAsync(): Promise<string[]> {
private async _getChangeFilesSinceBaseBranchAsync(): Promise<string[]> {
const repoRoot: string = getRepoRoot(this.rushConfiguration.rushJsonFolder);
const relativeChangesFolder: string = path.relative(repoRoot, this.rushConfiguration.changesFolder);
const targetBranch: string = await this._getTargetBranchAsync();
Expand Down Expand Up @@ -452,15 +567,12 @@ export class ChangeAction extends BaseRushAction {
packageName: string,
existingChangeComments: Map<string, string[]>
): Promise<IChangeInfo | undefined> {
// eslint-disable-next-line no-console
console.log(`\n${packageName}`);
this.terminal.writeLine(`\n${packageName}`);
const comments: string[] | undefined = existingChangeComments.get(packageName);
if (comments) {
// eslint-disable-next-line no-console
console.log(`Found existing comments:`);
this.terminal.writeLine(`Found existing comments:`);
comments.forEach((comment) => {
// eslint-disable-next-line no-console
console.log(` > ${comment}`);
this.terminal.writeLine(` > ${comment}`);
});
const { appendComment }: { appendComment: 'skip' | 'append' } = await promptModule({
name: 'appendComment',
Expand Down Expand Up @@ -595,8 +707,7 @@ export class ChangeAction extends BaseRushAction {
.toString()
.replace(/(\r\n|\n|\r)/gm, '');
} catch (err) {
// eslint-disable-next-line no-console
console.log('There was an issue detecting your Git email...');
this.terminal.writeLine('There was an issue detecting your Git email...');
return undefined;
}
}
Expand Down Expand Up @@ -646,8 +757,7 @@ export class ChangeAction extends BaseRushAction {
try {
const hasUnstagedChanges: boolean = await this._git.hasUnstagedChangesAsync();
if (hasUnstagedChanges) {
// eslint-disable-next-line no-console
console.log(
this.terminal.writeLine(
'\n' +
Colorize.yellow(
'Warning: You have unstaged changes, which do not trigger prompting for change ' +
Expand All @@ -656,8 +766,7 @@ export class ChangeAction extends BaseRushAction {
);
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(`An error occurred when detecting unstaged changes: ${error}`);
this.terminal.writeLine(`An error occurred when detecting unstaged changes: ${error}`);
}
}

Expand Down Expand Up @@ -726,8 +835,7 @@ export class ChangeAction extends BaseRushAction {
if (overwrite) {
return true;
} else {
// eslint-disable-next-line no-console
console.log(`Not overwriting ${filePath}`);
this.terminal.writeLine(`Not overwriting ${filePath}`);
return false;
}
}
Expand All @@ -738,16 +846,13 @@ export class ChangeAction extends BaseRushAction {
private _writeFile(fileName: string, output: string, isOverwrite: boolean): void {
FileSystem.writeFile(fileName, output, { ensureFolderExists: true });
if (isOverwrite) {
// eslint-disable-next-line no-console
console.log(`Overwrote file: ${fileName}`);
this.terminal.writeLine(`Overwrote file: ${fileName}`);
} else {
// eslint-disable-next-line no-console
console.log(`Created file: ${fileName}`);
this.terminal.writeLine(`Created file: ${fileName}`);
}
}

private _logNoChangeFileRequired(): void {
// eslint-disable-next-line no-console
console.log('No changes were detected to relevant packages on this branch. Nothing to do.');
this.terminal.writeLine('No changes were detected to relevant packages on this branch. Nothing to do.');
}
}
8 changes: 2 additions & 6 deletions libraries/rush-lib/src/cli/actions/PublishAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,7 @@ export class PublishAction extends BaseRushAction {
allPackages: ReadonlyMap<string, RushConfigurationProject>
): Promise<void> {
const changeManager: ChangeManager = new ChangeManager(this.rushConfiguration);
await changeManager.loadAsync(
this.rushConfiguration.changesFolder,
this._prereleaseToken,
this._addCommitDetails.value
);
await changeManager.loadAsync(this._prereleaseToken, this._addCommitDetails.value);

if (changeManager.hasChanges()) {
const orderedChanges: IChangeInfo[] = changeManager.packageChanges;
Expand All @@ -298,7 +294,7 @@ export class PublishAction extends BaseRushAction {

// Make changes to package.json and change logs.
changeManager.apply(this._apply.value);
await changeManager.updateChangelogAsync(this._apply.value);
await changeManager.updateChangelogAsync(this.terminal, this._apply.value);

this._setDependenciesBeforeCommit();

Expand Down
Loading