Use `ci-jobs` Utility For `ci.yml` Matrix (#43532)

This adds support for using the `pnpm utils ci-jobs` command in our `ci.yml` file. One of the bigger benefits to this change too is that we're now distributing a bundled version of the utils tool. This lets us run it without actually having to install the repo and will let us speed up any workflows that currently do.
This commit is contained in:
Christopher Allford 2024-01-12 20:32:14 -08:00 committed by GitHub
parent 4f8922aa0b
commit 758df4854d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
89 changed files with 3808 additions and 1217 deletions

1
.gitattributes vendored
View File

@ -13,3 +13,4 @@
*.tsx text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.flf text eol=lf

View File

@ -9,15 +9,16 @@ concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
cancel-in-progress: true
jobs:
project-matrix:
project-jobs:
# Since this is a monorepo, not every pull request or change is going to impact every project.
# Instead of running CI tasks on all projects indiscriminately, we use a script to detect
# which projects have changed and what kind of change occurred. This lets us build a
# matrix that we can use to run CI tasks only on the projects that need them.
name: 'Build Project Matrix'
# Instead of running CI tasks on all projects indiscriminately, we use a command to detect
# which projects have changed and what kind of change occurred. This lets us build the
# matrices that we can use to run CI tasks only on the projects that need them.
name: 'Build Project Jobs'
runs-on: 'ubuntu-20.04'
outputs:
matrix: ${{ steps.project-matrix.outputs.matrix }}
lint-jobs: ${{ steps.project-jobs.outputs.lint-jobs }}
test-jobs: ${{ steps.project-jobs.outputs.test-jobs }}
steps:
- uses: 'actions/checkout@v3'
name: 'Checkout'
@ -29,27 +30,45 @@ jobs:
php-version: false # We don't want to waste time installing PHP since we aren't using it in this job.
- uses: actions/github-script@v6
name: 'Build Matrix'
id: 'project-matrix'
id: 'project-jobs'
with:
script: |
let baseRef = ${{ toJson( github.base_ref ) }};
if ( baseRef ) {
baseRef = 'origin/' + baseRef;
}
const buildCIMatrix = require( './.github/workflows/scripts/build-ci-matrix' );
core.setOutput( 'matrix', JSON.stringify( await buildCIMatrix( baseRef ) ) );
project-task-matrix:
# This is the actual CI job that will be ran against every project with applicable changes.
# Note that we only run the tasks that have commands set. Our script will set them if
# they are needed and so all the workflow needs to do is run them.
name: '${{ matrix.projectName }} - ${{ matrix.taskName }}' # Note: GitHub doesn't process expressions for skipped jobs so when there's no matrix the name will literally be this.
const child_process = require( 'node:child_process' );
child_process.execSync( 'pnpm utils ci-jobs ' + baseRef );
project-lint-jobs:
name: 'Lint - ${{ matrix.projectName }}'
runs-on: 'ubuntu-20.04'
needs: 'project-matrix'
if: ${{ needs.project-matrix.outputs.matrix != '[]' }}
needs: 'project-jobs'
if: ${{ needs.project-jobs.outputs.lint-jobs != '[]' }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON( needs.project-matrix.outputs.matrix ) }}
include: ${{ fromJSON( needs.project-jobs.outputs.lint-jobs ) }}
steps:
- uses: 'actions/checkout@v3'
name: 'Checkout'
with:
fetch-depth: 0
- uses: './.github/actions/setup-woocommerce-monorepo'
name: 'Setup Monorepo'
id: 'setup-monorepo'
with:
install: '${{ matrix.projectName }}...'
- name: 'Lint'
run: 'pnpm --filter="${{ matrix.projectName }}" ${{ matrix.command }}'
project-test-jobs:
name: 'Test - ${{ matrix.projectName }} - ${{ matrix.name }}'
runs-on: 'ubuntu-20.04'
needs: 'project-jobs'
if: ${{ needs.project-jobs.outputs.test-jobs != '[]' }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON( needs.project-jobs.outputs.test-jobs ) }}
steps:
- uses: 'actions/checkout@v3'
name: 'Checkout'
@ -61,44 +80,43 @@ jobs:
with:
install: '${{ matrix.projectName }}...'
build: '${{ matrix.projectName }}'
- name: 'Lint'
if: ${{ ! cancelled() && matrix.lintCommand && steps.setup-monorepo.conclusion == 'success' }}
run: 'pnpm --filter="${{ matrix.projectName }}" ${{ matrix.lintCommand }}'
- name: 'Prepare Test Environment'
id: 'prepare-test-environment'
if: ${{ ! cancelled() && matrix.testEnvCommand && steps.setup-monorepo.conclusion == 'success' }}
env: ${{ matrix.testEnvVars }}
run: 'pnpm --filter="${{ matrix.projectName }}" ${{ matrix.testEnvCommand }}'
- name: 'Test - JS'
if: ${{ ! cancelled() && matrix.jsTestCommand && steps.setup-monorepo.conclusion == 'success' && ( ! matrix.testEnvCommand || steps.prepare-test-environment.conclusion == 'success' ) }}
run: 'pnpm --filter="${{ matrix.projectName }}" ${{ matrix.jsTestCommand }}'
- name: 'Test - PHP'
if: ${{ ! cancelled() && matrix.phpTestCommand && steps.setup-monorepo.conclusion == 'success' && ( ! matrix.testEnvCommand || steps.prepare-test-environment.conclusion == 'success' ) }}
run: 'pnpm --filter="${{ matrix.projectName }}" ${{ matrix.phpTestCommand }}'
project-task-matrix-evaluation:
if: ${{ matrix.testEnv.shouldCreate }}
env: ${{ matrix.testEnv.envVars }}
run: 'pnpm --filter="${{ matrix.projectName }}" ${{ matrix.testEnv.start }}'
- name: 'Test'
run: 'pnpm --filter="${{ matrix.projectName }}" ${{ matrix.command }}'
evaluate-project-jobs:
# In order to add a required status check we need a consistent job that we can grab onto.
# Since we are dynamically generating a project matrix, however, we can't rely on
# on any specific job being present. We can get around this limitation by using
# a job that runs after all the others and either passes or fails based on the
# results of the other jobs in the workflow.
name: 'Evaluate Project Matrix'
# Since we are dynamically generating a matrix for the project jobs, however, we can't
# rely on on any specific job being present. We can get around this limitation by
# using a job that runs after all the others and either passes or fails based
# on the results of the other jobs in the workflow.
name: 'Evaluate Project Job Statuses'
runs-on: 'ubuntu-20.04'
needs: [
'project-matrix',
'project-task-matrix'
'project-jobs',
'project-lint-jobs',
'project-test-jobs'
]
if: ${{ always() }}
steps:
- name: 'Check Matrix Success'
- name: 'Evaluation'
run: |
result="${{ needs.project-matrix.result }}"
result="${{ needs.project-jobs.result }}"
if [[ $result != "success" && $result != "skipped" ]]; then
echo "An error occurred generating the CI matrix."
echo "An error occurred generating the CI jobs."
exit 1
fi
result="${{ needs.project-task-matrix.result }}"
result="${{ needs.project-lint-jobs.result }}"
if [[ $result != "success" && $result != "skipped" ]]; then
echo "One or more jobs in the matrix has failed."
echo "One or more lint jobs have failed."
exit 1
fi
echo "The matrix has completed successfully."
result="${{ needs.project-test-jobs.result }}"
if [[ $result != "success" && $result != "skipped" ]]; then
echo "One or more test jobs have failed."
exit 1
fi
echo "All jobs have completed successfully."

View File

@ -1,973 +0,0 @@
/**
* External dependencies.
*/
const child_process = require( 'child_process' );
const fs = require( 'fs' );
const https = require( 'http' );
/**
* Uses the WordPress API to get the downlod URL to the latest version of an X.X version line. This
* also accepts "latest-X" to get an offset from the latest version of WordPress.
*
* @param {string} wpVersion The version of WordPress to look for.
* @return {Promise.<string>} The precise WP version download URL.
*/
async function getPreciseWPVersionURL( wpVersion ) {
return new Promise( ( resolve, reject ) => {
// We're going to use the WordPress.org API to get information about available versions of WordPress.
const request = https.get(
'http://api.wordpress.org/core/stable-check/1.0/',
( response ) => {
// Listen for the response data.
let responseData = '';
response.on( 'data', ( chunk ) => {
responseData += chunk;
} );
// Once we have the entire response we can process it.
response.on( 'end', () =>
resolve( JSON.parse( responseData ) )
);
}
);
request.on( 'error', ( error ) => {
reject( error );
} );
} ).then( ( allVersions ) => {
// Note: allVersions is an object where the keys are the version and the value is information about the version's status.
// If we're requesting a "latest" offset then we need to figure out what version line we're offsetting from.
const latestSubMatch = wpVersion.match( /^latest(?:-([0-9]+))?$/i );
if ( latestSubMatch ) {
for ( const version in allVersions ) {
if ( allVersions[ version ] !== 'latest' ) {
continue;
}
// We don't care about the patch version because we will
// the latest version from the version line below.
const versionParts = version.match( /^([0-9]+)\.([0-9]+)/ );
// We're going to subtract the offset to figure out the right version.
let offset = parseInt( latestSubMatch[ 1 ] ?? 0, 10 );
let majorVersion = parseInt( versionParts[ 1 ], 10 );
let minorVersion = parseInt( versionParts[ 2 ], 10 );
while ( offset > 0 ) {
minorVersion--;
if ( minorVersion < 0 ) {
majorVersion--;
minorVersion = 9;
}
offset--;
}
// Set the version that we found in the offset.
wpVersion = majorVersion + '.' + minorVersion;
}
}
// Scan through all of the versions to find the latest version in the version line.
let latestVersion = null;
let latestPatch = -1;
for ( const v in allVersions ) {
// Parse the version so we can make sure we're looking for the latest.
const matches = v.match( /([0-9]+)\.([0-9]+)(?:\.([0-9]+))?/ );
// We only care about the correct minor version.
const minor = `${ matches[ 1 ] }.${ matches[ 2 ] }`;
if ( minor !== wpVersion ) {
continue;
}
// Track the latest version in the line.
const patch =
matches[ 3 ] === undefined ? 0 : parseInt( matches[ 3 ], 10 );
if ( patch > latestPatch ) {
latestPatch = patch;
latestVersion = v;
}
}
if ( ! latestVersion ) {
throw new Error(
`Unable to find latest version for version line ${ wpVersion }.`
);
}
return `https://wordpress.org/wordpress-${ latestVersion }.zip`;
} );
}
/**
* Parses a display-friendly WordPress version and returns a link to download the given version.
*
* @param {string} wpVersion A display-friendly WordPress version. Supports ("master", "trunk", "nightly", "latest", "latest-X", "X.X" for version lines, and "X.X.X" for specific versions)
* @return {Promise.<string>} A link to download the given version of WordPress.
*/
async function parseWPVersion( wpVersion ) {
// Allow for download URLs in place of a version.
if ( wpVersion.match( /[a-z]+:\/\//i ) ) {
return wpVersion;
}
// Start with versions we can infer immediately.
switch ( wpVersion ) {
case 'master':
case 'trunk': {
return 'WordPress/WordPress#master';
}
case 'nightly': {
return 'https://wordpress.org/nightly-builds/wordpress-latest.zip';
}
case 'latest': {
return 'https://wordpress.org/latest.zip';
}
}
// We can also infer X.X.X versions immediately.
const parsedVersion = wpVersion.match( /^([0-9]+)\.([0-9]+)\.([0-9]+)$/ );
if ( parsedVersion ) {
// Note that X.X.0 versions use a X.X download URL.
let urlVersion = `${ parsedVersion[ 1 ] }.${ parsedVersion[ 2 ] }`;
if ( parsedVersion[ 3 ] !== '0' ) {
urlVersion += `.${ parsedVersion[ 3 ] }`;
}
return `https://wordpress.org/wordpress-${ urlVersion }.zip`;
}
// Since we haven't found a URL yet we're going to use the WordPress.org API to try and infer one.
return getPreciseWPVersionURL( wpVersion );
}
/**
* Given a path within a project,
*
* @param {string} absolutePath An absolute path to a project or a project file.
* @return {string} The path to the project.
*/
function getProjectPathFromAbsolutePath( absolutePath ) {
const matches = absolutePath.match(
// Note the special handling for `plugins/woocommerce/client/*` packages.
/((?:plugins\/woocommerce\/client\/[a-z0-9\-_.]+|plugins\/|packages\/[a-z0-9\-_.]+\/|tools\/)[a-z0-9\-_.]+)\/?/i
);
if ( ! matches ) {
return null;
}
return matches[ 1 ];
}
/**
* A record for a project and all of the changes that have occurred to it.
*
* @typedef {Object} ProjectChanges
* @property {string} path The path to the project.
* @property {boolean} phpSourceChanges Whether or not the project has changes to PHP source files.
* @property {boolean} jsSourceChanges Whether or not the project has changes to JS source files.
* @property {boolean} assetSourceChanges Whether or not the project has changed to asset source files.
* @property {boolean} documentationChanges Whether or not the project has documentation changes.
* @property {boolean} phpTestChanges Whether or not the project has changes to PHP test files.
* @property {boolean} jsTestChanges Whether or not the project has changes to JS test files.
* @property {boolean} e2eTestChanges Whether or not the project has changes to e2e test files.
*/
/**
* Scans through the files that have been changed since baseRef and returns information about the projects that have
* changes and the kind of changes that have taken place.
*
* @param {string} baseRef The base branch to check for changes against.
* @return {Array.<ProjectChanges>} An array of projects and the kinds of changes that have occurred.
*/
function detectProjectChanges( baseRef ) {
// Using a diff will not only allow us to find the projects that have changed but we can also identify the nature of the change.
const output = child_process.execSync(
`git diff --relative --name-only ${ baseRef }`,
{ encoding: 'utf8' }
);
const changedFilePaths = output.split( '\n' );
// Scan all of the changed files into the projects they belong to.
const projectsWithChanges = {};
for ( const filePath of changedFilePaths ) {
if ( ! filePath ) {
continue;
}
const projectPath = getProjectPathFromAbsolutePath( filePath );
if ( ! projectPath ) {
console.log(
`${ filePath }: ignoring change because it is not part of a project.`
);
continue;
}
if ( ! projectsWithChanges[ projectPath ] ) {
projectsWithChanges[ projectPath ] = [];
}
projectsWithChanges[ projectPath ].push( filePath );
console.log(
`${ filePath }: marked as a change in project "${ projectPath }".`
);
}
// Scan through the projects that have changes and identify the type of changes that have occurred.
const projectChanges = [];
for ( const projectPath in projectsWithChanges ) {
// We are only interested in projects that are part of our workspace.
if ( ! fs.existsSync( `${ projectPath }/package.json` ) ) {
console.error( `${ projectPath }: no "package.json" file found.` );
continue;
}
// Keep track of the kind of changes that have occurred.
let phpTestChanges = false;
let jsTestChanges = false;
let e2eTestChanges = false;
let phpSourceChanges = false;
let jsSourceChanges = false;
let assetSourceChanges = false;
let documentationChanges = false;
// Now we can look through all of the files that have changed and figure out the type of changes that have occurred.
const fileChanges = projectsWithChanges[ projectPath ];
for ( const filePath of fileChanges ) {
// Some types of changes are not interesting and should be ignored completely.
if ( filePath.match( /\/changelog\//i ) ) {
console.log(
`${ projectPath }: ignoring changelog file "${ filePath }".`
);
continue;
}
// As a preface, the detection of changes here is likely not absolutely perfect. We're going to be making some assumptions
// about file extensions and paths in order to decide whether or not something is a type of change. This should still
// be okay though since we have other cases where we check everything without looking at any changes to filter.
// We can identify PHP test files using PSR-4 or WordPress file naming conventions. We also have
// a fallback to any PHP files in a "tests" directory or its children.
// Note: We need to check for this before we check for source files, otherwise we will
// consider test file changes to be PHP source file changes.
if (
filePath.match( /(?:[a-z]+Test|-test|\/tests?\/[^\.]+)\.php$/i )
) {
phpTestChanges = true;
console.log(
`${ projectPath }: detected PHP test file change in "${ filePath }".`
);
continue;
}
// We can identify JS test files using Jest file file naming conventions. We also have
// a fallback to any JS files in a "tests" directory or its children, but we need to
// avoid picking up E2E test files in the process.
// Note: We need to check for this before we check for source files, otherwise we will
// consider test file changes to be JS source file changes.
if (
filePath.match(
/(?:(?<!e2e[^\.]+)\.(?:spec|test)|\/tests?\/(?!e2e)[^\.]+)\.(?:t|j)sx?$/i
)
) {
jsTestChanges = true;
console.log(
`${ projectPath }: detected JS test file change in "${ filePath }".`
);
continue;
}
// We're going to make an assumption about where E2E test files live based on what seems typical.
if ( filePath.match( /\/test?\/e2e/i ) ) {
e2eTestChanges = true;
console.log(
`${ projectPath }: detected E2E test file change in "${ filePath }".`
);
continue;
}
// Generally speaking, PHP files and changes to Composer dependencies affect PHP source code.
if (
filePath.match( /\.(?:php|html)$|composer\.(?:json|lock)$/i )
) {
phpSourceChanges = true;
console.log(
`${ projectPath }: detected PHP source file change in "${ filePath }".`
);
continue;
}
// JS changes should also include JSX and TS files.
if (
filePath.match( /\.(?:(?:t|j)sx?|json|html)$|package\.json$/i )
) {
jsSourceChanges = true;
console.log(
`${ projectPath }: detected JS source file change in "${ filePath }".`
);
continue;
}
// We should also keep an eye on asset file changes since these may affect
// presentation in different tests that have expectations about this data.
if (
filePath.match(
/\.(?:png|jpg|gif|scss|css|ttf|svg|eot|woff|xml|csv|txt|ya?ml)$/i
)
) {
assetSourceChanges = true;
console.log(
`${ projectPath }: detected asset file change in "${ filePath }".`
);
continue;
}
// We can be a strict with documentation changes because they are only ever going to be markdown files.
if ( filePath.match( /\.md$/i ) ) {
documentationChanges = true;
console.log(
`${ projectPath }: detected documentation change in "${ filePath }".`
);
continue;
}
}
// We only want to track a changed project when we have encountered file changes that we care about.
if (
! phpSourceChanges &&
! jsSourceChanges &&
! assetSourceChanges &&
! documentationChanges &&
! phpTestChanges &&
! jsSourceChanges &&
! e2eTestChanges
) {
console.log( `${ projectPath }: no changes detected.` );
continue;
}
// We can use the information we've collected to generate the project change object.
projectChanges.push( {
path: projectPath,
phpSourceChanges,
jsSourceChanges,
assetSourceChanges,
documentationChanges,
phpTestChanges,
jsTestChanges,
e2eTestChanges,
} );
}
return projectChanges;
}
/**
* Check the changes that occurred in each project and add any projects that are affected by those changes.
*
* @param {Array.<ProjectChanges>} projectChanges The project changes to cascade.
* @return {Array.<ProjectChanges>} The project changes with any cascading changes.
*/
function cascadeProjectChanges( projectChanges ) {
const cascadedChanges = {};
// Scan through all of the changes and add any other projects that are affected by the changes.
for ( const changes of projectChanges ) {
// Populate the change object for the project if it doesn't already exist.
// It might exist if the project has been affected by another project.
if ( ! cascadedChanges[ changes.path ] ) {
cascadedChanges[ changes.path ] = changes;
}
// Make sure that we are recording any "true" changes that have occurred either in the project itself or as a result of another project.
for ( const property in changes ) {
// We're going to assume the only properties on this object are "path" and the change flags.
if ( property === 'path' ) {
continue;
}
cascadedChanges[ changes.path ][ property ] =
changes[ property ] ||
cascadedChanges[ changes.path ][ property ];
}
// Use PNPM to get a list of dependent packages that may have been affected.
// Note: This is actually a pretty slow way of doing this. If we find it is
// taking too long we can instead use `--depth="Infinity" --json` and then
// traverse the dependency tree ourselves.
const output = child_process.execSync(
`pnpm list --filter='...{./${ changes.path }}' --only-projects --depth='-1' --parseable`,
{ encoding: 'utf8' }
);
// The `--parseable` flag returns a list of package directories separated by newlines.
const affectedProjects = output.split( '\n' );
// At the VERY least PNPM will return the path to the project if it exists. The only way
// this will happen is if the project isn't part of the workspace and we can ignore it.
// We expect this to happen and thus haven't use the caret in the filter above.
if ( ! affectedProjects ) {
continue;
}
// Run through and decide whether or not the project has been affected by the changes.
for ( const affected of affectedProjects ) {
const affectedProjectPath =
getProjectPathFromAbsolutePath( affected );
if ( ! affectedProjectPath ) {
continue;
}
// Skip the project we're checking against since it'll be in the results.
if ( affectedProjectPath === changes.path ) {
continue;
}
// Only changes to source files will impact other projects.
if (
! changes.phpSourceChanges &&
! changes.jsSourceChanges &&
! changes.assetSourceChanges
) {
continue;
}
console.log(
`${ changes.path }: cascading source file changes to ${ affectedProjectPath }.`
);
// Populate the change object for the affected project if it doesn't already exist.
if ( ! cascadedChanges[ affectedProjectPath ] ) {
cascadedChanges[ affectedProjectPath ] = {
path: affectedProjectPath,
phpSourceChanges: false,
jsSourceChanges: false,
assetSourceChanges: false,
documentationChanges: false,
phpTestChanges: false,
jsTestChanges: false,
e2eTestChanges: false,
};
}
// Consider the source files to have changed in the affected project because they are dependent on the source files in the changed project.
if ( changes.phpSourceChanges ) {
cascadedChanges[ affectedProjectPath ].phpSourceChanges = true;
}
if ( changes.jsSourceChanges ) {
cascadedChanges[ affectedProjectPath ].jsSourceChanges = true;
}
if ( changes.assetSourceChanges ) {
cascadedChanges[
affectedProjectPath
].assetSourceChanges = true;
}
}
}
return Object.values( cascadedChanges );
}
/**
* The valid commands that we can execute.
*
* @typedef {string} CommandType
* @enum {CommandType}
*/
const COMMAND_TYPE = {
Lint: 'lint',
TestPHP: 'test:php',
TestJS: 'test:js',
E2E: 'e2e',
};
/**
* Checks a command to see whether or not it is valid.
*
* @param {CommandType} command The command to check.
* @return {boolean} Whether or not the command is valid.T
*/
function isValidCommand( command ) {
for ( const commandType in COMMAND_TYPE ) {
if ( COMMAND_TYPE[ commandType ] === command ) {
return true;
}
}
return false;
}
/**
* Indicates whether or not the command is a test command.
*
* @param {CommandType} command The command to check.
* @return {boolean} Whether or not the command is a test command.
*/
function isTestCommand( command ) {
return (
command === COMMAND_TYPE.TestPHP ||
command === COMMAND_TYPE.TestJS ||
command === COMMAND_TYPE.E2E
);
}
/**
* Details about a task that should be run for a project.
*
* @typedef {Object} ProjectTask
* @property {string} name The name of the task.
* @property {Array.<CommandType>} commandsToRun The commands that the project should run.
* @property {Object.<string,string>} customCommands Any commands that should be run in place of the default commands.
* @property {string|null} testEnvCommand The command that should be run to start the test environment if one is needed.
* @property {Object.<string,string>} testEnvConfig Any configuration for the test environment if one is needed.
*/
/**
* Parses the task configuration from the package.json file and returns a task object.
*
* @param {Object} packageFile The package file for the project.
* @param {Object} config The taw task configuration.
* @param {Array.<CommandType>} commandsForChanges The commands that we should run for the project.
* @param {ProjectTask|null} parentTask The task that this task is a child of.
* @return {ProjectTask|null} The parsed task.
*/
function parseTaskConfig(
packageFile,
config,
commandsForChanges,
parentTask
) {
// Child tasks are required to have a name because otherwise
// every task for a project would be named "default".
let taskName = 'default';
if ( parentTask ) {
taskName = config.name;
if ( ! taskName ) {
throw new Error( `${ packageFile.name }: missing name for task.` );
}
}
// When the config object declares a command filter we should remove any
// of the commands it contains from the list of commands to run.
if ( config?.commandFilter ) {
// Check for invalid commands being used since they won't do anything.
for ( const command of config.commandFilter ) {
if ( ! isValidCommand( command ) ) {
throw new Error(
`${ packageFile.name }: invalid command filter type of "${ command }" for task "${ taskName }".`
);
}
}
// Apply the command filter.
commandsForChanges = commandsForChanges.filter( ( command ) =>
config.commandFilter.includes( command )
);
}
// Custom commands developers to support a command without having to use the
// standardized script name for it. For ease of use we will add parent task
// custom commands to children and allow the children to override any
// specific tasks they want.
const customCommands = Object.assign(
{},
parentTask?.customCommands ?? {}
);
if ( config?.customCommands ) {
for ( const customCommandType in config.customCommands ) {
// Check for invalid commands being mapped since they won't do anything.
if ( ! isValidCommand( customCommandType ) ) {
throw new Error(
`${ packageFile.name }: invalid custom command type "${ customCommandType } for task "${ taskName }".`
);
}
// Custom commands may have tokens that we need to remove in order to check them for existence.
const split =
config.customCommands[ customCommandType ].split( ' ' );
const customCommand = split[ 0 ];
if ( ! packageFile.scripts?.[ customCommand ] ) {
throw new Error(
`${ packageFile.name }: unknown custom "${ customCommandType }" command "${ customCommand }" for task "${ taskName }".`
);
}
// We only need to bother with commands we can actually run.
if ( commandsForChanges.includes( customCommandType ) ) {
customCommands[ customCommandType ] =
config.customCommands[ customCommandType ];
}
}
}
// Our goal is to run only the commands that have changes, however, not all
// projects will have scripts for all of the commands we want to run.
const commandsToRun = [];
for ( const command of commandsForChanges ) {
// We have already filtered and confirmed custom commands.
if ( customCommands[ command ] ) {
commandsToRun.push( command );
continue;
}
// Commands that don't have a script to run should be ignored.
if ( ! packageFile.scripts?.[ command ] ) {
continue;
}
commandsToRun.push( command );
}
// We don't want to create a task if there aren't any commands to run.
if ( ! commandsToRun.length ) {
return null;
}
// The test environment command only needs to be set when a test environment is needed.
let testEnvCommand = null;
if ( commandsToRun.some( ( command ) => isTestCommand( command ) ) ) {
if ( config?.testEnvCommand ) {
// Make sure that a developer hasn't put in a test command that doesn't exist.
if ( ! packageFile.scripts?.[ config.testEnvCommand ] ) {
throw new Error(
`${ packageFile.name }: unknown test environment command "${ config.testEnvCommand }" for task "${ taskName }".`
);
}
testEnvCommand =
config?.testEnvCommand ?? parentTask?.testEnvCommand;
} else if ( packageFile.scripts?.[ 'test:env:start' ] ) {
testEnvCommand = 'test:env:start';
}
}
// The test environment configuration should also cascade from parent task to child task.
const testEnvConfig = Object.assign(
{},
parentTask?.testEnvConfig ?? {},
config?.testEnvConfig ?? {}
);
return {
name: taskName,
commandsToRun,
customCommands,
testEnvCommand,
testEnvConfig,
};
}
/**
* Details about a project and the tasks that should be run for it.
*
* @typedef {Object} ProjectTasks
* @property {string} name The name of the project.
* @property {Array.<ProjectTask>} tasks The tasks that should be run for the project.
*/
/**
* Evaluates the given changes against the possible commands and returns those that should run as
* a result of the change criteria being met.
*
* @param {ProjectChanges|null} changes Any changes that have occurred to the project.
* @return {Array.<string>} The commands that can be run for the project.
*/
function getCommandsForChanges( changes ) {
// Here are all of the commands that we support and the change criteria that they require to execute.
// We treat the command's criteria as passing if any of the properties are true.
const commandCriteria = {
[ COMMAND_TYPE.Lint ]: [
'phpSourceChanges',
'jsSourceChanges',
'assetSourceChanges',
'phpTestChanges',
'jsTestChanges',
],
[ COMMAND_TYPE.TestPHP ]: [ 'phpSourceChanges', 'phpTestChanges' ],
[ COMMAND_TYPE.TestJS ]: [ 'jsSourceChanges', 'jsTestChanges' ],
//[ COMMAND_TYPE.E2E ]: [ 'phpSourceChanges', 'jsSourceChanges', 'assetSourceChanges', 'e2eTestFileChanges' ],
};
// We only want the list of possible commands to contain those that
// the project actually has and meet the criteria for execution.
const commandsForChanges = [];
for ( const command in commandCriteria ) {
// The criteria only needs to be checked if there is a change object to evaluate.
if ( changes ) {
let commandCriteriaMet = false;
for ( const criteria of commandCriteria[ command ] ) {
// Confidence check to make sure the criteria wasn't misspelled.
if ( ! changes.hasOwnProperty( criteria ) ) {
throw new Error(
`Invalid criteria "${ criteria }" for command "${ command }".`
);
}
if ( changes[ criteria ] ) {
commandCriteriaMet = true;
break;
}
}
// As long as we meet one of the criteria requirements we can add the command.
if ( ! commandCriteriaMet ) {
continue;
}
console.log( `${ changes.path }: command "${ command }" added based on given changes.` );
}
commandsForChanges.push( command );
}
return commandsForChanges;
}
/**
* Builds a task object for the project with support for limiting the tasks to only those that have changed.
*
* @param {string} projectPath The path to the project.
* @param {ProjectChanges|null} changes Any changes that have occurred to the project.
* @return {ProjectTasks|null} The tasks that should be run for the project.
*/
function buildTasksForProject( projectPath, changes ) {
// There's nothing to do if the project has no tasks.
const commandsForChanges = getCommandsForChanges( changes );
if ( ! commandsForChanges.length ) {
return null;
}
// Load the package file so we can check for task existence before adding them.
const rawPackageFile = fs.readFileSync(
`${ projectPath }/package.json`,
'utf8'
);
const packageFile = JSON.parse( rawPackageFile );
// We're going to parse each of the projects and add them to the list of tasks if necessary.
const projectTasks = [];
// Parse the task configuration from the package.json file.
const parentTask = parseTaskConfig(
packageFile,
packageFile.config?.ci,
commandsForChanges,
null
);
if ( parentTask ) {
projectTasks.push( parentTask );
}
if ( packageFile.config?.ci?.additionalTasks ) {
for ( const additionalTask of packageFile.config.ci.additionalTasks ) {
const task = parseTaskConfig(
packageFile,
additionalTask,
commandsForChanges,
parentTask
);
if ( task ) {
projectTasks.push( task );
}
}
}
if ( ! projectTasks.length ) {
return null;
}
return {
name: packageFile.name,
tasks: projectTasks,
};
}
/**
* This function takes a list of project changes and generates a list of tasks that should be run for each project.
*
* @param {Array.<ProjectChanges>} projectChanges The project changes to generate tasks for.
* @return {Array.<ProjectTasks>} All of the projects and the tasks that they should undertake.
*/
function generateProjectTasksForChanges( projectChanges ) {
const projectTasks = [];
// Scan through all of the changes and generate task objects for them.
for ( const changes of projectChanges ) {
const tasks = buildTasksForProject( changes.path, changes );
if ( tasks ) {
projectTasks.push( tasks );
}
}
return projectTasks;
}
/**
* Generates a list of tasks that should be run for each project in the workspace.
*
* @return {Array.<ProjectTasks>} All of the projects and the tasks that they should undertake.
*/
function generateProjectTasksForWorkspace() {
// We can use PNPM to quickly get a list of every project in the workspace.
const output = child_process.execSync(
"pnpm list --filter='*' --only-projects --depth='-1' --parseable",
{ encoding: 'utf8' }
);
// The `--parseable` flag returns a list of package directories separated by newlines.
const workspaceProjects = output.split( '\n' );
const projectTasks = [];
for ( const project of workspaceProjects ) {
const projectPath = getProjectPathFromAbsolutePath( project );
if ( ! projectPath ) {
continue;
}
const tasks = buildTasksForProject( projectPath, null );
if ( tasks ) {
projectTasks.push( tasks );
}
}
return projectTasks;
}
/**
* A CI matrix for the GitHub workflow.
*
* @typedef {Object} CIMatrix
* @property {string} projectName The name of the project.
* @property {string} taskName The name of the task.
* @property {Object.<string,string>} testEnvVars The environment variables for the test environment.
* @property {string|null} lintCommand The command to run if linting is necessary.
* @property {string|null} phpTestCommand The command to run if PHP tests are necessary.
* @property {string|null} jsTestCommand The command to run if JS tests are necessary.
* @property {string|null} e2eCommand The command to run if E2E is necessary.
*/
/**
* Parses the test environment's configuration and returns any environment variables that
* should be set.
*
* @param {Object} testEnvConfig The test environment configuration.
* @return {Promise.<Object>} The environment variables for the test environment.
*/
async function parseTestEnvConfig( testEnvConfig ) {
const envVars = {};
// Convert `wp-env` configuration options to environment variables.
if ( testEnvConfig.wpVersion ) {
try {
envVars.WP_ENV_CORE = await parseWPVersion(
testEnvConfig.wpVersion
);
} catch ( error ) {
throw new Error(
`Failed to parse WP version: ${ error.message }.`
);
}
}
if ( testEnvConfig.phpVersion ) {
envVars.WP_ENV_PHP_VERSION = testEnvConfig.phpVersion;
}
return envVars;
}
/**
* Generates a command for the task that can be executed in the CI matrix. This will check the task
* for the command, apply any command override, and replace any valid tokens with their values.
*
* @param {ProjectTask} task The task to get the command for.
* @param {CommandType} command The command to run.
* @param {Object.<string,string>} tokenValues Any tokens that should be replaced and their associated values.
* @return {string|null} The command that should be run for the task or null if the command should not be run.
*/
function getCommandForMatrix( task, command, tokenValues ) {
if ( ! task.commandsToRun.includes( command ) ) {
return null;
}
// Support overriding the default command with a custom one.
command = task.customCommands[ command ] ?? command;
// Replace any of the tokens that are used in commands with their values if one exists.
let matrixCommand = command;
const matches = command.matchAll( /\${([a-z0-9_\-]+)}/gi );
if ( matches ) {
for ( const match of matches ) {
if ( ! tokenValues.hasOwnProperty( match[ 1 ] ) ) {
throw new Error(
`Command "${ command }" contains unknown token "${ match[ 1 ] }".`
);
}
matrixCommand = matrixCommand.replace(
match[ 0 ],
tokenValues[ match[ 1 ] ]
);
}
}
return matrixCommand;
}
/**
* Generates a matrix for the CI GitHub Workflow.
*
* @param {string} baseRef The base branch to check for changes against. If empty we check for everything.
* @return {Promise.<Array.<CIMatrix>>} The CI matrix to be used in the CI workflows.
*/
async function buildCIMatrix( baseRef ) {
const matrix = [];
// Build the project tasks based on the branch we are comparing against.
let projectTasks = [];
if ( baseRef ) {
const projectChanges = detectProjectChanges( baseRef );
const cascadedProjectChanges = cascadeProjectChanges( projectChanges );
projectTasks = generateProjectTasksForChanges( cascadedProjectChanges );
} else {
projectTasks = generateProjectTasksForWorkspace();
}
// Prepare the tokens that are able to be replaced in commands.
const commandTokens = {
baseRef: baseRef ?? '',
};
// Parse the tasks and generate matrix entries for each of them.
for ( const project of projectTasks ) {
for ( const task of project.tasks ) {
matrix.push( {
projectName: project.name,
taskName: task.name,
testEnvCommand: task.testEnvCommand,
testEnvVars: await parseTestEnvConfig( task.testEnvConfig ),
lintCommand: getCommandForMatrix(
task,
COMMAND_TYPE.Lint,
commandTokens
),
phpTestCommand: getCommandForMatrix(
task,
COMMAND_TYPE.TestPHP,
commandTokens
),
jsTestCommand: getCommandForMatrix(
task,
COMMAND_TYPE.TestJS,
commandTokens
),
e2eCommand: getCommandForMatrix(
task,
COMMAND_TYPE.E2E,
commandTokens
),
} );
}
}
return matrix;
}
module.exports = buildCIMatrix;

View File

@ -17,9 +17,6 @@
"bugs": {
"url": "https://github.com/woocommerce/woocommerce/issues"
},
"bin": {
"utils": "./tools/monorepo-utils/bin/run"
},
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
@ -31,13 +28,14 @@
"git:update-hooks": "if test -d .git; then rm -rf .git/hooks && mkdir -p .git/hooks && husky install; else husky install; fi",
"create-extension": "node ./tools/create-extension/index.js",
"sync-dependencies": "pnpm exec syncpack -- fix-mismatches",
"utils": "node ./tools/monorepo-utils/dist/index.js"
"utils": "./tools/monorepo-utils/bin/run"
},
"devDependencies": {
"@babel/preset-env": "^7.23.5",
"@babel/runtime": "^7.23.5",
"@types/node": "^16.18.68",
"@woocommerce/eslint-plugin": "workspace:*",
"@woocommerce/monorepo-utils": "workspace:*",
"@wordpress/data": "wp-6.0",
"@wordpress/eslint-plugin": "14.7.0",
"@wordpress/prettier-config": "2.17.0",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -75,6 +75,14 @@
"publishConfig": {
"access": "public"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
}
}
},
"wireit": {
"build:project:typescript": {
"command": "tsc --project tsconfig.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -83,6 +83,14 @@
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
}
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -105,6 +105,22 @@
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": "src/**/*.{js,jsx,ts,tsx}",
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",

View File

@ -49,5 +49,18 @@
"*.(t|j)s?(x)": [
"eslint --fix"
]
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": [
"data/**/*.{js,ts,tsx}",
"endpoints/**/*.{js,ts,tsx}",
"tests/**/*.{js,ts,tsx}",
"utils/**/*.{js,ts,tsx}"
]
}
}
}
}

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -70,6 +70,27 @@
"publishConfig": {
"access": "public"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:typescript": {
"command": "tsc --project tsconfig.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -96,6 +96,27 @@
"publishConfig": {
"access": "public"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -179,12 +179,36 @@
"webpack-cli": "^3.3.12",
"wireit": "0.14.1"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"webpack.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",
"clean": "if-file-deleted",
"files": [
"webpack.config.js",
"babel.config.js",
"src/**/*.scss"
],
"output": [

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -75,6 +75,28 @@
"publishConfig": {
"access": "public"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -78,6 +78,28 @@
"typescript": "^5.3.3",
"wireit": "0.14.1"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -104,6 +104,28 @@
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -107,6 +107,28 @@
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -85,6 +85,28 @@
"pnpm test-staged"
]
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -54,5 +54,13 @@
"*.(t|j)s?(x)": [
"eslint --fix"
]
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.js"
}
}
}
}

View File

@ -64,5 +64,13 @@
},
"publishConfig": {
"access": "public"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,ts,tsx}"
}
}
}
}

View File

@ -88,5 +88,13 @@
},
"publishConfig": {
"access": "public"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,ts,tsx}"
}
}
}
}

View File

@ -59,5 +59,13 @@
},
"publishConfig": {
"access": "public"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,ts,tsx}"
}
}
}
}

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -61,5 +61,16 @@
"*.(t|j)s?(x)": [
"pnpm lint:fix"
]
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": [
"rules/**/*.js",
"configs/**/*.js"
]
}
}
}
}

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -114,6 +114,28 @@
"pnpm test-staged"
]
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -82,6 +82,28 @@
"publishConfig": {
"access": "public"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -74,6 +74,14 @@
"pnpm test-staged"
]
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
}
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -79,6 +79,28 @@
"webpack-cli": "^3.3.12",
"wireit": "0.14.1"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -44,5 +44,13 @@
"*.(t|j)s?(x)": [
"pnpm lint:fix"
]
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "build.js"
}
}
}
}

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -67,6 +67,14 @@
"pnpm lint:fix"
]
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
}
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -59,5 +59,13 @@
"typescript": "^5.3.3",
"webpack": "^5.89.0",
"wireit": "0.14.1"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "index.js"
}
}
}
}

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -88,6 +88,28 @@
"typescript": "^5.3.3",
"wireit": "0.14.1"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -83,6 +83,14 @@
"typescript": "^5.3.3",
"wireit": "0.14.1"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
}
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -74,6 +74,28 @@
"pnpm test-staged"
]
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -100,6 +100,28 @@
"publishConfig": {
"access": "public"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -153,6 +153,29 @@
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"webpack.config.js",
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -70,6 +70,14 @@
"pnpm lint:fix"
]
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
}
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",

View File

@ -237,6 +237,28 @@
"node": "^16.14.1",
"pnpm": "^8.12.1"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "client/**/*{js,ts,tsx,scss}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"webpack.config.js",
"babel.config.js",
"tsconfig.json",
"client/**/*.{js,jsx,ts,tsx,scss}"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",
@ -248,8 +270,7 @@
"files": [
"webpack.config.js",
"tsconfig.json",
"client/**/*.{js,jsx,ts,tsx}",
"client/**/*.scss"
"client/**/*.{js,jsx,ts,tsx,scss}"
],
"output": [
"build"

View File

@ -350,6 +350,28 @@
"build",
"blocks.ini"
],
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "assets/**/*{js,ts,tsx,scss}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"webpack.config.js",
"babel.config.js",
"tsconfig.json",
"assets/**/*{js,ts,tsx,scss}"
],
"cascade": "test:js"
}
]
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Comment: This is a CI-only change.

View File

@ -12,8 +12,6 @@
"build": "WIREIT_LOGGER='quiet-ci' pnpm --if-present --workspace-concurrency=Infinity --stream --filter=\"$npm_package_name...\" build:project",
"build:project": "pnpm --if-present /^build:project:.*$/",
"build:project:assets": "wireit",
"lint": "pnpm --if-present '/^lint:lang:.*$/'",
"lint:fix": "pnpm --if-present '/^lint:fix:lang:.*$/'",
"watch:build": "WIREIT_LOGGER='quiet-ci' pnpm --if-present --workspace-concurrency=Infinity --filter=\"$npm_package_name...\" --parallel watch:build:project",
"watch:build:project": "pnpm build:project --watch"
},

View File

@ -70,50 +70,109 @@
"wp_org_slug": "woocommerce",
"build_step": "pnpm build:zip",
"ci": {
"name": "WP: latest",
"customCommands": {
"lint": "lint:php:changes:branch ${baseRef}",
"test:php": "test:php:env"
"lint": {
"command": "lint:php:changes:branch <baseRef>",
"changes": [
"composer.lock",
"includes/**/*.php",
"patterns/**/*.php",
"src/**/*.php",
"templates/**/*.php",
"tests/php/**/*.php",
"tests/unit-tests/**/*.php"
]
},
"testEnvCommand": "env:test",
"testEnvConfig": {
"wpVersion": "latest"
},
"additionalTasks": [
"tests": [
{
"name": "PHP",
"command": "test:php:env",
"changes": [
"composer.lock",
"includes/**/*.php",
"patterns/**/*.php",
"src/**/*.php",
"templates/**/*.php",
"tests/php/**/*.php",
"tests/unit-tests/**/*.php"
],
"testEnv": {
"start": "env:test"
}
},
{
"name": "PHP 8.0",
"commandFilter": [
"test:php"
"command": "test:php:env",
"changes": [
"composer.lock",
"includes/**/*.php",
"patterns/**/*.php",
"src/**/*.php",
"templates/**/*.php",
"tests/php/**/*.php",
"tests/unit-tests/**/*.php"
],
"testEnvConfig": {
"phpVersion": "8.0"
"testEnv": {
"start": "env:test",
"config": {
"phpVersion": "8.0"
}
}
},
{
"name": "WP: nightly",
"commandFilter": [
"test:php"
"name": "PHP WP: nightly",
"command": "test:php:env",
"changes": [
"composer.lock",
"includes/**/*.php",
"patterns/**/*.php",
"src/**/*.php",
"templates/**/*.php",
"tests/php/**/*.php",
"tests/unit-tests/**/*.php"
],
"testEnvConfig": {
"wpVersion": "nightly"
"testEnv": {
"start": "env:test",
"config": {
"wpVersion": "nightly"
}
}
},
{
"name": "WP: latest-1",
"commandFilter": [
"test:php"
"name": "PHP WP: latest - 1",
"command": "test:php:env",
"changes": [
"composer.lock",
"includes/**/*.php",
"patterns/**/*.php",
"src/**/*.php",
"templates/**/*.php",
"tests/php/**/*.php",
"tests/unit-tests/**/*.php"
],
"testEnvConfig": {
"wpVersion": "latest-1"
"testEnv": {
"start": "env:test",
"config": {
"wpVersion": "latest-1"
}
}
},
{
"name": "WP: latest-2",
"commandFilter": [
"test:php"
"name": "PHP WP: latest - 2",
"command": "test:php:env",
"changes": [
"composer.lock",
"includes/**/*.php",
"patterns/**/*.php",
"src/**/*.php",
"templates/**/*.php",
"tests/php/**/*.php",
"tests/unit-tests/**/*.php"
],
"testEnvConfig": {
"wpVersion": "latest-2"
"testEnv": {
"start": "env:test",
"config": {
"wpVersion": "latest-2"
}
}
}
]
@ -222,6 +281,7 @@
"node_modules/@woocommerce/e2e-core-tests/CHANGELOG.md",
"node_modules/@woocommerce/api/dist/",
"node_modules/@woocommerce/admin-e2e-tests/build",
"node_modules/@woocommerce/classic-assets/build",
"node_modules/@woocommerce/block-library/build",
"node_modules/@woocommerce/block-library/blocks.ini",
"node_modules/@woocommerce/admin-library/build",

View File

@ -41,6 +41,9 @@ importers:
'@woocommerce/eslint-plugin':
specifier: workspace:*
version: link:packages/js/eslint-plugin
'@woocommerce/monorepo-utils':
specifier: workspace:*
version: link:tools/monorepo-utils
'@wordpress/data':
specifier: wp-6.0
version: 6.6.1(react@17.0.2)
@ -4785,6 +4788,9 @@ importers:
luxon:
specifier: ^3.4.4
version: 3.4.4
minimatch:
specifier: ^9.0.3
version: 9.0.3
octokit:
specifier: ^2.1.0
version: 2.1.0
@ -4813,18 +4819,33 @@ importers:
'@woocommerce/eslint-plugin':
specifier: workspace:*
version: link:../../packages/js/eslint-plugin
copy-webpack-plugin:
specifier: ^9.1.0
version: 9.1.0(webpack@5.89.0)
eslint:
specifier: ^8.55.0
version: 8.55.0
jest:
specifier: ~27.5.1
version: 27.5.1(ts-node@10.9.2)
replace:
specifier: ^1.2.2
version: 1.2.2
ts-jest:
specifier: ~29.1.1
version: 29.1.1(@babel/core@7.23.6)(jest@27.5.1)(typescript@5.3.3)
ts-loader:
specifier: ^9.5.1
version: 9.5.1(typescript@5.3.3)(webpack@5.89.0)
typescript:
specifier: ^5.3.3
version: 5.3.3
webpack:
specifier: ^5.89.0
version: 5.89.0(webpack-cli@3.3.12)
webpack-cli:
specifier: ^3.3.12
version: 3.3.12(webpack@5.89.0)
wireit:
specifier: 0.14.1
version: 0.14.1
@ -5762,6 +5783,7 @@ packages:
'@babel/helper-skip-transparent-expression-wrappers': 7.22.5
'@babel/helper-split-export-declaration': 7.22.6
semver: 6.3.1
dev: true
/@babel/helper-create-class-features-plugin@7.23.6(@babel/core@7.23.6):
resolution: {integrity: sha512-cBXU1vZni/CpGF29iTu4YRbOZt3Wat6zCoMDxRF1MayiEc4URxOj31tT65HUM0CRpMowA3HCJaAOVOUnMf96cw==}
@ -8576,7 +8598,7 @@ packages:
dependencies:
'@babel/core': 7.12.9
'@babel/helper-annotate-as-pure': 7.22.5
'@babel/helper-create-class-features-plugin': 7.23.6(@babel/core@7.12.9)
'@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.12.9)
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.12.9)
@ -8588,7 +8610,7 @@ packages:
dependencies:
'@babel/core': 7.23.5
'@babel/helper-annotate-as-pure': 7.22.5
'@babel/helper-create-class-features-plugin': 7.23.6(@babel/core@7.23.5)
'@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.23.5)
'@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.23.5)

View File

@ -40,5 +40,24 @@
"engines": {
"node": "^16.14.1",
"pnpm": "^8.12.1"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.ts"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"tsconfig.json",
"src/**/*.ts"
]
}
]
}
}
}

View File

@ -65,5 +65,13 @@
"node": "^16.14.1",
"pnpm": "^8.12.1"
},
"types": "dist/index.d.ts"
"types": "dist/index.d.ts",
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.ts"
}
}
}
}

4
tools/monorepo-utils/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# We are going to include the bundled version of the tool so that we don't need
# to build the tool in order to use it. This saves a lot of time in CI since
# we don't need to install any dependencies.
!dist

13
tools/monorepo-utils/bin/run Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env node
const fs = require( 'node:fs' );
const path = require( 'node:path' );
// We need to make sure that the tool has been built before we can use it.
const runFile = path.join( __dirname, '..', 'dist', 'index.js' );
if ( ! fs.existsSync( runFile ) ) {
console.error( 'The "monorepo-utils" tool has not been built.' );
process.exit( 1 );
}
// Execute the tool now that we've confirmed it exists.
require( '../dist/index.js' );

View File

@ -0,0 +1,3 @@
@echo off
node "%~dp0\run" %*

2
tools/monorepo-utils/dist/index.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,42 @@
/*!
* is-extendable <https://github.com/jonschlinkert/is-extendable>
*
* Copyright (c) 2015, Jon Schlinkert.
* Licensed under the MIT License.
*/
/*!
* is-plain-object <https://github.com/jonschlinkert/is-plain-object>
*
* Copyright (c) 2014-2017, Jon Schlinkert.
* Released under the MIT License.
*/
/*!
* mime-db
* Copyright(c) 2014 Jonathan Ong
* Copyright(c) 2015-2022 Douglas Christopher Wilson
* MIT Licensed
*/
/*!
* mime-types
* Copyright(c) 2014 Jonathan Ong
* Copyright(c) 2015 Douglas Christopher Wilson
* MIT Licensed
*/
/*!
* strip-bom-string <https://github.com/jonschlinkert/strip-bom-string>
*
* Copyright (c) 2015, 2017, Jon Schlinkert.
* Released under the MIT License.
*/
/*! formdata-polyfill. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
/*! fromentries. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! ws. MIT License. Einar Otto Stangvik <einaros@gmail.com> */

File diff suppressed because it is too large Load Diff

View File

@ -6,13 +6,20 @@
"homepage": "https://github.com/woocommerce/woocommerce",
"license": "GPLv2",
"repository": "woocommerce/woocommerce",
"private": true,
"bin": {
"monorepo-utils": "./bin/run"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"bin",
"dist",
"fonts"
],
"scripts": {
"build": "tsc",
"postinstall": "pnpm build",
"build": "webpack --config webpack.config.js && replace '\r\n' '\n' fonts/Standard.flf",
"lint": "eslint . --ext .ts",
"start": "tsc --watch",
"start": "webpack --config webpack.config.js --watch",
"test": "pnpm test:js",
"test:js": "jest"
},
@ -35,6 +42,7 @@
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"luxon": "^3.4.4",
"minimatch": "^9.0.3",
"octokit": "^2.1.0",
"ora": "^5.4.1",
"promptly": "^3.2.0",
@ -46,14 +54,38 @@
"@types/jest": "^27.5.2",
"@types/node": "^16.18.68",
"@woocommerce/eslint-plugin": "workspace:*",
"copy-webpack-plugin": "^9.1.0",
"eslint": "^8.55.0",
"jest": "~27.5.1",
"replace": "^1.2.2",
"ts-jest": "~29.1.1",
"ts-loader": "^9.5.1",
"typescript": "^5.3.3",
"webpack": "^5.89.0",
"webpack-cli": "^3.3.12",
"wireit": "0.14.1"
},
"engines": {
"node": "^16.14.1",
"pnpm": "^8.12.1"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.ts"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"tsconfig.json",
"src/**/*.ts"
]
}
]
}
}
}

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import { Command } from '@commander-js/extra-typings';
import { setOutput } from '@actions/core';
/**
* Internal dependencies
@ -10,6 +11,7 @@ import { Logger } from '../core/logger';
import { buildProjectGraph } from './lib/project-graph';
import { getFileChanges } from './lib/file-changes';
import { createJobsForChanges } from './lib/job-processing';
import { isGithubCI } from '../core/environment';
const program = new Command( 'ci-jobs' )
.description(
@ -20,10 +22,45 @@ const program = new Command( 'ci-jobs' )
'Base ref to compare the current ref against for change detection.'
)
.action( async ( baseRef: string ) => {
Logger.startTask( 'Parsing Project Graph', true );
const projectGraph = buildProjectGraph();
Logger.endTask( true );
Logger.startTask( 'Pulling File Changes', true );
const fileChanges = getFileChanges( projectGraph, baseRef );
const jobs = createJobsForChanges( projectGraph, fileChanges );
Logger.notice( JSON.stringify( jobs, null, '\\t' ) );
Logger.endTask( true );
Logger.startTask( 'Creating Jobs', true );
const jobs = await createJobsForChanges( projectGraph, fileChanges, {
commandVars: {
baseRef,
},
} );
Logger.endTask( true );
if ( isGithubCI() ) {
setOutput( 'lint-jobs', JSON.stringify( jobs.lint ) );
setOutput( 'test-jobs', JSON.stringify( jobs.test ) );
return;
}
if ( jobs.lint.length > 0 ) {
Logger.notice( 'Lint Jobs' );
for ( const job of jobs.lint ) {
Logger.notice( `- ${ job.projectName } - ${ job.command }` );
}
} else {
Logger.notice( 'No lint jobs to run.' );
}
if ( jobs.test.length > 0 ) {
Logger.notice( 'Test Jobs' );
for ( const job of jobs.test ) {
Logger.notice( `- ${ job.projectName } - ${ job.name }` );
}
} else {
Logger.notice( 'No test jobs to run.' );
}
} );
export default program;

View File

@ -1,3 +1,8 @@
/**
* External dependencies
*/
import { makeRe } from 'minimatch';
/**
* Internal dependencies
*/
@ -17,7 +22,7 @@ describe( 'Config', () => {
config: {
ci: {
lint: {
changes: '/src\\/.*\\.[jt]sx?$/',
changes: '/src/**/*.{js,jsx,ts,tsx}',
command: 'foo',
},
},
@ -28,13 +33,52 @@ describe( 'Config', () => {
jobs: [
{
type: JobType.Lint,
changes: [ new RegExp( '/src\\/.*\\.[jt]sx?$/' ) ],
changes: [ makeRe( '/src/**/*.{js,jsx,ts,tsx}' ) ],
command: 'foo',
},
],
} );
} );
it( 'should validate lint command vars', () => {
const parsed = parseCIConfig( {
name: 'foo',
config: {
ci: {
lint: {
changes: '/src/**/*.{js,jsx,ts,tsx}',
command: 'foo <baseRef>',
},
},
},
} );
expect( parsed ).toMatchObject( {
jobs: [
{
type: JobType.Lint,
changes: [ makeRe( '/src/**/*.{js,jsx,ts,tsx}' ) ],
command: 'foo <baseRef>',
},
],
} );
const expectation = () => {
parseCIConfig( {
name: 'foo',
config: {
ci: {
lint: {
changes: '/src/**/*.{js,jsx,ts,tsx}',
command: 'foo <invalid>',
},
},
},
} );
};
expect( expectation ).toThrow();
} );
it( 'should parse lint config with changes array', () => {
const parsed = parseCIConfig( {
name: 'foo',
@ -42,8 +86,8 @@ describe( 'Config', () => {
ci: {
lint: {
changes: [
'/src\\/.*\\.[jt]sx?$/',
'/test\\/.*\\.[jt]sx?$/',
'/src/**/*.{js,jsx,ts,tsx}',
'/test/**/*.{js,jsx,ts,tsx}',
],
command: 'foo',
},
@ -56,8 +100,8 @@ describe( 'Config', () => {
{
type: JobType.Lint,
changes: [
new RegExp( '/src\\/.*\\.[jt]sx?$/' ),
new RegExp( '/test\\/.*\\.[jt]sx?$/' ),
makeRe( '/src/**/*.{js,jsx,ts,tsx}' ),
makeRe( '/test/**/*.{js,jsx,ts,tsx}' ),
],
command: 'foo',
},
@ -73,7 +117,7 @@ describe( 'Config', () => {
tests: [
{
name: 'default',
changes: '/src\\/.*\\.[jt]sx?$/',
changes: '/src/**/*.{js,jsx,ts,tsx}',
command: 'foo',
},
],
@ -86,7 +130,7 @@ describe( 'Config', () => {
{
type: JobType.Test,
name: 'default',
changes: [ new RegExp( '/src\\/.*\\.[jt]sx?$/' ) ],
changes: [ makeRe( '/src/**/*.{js,jsx,ts,tsx}' ) ],
command: 'foo',
},
],
@ -101,7 +145,7 @@ describe( 'Config', () => {
tests: [
{
name: 'default',
changes: '/src\\/.*\\.[jt]sx?$/',
changes: '/src/**/*.{js,jsx,ts,tsx}',
command: 'foo',
testEnv: {
start: 'bar',
@ -120,7 +164,7 @@ describe( 'Config', () => {
{
type: JobType.Test,
name: 'default',
changes: [ new RegExp( '/src\\/.*\\.[jt]sx?$/' ) ],
changes: [ makeRe( '/src/**/*.{js,jsx,ts,tsx}' ) ],
command: 'foo',
testEnv: {
start: 'bar',
@ -141,7 +185,7 @@ describe( 'Config', () => {
tests: [
{
name: 'default',
changes: '/src\\/.*\\.[jt]sx?$/',
changes: '/src/**/*.{js,jsx,ts,tsx}',
command: 'foo',
cascade: 'bar',
},
@ -155,7 +199,7 @@ describe( 'Config', () => {
{
type: JobType.Test,
name: 'default',
changes: [ new RegExp( '/src\\/.*\\.[jt]sx?$/' ) ],
changes: [ makeRe( '/src/**/*.{js,jsx,ts,tsx}' ) ],
command: 'foo',
cascadeKeys: [ 'bar' ],
},

View File

@ -16,6 +16,7 @@ describe( 'Job Processing', () => {
path: 'test',
dependencies: [],
},
{},
{}
);
@ -41,7 +42,8 @@ describe( 'Job Processing', () => {
},
{
test: [ 'test.js' ],
}
},
{}
);
expect( jobs.lint ).toHaveLength( 1 );
@ -52,6 +54,92 @@ describe( 'Job Processing', () => {
expect( jobs.test ).toHaveLength( 0 );
} );
it( 'should replace vars in lint command', async () => {
const jobs = await createJobsForChanges(
{
name: 'test',
path: 'test',
ciConfig: {
jobs: [
{
type: JobType.Lint,
changes: [ /test.js$/ ],
command: 'test-lint <baseRef>',
},
],
},
dependencies: [],
},
{
test: [ 'test.js' ],
},
{
commandVars: {
baseRef: 'test-base-ref',
},
}
);
expect( jobs.lint ).toHaveLength( 1 );
expect( jobs.lint ).toContainEqual( {
projectName: 'test',
command: 'test-lint test-base-ref',
} );
expect( jobs.test ).toHaveLength( 0 );
} );
it( 'should throw when invalid var to replace in lint command', () => {
const promise = createJobsForChanges(
{
name: 'test',
path: 'test',
ciConfig: {
jobs: [
{
type: JobType.Lint,
changes: [ /test.js$/ ],
command: 'test-lint <invalid>',
},
],
},
dependencies: [],
},
{
test: [ 'test.js' ],
},
{}
);
expect( promise ).rejects.toThrow();
} );
it( 'should not trigger a lint job that has already been created', async () => {
const jobs = await createJobsForChanges(
{
name: 'test',
path: 'test',
ciConfig: {
jobs: [
{
type: JobType.Lint,
changes: [ /test.js$/ ],
command: 'test-lint',
jobCreated: true,
},
],
},
dependencies: [],
},
{
test: [ 'test.js' ],
},
{}
);
expect( jobs.lint ).toHaveLength( 0 );
expect( jobs.test ).toHaveLength( 0 );
} );
it( 'should not trigger lint job for single node with no changes', async () => {
const jobs = await createJobsForChanges(
{
@ -68,6 +156,7 @@ describe( 'Job Processing', () => {
},
dependencies: [],
},
{},
{}
);
@ -124,7 +213,8 @@ describe( 'Job Processing', () => {
test: [ 'test.js' ],
'test-a': [ 'test-ignored.js' ],
'test-b': [ 'test-b.js' ],
}
},
{}
);
expect( jobs.lint ).toHaveLength( 2 );
@ -179,7 +269,8 @@ describe( 'Job Processing', () => {
test: [ 'test.js' ],
'test-a': [ 'test-a.js' ],
'test-b': [ 'test-b.js' ],
}
},
{}
);
expect( jobs.lint ).toHaveLength( 2 );
@ -213,7 +304,8 @@ describe( 'Job Processing', () => {
},
{
test: [ 'test.js' ],
}
},
{}
);
expect( jobs.lint ).toHaveLength( 0 );
@ -222,9 +314,81 @@ describe( 'Job Processing', () => {
projectName: 'test',
name: 'Default',
command: 'test-cmd',
testEnv: {
shouldCreate: false,
envVars: {},
},
} );
} );
it( 'should replace vars in test command', async () => {
const jobs = await createJobsForChanges(
{
name: 'test',
path: 'test',
ciConfig: {
jobs: [
{
type: JobType.Test,
name: 'Default',
changes: [ /test.js$/ ],
command: 'test-cmd <baseRef>',
},
],
},
dependencies: [],
},
{
test: [ 'test.js' ],
},
{
commandVars: {
baseRef: 'test-base-ref',
},
}
);
expect( jobs.lint ).toHaveLength( 0 );
expect( jobs.test ).toHaveLength( 1 );
expect( jobs.test ).toContainEqual( {
projectName: 'test',
name: 'Default',
command: 'test-cmd test-base-ref',
testEnv: {
shouldCreate: false,
envVars: {},
},
} );
} );
it( 'should not trigger a test job that has already been created', async () => {
const jobs = await createJobsForChanges(
{
name: 'test',
path: 'test',
ciConfig: {
jobs: [
{
type: JobType.Test,
name: 'Default',
changes: [ /test.js$/ ],
command: 'test-cmd',
jobCreated: true,
},
],
},
dependencies: [],
},
{
test: [ 'test.js' ],
},
{}
);
expect( jobs.lint ).toHaveLength( 0 );
expect( jobs.test ).toHaveLength( 0 );
} );
it( 'should not trigger test job for single node with no changes', async () => {
const jobs = await createJobsForChanges(
{
@ -242,6 +406,7 @@ describe( 'Job Processing', () => {
},
dependencies: [],
},
{},
{}
);
@ -301,7 +466,8 @@ describe( 'Job Processing', () => {
test: [ 'test.js' ],
'test-a': [ 'test-ignored.js' ],
'test-b': [ 'test-b.js' ],
}
},
{}
);
expect( jobs.lint ).toHaveLength( 0 );
@ -310,11 +476,19 @@ describe( 'Job Processing', () => {
projectName: 'test',
name: 'Default',
command: 'test-cmd',
testEnv: {
shouldCreate: false,
envVars: {},
},
} );
expect( jobs.test ).toContainEqual( {
projectName: 'test-b',
name: 'Default B',
command: 'test-cmd-b',
testEnv: {
shouldCreate: false,
envVars: {},
},
} );
} );
@ -355,7 +529,8 @@ describe( 'Job Processing', () => {
},
{
'test-a': [ 'test-a.js' ],
}
},
{}
);
expect( jobs.lint ).toHaveLength( 0 );
@ -364,11 +539,19 @@ describe( 'Job Processing', () => {
projectName: 'test',
name: 'Default',
command: 'test-cmd',
testEnv: {
shouldCreate: false,
envVars: {},
},
} );
expect( jobs.test ).toContainEqual( {
projectName: 'test-a',
name: 'Default A',
command: 'test-cmd-a',
testEnv: {
shouldCreate: false,
envVars: {},
},
} );
} );
@ -425,7 +608,8 @@ describe( 'Job Processing', () => {
},
{
'test-a': [ 'test-a.js' ],
}
},
{}
);
expect( jobs.lint ).toHaveLength( 0 );
@ -434,11 +618,19 @@ describe( 'Job Processing', () => {
projectName: 'test',
name: 'Default',
command: 'test-cmd',
testEnv: {
shouldCreate: false,
envVars: {},
},
} );
expect( jobs.test ).toContainEqual( {
projectName: 'test-a',
name: 'Default A',
command: 'test-cmd-a',
testEnv: {
shouldCreate: false,
envVars: {},
},
} );
} );
@ -459,7 +651,7 @@ describe( 'Job Processing', () => {
changes: [ /test.js$/ ],
command: 'test-cmd',
testEnv: {
start: 'test-start',
start: 'test-start <baseRef>',
config: {
wpVersion: 'latest',
},
@ -471,6 +663,11 @@ describe( 'Job Processing', () => {
},
{
test: [ 'test.js' ],
},
{
commandVars: {
baseRef: 'test-base-ref',
},
}
);
@ -481,7 +678,8 @@ describe( 'Job Processing', () => {
name: 'Default',
command: 'test-cmd',
testEnv: {
start: 'test-start',
shouldCreate: true,
start: 'test-start test-base-ref',
envVars: {
WP_ENV_CORE: 'https://wordpress.org/latest.zip',
},

View File

@ -7,12 +7,10 @@ import fs from 'node:fs';
/**
* Internal dependencies
*/
import { parseCIConfig } from '../config';
import { loadPackage } from '../package-file';
import { buildProjectGraph } from '../project-graph';
jest.mock( 'node:child_process' );
jest.mock( '../config' );
jest.mock( '../package-file' );
describe( 'Project Graph', () => {
@ -33,51 +31,68 @@ describe( 'Project Graph', () => {
} );
jest.mocked( loadPackage ).mockImplementation( ( path ) => {
if ( ! path.endsWith( 'package.json' ) ) {
throw new Error( 'Invalid path' );
const matches = path.match( /project-([abcd])\/package.json$/ );
if ( ! matches ) {
throw new Error( `Invalid project path: ${ path }.` );
}
const matches = path.match( /\/([^/]+)\/package.json$/ );
const packageFile = JSON.parse(
fs.readFileSync( __dirname + '/test-package.json', {
encoding: 'utf8',
} )
);
return {
name: matches[ 1 ],
};
packageFile.name = 'project-' + matches[ 1 ];
switch ( matches[ 1 ] ) {
case 'a':
packageFile.dependencies = {
'project-b': 'workspace:*',
};
packageFile.devDependencies = {
'project-c': 'workspace:*',
};
break;
case 'b':
packageFile.dependencies = {
'project-c': 'workspace:*',
};
break;
case 'd':
packageFile.devDependencies = {
'project-c': 'workspace:*',
};
break;
}
return packageFile;
} );
jest.mocked( parseCIConfig ).mockImplementation(
( packageFile ) => {
expect( packageFile ).toMatchObject( {
name: expect.stringMatching( /project-[abcd]/ ),
} );
return { jobs: [] };
}
);
const graph = buildProjectGraph();
expect( loadPackage ).toHaveBeenCalled();
expect( parseCIConfig ).toHaveBeenCalled();
expect( graph ).toMatchObject( {
name: 'project-a',
path: 'project-a',
ciConfig: {
jobs: [],
jobs: [
{
command: 'foo',
type: 'lint',
changes: [
/^(?:src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.js|src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.jsx|src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.ts|src(?:\/|\/(?:(?!(?:\/|^)\.).)*?\/)(?!\.)[^/]*?\.tsx)$/,
],
},
],
},
dependencies: [
{
name: 'project-b',
path: 'project-b',
ciConfig: {
jobs: [],
},
dependencies: [
{
name: 'project-c',
path: 'project-c',
ciConfig: {
jobs: [],
},
dependencies: [],
},
],
@ -85,24 +100,15 @@ describe( 'Project Graph', () => {
{
name: 'project-c',
path: 'project-c',
ciConfig: {
jobs: [],
},
dependencies: [],
},
{
name: 'project-d',
path: 'project-d',
ciConfig: {
jobs: [],
},
dependencies: [
{
name: 'project-c',
path: 'project-c',
ciConfig: {
jobs: [],
},
dependencies: [],
},
],

View File

@ -3,26 +3,26 @@
"config": {
"ci": {
"lint": {
"changes": "src/.*\\.[jt]sx?$",
"changes": "src/**/*\\.{js,jsx,ts,tsx}",
"command": "foo"
},
"test": [
{
"name": "Minimal",
"changes": ".*",
"changes": "*",
"command": "foo"
},
{
"name": "Changes Array",
"changes": [
"foo/.*",
"bar/.*"
"foo/**/*",
"bar/**/*"
],
"command": "foo"
},
{
"name": "Test Environment",
"changes": "env/.*",
"changes": "env/**/*",
"command": "bar",
"testEnv": {
"start": "foo",
@ -33,13 +33,13 @@
},
{
"name": "Single Cascade",
"changes": "cascade/.*",
"changes": "cascade/**/*",
"command": "bar",
"cascade": "foo"
},
{
"name": "Array Cascade",
"changes": "cascade/.*",
"changes": "cascade/**/*",
"command": "bar",
"cascade": [
"foo",

View File

@ -1,32 +1,11 @@
[
{
"name": "project-a",
"path": "/test/monorepo/project-a",
"dependencies": {
"project-b": {
"from": "project-b",
"version": "link:../project-b",
"path": "/test/monorepo/project-b"
}
},
"devDependencies": {
"project-c": {
"from": "project-c",
"version": "link:../project-c",
"path": "/test/monorepo/project-c"
}
}
"path": "/test/monorepo/project-a"
},
{
"name": "project-b",
"path": "/test/monorepo/project-b",
"dependencies": {
"project-c": {
"from": "project-c",
"version": "link:../project-c",
"path": "/test/monorepo/project-c"
}
}
"path": "/test/monorepo/project-b"
},
{
"name": "project-c",
@ -34,13 +13,6 @@
},
{
"name": "project-d",
"path": "/test/monorepo/project-d",
"dependencies": {
"project-c": {
"from": "project-c",
"version": "link:../project-c",
"path": "/test/monorepo/project-c"
}
}
"path": "/test/monorepo/project-d"
}
]

View File

@ -1,4 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* External dependencies
*/
import { makeRe } from 'minimatch';
/**
* Internal dependencies
@ -18,6 +21,39 @@ export const enum JobType {
Test = 'test',
}
/**
* The variables that can be used in tokens on command strings
* that will be replaced during job creation.
*/
export enum CommandVarOptions {
BaseRef = 'baseRef',
}
/**
* The base config requirements for all jobs.
*/
interface BaseJobConfig {
/**
* The type of the job.
*/
type: JobType;
/**
* The changes that should trigger this job.
*/
changes: RegExp[];
/**
* The command to run for the job.
*/
command: string;
/**
* Indicates whether or not a job has been created for this config.
*/
jobCreated?: boolean;
}
/**
* Parses and validates a raw change config entry.
*
@ -25,7 +61,14 @@ export const enum JobType {
*/
function parseChangesConfig( raw: unknown ): RegExp[] {
if ( typeof raw === 'string' ) {
return [ new RegExp( raw ) ];
const regex = makeRe( raw );
if ( ! regex ) {
throw new ConfigError(
'Changes configuration is an invalid glob pattern.'
);
}
return [ regex ];
}
if ( ! Array.isArray( raw ) ) {
@ -42,7 +85,14 @@ function parseChangesConfig( raw: unknown ): RegExp[] {
);
}
changes.push( new RegExp( entry ) );
const regex = makeRe( entry );
if ( ! regex ) {
throw new ConfigError(
'Changes configuration is an invalid glob pattern.'
);
}
changes.push( regex );
}
return changes;
}
@ -50,21 +100,38 @@ function parseChangesConfig( raw: unknown ): RegExp[] {
/**
* The configuration of the lint job.
*/
export interface LintJobConfig {
export interface LintJobConfig extends BaseJobConfig {
/**
* The type of the job.
*/
type: JobType.Lint;
}
/**
* The changes that should trigger this job.
*/
changes: RegExp[];
/**
* Checks to see whether or not the variables in a command are valid.
*
* @param {string} command The command to validate.
*/
function validateCommandVars( command: string ) {
const matches = command.matchAll( /<([^>]+)>/g );
if ( ! matches ) {
return;
}
/**
* The linting command to run.
*/
command: string;
const commandOptions: string[] = Object.values( CommandVarOptions );
for ( const match of matches ) {
if ( match.length !== 2 ) {
throw new ConfigError(
'Invalid command variable. Variables must be in the format "<variable>".'
);
}
if ( ! commandOptions.includes( match[ 1 ] ) ) {
throw new ConfigError(
`Invalid command variable "${ match[ 1 ] }".`
);
}
}
}
/**
@ -85,6 +152,8 @@ function parseLintJobConfig( raw: any ): LintJobConfig {
);
}
validateCommandVars( raw.command );
return {
type: JobType.Lint,
changes: parseChangesConfig( raw.changes ),
@ -114,6 +183,9 @@ export interface TestEnvConfigVars {
*/
function parseTestEnvConfigVars( raw: any ): TestEnvConfigVars {
const config: TestEnvConfigVars = {};
if ( ! raw ) {
return config;
}
if ( raw.wpVersion ) {
if ( typeof raw.wpVersion !== 'string' ) {
@ -154,7 +226,7 @@ interface TestEnvConfig {
/**
* The configuration of a test job.
*/
export interface TestJobConfig {
export interface TestJobConfig extends BaseJobConfig {
/**
* The type of the job.
*/
@ -165,16 +237,6 @@ export interface TestJobConfig {
*/
name: string;
/**
* The changes that should trigger this job.
*/
changes: RegExp[];
/**
* The test command to run.
*/
command: string;
/**
* The configuration for the test environment if one is needed.
*/
@ -239,6 +301,8 @@ function parseTestJobConfig( raw: any ): TestJobConfig {
);
}
validateCommandVars( raw.command );
const config: TestJobConfig = {
type: JobType.Test,
name: raw.name,
@ -257,6 +321,8 @@ function parseTestJobConfig( raw: any ): TestJobConfig {
);
}
validateCommandVars( raw.testEnv.start );
config.testEnv = {
start: raw.testEnv.start,
config: parseTestEnvConfigVars( raw.testEnv.config ),

View File

@ -1,7 +1,12 @@
/**
* Internal dependencies
*/
import { JobType, LintJobConfig, TestJobConfig } from './config';
import {
CommandVarOptions,
JobType,
LintJobConfig,
TestJobConfig,
} from './config';
import { ProjectFileChanges } from './file-changes';
import { ProjectNode } from './project-graph';
import { TestEnvVars, parseTestEnvConfig } from './test-environment';
@ -18,8 +23,9 @@ interface LintJob {
* A testing job environment.
*/
interface TestJobEnv {
start: string;
shouldCreate: boolean;
envVars: TestEnvVars;
start?: string;
}
/**
@ -29,7 +35,7 @@ interface TestJob {
projectName: string;
name: string;
command: string;
testEnv?: TestJobEnv;
testEnv: TestJobEnv;
}
/**
@ -40,18 +46,44 @@ interface Jobs {
test: TestJob[];
}
/**
* The options to be used when creating jobs.
*/
export interface CreateOptions {
commandVars?: { [ key in CommandVarOptions ]: string };
}
/**
* Replaces any variable tokens in the command with their value.
*
* @param {string} command The command to process.
* @param {Object} options The options to use when creating the job.
* @return {string} The command after token replacements.
*/
function replaceCommandVars( command: string, options: CreateOptions ): string {
return command.replace( /<([^>]+)>/g, ( _match, key ) => {
if ( options.commandVars?.[ key ] === undefined ) {
throw new Error( `Missing command variable '${ key }'.` );
}
return options.commandVars[ key ];
} );
}
/**
* 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 {Object} config The config object for the lint job.
* @param {Array.<string>} changes The file changes that have occurred for the project.
* @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.
*/
function createLintJob(
projectName: string,
config: LintJobConfig,
changes: string[]
changes: string[],
options: CreateOptions
): LintJob | null {
let triggered = false;
@ -76,7 +108,7 @@ function createLintJob(
return {
projectName,
command: config.command,
command: replaceCommandVars( config.command, options ),
};
}
@ -86,6 +118,7 @@ function createLintJob(
* @param {string} projectName The name of the project that the job is for.
* @param {Object} config The config object for the test job.
* @param {Array.<string>} changes The file changes that have occurred for the project.
* @param {Object} options The options to use when creating the job.
* @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.
*/
@ -93,6 +126,7 @@ async function createTestJob(
projectName: string,
config: TestJobConfig,
changes: string[],
options: CreateOptions,
cascadeKeys: string[]
): Promise< TestJob | null > {
let triggered = false;
@ -132,15 +166,20 @@ async function createTestJob(
const createdJob: TestJob = {
projectName,
name: config.name,
command: config.command,
command: replaceCommandVars( config.command, options ),
testEnv: {
shouldCreate: false,
envVars: {},
},
};
// We want to make sure that we're including the configuration for
// any test environment that the job will need in order to run.
if ( config.testEnv ) {
createdJob.testEnv = {
start: config.testEnv.start,
shouldCreate: true,
envVars: await parseTestEnvConfig( config.testEnv.config ),
start: replaceCommandVars( config.testEnv.start, options ),
};
}
@ -152,12 +191,14 @@ async function createTestJob(
*
* @param {Object} node The current project node to examine.
* @param {Object} changedFiles The files that have changed for the project.
* @param {Object} options The options to use when creating the job.
* @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.
*/
async function createJobsForProject(
node: ProjectNode,
changedFiles: ProjectFileChanges,
options: CreateOptions,
cascadeKeys: string[]
): Promise< Jobs > {
// We're going to traverse the project graph and check each node for any jobs that should be triggered.
@ -171,11 +212,17 @@ async function createJobsForProject(
const newCascadeKeys = [];
for ( const dependency of node.dependencies ) {
// Each dependency needs to have its own cascade keys so that they don't cross-contaminate.
// Keey in mind that arrays are passed by reference in JavaScript. This means that any changes
// we make to the cascade keys array will be reflected in the parent scope. We need to copy
// the array before recursing our dependencies so that we don't accidentally add keys from
// one dependency to a sibling and accidentally trigger jobs that shouldn't be run.
const dependencyCascade = [ ...cascadeKeys ];
const dependencyJobs = await createJobsForProject(
dependency,
changedFiles,
options,
dependencyCascade
);
newJobs.lint.push( ...dependencyJobs.lint );
@ -189,8 +236,11 @@ async function createJobsForProject(
}
// Now that we're done looking at the dependencies we can add the cascade keys that
// they created. Make sure to avoid adding duplicates so that we don't waste time
// checking the same keys multiple times when we create the jobs.
// they created. This is deliberately modifying the function argument so that the
// "dependencyCascade" array created above will contain any of the keys that were
// triggered by children downstream. Make sure to avoid adding duplicates
// so that we don't waste time checking the same keys multiple times
// when we create the jobs.
cascadeKeys.push(
...newCascadeKeys.filter( ( value ) => ! cascadeKeys.includes( value ) )
);
@ -201,17 +251,24 @@ async function createJobsForProject(
}
for ( const jobConfig of node.ciConfig.jobs ) {
// Make sure that we don't queue the same job more than once.
if ( jobConfig.jobCreated ) {
continue;
}
switch ( jobConfig.type ) {
case JobType.Lint: {
const created = createLintJob(
node.name,
jobConfig,
changedFiles[ node.name ] ?? []
changedFiles[ node.name ] ?? [],
options
);
if ( ! created ) {
break;
}
jobConfig.jobCreated = true;
newJobs.lint.push( created );
break;
}
@ -221,12 +278,14 @@ async function createJobsForProject(
node.name,
jobConfig,
changedFiles[ node.name ] ?? [],
options,
cascadeKeys
);
if ( ! created ) {
break;
}
jobConfig.jobCreated = true;
newJobs.test.push( created );
// We need to track any cascade keys that this job is associated with so that
@ -249,11 +308,13 @@ async function createJobsForProject(
*
* @param {Object} root The root node for the project graph.
* @param {Object} changes The file changes that have occurred.
* @param {Object} options The options to use when creating the job.
* @return {Promise.<Object>} The jobs that should be run.
*/
export function createJobsForChanges(
root: ProjectNode,
changes: ProjectFileChanges
changes: ProjectFileChanges,
options: CreateOptions
): Promise< Jobs > {
return createJobsForProject( root, changes, [] );
return createJobsForProject( root, changes, options, [] );
}

View File

@ -7,6 +7,8 @@ import path from 'node:path';
export interface PackageJSON {
name: string;
config?: { ci?: any };
dependencies?: { [ key: string ]: string };
devDependencies?: { [ key: string ]: string };
}
// We're going to store a cache of package files so that we don't load

View File

@ -8,7 +8,7 @@ import path from 'node:path';
* Internal dependencies
*/
import { CIConfig, parseCIConfig } from './config';
import { loadPackage } from './package-file';
import { PackageJSON, loadPackage } from './package-file';
/**
* A node in the project dependency graph.
@ -20,6 +20,41 @@ export interface ProjectNode {
dependencies: ProjectNode[];
}
/**
* Parses the dependency list from the package file and returns the package names.
*
* @param {Object} packageFile The package file contents.
* @return {Array.<string>} The list of dependencies.
*/
function parseWorkspaceDependencies( packageFile: PackageJSON ): string[] {
const dependencyList: string[] = [];
// We're going to grab a list of all of the dependencies that are mapped
// to packages within our PNPM workspace. This will let us know which
// ones to map when we create the project graph.
const dependencyTypes = [ 'dependencies', 'devDependencies' ];
for ( const type of dependencyTypes ) {
if ( ! packageFile[ type ] ) {
continue;
}
for ( const dependency in packageFile[ type ] ) {
const constraint: string = packageFile[ type ][ dependency ];
if ( ! constraint.startsWith( 'workspace:' ) ) {
continue;
}
if ( dependencyList.includes( dependency ) ) {
continue;
}
dependencyList.push( dependency );
}
}
return dependencyList;
}
/**
* Builds a dependency graph of all projects in the monorepo and returns the root node.
*/
@ -30,12 +65,16 @@ export function buildProjectGraph(): ProjectNode {
'..'
);
// PNPM provides us with a flat list of all projects
// in the workspace and their dependencies.
// PNPM provides us with a flat list of all projects in the workspace.
const workspace = JSON.parse(
execSync( 'pnpm -r list --only-projects --json', { encoding: 'utf-8' } )
);
// Unfortunately, PNPM does not provide us with any dependency information
// unless install has been run. In order to get around this we will pull
// the dependency information directly from the package files.
const packageDependencies: { [ name: string ]: string[] } = {};
// Start by building an object containing all of the nodes keyed by their project name.
// This will let us link them together quickly by iterating through the list of
// dependencies and adding the applicable nodes.
@ -69,6 +108,11 @@ export function buildProjectGraph(): ProjectNode {
rootNode = node;
}
// Track the dependencies for the project so that we can link
// them together after all of the nodes have been created.
packageDependencies[ project.name ] =
parseWorkspaceDependencies( packageFile );
nodes[ project.name ] = node;
}
@ -80,18 +124,18 @@ export function buildProjectGraph(): ProjectNode {
// from the rootless list if they are added as a dependency.
const rootlessDependencies = workspace.map( ( project ) => project.name );
// Now we can scan through all of the nodes and hook them up to their respective dependency nodes.
for ( const project of workspace ) {
const node = nodes[ project.name ];
if ( project.dependencies ) {
for ( const dependency in project.dependencies ) {
node.dependencies.push( nodes[ dependency ] );
}
for ( const packageName in packageDependencies ) {
const node = nodes[ packageName ];
if ( ! node ) {
throw new Error( `Unable to find node for ${ packageName }` );
}
if ( project.devDependencies ) {
for ( const dependency in project.devDependencies ) {
node.dependencies.push( nodes[ dependency ] );
for ( const dependency of packageDependencies[ packageName ] ) {
if ( ! nodes[ dependency ] ) {
throw new Error( `Unable to find node for ${ dependency }` );
}
node.dependencies.push( nodes[ dependency ] );
}
// Mark any dependencies that have a dependent as not being rootless.

View File

@ -4,22 +4,36 @@
import { isGithubCI } from '../environment';
describe( 'isGithubCI', () => {
it( 'should return true if GITHUB_ACTIONS is true', () => {
// Store the original environment variables so we can restore them.
const originalEnv = process.env;
beforeEach( () => {
delete process.env.CI;
delete process.env.GITHUB_ACTIONS;
} );
it( 'should return true if CI is set', () => {
process.env.CI = 'true';
expect( isGithubCI() ).toBe( true );
} );
it( 'should return false if CI is not set', () => {
expect( isGithubCI() ).toBe( false );
} );
it( 'should return true if GITHUB_ACTIONS is set', () => {
process.env.GITHUB_ACTIONS = 'true';
expect( isGithubCI() ).toBe( true );
} );
it( 'should return false if GITHUB_ACTIONS is false', () => {
process.env.GITHUB_ACTIONS = 'false';
expect( isGithubCI() ).toBe( false );
} );
it( 'should return false if GITHUB_ACTIONS is not set', () => {
process.env.GITHUB_ACTIONS = undefined;
expect( isGithubCI() ).toBe( false );
} );
// Once we've completed all of the tests we need to set the environment
// back to the way it was before we started. This makes sure we don't
// leak anything to any other tests.
afterAll( () => {
delete process.env.GITHUB_ACTIONS;
process.env = originalEnv;
} );
} );

View File

@ -16,5 +16,5 @@ export const getEnvVar = ( varName: string, isRequired = false ) => {
};
export const isGithubCI = () => {
return process.env.GITHUB_ACTIONS === 'true';
return !! ( process.env.CI || process.env.GITHUB_ACTIONS );
};

View File

@ -55,11 +55,11 @@ export class Logger {
}
}
static startTask( message: string ) {
static startTask( message: string, isSilentInCI = false ) {
if ( Logger.loggingLevel > LOGGING_LEVELS.silent && ! isGithubCI() ) {
const spinner = ora( chalk.green( `${ message }...` ) ).start();
Logger.lastSpinner = spinner;
} else if ( isGithubCI() ) {
} else if ( isGithubCI() && ! isSilentInCI ) {
Logger.notice( message );
}
}
@ -71,7 +71,7 @@ export class Logger {
}
}
static endTask() {
static endTask( isSilentInCI = false ) {
if (
Logger.loggingLevel > LOGGING_LEVELS.silent &&
Logger.lastSpinner &&
@ -81,7 +81,7 @@ export class Logger {
`${ Logger.lastSpinner.text } complete.`
);
Logger.lastSpinner = null;
} else if ( isGithubCI() ) {
} else if ( isGithubCI() && ! isSilentInCI ) {
Logger.notice( 'Task complete.' );
}
}

View File

@ -0,0 +1,34 @@
const path = require( 'path' );
const CopyPlugin = require( 'copy-webpack-plugin' );
const buildMode = process.env.NODE_ENV || 'production';
module.exports = {
entry: './src/index.ts',
target: 'node',
mode: buildMode,
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [ '.tsx', '.ts', '.js' ],
},
plugins: [
new CopyPlugin( {
patterns: [
{ from: 'node_modules/figlet/fonts/Standard.flf', to: '../fonts/Standard.flf' },
],
} ),
],
output: {
filename: 'index.js',
path: path.resolve( __dirname, 'dist' ),
clean: true,
},
};

View File

@ -60,5 +60,13 @@
"node": "^16.14.1",
"pnpm": "^8.12.1"
},
"types": "dist/index.d.ts"
"types": "dist/index.d.ts",
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.ts"
}
}
}
}

View File

@ -40,5 +40,16 @@
"open": "^8.4.2",
"semver": "^7.5.4",
"ts-node": "^10.9.2"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": [
"commands/**/*.ts",
"lib/**/*.ts"
]
}
}
}
}