Files
react-native/scripts/circleci/pipeline_selection.js
T
Riccardo Cipolleschi 641be98b1e Split Config.yml to run jobs selectively (#39042)
Summary:
Right now, every PR runs the whole test suite. For example, a changelog PR, will run all the tests. As of last month, that meant quite a few $s per single run.

With this PR, we are going to leverage dynamic configuration and file filtering to create a config.yml on the flight, depending on the files changed by the commit/pr.

They way it works is the following:
- It starts a setup workflow in CircleCI.
- This workflow fetch the list of files that have been changed in the current commit.
- It executes a bunch of filtering and computation to understand which tests makes sense to run.
- It creates a config on the flight to run those.
- It continue the pipeline on that config.

Currently, the way it works is the following:
- If a `.md` file has been modified => run nothing
- If only files in the `ReactAndroid` folder are modified => run tests for android only
- If only files in the `React` folder are modified or `ruby` files are modified => run only iOS tests
- If only js files, not in the scripts folder are modified => run only JS tests
- if only files in the e2e folder are modified => run only e2e tests
- else => run everything.

Of course, we can play and modify those filters t make sure that they reflect the work and the tests to the best we can.

bypass-github-exports-checks

## Changelog:
[Internal] - Split circleci config and run test selectively.

Pull Request resolved: https://github.com/facebook/react-native/pull/39042

Test Plan:
- [X] Tested on the local branch for general sanity check.
- [X] Import it in fbsource
- [x] Create a stacked diff which changes only a md file => verify that no tests are run.
- [x] Create a stacked diff which changes only files in ReactAndroid => verify that only android tests run.
- [x] Create a stacked diff which changes only files in React => verify that only iOS tests run.
- [x] Create a stacked diff which changes only ruby files => verify that only iOS tests run.
- [x] Create a stacked diff which changes ruby files and file in React => verify that only iOS tests run.
- [x] Create a stacked diff which changes only files JS not in the script folder => verify that JS tests run.
- [x] Create a stacked diff which changes only JS files in the script folder => verify that the whole suite starts.
- [x] Create a stacked diff which changes only files in the E2E folder => verify that only E2E files runs.
- [x] Trigger a nightly pipeline => verify that parameters are passed to the generated config.

Reviewed By: NickGerleman

Differential Revision: D48394437

Pulled By: cipolleschi

fbshipit-source-id: 771f3e68daa8318d2b73dd91ce85a41488110c04
2023-08-18 07:22:22 -07:00

231 lines
5.9 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
/**
* This script is meant to be used only in CircleCI to compute which tests we should execute
* based on the files modified by the commit.
*/
const fs = require('fs');
const yargs = require('yargs');
const {execSync} = require('child_process');
/**
* Check whether the filename is a JS/TS file and not in the script folder
*/
function isJSChange(name) {
const isJS =
name.endsWith('.js') ||
name.endsWith('.jsx') ||
name.endsWith('.ts') ||
name.endsWith('.tsx');
const notAScript = name.indexOf('scripts/') < 0;
return isJS && notAScript;
}
function isIOS(name) {
return (
name.indexOf('React/') > -1 ||
name.endsWith('.rb') ||
name.endsWith('.podspec')
);
}
/**
* This maps some high level pipelines to some conditions.
* If those conditions are true, we are going to run the associated pipeline in CI.
* So, for example, is the commit the CI is analyzing only contains changes in the React/ folder or changes to ruby files
* the RUN_IOS flag will be turned on and we will run only the CI suite for iOS tests.
*
* This mapping is not final and we can update it with more pipelines or we can update the filter condition in the future.
*/
const mapping = [
{
name: 'RUN_IOS',
filterFN: name => isIOS(name),
},
{
name: 'RUN_ANDROID',
filterFN: name => name.indexOf('ReactAndroid/') > -1,
},
{
name: 'RUN_E2E',
filterFN: name => name.indexOf('rn-tester-e2e/') > -1,
},
{
name: 'RUN_JS',
filterFN: name => isJSChange(name),
},
{
name: 'SKIP',
filterFN: name => name.endsWith('.md'),
},
];
// ===== //
// Yargs //
// ===== //
const createConfigsOption = {
input_path: {
alias: 'i',
describe: 'The path to the folder where the Config parts are located',
default: '.circleci/configurations',
},
output_path: {
alias: 'o',
describe: 'The path where the `generated_config.yml` will be created',
default: '.circleci',
},
config_file: {
alias: 'c',
describe: 'The configuration file with the parameter to set',
default: '/tmp/circleci/pipeline_config.json',
},
};
const filterJobsOptions = {
output_path: {
alias: 'o',
ddescribe: 'The output path for the configurations',
default: '/tmp/circleci',
},
};
yargs
.command(
'create-configs',
'Creates the circleci config from its files',
createConfigsOption,
argv => createConfigs(argv.input_path, argv.output_path, argv.config_file),
)
.command(
'filter-jobs',
'Filters the jobs based on the list of chaged files in the PR',
filterJobsOptions,
argv =>
filterJobs(argv.output_path).then(() => console.info('Filtering done!')),
)
.demandCommand()
.strict()
.help().argv;
// ============== //
// GITHUB UTILS //
// ============== //
function _getFilesFromGit() {
try {
execSync('git fetch');
const commonCommit = String(
execSync('git merge-base HEAD origin/HEAD '),
).trim();
return String(execSync(`git diff --name-only HEAD ${commonCommit}`))
.trim()
.split('\n');
} catch (error) {
console.error(error);
return [];
}
}
async function _computeAndSavePipelineParameters(pipelineType, outputPath) {
fs.mkdirSync(outputPath, {recursive: true});
const filePath = `${outputPath}/pipeline_config.json`;
if (pipelineType === 'SKIP') {
fs.writeFileSync(filePath, JSON.stringify({}, null, 2));
return;
}
if (pipelineType === 'ALL') {
fs.writeFileSync(filePath, JSON.stringify({run_all: true}, null, 2));
return;
}
const params = {
run_all: false,
run_ios: pipelineType === 'RUN_IOS',
run_android: pipelineType === 'RUN_ANDROID',
run_js: pipelineType === 'RUN_JS',
run_e2e: pipelineType === 'RUN_E2E' || pipelineType === 'RUN_JS',
};
const stringifiedParams = JSON.stringify(params, null, 2);
fs.writeFileSync(filePath, stringifiedParams);
console.info(`Generated params:\n${stringifiedParams}`);
}
// ============== //
// COMMANDS //
// ============== //
function createConfigs(inputPath, outputPath, configFile) {
// Order is important!
const baseConfigFiles = [
'top_level.yml',
'executors.yml',
'commands.yml',
'jobs.yml',
'workflows.yml',
];
const baseFolder = 'test_workflows';
const testConfigs = {
run_ios: ['testIOS.yml'],
run_android: ['testAndroid.yml'],
run_e2e: ['testE2E.yml'],
run_all: ['testE2E.yml', 'testJS.yml', 'testAll.yml'],
run_js: ['testJS.yml'],
};
if (!fs.existsSync(configFile)) {
throw new Error(`Config file: ${configFile} does not exists`);
}
const jsonParams = JSON.parse(fs.readFileSync(configFile));
let configurationPartsFiles = baseConfigFiles;
for (const [key, shouldTest] of Object.entries(jsonParams)) {
if (shouldTest) {
const mappedFiles = testConfigs[key].map(f => `${baseFolder}/${f}`);
configurationPartsFiles = configurationPartsFiles.concat(mappedFiles);
}
}
console.log(configurationPartsFiles);
let configParts = [];
configurationPartsFiles.forEach(yamlPart => {
const fileContent = fs.readFileSync(`${inputPath}/${yamlPart}`);
configParts.push(fileContent);
});
console.info(`writing to: ${outputPath}/generated_config.yml`);
fs.writeFileSync(
`${outputPath}/generated_config.yml`,
configParts.join('\n'),
);
}
async function filterJobs(outputPath) {
const fileList = _getFilesFromGit();
if (fileList.length === 0) {
await _computeAndSavePipelineParameters('SKIP', outputPath);
return;
}
for (const filter of mapping) {
const found = fileList.every(filter.filterFN);
if (found) {
await _computeAndSavePipelineParameters(filter.name, outputPath);
return;
}
}
await _computeAndSavePipelineParameters('ALL', outputPath);
}