Adding Visual Studio 2022 to Azure DevOps Server 2020

Visual Studio 2022 is out! But Azure DevOps Server hasn't had a release to support it yet. This means that in Azure Pipelines it won't detect your freshly installed copy of Visual Studio 2022.

Adding Visual Studio 2022 to Azure DevOps Server 2020
Install updates to Azure DevOps Server 2020

I suspect that either the next major version of Azure DevOps Server (2022?) will bring official support for Visual Studio 2022, or - if we're lucky - the next update pack, Azure DevOps Server 2020.2.

Download the tasks from my GitHub

If you trust me, you can download the tasks straight from my GitHub repo. You will find the original tasks as well as a version with a consistent unique id.

Find the original and patched tasks in my GitHub repo

To install the tasks into your Azure DevOps Server use tfx:

npm install tfx-cli -g
tfx build tasks upload --task-zip-path VSBuild.71a9a2d3-a98a-4caa-96ab-affca411ecda-1.198.1.zip --service-url https://yourtfs.com/tfs/DefaultCollection/

The side-by-side tasks will show up as a new task on your server and can be used safely alongside the default built-in ones.

The side-by-side version is now also available as an extension on the marketplace:

Visual Studio 2022 for Azure DevOps Server - Visual Studio Marketplace
Extension for Azure DevOps - Adds side-by-side version of Visual Studio 2022 for Azure DevOps Server.
DotNetCore 6 for Azure DevOps Server - Visual Studio Marketplace
Extension for Azure DevOps - Adds side-by-side version of DotNetCore 6 for Azure DevOps Server.

Download the tasks from Azure DevOps cloud

If you have access to an Azure DevOps organization, you can download the original zips straight from its stores and install them into your target project collection:

$tasksToDownload = @("VSBuild", "VsTest", "VsTestPlatformToolInstaller", 
                  "MSBuild", "DotNetCoreInstaller", "DotNetCoreCLI")

$org = "<<insert source org>>"
$pat = "<<insert PAT | Agent Pool (Manage)>>"
$projectCollectionUri = "https://yourtfs/yourcollection"

$url = "https://dev.azure.com/$org"
$header = @{authorization = "Basic $([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(".:$pat")))"}

$tasks = Invoke-RestMethod -Uri "$url/_apis/distributedtask/tasks" -Method Get -ContentType "application/json" -Headers $header | ConvertFrom-Json -AsHashtable

foreach ($taskName in $tasksToDownload)
{
    $taskMetadatas = $tasks.value | ?{ $_.name -ieq $taskName }
    foreach ($taskMetadata in $taskMetadatas)
    {
        $taskid = $taskMetadata.id
        $taskversion = "$($taskMetadata.version.major).$($taskMetadata.version.minor).$($taskMetadata.version.patch)"
        $taskZip = "$taskName.$taskid.$taskversion.zip"
        Invoke-WebRequest -Uri "$url/_apis/distributedtask/tasks/$taskid/$taskversion" -OutFile $taskZip -Headers $header

        & tfx build tasks upload --task-zip-path "$taskZip" --service-url $projectCollectionUri
    }
}
Download the prebuilt tasks straight from an Azure DevOps organization

Manually build the tasks and install them directly

If you want to use Visual Studio 2022, you can manually push the latest version of the tasks to your server. I've created a script that will do that for you:

# Update the list of tasks you need below
$tasksToBuild = @("VSBuildV1", "VsTestV1", "VsTestV2", "VsTestPlatformToolInstallerV1", 
                  "MSBuildV1", "DotNetCoreInstallerV1", "DotNetCoreCLIV2")
                  
# Update the collection uri below
$projectCollectionUri = "https://yourtfs/yourcollection"

$outputDir = md _build -force

& git clone https://github.com/microsoft/azure-pipelines-tasks.git --quiet
cd azure-pipelines-tasks

& git config --local pager.branch false
$branches = & git branch -r
$version = (($branches | Select-String -pattern "(?<=origin/releases/m)\d+$").Matches) | %{ [int32]$_.Value } | measure-object -maximum
$version = $version.Maximum

& git reset --hard origin/releases/m$version

& npm install
& npm install tfx-cli -g

foreach ($task in $tasksToBuild)
{
    & node make.js build --task $task
    
    $taskDir = "$outputDir/$task"
    copy "./_build/Tasks/$task" $taskDir -Recurse

    & tfx build tasks upload --task-path "./_build/Tasks/$task" --service-url $projectCollectionUri
}
Install tasks directly into Azure DevOps Server project collection

Manually build the tasks and install them through an extension

Alternatively, build a custom extension pack and upload that (privately) to the Azure DevOps Marketplace. you'll need to create an extension manifest:

{
  "manifestVersion": 1,
  "name": "Visual Studio 2022",
  "version": "1.196.0",
  "publisher": "jessehouwing",
  "targets": [
    {
      "id": "Microsoft.VisualStudio.Services"
    }
  ],
  "public": false,
  "description": "Visual Studio 2022",
  "categories": [
    "Azure Pipelines"
  ],
  "files": [
    {
      "path": "_build"
    }
  ],
  "contributions": [
  ]
}
Create an extension manifest for your private extension.

Then run the script below to update the manifest above and package the extension with the tasks you have specified. Upload the extension to your server's local marketplace or upload the extension to the Azure DevOps Marketplace under your own publisher.

# Update the list of tasks you need below
$tasksToBuild = @("VSBuildV1", "VsTestV2", "VsTestPlatformToolInstallerV1", 
                  "MSBuildV1", "DotNetCoreInstallerV1", "DotNetCoreCLIV2")
                  
# Update the marketplace details below
$publisherId = "jessehouwing"
$extensionId = "visual-studio-2022-tasks"

$Source = @”
    using System;
    using System.Security.Cryptography;
    using System.Text;
    public static class UUIDv5
    {
        public static Guid Create(Guid namespaceId, string name)
        {
            if (name == null)
                throw new ArgumentNullException("name");
            // convert the name to a sequence of octets (as defined by the standard or conventions of its namespace) (step 3)
            // ASSUME: UTF-8 encoding is always appropriate
            byte[] nameBytes = Encoding.UTF8.GetBytes(name);
            // convert the namespace UUID to network order (step 3)
            byte[] namespaceBytes = namespaceId.ToByteArray();
            SwapByteOrder(namespaceBytes);
            // comput the hash of the name space ID concatenated with the name (step 4)
            byte[] hash;
            using (HashAlgorithm algorithm =  SHA1.Create())
            {
                algorithm.TransformBlock(namespaceBytes, 0, namespaceBytes.Length, null, 0);
                algorithm.TransformFinalBlock(nameBytes, 0, nameBytes.Length);
                hash = algorithm.Hash;
            }
            // most bytes from the hash are copied straight to the bytes of the new GUID (steps 5-7, 9, 11-12)
            byte[] newGuid = new byte[16];
            Array.Copy(hash, 0, newGuid, 0, 16);
            // set the four most significant bits (bits 12 through 15) of the time_hi_and_version field to the appropriate 4-bit version number from Section 4.1.3 (step 8)
            newGuid[6] = (byte)((newGuid[6] & 0x0F) | (5 << 4));
            // set the two most significant bits (bits 6 and 7) of the clock_seq_hi_and_reserved to zero and one, respectively (step 10)
            newGuid[8] = (byte)((newGuid[8] & 0x3F) | 0x80);
            // convert the resulting UUID to local byte order (step 13)
            SwapByteOrder(newGuid);
            return new Guid(newGuid);
        }
        /// <summary>
        /// The namespace for fully-qualified domain names (from RFC 4122, Appendix C).
        /// </summary>
        public static readonly Guid DnsNamespace = new Guid("6ba7b810-9dad-11d1-80b4-00c04fd430c8");
        /// <summary>
        /// The namespace for URLs (from RFC 4122, Appendix C).
        /// </summary>
        public static readonly Guid UrlNamespace = new Guid("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
        /// <summary>
        /// The namespace for ISO OIDs (from RFC 4122, Appendix C).
        /// </summary>
        public static readonly Guid IsoOidNamespace = new Guid("6ba7b812-9dad-11d1-80b4-00c04fd430c8");
        // Converts a GUID (expressed as a byte array) to/from network order (MSB-first).
        internal static void SwapByteOrder(byte[] guid)
        {
            SwapBytes(guid, 0, 3);
            SwapBytes(guid, 1, 2);
            SwapBytes(guid, 4, 5);
            SwapBytes(guid, 6, 7);
        }
        private static void SwapBytes(byte[] guid, int left, int right)
        {
            byte temp = guid[left];
            guid[left] = guid[right];
            guid[right] = temp;
        }
    }
“@

Add-Type -TypeDefinition $Source -Language CSharp 

$outputDir = md _build -force

$extensionManifest = gc "vss-extension.json" | ConvertFrom-Json
$extensionManifest.contributions = @()

& git clone https://github.com/microsoft/azure-pipelines-tasks.git --quiet
cd azure-pipelines-tasks

& git config --local pager.branch false
$branches = & git branch -r
$version = (($branches | Select-String -pattern "(?<=origin/releases/m)\d+$").Matches) | %{ [int32]$_.Value } | measure-object -maximum
$version = $version.Maximum

& git reset --hard origin/releases/m$version

& npm install

foreach ($task in $tasksToBuild)
{
    & node make.js build --task $task
    
    $taskDir = "$outputDir/$task"
    copy "./_build/Tasks/$task" $taskDir -Recurse
    $kind = "tmp"
    $taskManifests = @("task.json", "task.loc.json")

    foreach ($taskManifest in $taskManifests)
    {
        $manifestPath = "$taskDir/$taskManifest"
        $manifest = (gc $manifestPath) | ConvertFrom-Json
        $manifest.name = "$kind-$($manifest.name)"

        if ($taskManifest -eq "task.json")
        {
            $manifest.friendlyName = "$($manifest.friendlyName) ($kind)"
            Write-Host "Updating resources..."
            $resourceFiles = dir "$taskDir\Strings\resources.resjson\resources.resjson" -recurse
            foreach ($resourceFile in $resourceFiles)
            {
                $resources = (gc $resourceFile) | ConvertFrom-Json
                $resources."loc.friendlyName" = $manifest.friendlyName
                $resources | ConvertTo-Json -depth 100 | Out-File $resourceFile -Encoding utf8NoBOM
            }
        }
        $manifest.id = [UUIDv5]::Create([guid]$manifest.id, [string]$manifest.name).ToString()
        $manifest.author = "Jesse Houwing"
        $manifest | ConvertTo-Json -depth 100 | Out-File $manifestPath -Encoding utf8NoBOM
    }

    $extensionManifest.contributions += [ordered] @{
        "id" = "$task"
        "type" = "ms.vss-distributed-task.task"
        "targets" = @("ms.vss-distributed-task.tasks")
        "properties" = @{
            "name" = "_build/$task"
        }
    }
}

cd ..

$extensionManifest.version = "1.$version.0"
$extensionManifest | ConvertTo-Json -depth 100 | Out-File "vss-extension.json" -Encoding utf8NoBOM

& npm install tfx-cli -g
& tfx extension create --manifests vss-extension.json --output-path visual-studio-2022-tasks.vsix --publisher $publisherId --extension-id $extensionId
Build private extension pack for the required tasks

When Microsoft releases its updated tasks, simply uninstall the extension from your project collection.

Note: These tasks won't automatically upgrade your tasks in-place. Instead, they will be installed side-by-side as true temporary tasks with a unique name and a unique task id.

Required agent version

You will need to install the most recent agent from the azure-pipelines-agent repository for it to auto-detect Visual Studio 2022, or alternatively add the capabilities to the agent manually.

You may need to force Azure DevOps Server to not downgrade back to its preferred agent version. You can do so by setting the following environment variable at the system level on your server before launching the agent:

 AZP_AGENT_DOWNGRADE_DISABLED=true 

These tricks will work for most tasks in the azure-pipelines-tasks repository, as long as it doesn't depend on a UI extension or service connection type that isn't available in your version of Azure DevOps Server.