Azure DevOps Services | Azure DevOps Server 2022 - Azure DevOps Server 2019
This guide walks you through creating, testing, and publishing custom build or release tasks as Azure DevOps extensions. Custom pipeline tasks let you extend Azure DevOps with specialized functionality tailored to your team's workflows, from simple utilities to complex integrations with external systems.
Learn how to do the following tasks:
For more information about Azure Pipelines, see What is Azure Pipelines?
Note
This article covers agent tasks in agent-based extensions. For information about server tasks and server-based extensions, see Server Task Authoring.
PrerequisitesBefore you begin, ensure you have the following requirements in place:
Component Requirement Description Azure DevOps organization Required Create an organization if you don't have one Text editor Recommended Visual Studio Code for IntelliSense and debugging support Node.js Required Install the latest version (Node.js 20 or later recommended) TypeScript compiler Required Install the latest version (version 4.6.3 or later) Azure DevOps CLI (tfx-cli) Required Install usingnpm i -g tfx-cli
to package extensions Azure DevOps Extension SDK Required Install the azure-devops-extension-sdk package Testing framework Required Mocha for unit testing (installed during setup) Project structure
Create a home
directory for your project. After you complete this tutorial, your extension should have the following structure:
|--- README.md
|--- images
|--- extension-icon.png
|--- buildandreleasetask // Task scripts location
|--- task.json // Task definition
|--- index.ts // Main task logic
|--- package.json // Node.js dependencies
|--- tests/ // Unit tests
|--- _suite.ts
|--- success.ts
|--- failure.ts
|--- vss-extension.json // Extension manifest
Important
Your development machine must run the latest version of Node.js to ensure compatibility with the production environment. Update your task.json
file to use Node 20:
"execution": {
"Node20_1": {
"target": "index.js"
}
}
1. Create a custom task
This section guides you through creating the basic structure and implementation of your custom task. All files in this step should be created within the buildandreleasetask
folder inside your project's home
directory.
Note
This walkthrough uses Windows with PowerShell. The steps work on all platforms, but environment variable syntax differs. On Mac or Linux, replace $env:<var>=<val>
with export <var>=<val>
.
Create the basic project structure and install required dependencies:
To initialize the Node.js project, open PowerShell, go to your buildandreleasetask
folder, and run:
npm init --yes
The package.json
file gets created with default settings. The --yes
flag accepts all default options automatically.
Tip
Azure Pipelines agents expect task folders to include node modules. Copy node_modules
to your buildandreleasetask
folder. To manage VSIX file size (50-MB limit), consider running npm install --production
or npm prune --production
before packaging.
Install the Azure Pipelines Task Library:
npm install azure-pipelines-task-lib --save
Install TypeScript type definitions:
npm install @types/node --save-dev
npm install @types/q --save-dev
Set up version control exclusions
echo node_modules > .gitignore
Your build process should run npm install
to rebuild node_modules each time.
Install testing dependencies:
npm install mocha --save-dev -g
npm install sync-request --save-dev
npm install @types/mocha --save-dev
Install TypeScript compiler:
npm install typescript@4.6.3 -g --save-dev
Note
Install TypeScript globally to ensure the tsc
command is available. Without it, TypeScript 2.3.4 is used by default.
Configure TypeScript compilation:
tsc --init --target es2022
The tsconfig.json
file gets created with ES2022 target settings.
With scaffolding complete, create the core task files that define functionality and metadata:
Create the task definition file: Create task.json
in the buildandreleasetask
folder. This file describes your task to the Azure Pipelines system, defining inputs, execution settings, and UI presentation.
{
"$schema": "https://raw.githubusercontent.com/Microsoft/azure-pipelines-task-lib/master/tasks.schema.json",
"id": "{{taskguid}}",
"name": "{{taskname}}",
"friendlyName": "{{taskfriendlyname}}",
"description": "{{taskdescription}}",
"helpMarkDown": "",
"category": "Utility",
"author": "{{taskauthor}}",
"version": {
"Major": 0,
"Minor": 1,
"Patch": 0
},
"instanceNameFormat": "Echo $(samplestring)",
"inputs": [
{
"name": "samplestring",
"type": "string",
"label": "Sample String",
"defaultValue": "",
"required": true,
"helpMarkDown": "A sample string"
}
],
"execution": {
"Node20_1": {
"target": "index.js"
}
}
}
Note
Replace {{placeholders}}
with your task's actual information. The taskguid
must be unique. Generate one using PowerShell: (New-Guid).Guid
To implement the task logic, create index.ts
with your task's main functionality:
import tl = require('azure-pipelines-task-lib/task');
async function run() {
try {
const inputString: string | undefined = tl.getInput('samplestring', true);
if (inputString == 'bad') {
tl.setResult(tl.TaskResult.Failed, 'Bad input was given');
return;
}
console.log('Hello', inputString);
}
catch (err: any) {
tl.setResult(tl.TaskResult.Failed, err.message);
}
}
run();
Compile TypeScript to JavaScript:
tsc
The index.js
file gets created from your TypeScript source.
The task.json
file is the heart of your task definition. Here are the key properties:
id
Unique GUID identifier for your task Generated using (New-Guid).Guid
name
Task name without spaces (used internally) MyCustomTask
friendlyName
Display name shown in the UI My Custom Task
description
Detailed description of task functionality Performs custom operations on files
author
Publisher or author name My Company
instanceNameFormat
How the task appears in pipeline steps Process $(inputFile)
inputs
Array of input parameters See the following input types execution
Execution environment specification Node20_1
, PowerShell3
, etc. restrictions
Security restrictions for commands and variables Recommended for new tasks Security restrictions
For production tasks, add security restrictions to limit command usage and variable access:
"restrictions": {
"commands": {
"mode": "restricted"
},
"settableVariables": {
"allowed": ["variable1", "test*"]
}
}
Restricted mode allows only these commands:
logdetail
, logissue
, complete
, setprogress
setsecret
, setvariable
, debug
, settaskvariable
prependpath
, publish
Variable allowlist controls which variables can be set via setvariable
or prependpath
. Supports basic regex patterns.
Common input types for task parameters:
"inputs": [
{
"name": "stringInput",
"type": "string",
"label": "Text Input",
"defaultValue": "",
"required": true,
"helpMarkDown": "Enter a text value"
},
{
"name": "boolInput",
"type": "boolean",
"label": "Enable Feature",
"defaultValue": "false",
"required": false
},
{
"name": "picklistInput",
"type": "pickList",
"label": "Select Option",
"options": {
"option1": "First Option",
"option2": "Second Option"
},
"defaultValue": "option1"
},
{
"name": "fileInput",
"type": "filePath",
"label": "Input File",
"required": true,
"helpMarkDown": "Path to the input file"
}
]
Test your task locally
Before packaging, test your task to ensure it works correctly:
Test with missing input (should fail):
node index.js
Expected output:
##vso[task.debug]agent.workFolder=undefined
##vso[task.debug]loading inputs and endpoints
##vso[task.debug]loaded 0
##vso[task.debug]task result: Failed
##vso[task.issue type=error;]Input required: samplestring
##vso[task.complete result=Failed;]Input required: samplestring
Test with valid input (should succeed):
$env:INPUT_SAMPLESTRING="World"
node index.js
Expected output:
##vso[task.debug]agent.workFolder=undefined
##vso[task.debug]loading inputs and endpoints
##vso[task.debug]loading INPUT_SAMPLESTRING
##vso[task.debug]loaded 1
##vso[task.debug]samplestring=World
Hello World
Test error handling:
$env:INPUT_SAMPLESTRING="bad"
node index.js
This action should trigger the error handling path in your code.
For more information, see the Build/release task reference.
2. Implement comprehensive unit testingTesting your task thoroughly ensures reliability and helps catch issues before deployment to production pipelines.
Install testing dependenciesInstall the required testing tools:
npm install mocha --save-dev -g
npm install sync-request --save-dev
npm install @types/mocha --save-dev
Create test
Create a tests
folder in your task directory containing a _suite.ts
file:
import * as path from 'path';
import * as assert from 'assert';
import * as ttm from 'azure-pipelines-task-lib/mock-test';
describe('Sample task tests', function () {
before( function() {
// Setup before tests
});
after(() => {
// Cleanup after tests
});
it('should succeed with simple inputs', function(done: Mocha.Done) {
// Success test implementation
});
it('should fail if tool returns 1', function(done: Mocha.Done) {
// Failure test implementation
});
});
Tip
Your test folder should be located in the task folder (for example, buildandreleasetask
). If you encounter a sync-request error, install it in the task folder: npm i --save-dev sync-request
.
Create success.ts
in your test directory to simulate successful task execution:
import ma = require('azure-pipelines-task-lib/mock-answer');
import tmrm = require('azure-pipelines-task-lib/mock-run');
import path = require('path');
let taskPath = path.join(__dirname, '..', 'index.js');
let tmr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath);
// Set valid input for success scenario
tmr.setInput('samplestring', 'human');
tmr.run();
Add the success test to your _suite.ts
file:
it('should succeed with simple inputs', function(done: Mocha.Done) {
this.timeout(1000);
let tp: string = path.join(__dirname, 'success.js');
let tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
tr.runAsync().then(() => {
console.log(tr.succeeded);
assert.equal(tr.succeeded, true, 'should have succeeded');
assert.equal(tr.warningIssues.length, 0, "should have no warnings");
assert.equal(tr.errorIssues.length, 0, "should have no errors");
console.log(tr.stdout);
assert.equal(tr.stdout.indexOf('Hello human') >= 0, true, "should display Hello human");
done();
}).catch((error) => {
done(error); // Ensure the test case fails if there's an error
});
});
Create failure.ts
in your test directory to test error handling:
import ma = require('azure-pipelines-task-lib/mock-answer');
import tmrm = require('azure-pipelines-task-lib/mock-run');
import path = require('path');
let taskPath = path.join(__dirname, '..', 'index.js');
let tmr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath);
// Set invalid input to trigger failure
tmr.setInput('samplestring', 'bad');
tmr.run();
Add the failure test to your _suite.ts
file:
it('should fail if tool returns 1', function(done: Mocha.Done) {
this.timeout(1000);
const tp = path.join(__dirname, 'failure.js');
const tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
tr.runAsync().then(() => {
console.log(tr.succeeded);
assert.equal(tr.succeeded, false, 'should have failed');
assert.equal(tr.warningIssues.length, 0, 'should have no warnings');
assert.equal(tr.errorIssues.length, 1, 'should have 1 error issue');
assert.equal(tr.errorIssues[0], 'Bad input was given', 'error issue output');
assert.equal(tr.stdout.indexOf('Hello bad'), -1, 'Should not display Hello bad');
done();
});
});
Execute the test suite:
# Compile TypeScript
tsc
# Run tests
mocha tests/_suite.js
Both tests should pass. For verbose output (similar to build console output), set the trace environment variable:
$env:TASK_TEST_TRACE=1
mocha tests/_suite.js
Test coverage best practices
setSecret
for sensitive dataAfter testing your task locally and implementing comprehensive unit tests, package it into an extension for Azure DevOps.
Install packaging toolsInstall the Cross Platform Command Line Interface (tfx-cli):
npm install -g tfx-cli
Create the extension manifest
The extension manifest (vss-extension.json
) contains all information about your extension, including references to your task folders and images.
Create an images folder with an extension-icon.png
file
Create vss-extension.json
in your extension's root directory (not in the task folder):
{
"manifestVersion": 1,
"id": "my-custom-tasks",
"name": "My Custom Tasks",
"version": "1.0.0",
"publisher": "your-publisher-id",
"targets": [
{
"id": "Microsoft.VisualStudio.Services"
}
],
"description": "Custom build and release tasks for Azure DevOps",
"categories": [
"Azure Pipelines"
],
"icons": {
"default": "images/extension-icon.png"
},
"files": [
{
"path": "MyCustomTask"
}
],
"contributions": [
{
"id": "my-custom-task",
"type": "ms.vss-distributed-task.task",
"targets": [
"ms.vss-distributed-task.tasks"
],
"properties": {
"name": "MyCustomTask"
}
}
]
}
publisher
Your marketplace publisher identifier contributions.id
Unique identifier within the extension contributions.properties.name
Must match your task folder name files.path
Path to your task folder relative to the manifest
Note
Change the publisher value to your publisher name. For information about creating a publisher, see Create your publisher.
Package your extensionPackage your extension into a .vsix file:
tfx extension create --manifest-globs vss-extension.json
Version management
vss-extension.json
for each updatetask.json
for each task update--rev-version
to automatically increment the patch versiontfx extension create --manifest-globs vss-extension.json --rev-version
Important
Both the task version and extension version must be updated for changes to take effect in Azure DevOps.
Versioning strategyFollow semantic versioning principles for your task updates:
Update process:
task.json
versionvss-extension.json
versionmycompany-myteam
)My Team
)Web interface method:
.vsix
fileCommand-line method:
tfx extension publish --manifest-globs vss-extension.json --share-with yourOrganization
Important
Publishers must be verified to share extensions publicly. For more information, see Package/Publish/Install.
4. Install to your organizationAfter sharing, install the extension to your Azure DevOps organization:
After installation, verify your task works correctly:
To maintain your custom task effectively, create automated build and release pipelines that handle testing, packaging, and publishing.
Prerequisites for automationpublisherId
: Your marketplace publisher IDextensionId
: Extension ID from vss-extension.jsonextensionName
: Extension name from vss-extension.jsonartifactName
: Name for the VSIX artifactCreate a YAML pipeline with comprehensive stages for testing, packaging, and publishing:
trigger:
- main
pool:
vmImage: "ubuntu-latest"
variables:
- group: extension-variables # Your variable group name
stages:
- stage: Test_and_validate
displayName: 'Run Tests and Validate Code'
jobs:
- job: RunTests
displayName: 'Execute unit tests'
steps:
- task: TfxInstaller@4
displayName: 'Install TFX CLI'
inputs:
version: "v0.x"
- task: Npm@1
displayName: 'Install task dependencies'
inputs:
command: 'install'
workingDir: '/MyCustomTask' # Update to your task directory
- task: Bash@3
displayName: 'Compile TypeScript'
inputs:
targetType: "inline"
script: |
cd MyCustomTask # Update to your task directory
tsc
- task: Npm@1
displayName: 'Run unit tests'
inputs:
command: 'custom'
workingDir: '/MyCustomTask' # Update to your task directory
customCommand: 'test' # Ensure this script exists in package.json
- task: PublishTestResults@2
displayName: 'Publish test results'
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '**/test-results.xml'
searchFolder: '$(System.DefaultWorkingDirectory)'
- stage: Package_extension
displayName: 'Package Extension'
dependsOn: Test_and_validate
condition: succeeded()
jobs:
- job: PackageExtension
displayName: 'Create VSIX package'
steps:
- task: TfxInstaller@4
displayName: 'Install TFX CLI'
inputs:
version: "v0.x"
- task: Npm@1
displayName: 'Install dependencies'
inputs:
command: 'install'
workingDir: '/MyCustomTask'
- task: Bash@3
displayName: 'Compile TypeScript'
inputs:
targetType: "inline"
script: |
cd MyCustomTask
tsc
- task: QueryAzureDevOpsExtensionVersion@4
name: QueryVersion
displayName: 'Query current extension version'
inputs:
connectTo: 'VsTeam'
connectedServiceName: 'marketplace-connection'
publisherId: '$(publisherId)'
extensionId: '$(extensionId)'
versionAction: 'Patch'
- task: PackageAzureDevOpsExtension@4
displayName: 'Package extension'
inputs:
rootFolder: '$(System.DefaultWorkingDirectory)'
publisherId: '$(publisherId)'
extensionId: '$(extensionId)'
extensionName: '$(extensionName)'
extensionVersion: '$(QueryVersion.Extension.Version)'
updateTasksVersion: true
updateTasksVersionType: 'patch'
extensionVisibility: 'private'
extensionPricing: 'free'
- task: PublishBuildArtifacts@1
displayName: 'Publish VSIX artifact'
inputs:
PathtoPublish: '$(System.DefaultWorkingDirectory)/*.vsix'
ArtifactName: '$(artifactName)'
publishLocation: 'Container'
- stage: Publish_to_marketplace
displayName: 'Publish to Marketplace'
dependsOn: Package_extension
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: PublishExtension
displayName: 'Deploy to marketplace'
environment: 'marketplace-production'
strategy:
runOnce:
deploy:
steps:
- task: TfxInstaller@4
displayName: 'Install TFX CLI'
inputs:
version: "v0.x"
- task: PublishAzureDevOpsExtension@4
displayName: 'Publish to marketplace'
inputs:
connectTo: 'VsTeam'
connectedServiceName: 'marketplace-connection'
fileType: 'vsix'
vsixFile: '$(Pipeline.Workspace)/$(artifactName)/*.vsix'
publisherId: '$(publisherId)'
extensionId: '$(extensionId)'
extensionName: '$(extensionName)'
updateTasksVersion: false
extensionVisibility: 'private'
extensionPricing: 'free'
Configure package.json for testing
Add test scripts to your package.json
:
{
"scripts": {
"test": "mocha tests/_suite.js --reporter xunit --reporter-option output=test-results.xml",
"test-verbose": "cross-env TASK_TEST_TRACE=1 npm test"
}
}
Pipeline stage breakdown Stage 1: Test and validate
For classic build pipelines, follow these steps to set up extension packaging and publishing:
Add the Bash
task to compile the TypeScript into JavaScript.
To query the existing version, add the Query Extension Version task using the following inputs:
vss-extension.json
fileTask.Extension.Version
To package the extensions based on manifest Json, add the Package Extension task using the following inputs:
$(System.DefaultWorkingDirectory)
is the root directoryvss-extension.json
vss-extension.json
filevss-extension.json
file$(Task.Extension.Version)
To copy to published files, add the Copy files task using the following inputs:
$(Build.ArtifactStagingDirectory)
Add Publish build artifacts to publish the artifacts for use in other jobs or pipelines. Use the following inputs:
$(Build.ArtifactStagingDirectory)
To install the tfx-cli onto your build agent, add Use Node CLI for Azure DevOps (tfx-cli).
To download the artifacts onto a new job, add the Download build artifacts task using the following inputs:
To get the Publish Extension task, use the following inputs:
/Publisher.*.vsix
vss-extension.json
filevss-extension.json
fileAfter you publish your extension, it needs to be installed in Azure DevOps organizations.
Install extension to organizationInstall your shared extension in a few steps:
Go to Organization settings and select Extensions.
Locate your extension in the Extensions Shared With Me section:
Check that the extension appears in your Installed extensions list:
Note
If you don't see the Extensions tab, ensure you're at the organization administration level (https://dev.azure.com/{organization}/_admin
) and not at the project level.
After installation, perform comprehensive testing:
Create a test pipeline:
Validate functionality:
Test performance:
A: The pipeline agent sends SIGINT
and SIGTERM
signals to task processes. While the task library doesn't provide explicit cancellation handling, your task can implement signal handlers. For details, see Agent jobs cancellation.
A: Automatic deletion isn't supported as it would break existing pipelines. Instead:
A: Upgrade to the latest Node version for better performance and security. For migration guidance, see Upgrading tasks to Node 20.
Support multiple Node versions by including multiple execution sections in task.json
:
"execution": {
"Node20_1": {
"target": "index.js"
},
"Node10": {
"target": "index.js"
}
}
Agents with Node 20 use the preferred version, while older agents fall back to Node 10.
To upgrade your tasks:
To ensure your code behaves as expected, test your tasks on the various Node runner versions.
In your task's execution section, update from Node
or Node10
to Node16
or Node20
.
To support older server versions, you should leave the Node
/Node10
target. Older Azure DevOps Server versions might not have the latest Node runner version included.
You can choose to share the entry point defined in the target or have targets optimized to the Node version used.
"execution": {
"Node10": {
"target": "bash10.js",
"argumentFormat": ""
},
"Node16": {
"target": "bash16.js",
"argumentFormat": ""
},
"Node20_1": {
"target": "bash20.js",
"argumentFormat": ""
}
}
Important
If you don't add support for the Node 20 runner to your custom tasks, they fail on agents installed from the pipelines-agent-*
release feed.
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4