Azure Artifacts Feeds: Publishing NuGet and npm Packages, Upstream Sources and Views
An Azure Artifacts feed stores NuGet and npm packages as immutable versions, serves nuget.org and npmjs from one address through upstream sources, and uses views to decide which versions consumers see. We publish Bookstore's contracts package with NuGetAuthenticate@1 and dotnet nuget push, point CI restore at the feed, and cover build identity roles, promotion, retention and the roadmap status of the old Artifacts tasks.
Version note: The packaging, version scripts and NuGet commands in this post were run locally with .NET 10 (SDK 10.0.404) and npm 11; the pipeline and template files were parsed and checked with yq 4.54. How Azure Artifacts feeds, permissions, upstream sources, views and the tasks behave is based on the official documentation on Microsoft Learn (November 2026 versions).
Guess First
Hakan's team publishes version 1.0.0 of the Bookstore.Contracts package, which carries the order events of Bookstore, the online bookstore used as the shared example across this site's series, to the company's Azure Artifacts feed. An hour later they notice a field in the OrderPlaced record has the wrong name. Nobody uses the package yet, so the plan is simple: delete 1.0.0 from the feed and publish the fixed code as 1.0.0 again. Does the second push succeed?
First let us use a local folder as a NuGet source and send the same version twice:
$ dotnet nuget push pkgs/Bookstore.Contracts.1.0.0-ci.1302.nupkg --source …/localfeed
Pushing Bookstore.Contracts.1.0.0-ci.1302.nupkg to '…/localfeed'...
Your package was pushed.
$ dotnet nuget push pkgs/Bookstore.Contracts.1.0.0-ci.1302.nupkg --source …/localfeed
Pushing Bookstore.Contracts.1.0.0-ci.1302.nupkg to '…/localfeed'...
Your package was pushed.
The folder source quietly accepted the second push and overwrote the file. Azure Artifacts answers differently: the second push fails, and deleting does not change that. According to the documentation, packages in a feed are immutable: the content of a published version cannot be changed, its number cannot be reused for a new package, and even if the package is deleted the version number stays permanently reserved in the feed's history. All Hakan's team can do is publish 1.0.1. The rule may look annoying, but it makes it impossible for two machines to download different content under the same version number; the local folder source does exactly the opposite.
Feeds: Organization and Project Scope
A feed is where Azure Artifacts keeps packages and manages access to them. A single feed can host NuGet, npm, Maven, Python, Cargo and Universal Packages together; NuGet and npm are enough for Bookstore. You make two choices when you create a feed. Scope: a project-scoped feed is visible only from within that project; an organization-scoped feed is visible from every project in the organization and cannot be converted to project-scoped later. Visibility: who can see the feed; for other feeds to use it as an upstream, visibility must be open to members of the organization or members of the Microsoft Entra ID tenant. Since public projects are being retired, public feeds will also become inaccessible in 2027.
Scope also shows up in the feed address. The NuGet address of the project-scoped feed called bookstore in the Bookstore project is https://pkgs.dev.azure.com/bookstore-org/Bookstore/_packaging/bookstore/nuget/v3/index.json; an organization-scoped feed has no project name in the address. For npm the same feed's address ends with …/_packaging/bookstore/npm/registry/.
Feed Permissions and the Pipeline's Identity
A feed has four roles. Feed Reader can list and download packages. Feed and Upstream Reader (Collaborator) can additionally save packages from upstream sources. Feed Publisher (Contributor) can publish, promote, deprecate and unlist packages. Feed Owner manages feed settings, upstream sources and package deletion. That saving from upstream needs at least Collaborator is an important detail: someone with the Reader role cannot pull a nuget.org package through this feed if it has not been saved to the feed yet.
Pipelines run not on behalf of a user but with a build identity. The documentation names two: the organization-level Project Collection Build Service (bookstore-org) and the project-level Bookstore Build Service (bookstore-org). By default both get the Collaborator role on the feed; a pipeline can restore from the feed and save upstream packages, but cannot publish. For a pipeline that publishes packages, the documentation recommends giving both identities the Feed Publisher (Contributor) role. Which identity is used depends on the job authorization scope in the project settings; that setting, and why it should be narrowed to the project, is the topic of the eighth post.
Preparing the Contracts Package
Other services that will listen to Bookstore's order events (notifications, reporting) should not depend on the API's internal types. For that we added a separate class library to the solution: src/Bookstore.Contracts. It holds a single event for now:
namespace Bookstore.Contracts.Ordering;
/// <summary>Published when a customer's order has been placed and stock has been reserved.</summary>
/// <param name="OrderId">Ordering module's order id.</param>
/// <param name="CustomerEmail">Customer's e-mail address for notifications.</param>
/// <param name="PlacedAtUtc">When the order was placed, in UTC.</param>
/// <param name="Lines">Ordered books.</param>
/// <param name="Total">Order total.</param>
/// <param name="Currency">ISO 4217 currency code, for example TRY.</param>
public sealed record OrderPlaced(
int OrderId,
string CustomerEmail,
DateTime PlacedAtUtc,
IReadOnlyList<OrderPlacedLine> Lines,
decimal Total,
string Currency);
/// <summary>One ordered book inside <see cref="OrderPlaced"/>.</summary>
/// <param name="Isbn">ISBN-13 of the book.</param>
/// <param name="Title">Book title at the time of the order.</param>
/// <param name="Quantity">Number of copies.</param>
/// <param name="UnitPrice">Unit price at the time of the order.</param>
public sealed record OrderPlacedLine(string Isbn, string Title, int Quantity, decimal UnitPrice);
The project file defines the package's identity and its own version line: PackageId Bookstore.Contracts, VersionPrefix 1.0.0. The contracts package is versioned independently of the application; even if the API produces a new version every day, the package version stays put as long as the event schema does not change. The rules for versioning the package, the changelog and package design itself belong to the Versioning and Release Management series; here we only look at getting it to the feed and managing the feed. We packed it locally:
$ dotnet pack src/Bookstore.Contracts/Bookstore.Contracts.csproj --no-restore -c Release -p:Version=1.0.0-ci.1302 -o pkgs
Bookstore.Contracts -> …/src/Bookstore.Contracts/bin/Release/net10.0/Bookstore.Contracts.dll
The package Bookstore.Contracts.1.0.0-ci.1302 is missing a readme. Go to https://aka.ms/nuget/authoring-best-practices/readme to learn why package readmes are important.
Successfully created package '…/pkgs/Bookstore.Contracts.1.0.0-ci.1302.nupkg'.
$ unzip -l pkgs/Bookstore.Contracts.1.0.0-ci.1302.nupkg
Length Date Time Name
--------- ---------- ----- ----
482 11-15-2026 12:06 _rels/.rels
516 11-15-2026 12:06 Bookstore.Contracts.nuspec
10240 11-15-2026 09:06 lib/net10.0/Bookstore.Contracts.dll
4140 11-15-2026 09:06 lib/net10.0/Bookstore.Contracts.xml
521 11-15-2026 12:06 [Content_Types].xml
702 11-15-2026 12:06 package/services/metadata/core-properties/nuget.psmdcp
--------- -------
16601 6 files
The readme message is a reminder, not an error; the README shown on the package page is part of package design too. The XML file generated from these comments (Bookstore.Contracts.xml) ships inside the package, so a developer who uses the package sees the field descriptions in the IDE.
Pushing from the Pipeline to the Feed: NuGetAuthenticate@1 and dotnet nuget push
The package is published from a separate pipeline called bookstore-contracts. It was also the source in the pipeline completion trigger example in the second post.
parameters:
- name: publishStable
displayName: Publish a stable version (main only)
type: boolean
default: false
trigger:
branches:
include:
- main
paths:
include:
- src/Bookstore.Contracts
pool:
vmImage: ubuntu-24.04
variables:
project: src/Bookstore.Contracts/Bookstore.Contracts.csproj
nugetConfig: build/nuget.feed.config
steps:
- checkout: self
- task: UseDotNet@2
displayName: Install .NET SDK from global.json
inputs:
packageType: sdk
useGlobalJson: true
- task: NuGetAuthenticate@1
displayName: Authenticate to Azure Artifacts
- script: bash scripts/set-contracts-version.sh
displayName: Decide package version
env:
PUBLISH_STABLE: ${{ parameters.publishStable }}
- script: dotnet restore $(project) --configfile $(nugetConfig) --locked-mode
displayName: Restore through the feed
- script: dotnet pack $(project) --no-restore -c Release -p:Version=$(contractsVersion) -o $(Build.ArtifactStagingDirectory)/packages
displayName: Pack $(contractsVersion)
- script: >-
dotnet nuget push "$(Build.ArtifactStagingDirectory)/packages/*.nupkg"
--source bookstore --configfile $(nugetConfig) --api-key az --skip-duplicate
displayName: Push to feed
NuGetAuthenticate@1 does not download or upload packages by itself; it installs NuGet's credential provider and puts the pipeline identity's token where dotnet, NuGet and MSBuild will find it. The dotnet commands that follow connect to the feed with that identity. --api-key az is not a secret: Azure Artifacts does the real authentication through the credential provider and only wants a non-empty placeholder for the API key. The documentation for NuGetCommand@2, common in older examples, says hosted images no longer ship Mono starting with Ubuntu 24.04, that the nuget.exe under that task needs Mono, and that on Ubuntu you should move to NuGetAuthenticate@1 with the .NET CLI.
The feed address lives in one place, the build/nuget.feed.config file:
<?xml version="1.0" encoding="utf-8"?>
<!-- CI restores and pushes through the Bookstore feed only. nuget.org reaches the build as an upstream source of the feed. -->
<configuration>
<packageSources>
<clear />
<add key="bookstore" value="https://pkgs.dev.azure.com/bookstore-org/Bookstore/_packaging/bookstore/nuget/v3/index.json" />
</packageSources>
</configuration>
<clear /> drops sources coming from other NuGet config files on the machine; the documentation recommends a single source in the config for a feed that uses upstreams. We put the file under build/ instead of the default nuget.config at the repository root because of this series' snapshot: a feed config at the root would break local restore for a reader without access to the feed. In a real company developers are expected to use the feed too; the config then sits at the root and developers authenticate with the Azure Artifacts Credential Provider.
The version decision is in a small script. Every run produces a unique prerelease version; a stable version only comes out of main when publishStable is ticked at run time. The script accepts the parameter's text form in either capitalization:
$ BUILD_SOURCEBRANCH=refs/heads/main BUILD_BUILDID=1302 PUBLISH_STABLE=False bash scripts/set-contracts-version.sh
Contracts package version: 1.0.0-ci.1302
##vso[task.setvariable variable=contractsVersion]1.0.0-ci.1302
$ BUILD_SOURCEBRANCH=refs/heads/main BUILD_BUILDID=1302 PUBLISH_STABLE=True bash scripts/set-contracts-version.sh
Contracts package version: 1.0.0
##vso[task.setvariable variable=contractsVersion]1.0.0
$ BUILD_SOURCEBRANCH=refs/heads/feature/order-events BUILD_BUILDID=1302 PUBLISH_STABLE=True bash scripts/set-contracts-version.sh
##vso[task.logissue type=error]Stable contracts versions are published only from main (this run: refs/heads/feature/order-events).
Why --skip-duplicate is there also comes down to immutability. The pipeline artifacts documentation states it plainly: if a job that failed after the package was published is rerun, the push tries to send the same version a second time and breaks. --skip-duplicate skips the existing version and carries on. The price is that it stays quiet even when you try to send a package with genuinely different content under the same version; we balance that by making the version unique for every run. On a local folder source the same option warns that it is not supported for that type of push, so you can see this behaviour only on a real feed, not locally.
Upstream Sources: Using nuget.org and npmjs Through the Feed
If "Include packages from common public sources" is selected when the feed is created, nuget.org, npmjs.com and other public sources are added as upstream sources. From then on the feed serves its own packages and the public packages from a single address. According to the documentation the search order is fixed: first packages published directly to the feed, then packages previously saved from upstream, and last the upstream sources in the order they are configured. That removes the uncertainty NuGet creates by querying several sources in parallel and taking whichever answers first. When an identity with Collaborator or higher downloads a package from an upstream, a copy of the package is saved to the feed; even if nuget.org is unreachable, that version keeps coming from the feed.
We connected Bookstore's CI pipeline to the feed too. In the new version of the shared templates (v1.2.0), the restore step sets up identity first when a config file is given:
- ${{ if ne(parameters.nugetConfig, '') }}:
- task: NuGetAuthenticate@1
displayName: Authenticate to Azure Artifacts
- script: dotnet restore ${{ parameters.solution }} --configfile ${{ parameters.nugetConfig }} --locked-mode
displayName: Restore (locked, feed)
- ${{ else }}:
- script: dotnet restore ${{ parameters.solution }} --locked-mode
displayName: Restore (locked)
In the Angular job the same is done with .npmrc. The template copies the given file into the application folder as .npmrc, and npmAuthenticate@0 appends credentials for the registries in that file to its end:
- ${{ if ne(parameters.npmrc, '') }}:
- script: cp ${{ parameters.npmrc }} ${{ parameters.workingDirectory }}/.npmrc
displayName: Use feed .npmrc
- task: npmAuthenticate@0
displayName: Authenticate to Azure Artifacts
inputs:
workingFile: ${{ parameters.workingDirectory }}/.npmrc
registry=https://pkgs.dev.azure.com/bookstore-org/Bookstore/_packaging/bookstore/npm/registry/
always-auth=true
One more detail: the resolved field of every package in package-lock.json contains the registry.npmjs.org address. npm's replace-registry-host setting replaces that address with the configured registry; its default value is visible in a local npm too:
$ npm config ls -l | grep -E "replace-registry-host|^registry"
registry = "https://registry.npmjs.org/"
replace-registry-host = "npmjs"
The value npmjs means only npmjs.org addresses are replaced with the configured registry; that is how the same lock file works against npmjs locally and against the feed in CI. The application pipeline passes these two files through the nugetConfig: build/nuget.feed.config and npmrc: build/feed.npmrc parameters and moves its ref up to refs/tags/v1.2.0.
From a supply chain point of view, an upstream source cuts both ways. The good side: CI goes out not directly to public sources but through one company feed, so you can see in one place which version of which package came in; the saved copy stays in the feed even if its owner removes it from the public source. The side to watch: a version later found to be malicious has been saved to the feed the same way, and removing it from the public source does not delete it from the feed; a Feed Owner has to delete it by hand. One rule in the documentation can be surprising too: while an upstream source is enabled, you cannot publish a version to your feed that already exists in that upstream. A team that wants to put its own patch of a well-known package under the same version has to follow the documented order: disable the upstream, publish the package, enable the upstream again. Package source security and dependency scanning were covered in the DevSecOps series.
Promotion with Views, and Retention
Every feed comes with three views: @Local, @Prerelease and @Release. @Local is the default view; it contains everything published to the feed and saved from upstream. Publishing always goes to the feed itself, that is to @Local; there is no direct publishing to the other views. Moving a package version to @Prerelease or @Release is a "promote": it requires the Feed Publisher (Contributor) or Feed Owner role and is done in the web UI or through the REST API. According to the documentation promotion cannot be undone; once a version enters @Release it cannot be taken out.
The value of views shows on the consumer side. If the notification service reads Bookstore's contract packages only from the @Release view instead of the whole feed, it never sees prerelease versions such as 1.0.0-ci.1302 produced on every CI run. A view's NuGet address is formed by adding the view name to the feed name: …/_packaging/bookstore@Release/nuget/v3/index.json. In our flow the contracts pipeline publishes a prerelease to @Local on every run; when the team validates a version in the integration environment it promotes it to @Prerelease, and when it publishes the stable 1.0.0 it promotes that to @Release. Tying promotion to an approved deploy stage from the sixth post is also possible.
The price of producing a prerelease on every run is a feed that grows fast. The retention policy in the feed settings takes two values: the maximum number of versions to keep per package, and how many days to keep recently downloaded packages. According to the documentation a version is deleted only when both conditions hold: the version count has reached the limit and that version has not been downloaded within the given period. Packages promoted to a view are exempt from retention; promoting the versions you want to keep permanently is therefore also a retention decision. Deleted packages stay in the recycle bin for 30 days and keep counting toward storage during that time.
A Roadmap Note: The Old Artifacts Tasks
The Azure DevOps roadmap has an item titled "Deprecate old Azure Artifacts tasks in Azure Pipelines and default to new, auth-only tasks". There Microsoft describes how hard it is to troubleshoot and keep up to date the old, large tasks for NuGet, npm and Maven that carry their own command-line tools, and recommends tasks that only authenticate (NuGet Authenticate, Maven Authenticate, Python Pip Authenticate, Python Twine Upload Authenticate); the actual work is done by commands you write. The item says the old tasks will be deprecated in the future but gives no date; as of November 15, 2026 the roadmap timeline also lists it in the undated "Future" section. The same section has a "Package promote task" item that would do promotion from a pipeline. The Npm@1 task documentation says the task is no longer under active development, only critical bugs are fixed, and npmAuthenticate@0 should be used for Azure Artifacts. The pipelines in this post already follow that approach: the task only provides identity, while restore, pack and push are plain dotnet and npm commands.
In Production
Plan for the feed being unreachable. When CI gets every package through a single feed, a moment when the feed is unreachable stops every pipeline. Saved packages reduce that risk, but a newly added dependency needs the upstream the first time it is downloaded. The cache gives a second layer of protection here, because it restores the same packages without asking the feed.
Start permissions as narrow as possible. Only the contracts pipeline needs to publish packages; giving the CI pipeline's identity Contributor opens the door for a script changed in a pull request to upload a package to the feed. Keeping the publishing pipeline separate and running it only from main reduces that risk, together with the security settings in the eighth post.
Watch storage consumption. According to the documentation the Azure Artifacts free tier provides 2 GiB of storage, anything above that is billed by consumption, and usage is visible on the Storage page under Organization settings. Prerelease packages produced on every run, copies saved from upstream and packages in the recycle bin all add to that usage. Setting a retention policy on the day you create the feed is easier than dealing with thousands of versions a few months later.
Common Pitfalls
Trying to publish the same version twice. The situation from the opening: deleting does not help, the version number is permanently reserved. Produce a unique version for every run in the pipeline, and release stable versions through a deliberate step.
Forgetting that a package from upstream is permanent in the feed. A saved version keeps coming from the feed even if it is removed from the public source. When a problematic version is announced, updating your own dependencies is not enough; the copy in the feed may need deleting too.
Writing the feed address everywhere. If the address is written separately in one pipeline's script, in another's config and in a Dockerfile, moving the feed or switching to an organization-scoped feed means finding all of them. Keep the address in a single config file and let pipelines refer to it only by source name (bookstore).
Leaving more than one source in the config. Without <clear />, nuget.org from the machine's general NuGet config stays in the list, and restore can skip the feed and take a package straight from nuget.org. You then lose the single source and the record keeping that upstreams give you.
Forgetting to grant the build identity access. The first run that publishes to the feed usually fails with 403, because the default role is Collaborator. Look at the identity name in the error and give the right identity (project or organization build service) Contributor.
A Small Experiment
★☆☆ Add a README.md to Bookstore.Contracts, include it in the package with PackageReadmeFile, and watch the message disappear from the dotnet pack output.
★★☆ Write two nuget.config files for a consumer project: one pointing at the whole feed, one only at the bookstore@Release view. Explain in which of them a prerelease version will show up.
★★★ Add another stage to the contracts pipeline: when a stable version is published, it waits for approval and promotes the package to @Release through the REST API. Write down which identity will do it and which role you need to give it.
The build side is complete: the code is compiled, tested and packaged, and the shared parts go to the feed. In the next post we start moving the package through environments: environment resources, approvals and checks, deployment jobs, and going to production only from main and with an approval.