Enhance CI Change Detection (#43596)

Realistically speaking, if a `package.json` file is changed we should consider it to be a change
for the entire package. This helps because it might impact the config itself. The same is true
of changes to `pnpm-lock.yaml` since those may update dependencies for any package. I
have also added a `-f` option to the CI Jobs CLI command to trigger all jobs.
This commit is contained in:
Christopher Allford 2024-01-15 13:30:31 -08:00 committed by GitHub
parent 43be71a52f
commit 5425a9536a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 237 additions and 74 deletions

File diff suppressed because one or more lines are too long

View File

@ -21,14 +21,25 @@ const program = new Command( 'ci-jobs' )
'<base-ref>', '<base-ref>',
'Base ref to compare the current ref against for change detection.' 'Base ref to compare the current ref against for change detection.'
) )
.action( async ( baseRef: string ) => { .option(
'-f --force',
'Forces all projects to be marked as changed.',
false
)
.action( async ( baseRef: string, options ) => {
Logger.startTask( 'Parsing Project Graph', true ); Logger.startTask( 'Parsing Project Graph', true );
const projectGraph = buildProjectGraph(); const projectGraph = buildProjectGraph();
Logger.endTask( true ); Logger.endTask( true );
Logger.startTask( 'Pulling File Changes', true ); let fileChanges;
const fileChanges = getFileChanges( projectGraph, baseRef ); if ( options.force ) {
Logger.endTask( true ); Logger.warn( 'Forcing all projects to be marked as changed.' );
fileChanges = true;
} else {
Logger.startTask( 'Pulling File Changes', true );
fileChanges = getFileChanges( projectGraph, baseRef );
Logger.endTask( true );
}
Logger.startTask( 'Creating Jobs', true ); Logger.startTask( 'Creating Jobs', true );
const jobs = await createJobsForChanges( projectGraph, fileChanges, { const jobs = await createJobsForChanges( projectGraph, fileChanges, {

View File

@ -33,7 +33,10 @@ describe( 'Config', () => {
jobs: [ jobs: [
{ {
type: JobType.Lint, type: JobType.Lint,
changes: [ makeRe( '/src/**/*.{js,jsx,ts,tsx}' ) ], changes: [
/^package\.json$/,
makeRe( '/src/**/*.{js,jsx,ts,tsx}' ),
],
command: 'foo', command: 'foo',
}, },
], ],
@ -57,7 +60,10 @@ describe( 'Config', () => {
jobs: [ jobs: [
{ {
type: JobType.Lint, type: JobType.Lint,
changes: [ makeRe( '/src/**/*.{js,jsx,ts,tsx}' ) ], changes: [
/^package\.json$/,
makeRe( '/src/**/*.{js,jsx,ts,tsx}' ),
],
command: 'foo <baseRef>', command: 'foo <baseRef>',
}, },
], ],
@ -100,6 +106,7 @@ describe( 'Config', () => {
{ {
type: JobType.Lint, type: JobType.Lint,
changes: [ changes: [
/^package\.json$/,
makeRe( '/src/**/*.{js,jsx,ts,tsx}' ), makeRe( '/src/**/*.{js,jsx,ts,tsx}' ),
makeRe( '/test/**/*.{js,jsx,ts,tsx}' ), makeRe( '/test/**/*.{js,jsx,ts,tsx}' ),
], ],
@ -130,7 +137,10 @@ describe( 'Config', () => {
{ {
type: JobType.Test, type: JobType.Test,
name: 'default', name: 'default',
changes: [ makeRe( '/src/**/*.{js,jsx,ts,tsx}' ) ], changes: [
/^package\.json$/,
makeRe( '/src/**/*.{js,jsx,ts,tsx}' ),
],
command: 'foo', command: 'foo',
}, },
], ],
@ -164,7 +174,10 @@ describe( 'Config', () => {
{ {
type: JobType.Test, type: JobType.Test,
name: 'default', name: 'default',
changes: [ makeRe( '/src/**/*.{js,jsx,ts,tsx}' ) ], changes: [
/^package\.json$/,
makeRe( '/src/**/*.{js,jsx,ts,tsx}' ),
],
command: 'foo', command: 'foo',
testEnv: { testEnv: {
start: 'bar', start: 'bar',
@ -199,7 +212,10 @@ describe( 'Config', () => {
{ {
type: JobType.Test, type: JobType.Test,
name: 'default', name: 'default',
changes: [ makeRe( '/src/**/*.{js,jsx,ts,tsx}' ) ], changes: [
/^package\.json$/,
makeRe( '/src/**/*.{js,jsx,ts,tsx}' ),
],
command: 'foo', command: 'foo',
cascadeKeys: [ 'bar' ], cascadeKeys: [ 'bar' ],
}, },

View File

@ -57,4 +57,46 @@ baz/project-d/baz.js`;
} ); } );
} ); } );
} ); } );
it( 'should see pnpm-lock.yaml file changes as universal changes', () => {
jest.mocked( execSync ).mockImplementation( ( command ) => {
if ( command === 'git diff --name-only origin/trunk' ) {
return `test/project-a/package.json
foo/project-b/foo.js
pnpm-lock.yaml
bar/project-c/bar.js
baz/project-d/baz.js`;
}
throw new Error( 'Invalid command' );
} );
const fileChanges = getFileChanges(
{
name: 'project-a',
path: 'test/project-a',
dependencies: [
{
name: 'project-b',
path: 'foo/project-b',
dependencies: [
{
name: 'project-c',
path: 'bar/project-c',
dependencies: [],
},
],
},
{
name: 'project-c',
path: 'bar/project-c',
dependencies: [],
},
],
},
'origin/trunk'
);
expect( fileChanges ).toStrictEqual( true );
} );
} ); } );

View File

@ -8,7 +8,7 @@ import { parseTestEnvConfig } from '../test-environment';
jest.mock( '../test-environment' ); jest.mock( '../test-environment' );
describe( 'Job Processing', () => { describe( 'Job Processing', () => {
describe( 'getFileChanges', () => { describe( 'createJobsForChanges', () => {
it( 'should do nothing with no CI configs', async () => { it( 'should do nothing with no CI configs', async () => {
const jobs = await createJobsForChanges( const jobs = await createJobsForChanges(
{ {
@ -686,5 +686,48 @@ describe( 'Job Processing', () => {
}, },
} ); } );
} ); } );
it( 'should trigger all jobs for a single node with changes set to "true"', async () => {
const jobs = await createJobsForChanges(
{
name: 'test',
path: 'test',
ciConfig: {
jobs: [
{
type: JobType.Lint,
changes: [ /test.js$/ ],
command: 'test-lint',
},
{
type: JobType.Test,
name: 'Default',
changes: [ /test.js$/ ],
command: 'test-cmd',
},
],
},
dependencies: [],
},
true,
{}
);
expect( jobs.lint ).toHaveLength( 1 );
expect( jobs.lint ).toContainEqual( {
projectName: 'test',
command: 'test-lint',
} );
expect( jobs.test ).toHaveLength( 1 );
expect( jobs.test ).toContainEqual( {
projectName: 'test',
name: 'Default',
command: 'test-cmd',
testEnv: {
shouldCreate: false,
envVars: {},
},
} );
} );
} ); } );
} ); } );

View File

@ -80,6 +80,7 @@ describe( 'Project Graph', () => {
command: 'foo', command: 'foo',
type: 'lint', type: 'lint',
changes: [ changes: [
/^package\.json$/,
/^(?:src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.js|src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.jsx|src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.ts|src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.tsx)$/, /^(?:src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.js|src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.jsx|src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.ts|src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.tsx)$/,
], ],
}, },

View File

@ -57,9 +57,28 @@ interface BaseJobConfig {
/** /**
* Parses and validates a raw change config entry. * Parses and validates a raw change config entry.
* *
* @param {string|string[]} raw The raw config to parse. * @param {string|string[]} raw The raw config to parse.
* @param {string[]} extraGlobs Any extra globs that should be added to the configuration.
*/ */
function parseChangesConfig( raw: unknown ): RegExp[] { function parseChangesConfig(
raw: unknown,
extraGlobs: string[] = []
): RegExp[] {
const changes: RegExp[] = [];
// Make sure to include any extra glob patterns that were passed in.
// This allows us to make sure we're watching for changes in files
// that may implicitly be impactful but shouldn't need to be
// stated explicitly in the list of file changes.
for ( const entry of extraGlobs ) {
const regex = makeRe( entry );
if ( ! regex ) {
throw new Error( 'Invalid extra glob pattern.' );
}
changes.push( regex );
}
if ( typeof raw === 'string' ) { if ( typeof raw === 'string' ) {
const regex = makeRe( raw ); const regex = makeRe( raw );
if ( ! regex ) { if ( ! regex ) {
@ -68,7 +87,8 @@ function parseChangesConfig( raw: unknown ): RegExp[] {
); );
} }
return [ regex ]; changes.push( regex );
return changes;
} }
if ( ! Array.isArray( raw ) ) { if ( ! Array.isArray( raw ) ) {
@ -77,7 +97,6 @@ function parseChangesConfig( raw: unknown ): RegExp[] {
); );
} }
const changes: RegExp[] = [];
for ( const entry of raw ) { for ( const entry of raw ) {
if ( typeof entry !== 'string' ) { if ( typeof entry !== 'string' ) {
throw new ConfigError( throw new ConfigError(
@ -156,7 +175,7 @@ function parseLintJobConfig( raw: any ): LintJobConfig {
return { return {
type: JobType.Lint, type: JobType.Lint,
changes: parseChangesConfig( raw.changes ), changes: parseChangesConfig( raw.changes, [ 'package.json' ] ),
command: raw.command, command: raw.command,
}; };
} }
@ -306,7 +325,7 @@ function parseTestJobConfig( raw: any ): TestJobConfig {
const config: TestJobConfig = { const config: TestJobConfig = {
type: JobType.Test, type: JobType.Test,
name: raw.name, name: raw.name,
changes: parseChangesConfig( raw.changes ), changes: parseChangesConfig( raw.changes, [ 'package.json' ] ),
command: raw.command, command: raw.command,
}; };

View File

@ -77,20 +77,26 @@ function getChangedFilesForProject(
* *
* @param {Object} projectGraph The project graph to assign changes for. * @param {Object} projectGraph The project graph to assign changes for.
* @param {string} baseRef The git ref to compare against for changes. * @param {string} baseRef The git ref to compare against for changes.
* @return {Object} A map of changed files keyed by the project name. * @return {Object|true} A map of changed files keyed by the project name or true if all projects should be marked as changed.
*/ */
export function getFileChanges( export function getFileChanges(
projectGraph: ProjectNode, projectGraph: ProjectNode,
baseRef: string baseRef: string
): ProjectFileChanges { ): ProjectFileChanges | true {
const projectPaths = getProjectPaths( projectGraph );
// We're going to use git to figure out what files have changed. // We're going to use git to figure out what files have changed.
const output = execSync( `git diff --name-only ${ baseRef }`, { const output = execSync( `git diff --name-only ${ baseRef }`, {
encoding: 'utf8', encoding: 'utf8',
} ); } );
const changedFilePaths = output.split( '\n' ); const changedFilePaths = output.split( '\n' );
// If the root lockfile has been changed we have no easy way
// of knowing which projects have been impacted. We want
// to re-run all jobs in all projects for safety.
if ( changedFilePaths.includes( 'pnpm-lock.yaml' ) ) {
return true;
}
const projectPaths = getProjectPaths( projectGraph );
const changes: ProjectFileChanges = {}; const changes: ProjectFileChanges = {};
for ( const projectName in projectPaths ) { for ( const projectName in projectPaths ) {
// Projects with no paths have no changed files for us to identify. // Projects with no paths have no changed files for us to identify.

View File

@ -73,33 +73,39 @@ function replaceCommandVars( command: string, options: CreateOptions ): string {
/** /**
* Checks the config against the changes and creates one if it should be run. * Checks the config against the changes and creates one if it should be run.
* *
* @param {string} projectName The name of the project that the job is for. * @param {string} projectName The name of the project that the job is for.
* @param {Object} config The config object for the lint job. * @param {Object} config The config object for the lint job.
* @param {Array.<string>} changes The file changes that have occurred for the project. * @param {Array.<string>|true} changes The file changes that have occurred for the project or true if all projects should be marked as changed.
* @param {Object} options The options to use when creating the job. * @param {Object} options The options to use when creating the job.
* @return {Object|null} The job that should be run or null if no job should be run. * @return {Object|null} The job that should be run or null if no job should be run.
*/ */
function createLintJob( function createLintJob(
projectName: string, projectName: string,
config: LintJobConfig, config: LintJobConfig,
changes: string[], changes: string[] | true,
options: CreateOptions options: CreateOptions
): LintJob | null { ): LintJob | null {
let triggered = false; let triggered = false;
// Projects can configure jobs to be triggered when a // When we're forcing changes for all projects we don't need to check
// changed file matches a path regex. // for any changed files before triggering the job.
for ( const file of changes ) { if ( changes === true ) {
for ( const change of config.changes ) { triggered = true;
if ( change.test( file ) ) { } else {
triggered = true; // Projects can configure jobs to be triggered when a
// changed file matches a path regex.
for ( const file of changes ) {
for ( const change of config.changes ) {
if ( change.test( file ) ) {
triggered = true;
break;
}
}
if ( triggered ) {
break; break;
} }
} }
if ( triggered ) {
break;
}
} }
if ( ! triggered ) { if ( ! triggered ) {
@ -115,47 +121,55 @@ function createLintJob(
/** /**
* Checks the config against the changes and creates one if it should be run. * Checks the config against the changes and creates one if it should be run.
* *
* @param {string} projectName The name of the project that the job is for. * @param {string} projectName The name of the project that the job is for.
* @param {Object} config The config object for the test job. * @param {Object} config The config object for the test job.
* @param {Array.<string>} changes The file changes that have occurred for the project. * @param {Array.<string>|true} changes The file changes that have occurred for the project or true if all projects should be marked as changed.
* @param {Object} options The options to use when creating the job. * @param {Object} options The options to use when creating the job.
* @param {Array.<string>} cascadeKeys The cascade keys that have been triggered in dependencies. * @param {Array.<string>} cascadeKeys The cascade keys that have been triggered in dependencies.
* @return {Promise.<Object|null>} The job that should be run or null if no job should be run. * @return {Promise.<Object|null>} The job that should be run or null if no job should be run.
*/ */
async function createTestJob( async function createTestJob(
projectName: string, projectName: string,
config: TestJobConfig, config: TestJobConfig,
changes: string[], changes: string[] | true,
options: CreateOptions, options: CreateOptions,
cascadeKeys: string[] cascadeKeys: string[]
): Promise< TestJob | null > { ): Promise< TestJob | null > {
let triggered = false; let triggered = false;
// Some jobs can be configured to trigger when a dependency has a job that // When we're forcing changes for all projects we don't need to check
// was triggered. For example, a code change in a dependency might mean // for any changed files before triggering the job.
// that code is impacted in the current project even if no files were if ( changes === true ) {
// actually changed in this project.
if (
config.cascadeKeys &&
config.cascadeKeys.some( ( value ) => cascadeKeys.includes( value ) )
) {
triggered = true; triggered = true;
} } else {
// Some jobs can be configured to trigger when a dependency has a job that
// was triggered. For example, a code change in a dependency might mean
// that code is impacted in the current project even if no files were
// actually changed in this project.
if (
config.cascadeKeys &&
config.cascadeKeys.some( ( value ) =>
cascadeKeys.includes( value )
)
) {
triggered = true;
}
// Projects can configure jobs to be triggered when a // Projects can configure jobs to be triggered when a
// changed file matches a path regex. // changed file matches a path regex.
if ( ! triggered ) { if ( ! triggered ) {
for ( const file of changes ) { for ( const file of changes ) {
for ( const change of config.changes ) { for ( const change of config.changes ) {
if ( change.test( file ) ) { if ( change.test( file ) ) {
triggered = true; triggered = true;
break;
}
}
if ( triggered ) {
break; break;
} }
} }
if ( triggered ) {
break;
}
} }
} }
@ -189,15 +203,15 @@ async function createTestJob(
/** /**
* Recursively checks the project for any jobs that should be executed and returns them. * Recursively checks the project for any jobs that should be executed and returns them.
* *
* @param {Object} node The current project node to examine. * @param {Object} node The current project node to examine.
* @param {Object} changedFiles The files that have changed for the project. * @param {Object|true} changes The changed files keyed by their project or true if all projects should be marked as changed.
* @param {Object} options The options to use when creating the job. * @param {Object} options The options to use when creating the job.
* @param {Array.<string>} cascadeKeys The cascade keys that have been triggered in dependencies. * @param {Array.<string>} cascadeKeys The cascade keys that have been triggered in dependencies.
* @return {Promise.<Object>} The jobs that have been created for the project. * @return {Promise.<Object>} The jobs that have been created for the project.
*/ */
async function createJobsForProject( async function createJobsForProject(
node: ProjectNode, node: ProjectNode,
changedFiles: ProjectFileChanges, changes: ProjectFileChanges | true,
options: CreateOptions, options: CreateOptions,
cascadeKeys: string[] cascadeKeys: string[]
): Promise< Jobs > { ): Promise< Jobs > {
@ -221,7 +235,7 @@ async function createJobsForProject(
const dependencyJobs = await createJobsForProject( const dependencyJobs = await createJobsForProject(
dependency, dependency,
changedFiles, changes,
options, options,
dependencyCascade dependencyCascade
); );
@ -256,12 +270,23 @@ async function createJobsForProject(
continue; continue;
} }
// Jobs will check to see whether or not they should trigger based on the files
// that have been changed in the project. When "true" is given, however, it
// means that we should consider ALL files to have been changed and
// trigger any jobs for the project.
let projectChanges;
if ( changes === true ) {
projectChanges = true;
} else {
projectChanges = changes[ node.name ] ?? [];
}
switch ( jobConfig.type ) { switch ( jobConfig.type ) {
case JobType.Lint: { case JobType.Lint: {
const created = createLintJob( const created = createLintJob(
node.name, node.name,
jobConfig, jobConfig,
changedFiles[ node.name ] ?? [], projectChanges,
options options
); );
if ( ! created ) { if ( ! created ) {
@ -277,7 +302,7 @@ async function createJobsForProject(
const created = await createTestJob( const created = await createTestJob(
node.name, node.name,
jobConfig, jobConfig,
changedFiles[ node.name ] ?? [], projectChanges,
options, options,
cascadeKeys cascadeKeys
); );
@ -306,14 +331,14 @@ async function createJobsForProject(
/** /**
* Creates jobs to run for the given project graph and file changes. * Creates jobs to run for the given project graph and file changes.
* *
* @param {Object} root The root node for the project graph. * @param {Object} root The root node for the project graph.
* @param {Object} changes The file changes that have occurred. * @param {Object|true} changes The changed files keyed by their project or true if all projects should be marked as changed.
* @param {Object} options The options to use when creating the job. * @param {Object} options The options to use when creating the job.
* @return {Promise.<Object>} The jobs that should be run. * @return {Promise.<Object>} The jobs that should be run.
*/ */
export function createJobsForChanges( export function createJobsForChanges(
root: ProjectNode, root: ProjectNode,
changes: ProjectFileChanges, changes: ProjectFileChanges | true,
options: CreateOptions options: CreateOptions
): Promise< Jobs > { ): Promise< Jobs > {
return createJobsForProject( root, changes, options, [] ); return createJobsForProject( root, changes, options, [] );