Domain 3.1 · part of 50–55%

Design and implement a package management strategy

Where shared code lives, how consumers discover it, how a build is promoted from "just published" to "approved for production", and how you number the things you ship.

Recommending package management tools

Azure Artifacts

A universal package registry built into Azure DevOps. A single feed can host several package types side by side.

ProtocolEcosystemNotes
NuGet.NET.nupkg
npmNode.js / JavaScriptScoped packages supported
MavenJavapom.xml coordinates
Python (PyPI)Pythonwheels and sdists
CargoRustcrates
Universal PackagesAnythingVersioned blobs of arbitrary files — Azure Artifacts only

Caveat — Universal Packages

Universal Packages are unique to Azure Artifacts. When a scenario needs to version and distribute something that is not a recognised package format — an ML model, a game asset bundle, a firmware image, a folder of test data — the answer is a Universal Package, published with az artifacts universal publish.

GitHub Packages

Package typeRegistry host
Container imagesghcr.io GitHub Container Registry
npmnpm.pkg.github.com
NuGetnuget.pkg.github.com
Maven / Gradlemaven.pkg.github.com
RubyGemsrubygems.pkg.github.com

Packages inherit repository permissions by default, and workflows authenticate with the automatically-provided GITHUB_TOKEN — no secret to manage.

GitHub Actions publish to GitHub Packages with GITHUB_TOKEN
permissions:
  contents: read
  packages: write          # required to publish

steps:
  - uses: actions/checkout@v4

  - uses: actions/setup-node@v4
    with:
      node-version: 20
      registry-url: https://npm.pkg.github.com
      scope: '@my-org'

  - run: npm publish
    env:
      NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Azure Artifacts vs GitHub Packages

CapabilityAzure ArtifactsGitHub Packages
Scoped toAzure DevOps organization or projectGitHub repository or organization
Universal Packages (arbitrary files)
Upstream sources (proxy + cache public registries)✅ first-classLimited
Views for promotion (@local / @prerelease / @release)
Container registryUse Azure Container Registry (ACR)ghcr.io
Retention policies per feedLimited
Free storage tier2 GB500 MB (private; public packages are free)
Best forEnterprise, multi-protocol, promotion workflows, mixed source controlGitHub-native workflows, container-first teams

Caveat — the two discriminators

If the requirement mentions promotion through quality stages or caching/proxying a public registry, the answer is Azure Artifacts (views and upstream sources). If it mentions container images in a GitHub-centric workflow, the answer is GitHub Container Registry (ghcr.io).

Package feeds and views

Feed scope: project vs organization

Project-scoped feedOrganization-scoped feed
Visible toOne project (inherits project permissions)Every project in the organization
Use forPackages owned and consumed by a single teamShared platform/common libraries across teams
Recommended defaultYes — least privilegeOnly for genuinely shared code

Upstream sources

An upstream source makes your feed a transparent proxy and cache for a public registry. Consumers point at one URL and get both your packages and public ones.

flowchart LR D[Developer / build agent] -->|restore| F[Azure Artifacts feed] F -->|"1 · in the feed?"| L[(Local packages)] F -->|"2 · miss → fetch"| U["Upstream sources
nuget.org · npmjs · PyPI · Maven Central"] U -->|"3 · save a copy"| L
On a cache miss the feed fetches from upstream and saves a copy — later restores are served locally.
BenefitWhy it matters
AvailabilityBuilds keep working if the public registry is down or a package is unpublished/yanked
AuditabilityOne place shows every third-party package the organisation actually consumes
PerformanceCached packages restore from a nearby feed
Substitution protectionOnce a package name/version is saved locally, upstream cannot silently replace it

Caveat — dependency confusion / substitution attacks

If an internal package name also exists on a public registry, a client might resolve the public one. Mitigations the exam expects: use a single feed with upstream sources (never mix a private feed and a public registry in the same client config), reserve/prefix your internal package names (e.g. scoped @my-org/), and rely on the feed's saved copy so the version is pinned to what you first consumed.

Feed views and package promotion

Views are filtered read-only slices of a single feed. Promoting a package into a view does not copy or move it — it simply becomes visible through that view's URL.

ViewContainsConsumed by
@localEverything published to the feed, plus saved upstream packagesThe publishing team; CI
@prereleasePackages promoted as release candidatesTest/staging consumers
@releasePackages approved as stableProduction consumers
azure cli publish then promote a Universal Package
az artifacts universal publish \
  --organization https://dev.azure.com/MyOrg \
  --project MyProject --feed MyFeed \
  --name my-package --version 1.4.0 \
  --path ./dist --description "Release candidate"

# Promote into the @release view — same package, new visibility
az artifacts universal update \
  --organization https://dev.azure.com/MyOrg \
  --project MyProject --feed MyFeed \
  --name my-package --version 1.4.0 \
  --views @release
azure-pipelines.yml consuming a specific view
# A feed view is addressed as  FeedName@ViewName
- task: NuGetCommand@2
  displayName: Push to the feed
  inputs:
    command: push
    packagesToPush: '$(Build.ArtifactStagingDirectory)/*.nupkg'
    nuGetFeedType: internal
    publishVstsFeed: 'MyProject/MyFeed'

# Production build restores ONLY approved packages
- task: NuGetAuthenticate@1
- script: dotnet restore --source "https://pkgs.dev.azure.com/MyOrg/MyProject/_packaging/MyFeed@release/nuget/v3/index.json"

Caveat — views vs multiple feeds

"Promote packages through quality stages without duplicating storage or changing version numbers" → views in one feed. Separate feeds per stage is the wrong answer: it forces you to copy binaries between feeds, which breaks immutability and doubles storage. Also remember that immutability applies — once a version is published to a feed you cannot overwrite it; you must publish a new version.

Feed permissions

RoleCan
ReaderList and restore packages
CollaboratorReader + save packages from upstream sources
ContributorCollaborator + publish, deprecate and unlist packages
OwnerContributor + manage permissions, views, upstreams and retention

Build identities need Contributor to publish. A common least-privilege pattern: developers get Reader on @release, CI gets Contributor on the feed.

Dependency versioning strategy

Semantic versioning (SemVer)

spec MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
   2  .  4  .  1  -  beta.1  +  build.20260721
   │     │     │       │           │
   │     │     │       │           └─ build metadata — IGNORED when comparing versions
   │     │     │       └───────────── pre-release label — LOWER precedence than the release
   │     │     └─────────────────────  PATCH: backwards-compatible bug fixes
   │     └───────────────────────────  MINOR: backwards-compatible new functionality
   └─────────────────────────────────  MAJOR: incompatible / breaking API change

# Precedence (left is lower):
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-rc.1 < 1.0.0 < 1.0.1 < 1.1.0 < 2.0.0

Calendar versioning (CalVer)

PatternExampleKnown user
YY.MM26.04Ubuntu
YYYY.MM.MICRO2026.07.1pip / PyPA tooling
YY.MM.MICRO26.7.0Black

Caveat — SemVer or CalVer?

SemVer when consumers need to reason about compatibility — libraries, SDKs, anything with a public API. CalVer when the meaningful question is "how current is this?" — infrastructure, tooling, distributions, time-boxed releases, or products with no coherent notion of a breaking change. The giveaway phrases are "public API / downstream consumers" (SemVer) versus "monthly release train / date matters" (CalVer).

Version ranges and lock files

npm package.json
{
  "dependencies": {
    "express": "4.18.2",        // exact — pinned
    "lodash":  "~4.17.21",      // ~ patch only  (>=4.17.21 <4.18.0)
    "axios":   "^1.6.0",        // ^ minor+patch (>=1.6.0 <2.0.0)
    "react":   ">=18.0.0 <19"   // explicit range
  }
}
NuGet .csproj
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="[3.1.0,4.0.0)" />

<!-- Range notation:
     1.0.0     minimum (NuGet resolves to lowest applicable)
     [1.0.0]   exact
     [1.0,2.0) >= 1.0 and < 2.0
     (,1.0]    <= 1.0
     *         floating — avoid in production          -->

Reproducible builds

Always commit the lock file (package-lock.json, yarn.lock, pnpm-lock.yaml, packages.lock.json, poetry.lock) and restore with the strict command in CI — npm ci (not npm install) or dotnet restore --locked-mode. Without it, the same commit can produce different binaries on different days. This also matters for pipeline caching keys — see 3.6.

Versioning pipeline artifacts

Build numbers and counters

azure-pipelines.yml deriving a SemVer from a counter
variables:
  Major: 2
  Minor: 4
  # counter(prefix, seed) auto-increments and RESETS when the prefix changes,
  # so bumping Minor restarts Patch at 0.
  Patch: $[counter(format('{0}.{1}', variables['Major'], variables['Minor']), 0)]

# 'name' sets the run/build number — this becomes $(Build.BuildNumber)
name: $(Major).$(Minor).$(Patch)

# Alternative date-based run name:  name: $(Date:yyyyMMdd)$(Rev:.r)   → 20260721.3

steps:
  - script: echo "Building version $(Build.BuildNumber)"
VariableIs
Build.BuildIdGlobally unique, monotonically increasing integer per organization
Build.BuildNumberThe formatted run name defined by name: — this is what you version artifacts with
Build.SourceVersionThe commit SHA being built — the immutable link back to source
$(Rev:r)Auto-incrementing revision that resets when the rest of the name changes
github.run_numberGitHub Actions: run count for this workflow (resets if the workflow is renamed/recreated)
github.run_idGitHub Actions: globally unique, stable run identifier
github.shaGitHub Actions: the commit SHA

Caveat — BuildId vs BuildNumber

Build.BuildId is a unique internal ID; Build.BuildNumber is the display name you control with name:. Version your packages from BuildNumber (or an explicit SemVer), and always stamp the artifact with Build.SourceVersion so any deployed binary can be traced to a commit.

Publishing and consuming artifacts

azure-pipelines.yml artifacts across stages
- task: PublishPipelineArtifact@1
  inputs:
    targetPath: '$(Build.ArtifactStagingDirectory)'
    artifact: 'drop-$(Build.BuildNumber)'
    publishLocation: 'pipeline'

# In a later stage / job
- task: DownloadPipelineArtifact@2
  inputs:
    artifact: 'drop-$(Build.BuildNumber)'
    path: '$(Pipeline.Workspace)/drop'
GitHub Actions artifacts across jobs
- uses: actions/upload-artifact@v4
  with:
    name: app-${{ github.run_number }}
    path: ./dist/
    retention-days: 30      # max 90 (public) / 400 (enterprise)

# In a later job (needs: build)
- uses: actions/download-artifact@v4
  with:
    name: app-${{ github.run_number }}
    path: ./dist

Build once, deploy many

The artifact produced by the build stage should be the exact same binary promoted to every environment — never rebuild per environment. Environment differences belong in configuration (variable groups, App Configuration, environment secrets), not in separate builds. This principle underpins several deployment questions in 3.4.

3.1 rapid recap

The requirement says…Answer
Host private NuGet packages for .NET teamsAzure Artifacts feed
Version and distribute arbitrary files (ML model, assets)Universal Package (Azure Artifacts)
Share container images in a GitHub workflowghcr.io (GitHub Container Registry)
Builds must survive nuget.org being unavailableUpstream source on the feed (caches a copy)
Promote testing → stable without new version numbersFeed views (@prerelease@release)
Production must only restore approved packagesPoint the restore at the @release view URL
Prevent a public package shadowing an internal oneSingle feed with upstream sources + scoped/reserved names
Breaking change to a public library APIBump MAJOR (SemVer)
Tooling released on a monthly trainCalVer
Same commit must always produce the same binariesCommit lock files; restore with npm ci / locked mode
Auto-increment patch, reset when minor changes$[counter('$(Major).$(Minor)', 0)]
Trace a deployed binary back to a commitStamp the artifact with Build.SourceVersion