Skip to content

Commit

Permalink
Add 'after' support (microsoft#17925)
Browse files Browse the repository at this point in the history
Similar to 'before', 'after' will schedule the task to be executed after
all the specified task is executed.

Also,
- Allow '#' and '^' expands on before (and after) just like `dependsOn`.
- Added more validation of task definitions
- Detect circular references of tasks
- Output padding based on the number of total tasks.
  • Loading branch information
curtisman authored Oct 22, 2023
1 parent 34c0e05 commit 2758c95
Show file tree
Hide file tree
Showing 6 changed files with 311 additions and 173 deletions.
4 changes: 4 additions & 0 deletions build-tools/packages/build-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,16 @@ For example:
{
"fluidBuild": {
"tasks": {
"build": ["...", "build:docs", "copy:docs"],
"tsc": ["...", "typetests:gen"], // Depends on "typetests:gen", including dependencies
// in default definition (i.e. "^tsc" in the above example)
"build:test": [
"@fluidframework/merge-tree#build:test" // Overrides default, depends only on "build:test" task
// in dependent package "@fluidframework/merge-tree"
],
"copy:docs": [
"after": [ "build:docs" ] // if "build:docs" is trigger, "copy:docs" only run after it
],
"webpack": ["^tsc"] // Depends on `tsc` task of all of the dependent packages
// (if the task exists)
}
Expand Down
260 changes: 153 additions & 107 deletions build-tools/packages/build-tools/src/common/fluidTaskDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,45 @@ import { PackageJson } from "./npmPackage";
* Task names are represented as property name on the object and the value the task configuration
* (type `TaskConfig`). Task configuration can a plain array of string, presenting the task's
* dependencies or a full description (type `TaskConfigFull`).
*
* Task Dependencies Expansion:
* When specify task dependencies, the following syntax is supported:
* - "<name>": another task within the package
* - "^<name>": all the task with the name in dependent packages.
* - "*": any other task within the package (for 'before' and 'after' only, not allowed for 'dependsOn')
*
* When task definition is augmented in the package.json itself, the dependencies can also be:
* - "<package>#<name>": specific dependent package's task
* - "...": expand to the dependencies in global fluidBuild config (default is override)
*/

export type TaskDependencies = string[];
export interface TaskConfig {
/**
* Task dependencies as a plain string array.
* The strings specify dependencies for the task
* - "<name>": another task within the package
* - "^<name>": all the task with the name in dependent packages.
*
* When task definition is augmented in the package.json itself, the dependencies can also be:
* - "<package>#<name>": specific dependent package's task
* - "...": expand to the dependencies in global fluidBuild config (default is override)
* Task dependencies as a plain string array. Matched task will be scheduled to run before the current task.
* The strings specify dependencies for the task. See Task Dependencies Expansion above for details.
*/
dependsOn: TaskDependencies;

/**
* Tasks that needs to run before the current task (example clean). See Task Dependencies Expansion above for
* details. As compared to "dependsOn", that this will only affect ordering if matched task is already
* scheduled. It won't cause the matched tasks to be scheduled if it isn't already.
*
* Notes 'before' is disallowed for non-script tasks since it has no effect on non-script tasks because they has no
* action to perform.
*/
before: TaskDependencies;
/**
* Tasks that needs to run after the current task (example copy tasks). See Task Dependencies Expansion above for
* details. As compared to "dependsOn", that this will only affect ordering if matched task is already
* scheduled. It won't cause the matched tasks to be scheduled if it isn't already.
*
* Notes 'after' is disallowed for non-script tasks since it has no effect on non-script tasks because they has no
* action to perform.
*/
after: TaskDependencies;

/**
* Specify whether this is a script task or not. Default to true when this is omitted
* in the config file, or the task's config is just a plain string array.
Expand All @@ -36,18 +60,6 @@ export interface TaskConfig {
* It can be used as an alias to a group of tasks.
*/
script: boolean;
/**
* Tasks that needs to run before the current task (example clean)
* - "<name>": another task within the package
* - "*": any other task within the package
*
* When task definition is augmented in the package.json itself, the dependencies can also be:
* - "...": expand to the "before" in the global fluidBuild config
*
* Note that this will only affect ordering if matched task is already scheduled. It won't
* get the matched tasks to be scheduled if it isn't already
*/
before: TaskDependencies;
}

export interface TaskDefinitions {
Expand All @@ -67,16 +79,76 @@ export interface TaskDefinitionsOnDisk {
*/
function getFullTaskConfig(config: TaskConfigOnDisk): TaskConfig {
if (Array.isArray(config)) {
return { dependsOn: config, script: true, before: [] };
return { dependsOn: config, script: true, before: [], after: [] };
} else {
return {
dependsOn: config.dependsOn ?? [],
script: config.script ?? true,
before: config.before ?? [],
after: config.after ?? [],
};
}
}

const detectInvalid = (
config: string[],
isInvalid: (value: string) => boolean,
name: string,
kind: string,
isGlobal: boolean,
) => {
const invalid = config.filter((value) => isInvalid(value));
if (invalid.length !== 0) {
throw new Error(
`Invalid '${kind}' dependencies '${invalid.join()}' for${
isGlobal ? " global" : ""
} task definition ${name}`,
);
}
};

export function normalizeGlobalTaskDefinitions(
globalTaskDefinitionsOnDisk: TaskDefinitionsOnDisk | undefined,
): TaskDefinitions {
// Normalize all on disk config to full config and validate
const taskDefinitions: TaskDefinitions = {};
if (globalTaskDefinitionsOnDisk) {
for (const name in globalTaskDefinitionsOnDisk) {
const full = getFullTaskConfig(globalTaskDefinitionsOnDisk[name]);
if (!full.script) {
if (full.before.length !== 0 || full.after.length !== 0) {
throw new Error(
`Non-script global task definition '${name}' cannot have 'before' or 'after'`,
);
}
}
detectInvalid(
full.dependsOn,
(value) => value === "..." || value.includes("#") || value === "*",
name,
"dependsOn",
true,
);
detectInvalid(
full.before,
(value) => value === "..." || value.includes("#"),
name,
"before",
true,
);
detectInvalid(
full.after,
(value) => value === "..." || value.includes("#"),
name,
"after",
true,
);
taskDefinitions[name] = full;
}
}
return taskDefinitions;
}

/**
* Combine and fill in default values for task definitions for a package.
* @param json package.json content for the package
Expand All @@ -85,113 +157,87 @@ function getFullTaskConfig(config: TaskConfigOnDisk): TaskConfig {
*/
export function getTaskDefinitions(
json: PackageJson,
globalTaskDefinitions?: TaskDefinitionsOnDisk,
globalTaskDefinitions: TaskDefinitions,
isReleaseGroupRoot: boolean,
) {
const packageTaskDefinitions = json.fluidBuild?.tasks;
const taskConfig: TaskDefinitions = {};

// Initialize from global TaskDefinition, taking targets and scripts that exist in the package.json
if (globalTaskDefinitions) {
for (const name in globalTaskDefinitions) {
const config = globalTaskDefinitions[name];
const full = getFullTaskConfig(config);
if (full.script && json.scripts?.[name] === undefined) {
// Skip script global task definition if the package doesn't have the script
continue;
}
const invalidDependsOn = full.dependsOn.filter(
(value) => value === "..." || value.includes("#"),
);
if (invalidDependsOn.length !== 0) {
throw new Error(
`Invalid global dependencies '${invalidDependsOn.join()}' for task definition ${name}`,
);
}
const invalidBefore = full.before.filter((value) => value === "...");
if (invalidBefore.length !== 0) {
throw new Error(
`Invalid before dependencies '${invalidBefore.join()}' for task definition ${name}`,
);
}
taskConfig[name] = full;
}
// Only keep script that exists
for (const name in taskConfig) {
const config = taskConfig[name];
config.dependsOn = config.dependsOn.filter(
(value) =>
value.startsWith("^") ||
taskConfig[value] !== undefined ||
json.scripts?.[value] !== undefined,
);
config.before = config.before.filter(
(value) =>
value === "*" ||
taskConfig[value] !== undefined ||
json.scripts?.[value] !== undefined,
);
const taskDefinitions: TaskDefinitions = {};

// Initialize from global TaskDefinition, and filter out script tasks if the package doesn't have the script
for (const name in globalTaskDefinitions) {
const globalTaskDefinition = globalTaskDefinitions[name];
if (globalTaskDefinition.script && json.scripts?.[name] === undefined) {
// Skip script tasks if the package doesn't have the script
continue;
}
taskDefinitions[name] = { ...globalTaskDefinition };
}
const globalAllow = (value) =>
value.startsWith("^") ||
taskDefinitions[value] !== undefined ||
json.scripts?.[value] !== undefined;
const globalAllowExpansionsStar = (value) => value === "*" || globalAllow(value);
// Only keep task or script references that exists
for (const name in taskDefinitions) {
const taskDefinition = taskDefinitions[name];
taskDefinition.dependsOn = taskDefinition.dependsOn.filter(globalAllow);
taskDefinition.before = taskDefinition.before.filter(globalAllowExpansionsStar);
taskDefinition.after = taskDefinition.after.filter(globalAllowExpansionsStar);
}

const expandDotDotDot = (packageConfig, globalConfig) => {
const expanded = packageConfig.filter((value) => value !== "...");
if (globalConfig !== undefined && expanded.length !== packageConfig.length) {
return expanded.concat(globalConfig);
}
return expanded;
};

// Override from the package.json, and resolve "..." to the global dependencies if any
if (packageTaskDefinitions) {
for (const name in packageTaskDefinitions) {
const config = packageTaskDefinitions[name];
const full = getFullTaskConfig(config);
const packageTaskDefinition = packageTaskDefinitions[name];
const full = getFullTaskConfig(packageTaskDefinition);
if (full.script) {
const script = json.scripts?.[name];
if (script === undefined) {
throw new Error(`Script not found for task definition '${name}'`);
} else if (script.startsWith("fluid-build ")) {
throw new Error(`Script task should not invoke 'fluid-build' in '${name}'`);
}
}

const currentTaskConfig = taskConfig[name];
const dependsOn = full.dependsOn.filter((value) => value !== "...");
if (currentTaskConfig !== undefined && dependsOn.length !== full.dependsOn.length) {
full.dependsOn = dependsOn.concat(currentTaskConfig.dependsOn);
} else {
full.dependsOn = dependsOn;
}
const before = full.before.filter((value) => value !== "...");
if (currentTaskConfig !== undefined && before.length !== full.before.length) {
full.before = before.concat(currentTaskConfig.before);
} else {
full.before = before;
if (full.before.length !== 0 || full.after.length !== 0) {
throw new Error(
`Non-script task definition '${name}' cannot have 'before' or 'after'`,
);
}
}

taskConfig[name] = full;
const currentTaskConfig = taskDefinitions[name];
full.dependsOn = expandDotDotDot(full.dependsOn, currentTaskConfig?.dependsOn);
full.before = expandDotDotDot(full.before, currentTaskConfig?.before);
full.after = expandDotDotDot(full.after, currentTaskConfig?.after);
taskDefinitions[name] = full;
}
}

// Check to make sure all the dependencies either is an target or script
for (const name in taskConfig) {
const config = taskConfig[name];
// Find any non-existent tasks or scripts in the dependencies
const invalidDependsOn = config.dependsOn.filter(
(value) =>
!value.includes("#") &&
!value.startsWith("^") &&
taskConfig[value] === undefined &&
json.scripts?.[value] === undefined,
);
if (invalidDependsOn.length !== 0) {
throw new Error(
`Invalid dependencies '${invalidDependsOn.join()}' for task definition ${name}`,
);
}

const invalidBefore = config.before.filter(
(value) =>
value !== "*" &&
taskConfig[value] === undefined &&
json.scripts?.[value] === undefined,
);
if (invalidBefore.length !== 0) {
throw new Error(
`Invalid before dependencies '${invalidBefore.join()}' for task definition ${name}`,
);
// For release group root, the default for any task is to run all the tasks in the group
// even if there is not task definition or script for it.
if (!isReleaseGroupRoot) {
const packageInvalid = (value) =>
!value.includes("#") &&
!value.startsWith("^") &&
taskDefinitions[value] === undefined &&
json.scripts?.[value] === undefined;
const packageInvalidAllowStar = (value) => value !== "*" && packageInvalid(value);
for (const name in taskDefinitions) {
const taskDefinition = taskDefinitions[name];
// Find any non-existent tasks or scripts in the dependencies
detectInvalid(taskDefinition.dependsOn, packageInvalid, name, "dependsOn", false);
detectInvalid(taskDefinition.before, packageInvalidAllowStar, name, "before", false);
detectInvalid(taskDefinition.after, packageInvalidAllowStar, name, "after", false);
}
}
return taskConfig;
return taskDefinitions;
}
Loading

0 comments on commit 2758c95

Please sign in to comment.