Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ApiCompat DevOps task #6189

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 78 additions & 0 deletions tools/devops-tasks/ApiCompat/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@

# ApiCompat task

API Compat tool compares two sets of assemblies and determines if the implementation has incompatibilities with the contract.

This is an Azure Pipelines encapsulation of the ApiCompat tool. To get the latest version of this tool please visit [dotnet/arcade](https://github.com/dotnet/arcade/tree/master/src/Microsoft.DotNet.ApiCompat) repo.

## Configuration

### Contracts root folder

Path to the folder that contains the assemblies to compare *against*.

### Contracts files name

Space separated list of the names of the assemblies to compare to.

### Implementation assemblies folder

Folder that contains the assemblies that are going to be compared against the contracts.

### Fails on Issue

Checking this will cause the build process to fail when ApiCompat finds one or more incompatibilities. Leaving it unchecked will flag the task as *successful with issues*. For Pull Requests we recommend leaving this checked to alert the Pull Request user that his implementations caused incompatibilities.

### Resolve Fx

If a contract or implementation dependency cannot be found in the given directories, fallback to try to resolve against the framework directory on the machine.

### Warn on incorrect version

Warn if the contract version number doesn't match the found implementation version number.

### Warn on missing assemblies

Warn if the contract assembly cannot be found in the implementation directories. Default is to error and not do analysis.

### Create Comparison Result Log

If this is checked, a JSON file will be created with the comparison result.

The JSON will contain two properties:

- `issues`. This property contains the numeric value of the amount of found issues.
- `body`. This property contains the list of descriptions of the issues given by ApiCompat.

Checking this will also show two additional input boxes:

- **Output File Name.** Name and extension of the file to generate.
- **Output File Path.** Path where the generated file will be stored.

### Use Baseline File

A baseline file is a file that contains known incompatibilities. This comes useful whenever you want to release a new major version of the assembly, knowing beforehand that the new version will contain breaking incompatibilities.

The content of this file is simply the same output generated by ApiCompat, either by piping the result to a text file or by using the `--out` parameter.

Checking this option will display an input field to select a baseline file. This file will be used as `--baseline` parameter to ignore the issues in the file when running the comparison.

## Basic Example

If you have a build process for your assemblies, this tool can be very useful to prevent the integration of new changes that will cause incompatibilities with the current released version of your assemblies.

To do this in your build process, add this task and configure the following fields:

- **Contracts root folder.** Point this to the latest released version of your assemblies. One way to accomplish this is to use the NuGet task.
- **Contracts files name.** Configure the name of your assembly. If you wish to configure more than one assembly, use *multipliers variables*.
- **Implementation assemblies folder.** Point this field to the binaries produced by the *current build process*.
- **Fails on Issues.** Check this if you wish to prevent incompatibilities.

For the build trigger configure **Pull request validation**.

With this configuration, whenever a person opens a Pull Request introducing changes that will break compatibilities with the current released version, the build process will be marked as ***Failed***.

## More Information

- [Multi-Configuration](https://docs.microsoft.com/en-us/azure/devops/pipelines/process/phases?view=azure-devops&tabs=yaml#multi-configuration). To configure multiple assemblies and versions without having to clone the build.
- [Conditional Tasks](https://docs.microsoft.com/en-us/azure/devops/pipelines/process/conditions?view=azure-devops&tabs=yaml). To execute certain agents only if some conditions are met.
7 changes: 7 additions & 0 deletions tools/devops-tasks/ApiCompat/source/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
plugins: [ '@typescript-eslint' ],
extends: [
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
]
};
4 changes: 4 additions & 0 deletions tools/devops-tasks/ApiCompat/source/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
package-lock.json
lib
!.eslintrc.js
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added tools/devops-tasks/ApiCompat/source/icon.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions tools/devops-tasks/ApiCompat/source/icon.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions tools/devops-tasks/ApiCompat/source/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "source",
"version": "1.0.0",
"description": "",
"main": "./lib/index.js",
"scripts": {
"prebuild": "npm run lint",
"build": "tsc --p tsconfig.json",
"lint": "eslint ./src/**/*.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"azure-pipelines-task-lib": "^2.7.7"
},
"devDependencies": {
"@types/node": "^11.9.4",
"@types/q": "^1.5.1",
"eslint": "^5.16.0",
"@typescript-eslint/parser": "^1.6.0",
"@typescript-eslint/eslint-plugin": "^1.6.0",
"typescript": "^3.4.3"
}
}
29 changes: 29 additions & 0 deletions tools/devops-tasks/ApiCompat/source/src/apiCompat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { join } from 'path'
import { execSync } from "child_process";

export class ApiCompat {
private apiCompatPath: string;

public constructor() {
this.apiCompatPath = join(__dirname, '..', 'ApiCompat', 'Microsoft.DotNet.ApiCompat.exe');
}

public getVersion = (): string => {
return this.runCommand('--version').toString();
}

public compare = (contracts: string, implementation: string, optionalParameters?: string): string => {
const options = optionalParameters != undefined ? optionalParameters : '';
const command = `"${ contracts }" --impl-dirs "${ implementation }" ${ options }`;
return this.runCommand(command);
}

private runCommand = (command: string): string => {
return execSync(`"${this.apiCompatPath}" ` + command).toString();
}
}
70 changes: 70 additions & 0 deletions tools/devops-tasks/ApiCompat/source/src/configuration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { join } from 'path'
import { getInput, getBoolInput } from "azure-pipelines-task-lib";
import { existsSync } from 'fs';

export class Configuration {
public contractsFolder: string;
public contract: string;
public contractList: string;
public implementationFolder: string;
public failOnIssue: boolean;
public resolveFx: boolean;
public warnOnIncorrectVersion: boolean;
public warnOnMissingAssemblies: boolean;
public generateLog: boolean;
public outputFileName: string;
public outputFolder: string;
public useBaseline: boolean;
public baselineFile: string = '';

public constructor() {
this.contractsFolder = this.validatePath('contractsRootFolder');
this.contract = getInput('contractsFileName');
this.contractList = this.getContractsName();
this.implementationFolder = this.validatePath('implFolder');
this.failOnIssue = getBoolInput('failOnIssue');
this.resolveFx = getBoolInput('resolveFx');
this.warnOnIncorrectVersion = getBoolInput('warnOnIncorrectVersion');
this.warnOnMissingAssemblies = getBoolInput('warnOnMissingAssemblies');
this.generateLog = getBoolInput('generateLog');
this.outputFileName = getInput('outputFilename');
this.outputFolder = getInput('outputFolder');

this.useBaseline = getBoolInput('useBaseline');
if (this.useBaseline) {
this.baselineFile = this.validatePath('baselineFile');
}
}

private validatePath = (inputName: string): string => {
const path = getInput(inputName);

if (!existsSync(path)) {
throw new Error(`The file or directory "${ path }" specified in "${ inputName }" does not exist.`);
}

return path;
}

private getContractsName = (): string => {
const filesName: string[] = [];

getInput('contractsFileName').split(' ').forEach(file => {
const fullFilePath: string = join(this.validatePath('contractsRootFolder'), file);
if (existsSync(fullFilePath)) {
filesName.push(fullFilePath);
}
});

if (filesName.length == 0) {
throw new Error('The specified contracts were not found.');
}

return filesName.join(',');
}
}
70 changes: 70 additions & 0 deletions tools/devops-tasks/ApiCompat/source/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { setResult, TaskResult } from 'azure-pipelines-task-lib';
import { ApiCompat } from './ApiCompat'
import { Configuration } from './Configuration'
import { Result } from './Result';
import { existsSync, writeFileSync, mkdirSync } from 'fs';
import { EOL } from 'os';
import { join } from 'path'

const getOptionalParameters = (configuration: Configuration): string => {
let command = configuration.resolveFx ? ' --resolve-fx' : '';
command += configuration.warnOnIncorrectVersion ? ' --warn-on-incorrect-version' : '';
command += configuration.warnOnMissingAssemblies ? ' --warn-on-missing-assemblies' : '';
command += configuration.useBaseline ? ` --baseline "${ configuration.baselineFile }"` : '';

return command;
}

const resultText = (result: Result): string => {
return result.issuesCount != 0
? `There were ${ result.issuesCount } differences between the assemblies`
: 'No differences were found between the assemblies';
}

const writeResult = (result: Result, configuration: Configuration): void => {
const fileName: string = configuration.outputFileName;
const directory: string = configuration.outputFolder;

const resultText: string = result.issuesCount === 0
? `:heavy_check_mark: No Binary Compatibility issues for **${ configuration.contract }**`
: result.getFormattedResult()

if (!existsSync(directory)) {
mkdirSync(directory, { recursive: true });
}

writeFileSync(`${join(directory, fileName)}`, resultText );
}

const run = (): void => {
try {
const apiCompat: ApiCompat = new ApiCompat();
const configuration: Configuration = new Configuration();

console.log(apiCompat.getVersion());

const optionalParameters = getOptionalParameters(configuration);
const comparisonResult = apiCompat.compare(
configuration.contractList,
configuration.implementationFolder,
optionalParameters);
const result: Result = new Result(comparisonResult);

console.log(comparisonResult.concat(EOL));

if (configuration.generateLog) {
writeResult(result, configuration);
}

setResult(result.checkResult(configuration), resultText(result));
} catch (error) {
setResult(TaskResult.Failed, error);
}
}

run();
45 changes: 45 additions & 0 deletions tools/devops-tasks/ApiCompat/source/src/result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { EOL } from 'os';
import { Configuration } from './Configuration';
import { TaskResult } from "azure-pipelines-task-lib";

export class Result {
public assemblyName: string;
public issues: string;
public issuesCount: number;

public constructor(result: string) {
let resultLines: string[] = result.split(EOL);
this.assemblyName = this.getAssemblyName(resultLines[0]);
resultLines = resultLines.slice(1, -2);
this.issues = resultLines.join(EOL);
this.issuesCount = resultLines.length;
}

public checkResult = (configuration: Configuration): TaskResult => {
if (this.issuesCount === 0) {
return TaskResult.Succeeded;
} else if (configuration.failOnIssue) {
return TaskResult.Failed;
} else {
return TaskResult.SucceededWithIssues;
}
}

public getFormattedResult = (): string => {
const icon: string = this.issuesCount == 0 ? ':heavy_check_mark:' : ':x:';
const codeFence = '```';
const title = `${ icon } ${ this.issuesCount } Binary Compatibility issues for **${ this.assemblyName }**`;
const body = `<details>${ EOL + EOL + codeFence + EOL + this.issues + EOL + codeFence + EOL + EOL }</details>${ EOL }`;

return (title.concat(EOL).concat(body));
}

private getAssemblyName = (line: string): string => {
return line.replace('Compat issues with assembly ', '').replace(':', '');
}
}