Some time after we started to publish our packages to Azure DevOps Artifacts, users told us that they cannot see new versions of the packages until those are “promoted to Release view”. And indeed, there is a concept of “views”, and packages can be “promoted” to certain views:

Azure DevOps Artifacts package version promotion

Apparently, that is the case when someone consumes packages not directly from your feed, but from their own feed with your feed being an upstream source.

What are these

Feed views

So, as it turns out, package feeds have “views”. The default set is:

  • Local
  • Prerelease
  • Release

More views can be added in Feed settings, if needed.

The point of having these views in the first place is, apparently, to restrict users from consuming non-production-ready versions of a package from an upstream source. So for example only those versions that have Release view are ready to be used in production.

But why would anyone publish a package that is not ready to be used in production? I would expect every new package version contents to be tested before publishing, and so it is published only if everything’s fine. So, what is… oh well.

By default new versions of a package get Local view. There is a setting to assign a different default view:

Azure DevOps Artifacts feed default view

But having set Release view to be the default one, we still get new published packages versions with Local view only, so they still need to be explicitly promoted to Release view. Thank you, Microsoft?

Upstream source

A feed can be added as an upstream source in another feed, and upon doing so one can select a view, which defaults to Release:

Azure DevOps Artifacts add new upstream source

It is my understanding that this is what our users did, when they were adding our feed as an upstream source, and that is why they don’t see new published versions of our packages in their feed, until we promote them to Release view.

So the easiest solution here would be for our users to just re-add our feed as an upstream source with Local view selected. We only publish production-ready packages, so that would be a much better option than promoting every single package version to Release view (kind of a stupid exercise in this case). Although, it could be that I’m missing something about the point of having these views and the way they work with upstream sources.

Either way, let’s see how the process of promoting package versions to a certain view can be automated (as it would be crazy to perform this promotion manually, wouldn’t it).

How to promote a package version to a certain view

Manual process is described here, but we of course will be doing this in an automated manner via Azure DevOps REST API.

Endpoint

First thing you need is the endpoint, and you should notice that it is different for promoting NuGet and npm packages. Take a close look at the URLs, as I overlooked it at first.

What you might still miss is that the example URL for npm doesn’t contain /{project}/ part, while it is actually required. Here’s a screenshot of the documentation page:

Azure DevOps Artifacts wrong promotion URL

Thank you, Microsoft.

Authentication

Having gotten your Personal Access Token, you might think that you need to set it in Authorization header with Bearer type, like this:

$ curl "https://feeds.dev.azure.com/YOUR-ORGANIZATION/YOUR-PROJECT/_apis/packaging/feeds" \
-H 'Authorization: Bearer YOUR-PAT'

But nope, that’s not it, as it will only give you 302 redirection to the authentication page.

What you should set instead is this:

$ curl "https://feeds.dev.azure.com/YOUR-ORGANIZATION/YOUR-PROJECT/_apis/packaging/feeds" \
-u 'YOUR-PAT:'

Yep, you need to provide your PAT as user login with empty password. What is the actual fuck.

API version

Next thing you need to know is the API version, because if you’ll send your request without specifying the API version, you’ll get a 400 Bad Request error like this:

{
  "success": "false",
  "error": "BadRequest",
  "reason": "No api-version was supplied for the \"PATCH\" request. The version must be supplied either as part of the Accept header (e.g. \"application/json; api-version=1.0\") or as a query parameter (e.g. \"?api-version=1.0\").",
  "innerException": null,
  "message": "No api-version was supplied for the \"PATCH\" request. The version must be supplied either as part of the Accept header (e.g. \"application/json; api-version=1.0\") or as a query parameter (e.g. \"?api-version=1.0\").",
  "typeName": "Microsoft.VisualStudio.Services.WebApi.VssVersionNotSpecifiedException, Microsoft.VisualStudio.Services.WebApi, Version=14.0.0.0, Culture=neutral, PublicKeyToken=n0k85f74j3d50ag5",
  "typeKey": "VssVersionNotSpecifiedException",
  "errorCode": 0,
  "eventId": 3000
}

Okay, where to find which API version to use? Well, I don’t know, the only list I found is this one, and the latest version there is 5.1. Let’s try that one then (?api-version=5.1):

{
  "success": "false",
  "error": "BadRequest",
  "reason": "The requested version \"5.1\" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: \"5.1-preview\"",
  "innerException": null,
  "message": "The requested version \"5.1\" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: \"5.1-preview\"",
  "typeName": "Microsoft.VisualStudio.Services.WebApi.VssInvalidPreviewVersionException, Microsoft.VisualStudio.Services.WebApi, Version=14.0.0.0, Culture=neutral, PublicKeyToken=n0k85f74j3d50ag5",
  "typeKey": "VssInvalidPreviewVersionException",
  "errorCode": 0,
  "eventId": 3000
}

Hmm, okay, this one is in preview status, and we don’t want to use preview version of the API in production, do we, so let’s try 5.0 then:

{
  "success": "false",
  "error": "BadRequest",
  "reason": "The requested version \"5.0\" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: \"5.0-preview\"",
  "...": "..."
}

Hmmmm, okay, this one is in preview status too, let’s try 4.1 then:

{
  "success": "false",
  "error": "BadRequest",
  "reason": "The requested version \"4.1\" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: \"4.1-preview\"",
  "...": "..."
}

Hmmmmmm, okay, this one is in preview status as well, let’s try 4.0 then:

{
  "success": "false",
  "error": "BadRequest",
  "reason": "The requested version \"4.0\" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: \"4.0-preview\"",
  "...": "..."
}

Блядь. Okay, let’s try 3.2 then:

{
  "success": "false",
  "error": "BadRequest",
  "reason": "The requested version \"3.2\" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: \"3.2-preview\"",
  "...": "..."
}

Да сука! Okay, let’s try 3.1 then:

{
  "success": "false",
  "error": "BadRequest",
  "reason": "The requested version \"3.1\" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: \"3.1-preview\"",
  "...": "..."
}

Пиздец, блядь! Okay, let’s try 3.0 then

{
  "success": "false",
  "error": "BadRequest",
  "reason": "The requested version \"3.0\" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: \"3.0-preview\"",
  "...": "..."
}

Вы ебанулись там что ли, у вас хоть одна версия вообще вышла в релиз? Okay, let’s try… no, fuck it, I got the point, there is no a single non-preview version of the API, and requests to 1.0 just fail, as apparently this one is ancient history already, so I can just as well take the latest one. Which is not 5.1, by the way, but 6.1 (how should I have known that), so resulting URL query is ?api-version=6.1-preview.

Request body

Documentation says “use JsonPatchOperation to construct the body” and links to different pages for each type of package, but actually this object is the same for all of them, so here’s the one for npm.

What you might overlook is that it needs to be wrapped into views object on the root level of the JSON request body, so here’s the final JSON:

{
    "views":
    {
        "op": "add",
        "path": "/views/-",
        "value": "Release"
    }
}

Why add and not replace? Not sure, but if you’ll try it, then you’ll get:

{
  "success": "false",
  "error": "BadRequest",
  "reason": "Operation 'Replace' is not supported on views.",
  "innerException": null,
  "message": "Operation 'Replace' is not supported on views.",
  "typeName": "Microsoft.VisualStudio.Services.Packaging.Shared.WebApi.Exceptions.ViewOperationException, Microsoft.VisualStudio.Services.Packaging.Shared.WebApi, Version=14.0.0.0, Culture=neutral, PublicKeyToken=n0k85f74j3d50ag5",
  "typeKey": "ViewOperationException",
  "errorCode": 0,
  "eventId": 3000
}

Why "path": "/views/-"? Gee, I don’t know, because what’s the point of wrapping this into views object then? The point of adding - to the end I do now: documentation says it’s for taking the last element in the views array. Which begs for the question “why add and not replace”.

Package name and version

If you used Azure DevOps REST API before, you might have been sending requests like this one to get a list of package versions:

https://feeds.dev.azure.com/ORGANIZATION-NAME/PROJECT-NAME/_apis/packaging/feeds/FEED-ID/packages/370bf540-edf3-4105-a827-10962g54e224/versions

or this one to get information about specific package version:

https://feeds.dev.azure.com/ORGANIZATION-NAME/PROJECT-NAME/_apis/packaging/feeds/FEED-ID/packages/370bf540-edf3-4105-a827-10962g54e224/versions/bt54ceww-0545-4001-8724-43c18216ab5c

So here 370bf540-edf3-4105-a827-10962g54e224 is the package ID and bt54ceww-0545-4001-8724-43c18216ab5c is the ID of one of this package’s versions.

By the way, here there is no distinction between NuGet and npm packages, and endpoint URLs are the same for both, and they work in a consistent way, and IDs are the only thing that would be different (obviously). So, Microsoft, why the API for promotion PATCH requests is not like this?

But anyway, let’s now try to promote this version of the package (which is npm, as you can see from the endpoint URL):

$ curl -X "PATCH" "https://pkgs.dev.azure.com/YOUR-ORGANIZATION/YOUR-PROJECT/_apis/packaging/feeds/FEED-ID/npm/370bf540-edf3-4105-a827-10962g54e224/versions/bt54ceww-0545-4001-8724-43c18216ab5c?api-version=6.1-preview" \
-H 'Content-Type: application/json' \
-u 'YOUR-PAT:' \
-d $'{
  "views": {
    "op": "add",
    "path": "/views/-",
    "value": "Release"
  }
}'

The response will be:

{
  "success": "false",
  "error": "BadRequest",
  "reason": "The package version 'bt54ceww-0545-4001-8724-43c18216ab5c' is not a valid SemVer 2.0.0 version",
  "innerException": null,
  "message": "The package version 'bt54ceww-0545-4001-8724-43c18216ab5c' is not a valid SemVer 2.0.0 version",
  "typeName": "Microsoft.VisualStudio.Services.Npm.WebApi.Exceptions.InvalidPackageException, Microsoft.VisualStudio.Services.Npm.WebApi, Version=14.0.0.0, Culture=neutral, PublicKeyToken=n0k85f74j3d50ag5",
  "typeKey": "InvalidPackageException",
  "errorCode": 0,
  "eventId": 3000
}

First of all, I like how stable eventId is - it’s all the fucking same 3000 value for all sorts of errors.

The actual error though is not a valid SemVer 2.0.0 version. So it says that bt54ceww-0545-4001-8724-43c18216ab5c is not a valid version value, and that was the moment of enlightenment, as I realized that this thing expects an actual user-facing version of the package, not that version’s internal ID from Azure DevOps database.

Just how the fuck should I have known about this, especially given the fact that the other half of API methods operates exactly with internal IDs (which is the way it usually is in other services)? Fucking thank you, Microsoft.

Alright, the user-facing package version is 2021.3.57883, let’s try with it:

$ curl -X "PATCH" "https://pkgs.dev.azure.com/YOUR-ORGANIZATION/YOUR-PROJECT/_apis/packaging/feeds/FEED-ID/npm/370bf540-edf3-4105-a827-10962g54e224/versions/2021.3.57883?api-version=6.1-preview" \
-H 'Content-Type: application/json' \
-u 'YOUR-PAT:' \
-d $'{
  "views": {
    "op": "add",
    "path": "/views/-",
    "value": "Release"
  }
}'

And the response is:

{
  "success": "false",
  "error": "NotFound",
  "reason": "Cannot find the file 370bf540-edf3-4105-a827-10962g54e224-2021.3.57883.tgz in package '370bf540-edf3-4105-a827-10962g54e224 2021.3.57883' in feed 'FEED-NAME'",
  "...": "..."
}

So the version value was okay, but this time it complains about not finding the package. Since now we understand at least some logic behind this thing, let’s try replacing the package ID 370bf540-edf3-4105-a827-10962g54e224 with the user-facing package name. In our case it is prefixed with the namespace like this: @ourcompany/ourlib-windows-x64-msvc2019, so that needs to be URL-encoded, and so here’s the final request:

$ curl -X "PATCH" "https://pkgs.dev.azure.com/YOUR-ORGANIZATION/YOUR-PROJECT/_apis/packaging/feeds/FEED-ID/npm/%40ourcompany%2Fourlib-windows-x64-msvc2019/versions/2021.3.57883?api-version=6.1-preview" \
-H 'Content-Type: application/json' \
-u 'YOUR-PAT:' \
-d $'{
  "views": {
    "op": "add",
    "path": "/views/-",
    "value": "Release"
  }
}'

And the response is:

HTTP/1.1 202 Accepted
Cache-Control: no-cache
Pragma: no-cache
Expires: -1
P3P: CP="CAQ DSG VOR ADKa DEV CQNo TCLo CQR PSV PSD PAI IVBo OUR WAMi BMS DEa NAL SEA XNI COM INT PSY KNL CIN PUY LOC ENT"
X-TFS-ProcessId: 780cgvf8-0133-4a21-ad98-7c4gfbe52f25
Strict-Transport-Security: max-age=31536000; includeSubDomains
ActivityId: 3v2rah33-qf2g-4c22-abm3-7a90075wfaf5
X-TFS-Session: 3v2rah33-qf2g-4c22-abm3-7a90075wfaf5
X-VSS-E2EID: 3v2rah33-qf2g-4c22-abm3-7a90075wfaf5
X-VSS-UserData: 9gfa234e-85ag-5gfb-v558-02c6975b8345:yourname@yourcompany.com
X-FRAME-OPTIONS: SAMEORIGIN
X-Packaging-Migration: NpmBlobMetadata
X-Packaging-OperationId: evlpZCI3FjZlZTVjMjI0ZTVi34LjODc4YTdjYfP5ZDMxMTEwNmI3I4pic2vLIjoxOTc4kL
Request-Context: appId=cid-v1:234f64fg-73f5-475g-bl52-a93451f9t551
Access-Control-Expose-Headers: Request-Context
X-Content-Type-Options: nosniff
X-Cache: CONFIG_NOCACHE
X-MSEdge-Ref: Ref A: GE7ACC7R4FBB405OA33891B336F1CO8U Ref B: NYC39HDPL0132 Ref C: 2021-07-20T17:15:56Z
Date: Tue, 20 Jul 2021 17:15:57 GMT
Connection: close
Content-Length: 0

Fucking finally! And the package version got promoted to Release view:

Azure DevOps Artifacts promoted package version

Since we always know the package name and the version that is being published, it is trivial for us to send just this one request right after the new package version is published.

If you don’t have a luxury of knowing the latest package version, then you can query it like this:

$ curl -s "https://feeds.dev.azure.com/YOUR-ORGANIZATION/YOUR-PROJECT/_apis/packaging/feeds/FEED-ID/packages/PACKAGE-ID" \
-u 'YOUR-PAT:' \
| jq -r .versions[0].normalizedVersion

And then send the PATCH request (don’t forget that it has different URL structure for NuGet and npm).

How to do it in TeamCity

If you would like to perform these promotions as a build step in your TeamCity build configuration, and if the assigned build agent(s) is(are) running on Windows, then you’ll probably encounter some issues.

First of all, executing cURL directly from Windows command line is likely to fail, at least it did for me, so I wrapped it into a shell script like this:

#!/bin/sh

endpoint="[unknown]"
token="[unknown]"

apiStatusCode=0

while getopts ":e:t:" opt; do
  case $opt in
    e) endpoint="$OPTARG"
    ;;
    t) token="$OPTARG"
    ;;
    \?)
        echo "Unknown option -$OPTARG" >&2
        exit 2
    ;;
  esac
done

if [[ "$endpoint" == "[unknown]" || "$token" == "[unknown]" ]]; then
    echo "You need to provide endpoint (-e) and token (-t) values" >&2
    exit 3
fi

echo "Sending request..."

apiStatusCode=$(curl -s -o /dev/null -X PATCH "$endpoint" \
    -H 'Content-Type: application/json' \
    -u "$token:" \
    -d $'{"views": {"op":"add","path":"/views/-","value":"Release"}}' \
    -w "%{http_code}")

printf "API status code: %s\n" "$apiStatusCode"

if [[ -z "$apiStatusCode" || "$apiStatusCode" != 202 ]]; then
    echo "##teamcity[buildStatus status='FAILURE' text='Promotion failed']"
    exit 1
else
    echo "##teamcity[buildStatus status='SUCCESS' text='Promotion was successful']"
fi

And then call it on Windows using Git BASH (sh.exe). Having this as a shell script also makes it possible with a very little effort to use it on Linux and Mac OS build agents too, when/if that will be needed in future.

It might be also a good idea to move some things to project variables, so you could reuse those in different configurations:

TeamCity variables

Now you can just add a new build step with Command Line runner type:

TeamCity build step script

The full command for NuGet package would be:

sh.exe Tools/scripts/promote-azure-artifacts-package.sh -e "%system.commonEndpoint%/%system.feedID%/nuget/packages/%system.companyPrefix%.%system.packageName%/versions/%system.packageVersion%.%dep.sdk_publish.build.number%?api-version=%system.apiVersion%" -t "%system.PAT%"

So the endpoint URL for the promoting request is nicely comprised of project and configuration variables.

For npm, however, there is an addition complexity for Windows build agents, as we have %40ourcompany%2Fourlib-windows-x64-msvc2019 package name in the endpoint URL, and %-based URL escaping for @ (%40) and / (%2F) symbols will in turn need to be escaped for TeamCity, as it uses % symbol for placing variables, but then it also will need to be escaped once more for Windows, because there % symbol is also reserved in command line.

There two ways how you can do that. First one is to use the same Custom script run option and double-escape the % symbols in the package name (don’t get confused by %system.companyPrefix% variable in the middle):

sh.exe Tools/scripts/promote-azure-artifacts-package.sh -e "%system.commonEndpoint%/%system.feedID%/npm/%%%%40%system.companyPrefix%%%%%2F%system.packageName%/versions/%system.packageVersion%.%dep.sdk_publish.build.number%?api-version=%system.apiVersion%" -t "%system.PAT%"

Or you can set run option to Executable with parameters, and then it will be enough to escape % symbol in package name just once:

TeamCity build step executable