diff --git a/app/exec/extension/_lib/merger.ts b/app/exec/extension/_lib/merger.ts index b4acc438..5b38629f 100644 --- a/app/exec/extension/_lib/merger.ts +++ b/app/exec/extension/_lib/merger.ts @@ -164,11 +164,14 @@ export class Merger { return Promise.all(manifestPromises).then(partials => { // Determine the targets so we can construct the builders let targets: TargetDeclaration[] = []; - const taskJsonValidationPromises: Promise[] = []; + let allContributions: any[] = []; partials.forEach(partial => { if (_.isArray(partial["targets"])) { targets = targets.concat(partial["targets"]); } + if (_.isArray(partial["contributions"])) { + allContributions = allContributions.concat(partial["contributions"]); + } }); this.extensionComposer = ComposerFactory.GetComposer(this.settings, targets); this.manifestBuilders = this.extensionComposer.getBuilders(); @@ -223,9 +226,6 @@ export class Merger { absolutePath = path.join(path.dirname(partial.__origin), asset.path); } asset.path = path.relative(this.settings.root, absolutePath); - - const taskJsonPattern: string = path.join(absolutePath, '**', "task.json"); - taskJsonValidationPromises.push(this.validateTaskJson(taskJsonPattern)); }); } // Transform icon paths as above @@ -263,6 +263,9 @@ export class Merger { }); }); + // Validate task.json files based on build task contributions + const taskJsonValidationPromise = this.validateBuildTaskContributions(allContributions); + // Generate localization resources const locPrepper = new loc.LocPrep.LocKeyGenerator(this.manifestBuilders); const resources = locPrepper.generateLocalizationKeys(); @@ -283,9 +286,8 @@ export class Merger { // Finalize each builder return Promise.all( - [updateVersionPromise].concat( - this.manifestBuilders.map(b => b.finalize(packageFiles, resourceData, this.manifestBuilders)), - taskJsonValidationPromises + [updateVersionPromise, taskJsonValidationPromise].concat( + this.manifestBuilders.map(b => b.finalize(packageFiles, resourceData, this.manifestBuilders)) ), ).then(() => { // const the composer do validation @@ -408,27 +410,81 @@ export class Merger { return files; } - private async validateTaskJson(taskJsonSearchPattern: string): Promise { + private async validateBuildTaskContributions(contributions: any[]): Promise { try { - const matches: string[] = await promisify(glob)(taskJsonSearchPattern); - - if (matches.length === 0) { - trace.debug(`No task.json file found for validation in ${taskJsonSearchPattern}`); + // Filter contributions to only build tasks + const buildTaskContributions = contributions.filter(contrib => + contrib.type === "ms.vss-distributed-task.task" && + contrib.properties && + contrib.properties.name + ); + + if (buildTaskContributions.length === 0) { + trace.debug("No build task contributions found, skipping task.json validation"); return; } - const taskJsonPath = matches[0]; - const taskJsonExists = await exists(taskJsonPath); - - if (taskJsonExists) { - return validate(taskJsonPath, "no task.json in specified directory"); + const allTaskJsonPaths: string[] = []; + + // For each build task contribution, look for task.json files and validate them + for (const contrib of buildTaskContributions) { + const taskPath = contrib.properties.name; + const absoluteTaskPath = path.join(this.settings.root, taskPath); + const contributionTaskJsonPaths: string[] = []; + + // Check for task.json in the main directory + const mainTaskJsonPath = path.join(absoluteTaskPath, "task.json"); + if (fs.existsSync(mainTaskJsonPath)) { + contributionTaskJsonPaths.push(mainTaskJsonPath); + trace.debug(`Found task.json: ${mainTaskJsonPath}`); + } + + // Check for task.json in direct child directories (version folders) + if (fs.existsSync(absoluteTaskPath) && fs.lstatSync(absoluteTaskPath).isDirectory()) { + try { + const childDirs = fs.readdirSync(absoluteTaskPath); + for (const childDir of childDirs) { + const childPath = path.join(absoluteTaskPath, childDir); + if (fs.lstatSync(childPath).isDirectory()) { + const childTaskJsonPath = path.join(childPath, "task.json"); + if (fs.existsSync(childTaskJsonPath)) { + contributionTaskJsonPaths.push(childTaskJsonPath); + trace.debug(`Found task.json: ${childTaskJsonPath}`); + } + } + } + } catch (err) { + trace.warn(`Error reading task directory ${absoluteTaskPath}: ${err}`); + } + } + + // Validate task.json files for this contribution with backwards compatibility checking + if (contributionTaskJsonPaths.length > 0) { + trace.debug(`Validating ${contributionTaskJsonPaths.length} task.json files for contribution ${contrib.id || taskPath}`); + + for (const taskJsonPath of contributionTaskJsonPaths) { + validate(taskJsonPath, "no task.json in specified directory", contributionTaskJsonPaths); + } + + // Also collect for global tracking if needed + allTaskJsonPaths.push(...contributionTaskJsonPaths); + } else { + trace.warn(`Build task contribution '${contrib.id || taskPath}' does not have a task.json file. Expected task.json in ${absoluteTaskPath} or its subdirectories.`); + } + } + + if (allTaskJsonPaths.length === 0) { + trace.debug("No task.json files found in build task contributions"); + return; } + trace.debug(`Successfully validated ${allTaskJsonPaths.length} task.json files across ${buildTaskContributions.length} build task contributions`); + } catch (err) { const warningMessage = "Please, make sure the task.json file is correct. In the future, this warning will be treated as an error.\n"; trace.warn(err && err instanceof Error ? warningMessage + err.message - : `Error occurred while validating task.json. ${warningMessage}`); + : `Error occurred while validating build task contributions. ${warningMessage}`); } } } diff --git a/app/lib/jsonvalidate.ts b/app/lib/jsonvalidate.ts index 136326d2..5f1152c1 100644 --- a/app/lib/jsonvalidate.ts +++ b/app/lib/jsonvalidate.ts @@ -12,14 +12,15 @@ export interface TaskJson { /* * Checks a json file for correct formatting against some validation function * @param jsonFilePath path to the json file being validated - * @param jsonValidationFunction function that validates parsed json data against some criteria + * @param jsonMissingErrorMessage error message if json file doesn't exist + * @param allMatchedPaths optional array of all matched task.json paths for backwards compat detection * @return the parsed json file * @throws InvalidDirectoryException if json file doesn't exist, InvalidJsonException on failed parse or *first* invalid field in json */ -export function validate(jsonFilePath: string, jsonMissingErrorMessage?: string): TaskJson { +export function validate(jsonFilePath: string, jsonMissingErrorMessage?: string, allMatchedPaths?: string[]): TaskJson { trace.debug("Validating task json..."); var jsonMissingErrorMsg: string = jsonMissingErrorMessage || "specified json file does not exist."; - this.exists(jsonFilePath, jsonMissingErrorMsg); + exists(jsonFilePath, jsonMissingErrorMsg); var taskJson; try { @@ -29,7 +30,7 @@ export function validate(jsonFilePath: string, jsonMissingErrorMessage?: string) throw new Error("Invalid task json: " + jsonError); } - var issues: string[] = this.validateTask(jsonFilePath, taskJson); + var issues: string[] = validateTask(jsonFilePath, taskJson); if (issues.length > 0) { var output: string = "Invalid task json:"; for (var i = 0; i < issues.length; i++) { @@ -40,7 +41,7 @@ export function validate(jsonFilePath: string, jsonMissingErrorMessage?: string) } trace.debug("Json is valid."); - validateRunner(taskJson); + validateRunner(taskJson, allMatchedPaths); return taskJson; } @@ -55,16 +56,43 @@ export function exists(path: string, errorMessage: string) { } /* - * Validates a task against deprecated runner + * Counts the number of non-deprecated runners in a task's execution configuration * @param taskData the parsed json file + * @return number of valid (non-deprecated) runners, or 0 if no execution is defined */ -export function validateRunner(taskData: any) { +function countValidRunners(taskData: any): number { if (taskData == undefined || taskData.execution == undefined) - return + return 0; + + return Object.keys(taskData.execution).filter(itm => deprecatedRunners.indexOf(itm) == -1).length; +} - const validRunnerCount = Object.keys(taskData.execution).filter(itm => deprecatedRunners.indexOf(itm) == -1) || 0; - if (validRunnerCount == 0) { - trace.warn("Task %s is dependent on a task runner that is end-of-life and will be removed in the future. Please visit https://aka.ms/node-runner-guidance to learn how to upgrade the task.", taskData.name) +/* + * Validates a task against deprecated runner + * @param taskData the parsed json file + * @param allMatchedPaths optional array of all matched task.json paths for backwards compat detection + */ +export function validateRunner(taskData: any, allMatchedPaths?: string[]) { + if (countValidRunners(taskData) == 0) { + if (allMatchedPaths) { + for (const matchedPath of allMatchedPaths) { + let matchedTaskData; + try { + matchedTaskData = require(matchedPath); + } catch { + continue; + } + if (taskData.name == matchedTaskData.name && taskData.id == matchedTaskData.id && matchedTaskData.version?.Major > taskData.version?.Major) { + // Return if the other task is using a non-deprecated task runner + const otherValidRunnerCount = countValidRunners(matchedTaskData); + if (otherValidRunnerCount > 0) { + return; + } + } + } + } + + trace.warn("Task %s@%s is dependent on a task runner that is end-of-life and will be removed in the future. Please visit https://aka.ms/node-runner-guidance to learn how to upgrade the task.", taskData.name, taskData.version?.Major || "?") } } @@ -72,7 +100,7 @@ export function validateRunner(taskData: any) { * Validates a parsed json file describing a build task * @param taskPath the path to the original json file * @param taskData the parsed json file - * @return list of issues with the json file + * @return list of issues with the json file */ export function validateTask(taskPath: string, taskData: any): string[] { var vn = taskData.name || taskPath; diff --git a/package-lock.json b/package-lock.json index 07c52299..3ea3945e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "prompt": "^1.3.0", "read": "^1.0.6", "shelljs": "^0.8.5", - "tmp": "0.0.26", + "tmp": "^0.2.4", "tracer": "0.7.4", "util.promisify": "^1.0.0", "uuid": "^3.0.1", @@ -1805,15 +1805,6 @@ "node": ">=0.10.0" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://pkgs.dev.azure.com/mseng/PipelineTools/_packaging/PipelineTools_PublicPackages/npm/registry/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://pkgs.dev.azure.com/mseng/PipelineTools/_packaging/PipelineTools_PublicPackages/npm/registry/pako/-/pako-1.0.11.tgz", @@ -2364,14 +2355,12 @@ } }, "node_modules/tmp": { - "version": "0.0.26", - "resolved": "https://pkgs.dev.azure.com/mseng/PipelineTools/_packaging/PipelineTools_PublicPackages/npm/registry/tmp/-/tmp-0.0.26.tgz", - "integrity": "sha1-nvqCDOKhD4H4l5VVus4/FVJs4fI=", - "dependencies": { - "os-tmpdir": "~1.0.0" - }, + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", + "integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==", + "license": "MIT", "engines": { - "node": ">=0.4.0" + "node": ">=14.14" } }, "node_modules/to-buffer": { diff --git a/package.json b/package.json index 3fafdf8d..42ed4d72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tfx-cli", - "version": "0.21.2", + "version": "0.21.3", "description": "CLI for Azure DevOps Services and Team Foundation Server", "repository": { "type": "git", @@ -35,7 +35,7 @@ "prompt": "^1.3.0", "read": "^1.0.6", "shelljs": "^0.8.5", - "tmp": "0.0.26", + "tmp": "^0.2.4", "tracer": "0.7.4", "util.promisify": "^1.0.0", "uuid": "^3.0.1",