Artifacts & dependencies

Feeds, pinning, dependency integrity.

Intermediate25 min · lesson 6 of 15

Open the dependency file of any service you run in production and count what is actually yours. Most of the application is code other people wrote, often 80 to 90 percent of it by volume. That makes package management a security job rather than housekeeping. A hospital pharmacy solves the same problem: no drug reaches a patient except through one counter, and every batch arrives with a known supplier, an exact lot number, and a record of who dispensed it. Azure Artifacts is that counter for your builds. The word *artifact* covers two different things in Azure DevOps. There are the packages your code pulls in and publishes (npm for JavaScript, NuGet for .NET, Maven, Python, Cargo, Universal Packages), and there is the build artifact, the output your pipeline compiles once and then moves unchanged through every environment. Both want the pharmacy rules: one source, exact versions, no quiet substitutions.

Feeds: one door in for every package

A feed is a private, versioned package registry that lives inside Azure DevOps, scoped either to one project or to the whole organization. The setting that earns its keep is upstream sources. Instead of letting every build call npmjs.com or nuget.org directly, the feed stands in front of those public registries, and the first time anyone asks for a particular package version, the feed keeps a permanent copy. Every build after that gets *that* copy, even if the public package is later unpublished, deleted, or replaced with something malicious. Remember left-pad, the eleven-line npm package whose removal broke builds across the internet? A feed with upstreams turns that into a non-event. You also end up with a list of exactly which outside code has walked through the door.

Feeds also have views: @local, @prerelease, @release. A view is a promotion gate for the packages you publish yourself. A library lands in @local, gets promoted to @release only once it has passed validation, and each consuming team subscribes to whichever view matches the risk it is willing to take. Publishing and restoring always target the default @local view. The other two are suggestions you can rename or delete.

.npmrc + publish — everything through the feed
# .npmrc at repo root — ONE registry; public packages arrive via upstreams
registry=https://pkgs.dev.azure.com/contoso/_packaging/platform/npm/registry/
always-auth=true
# Publish the internal library (version comes from package.json)
$ npm publish
npm notice package: @contoso/[email protected]
npm notice total files: 14, unpacked size: 87.3 kB
# Consume — express is pulled through the upstream and cached in the feed
$ npm install @contoso/logging express
added 64 packages in 4s
# Universal Packages: version arbitrary blobs (ML models, CLIs) with az
$ az artifacts universal publish \
--organization https://dev.azure.com/contoso \
--feed platform --name fraud-model --version 3.4.1 --path ./model/
# uploads via ArtifactTool; exit code 0 on success

Pipelines sign in as the build service identity, and Azure Artifacts gives that identity the Feed and Upstream Reader (Collaborator) role by default. That is enough to restore packages and to save new ones through an upstream, though *publishing* into the feed needs the bigger Feed Publisher (Contributor) role. When the feed lives in another organization, or when a script or task runner has to authenticate on its own, wire the credential provider up explicitly with the npmAuthenticate@0 and NuGetAuthenticate@1 tasks (a service connection covers the cross-organization case). People, as opposed to pipelines, sign in through credential providers backed by a PAT (personal access token, a long-lived string that stands in for your password). Watch storage while you are at it. Each organization gets 2 GiB of Artifacts storage free, upstream caching counts against that number, and a busy monorepo will quietly turn the feed into a line on the invoice unless you set retention policies early.

Pin it, lock it, verify it

Pinning starts with the lockfile (package-lock.json for npm, packages.lock.json for NuGet, poetry.lock for Python). A lockfile writes down the exact version that got resolved plus a SHA-512 integrity hash, a fingerprint of the file's bytes, for every package in the tree. So a build either gets byte-identical dependencies or fails loudly. In CI (continuous integration, the automated build that runs on every change), reach for the install command that treats the lockfile as law: npm ci, never npm install. npm install will happily paper over drift by resolving new versions. The attack this defeats is dependency confusion. Say @contoso/billing-core exists only on your feed. If the package manager is also allowed to consult a public registry, an attacker who publishes a public billing-core at version 99.0.0 can win the resolution race, because the resolver grabs the highest version it can see. One registry in .npmrc, scoped package names, and upstream sources close that door. Several registries prop it back open.

npm ci — the lockfile is law
# Someone edited package.json without updating the lockfile — CI fails fast:
$ npm ci
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json are in sync. Please update your lock file
npm error with `npm install` before continuing.
npm error Missing: [email protected] from lock file
# In sync — exact versions, integrity hashes verified on extract:
$ npm ci
added 412 packages, and audited 413 packages in 9s
1 high severity vulnerability
# Gate the pipeline on known-vulnerable dependencies:
$ npm audit --audit-level=high
# npm audit report
lodash <=4.17.20
Severity: high
Command Injection - GHSA-35jh-r3h4-6jhm
fix available via `npm audit fix`
1 high severity vulnerability
A floating version is a trust decision nobody reviewed
Depending on latest, ^4.2.0, or any other range means the exact code in your build can change at any moment with no review. That gap is what dependency confusion, typosquats (packages named one keystroke away from a real one), and malicious updates all feed on. Pin instead: commit lockfiles to the repo, install with npm ci (or NuGet's RestoreLockedMode), set save-exact=true in .npmrc, and pull everything through one governed feed. On Python that means a single --index-url. Never --extra-index-url, which lets pip take the highest version it can find across *all* indexes, the attacker's public package included. Every dependency change should be a diff somebody approved.

Build once, then promote the same bytes

The same rule applies to what you ship. If every environment builds from source again, staging and production are only *probably* the same. A transitive dependency (a dependency of one of your dependencies) may have shifted. A base image may have moved. A compiler flag may differ on that agent. A pipeline artifact takes the doubt out of it: you compile and test exactly once, publish the output, and every stage after that downloads those same bytes. PublishPipelineArtifact@1 is the current task. It deduplicates content and uploads only the blocks that changed, so a 200 MB drop with 4 MB of new content finishes in seconds. The older PublishBuildArtifacts@1 hangs on mainly for legacy Azure DevOps Server installs.

azure-pipelines.yml — publish once, deploy the same bytes
stages:
- stage: Build
jobs:
- job: build
steps:
- script: npm ci && npm run build && npm test
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: '$(System.DefaultWorkingDirectory)/dist'
archiveFile: '$(Build.ArtifactStagingDirectory)/app.zip'
- task: PublishPipelineArtifact@1
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/app.zip'
artifact: drop
- stage: DeployStaging
dependsOn: Build
jobs:
- deployment: deploy
environment: staging
strategy:
runOnce:
deploy:
steps: # deployment jobs auto-download current artifacts
- script: ls -lh $(Pipeline.Workspace)/drop/
# --- run log ---
Starting: PublishPipelineArtifact
Uploading pipeline artifact from /home/vsts/work/1/a/app.zip
Uploaded 14,562,304 of 14,562,304 bytes
Associated artifact 2214 with run 4821
Finishing: PublishPipelineArtifact
# Verify from your terminal:
$ az pipelines runs artifact list --run-id 4821 \
--org https://dev.azure.com/contoso --project shop \
--query "[].{id:id, name:name, type:resource.type}" -o table
Id Name Type
---- ------ ----------------
2214 drop PipelineArtifact
One artifact, end to end
1Restore
deps from governed feed
2Build + test once
npm ci, lockfile verified
3Publish artifact
drop / app.zip
4Deploy to slot
staging, warmed up
5Swap
routing flip to prod
The same bytes travel the whole path. Only configuration changes per environment, and sticky settings hold it in place.

Slot swap: the last promotion step

App Service deployment slots are how promotion works at the far end of this pipeline. A slot is a live, separately addressable copy of your app (shop-api-staging.azurewebsites.net) running on the production App Service plan; slots need the Standard, Premium, or Isolated tier. You deploy the artifact to the staging slot, smoke-test it against real infrastructure, then swap. Inside, a swap is a routing change and not a file copy. Azure applies the *target* slot's configuration to the staging instance, restarts it, waits for the warmup endpoint (WEBSITE_SWAP_WARMUP_PING_PATH, default /) to answer healthy, and only then flips hostname routing. Production traffic never lands on a cold start.

Configuration is the part that bites. By default, app settings and connection strings ride along with the swap. Settings you mark as slot settings (the portal calls these sticky) stay glued to the slot they belong to. Connection strings and environment names belong in that group, which is what stops staging code from booting with production credentials, and stops production from inheriting a staging endpoint.

slot swap with sticky settings — az CLI
# Deploy the SAME app.zip your pipeline published — to the staging slot
$ az webapp deploy -g rg-shop -n shop-api --slot staging \
--src-path app.zip --type zip --track-status
Initiating deployment
Polling the status of async deployment...
Status: RuntimeSuccessful
Deployment has completed successfully
# Mark environment-bound settings sticky (--slot-settings, not --settings)
$ az webapp config appsettings set -g rg-shop -n shop-api --slot staging \
--slot-settings APP_ENV=staging WEBSITE_SWAP_WARMUP_PING_PATH=/healthz
[
{ "name": "APP_ENV", "slotSetting": true, "value": "staging" },
{ "name": "WEBSITE_SWAP_WARMUP_PING_PATH", "slotSetting": true,
"value": "/healthz" }
]
# Swap: staging code goes live; sticky settings stay put
$ az webapp deployment slot swap -g rg-shop -n shop-api \
--slot staging --target-slot production
# Rollback is the same swap again — --target-slot defaults to production
$ az webapp deployment slot swap -g rg-shop -n shop-api --slot staging

This is where "build once" stops being a philosophy and starts saving your evening. Rollback is instant *because* the previous artifact is still sitting there, warm, in the other slot. Pair it with WEBSITE_RUN_FROM_PACKAGE=1: the app then runs straight from the mounted zip, wwwroot turns read-only, deployment becomes atomic, and nobody can hot-patch a file on a production instance at 2am.

Put feeds and slots in code

Slot and sticky-setting configuration clicked into the portal is configuration nobody can review and nobody can rebuild after a bad day. Declare it instead. In Bicep, the sticky list is the slotConfigNames config resource on the site. In Terraform's azurerm provider it is the sticky_settings block. One honest limit: Azure Artifacts feeds are *not* ARM (Azure Resource Manager) resources, so Bicep cannot create them, because feeds live inside Azure DevOps rather than inside an Azure subscription. Terraform's separate azuredevops provider covers that gap with azuredevops_feed, or you script the Azure DevOps REST API (the web interface Azure DevOps exposes for automation).

sticky settings as code — Bicep and Terraform
// Bicep: declare WHICH app settings are sticky, on the production site
resource sticky 'Microsoft.Web/sites/config@2024-04-01' = {
parent: webApp
name: 'slotConfigNames'
properties: {
appSettingNames: [
'APP_ENV'
'WEBSITE_SWAP_WARMUP_PING_PATH'
]
}
}
# Terraform (azurerm): same intent, on the web app resource
resource "azurerm_linux_web_app" "api" {
# ...name, service_plan_id, site_config...
sticky_settings {
app_setting_names = ["APP_ENV", "WEBSITE_SWAP_WARMUP_PING_PATH"]
}
}
$ terraform apply
azurerm_linux_web_app.api: Modifying... [id=/subscriptions/.../sites/shop-api]
azurerm_linux_web_app.api: Modifications complete after 27s
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

Carry the trade-offs with you. A feed adds a hop, so the first restore of a package is slower while the upstream caches it. Views add process. Artifact retention shows up as a real storage bill. Each one buys reproducibility, and reproducibility is what turns an incident from an archaeology dig into a diff you can read in a minute. You have now seen Bicep twice with no introduction. That introduction is next: ARM & Bicep, the resource model sitting under every az command you ran here, and how to describe a whole environment the way you described two sticky settings.

Public registries have bad days. A maintainer yanks a version, npmjs.com goes read-only for an hour, a popular package gets hijacked over a weekend. With a feed and upstream sources in front of them, your restore keeps working, because the copy it needs already sits inside your organization. Add package quality gates and a retention policy on top so the feed stays a working pantry rather than an attic nobody has opened since 2021.

Keep the two jobs separate in your head. The feed holds dependencies and the internal libraries your teams publish. Pipeline artifacts, published by PublishPipelineArtifact, hold the build output that release stages deploy. Never rebuild between environments. Promote the same artifact ID through dev, test, and production, so the thing you tested is the thing that runs.

A feed with no retention becomes a museum of every alpha build anyone ever pushed. Set retention, and promote only the versions your release trains actually consume. Then, when a CVE (a Common Vulnerabilities and Exposures ID, the public identifier given to a known flaw) lands on a transitive dependency, you have one place to bump and rebuild, instead of seventeen microservices each pinned to a different vulnerable range because somebody copy-pasted a package.json six months ago.

Try this

Create an Azure Artifacts feed, push a tiny npm or NuGet package (a universal package works too), then restore it from a pipeline using an authenticated feed endpoint. Open the lockfile afterwards and check that the version is pinned there.

terminal
az artifacts feed list --org https://dev.azure.com/<org> -p <project> -o table
az artifacts universal download --organization https://dev.azure.com/<org> \
--project <project> --scope project --feed <feed> --name hello --version 1.0.0 --path ./out
ls ./out
output
$ az artifacts feed list -p contoso -o table
Name Upstream
------------ --------
app-packages yes
# Sample output
$ ls ./out
hello.txt

Takeaway

Two sentences worth keeping: a feed governs what your builds are allowed to restore, and a build artifact is the frozen output you promote. Pin your versions, because a floating tag is a bet on somebody else's repository that you never got to review.

Next time you set a project up: switch upstream sources on deliberately, one public registry at a time, turn on retention before the feed starts growing, and scan the packages you pull in with the same seriousness you scan container images.

Quick check
01Your Python build needs internal packages that live only in your Azure Artifacts feed, plus public packages from PyPI (the Python Package Index). Which pip setup actually stops a dependency-confusion attack?
Correct — With one index, pip never has to choose between a public package and your internal one, and the feed keeps an immutable copy of every public package it fetches.
Incorrect — No. --extra-index-url makes pip take the highest version it finds across all indexes, so an attacker's public package at version 99.0.0 wins the race.
Incorrect — No. That is still two indexes, with the public one in front, so your internal names stay exposed to the same version race.
Incorrect — No. Scoping is an npm defense. pip has no name-based index precedence, so with several indexes the highest version still wins whatever the package is called.
02An Azure Artifacts feed can proxy a public registry such as npmjs.com through an upstream source. The first time a build asks for a specific public package version, what does the feed do that protects every later build?
Correct — The copy is saved on first request, which is what protects you from a left-pad style disappearance and gives you an inventory of the outside code you depend on.
Incorrect — No. Feeds keep packages exactly as they arrived and leave version numbers alone.
Incorrect — No. Restores target the default @local view automatically, and pulling a package through an upstream needs no manual promotion.
Incorrect — No. That is the opposite of caching, and keeping the saved copy is the whole point of an upstream source.
03A team's pipeline builds the application from source separately in its Build, Staging, and Production stages, and staging and production occasionally behave differently even though the source is identical. Which change MOST reliably guarantees every environment runs byte-for-byte identical output?
Incorrect — No. Auditing flags known vulnerabilities, and does nothing to make the compiled output the same from one stage to the next.
Correct — Building once and promoting the published bytes removes the drift that per-stage rebuilds invite from shifted transitive dependencies, moved base images, or different compiler flags.
Incorrect — No. npm ci pins dependencies, but each stage still builds on its own, so base-image or toolchain drift can still pull the outputs apart. It is not a byte-for-byte guarantee.
Incorrect — No. This still rebuilds per stage, so the outputs can differ, and it leans on the legacy task kept mainly for Azure DevOps Server.

Related