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.
| Protocol | Ecosystem | Notes |
|---|---|---|
| NuGet | .NET | .nupkg |
| npm | Node.js / JavaScript | Scoped packages supported |
| Maven | Java | pom.xml coordinates |
| Python (PyPI) | Python | wheels and sdists |
| Cargo | Rust | crates |
| Universal Packages | Anything | Versioned 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 type | Registry host |
|---|---|
| Container images | ghcr.io GitHub Container Registry |
| npm | npm.pkg.github.com |
| NuGet | nuget.pkg.github.com |
| Maven / Gradle | maven.pkg.github.com |
| RubyGems | rubygems.pkg.github.com |
Packages inherit repository permissions by default, and workflows authenticate with the automatically-provided GITHUB_TOKEN — no secret to manage.
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
| Capability | Azure Artifacts | GitHub Packages |
|---|---|---|
| Scoped to | Azure DevOps organization or project | GitHub repository or organization |
| Universal Packages (arbitrary files) | ✅ | ❌ |
| Upstream sources (proxy + cache public registries) | ✅ first-class | Limited |
| Views for promotion (@local / @prerelease / @release) | ✅ | ❌ |
| Container registry | Use Azure Container Registry (ACR) | ✅ ghcr.io |
| Retention policies per feed | ✅ | Limited |
| Free storage tier | 2 GB | 500 MB (private; public packages are free) |
| Best for | Enterprise, multi-protocol, promotion workflows, mixed source control | GitHub-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 feed | Organization-scoped feed | |
|---|---|---|
| Visible to | One project (inherits project permissions) | Every project in the organization |
| Use for | Packages owned and consumed by a single team | Shared platform/common libraries across teams |
| Recommended default | Yes — least privilege | Only 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.
nuget.org · npmjs · PyPI · Maven Central"] U -->|"3 · save a copy"| L
| Benefit | Why it matters |
|---|---|
| Availability | Builds keep working if the public registry is down or a package is unpublished/yanked |
| Auditability | One place shows every third-party package the organisation actually consumes |
| Performance | Cached packages restore from a nearby feed |
| Substitution protection | Once 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.
| View | Contains | Consumed by |
|---|---|---|
@local | Everything published to the feed, plus saved upstream packages | The publishing team; CI |
@prerelease | Packages promoted as release candidates | Test/staging consumers |
@release | Packages approved as stable | Production consumers |
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
# 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
| Role | Can |
|---|---|
| Reader | List and restore packages |
| Collaborator | Reader + save packages from upstream sources |
| Contributor | Collaborator + publish, deprecate and unlist packages |
| Owner | Contributor + 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)
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
- Version 0.y.z means "initial development — anything may change at any time". Stability is only promised from 1.0.0.
- A pre-release version has lower precedence than the same version without a label:
1.0.0-rc.1 < 1.0.0. - Build metadata (
+…) is ignored for precedence —1.0.0+build1and1.0.0+build2are the same version.
Calendar versioning (CalVer)
| Pattern | Example | Known user |
|---|---|---|
YY.MM | 26.04 | Ubuntu |
YYYY.MM.MICRO | 2026.07.1 | pip / PyPA tooling |
YY.MM.MICRO | 26.7.0 | Black |
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
{
"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
}
}
<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
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)"
| Variable | Is |
|---|---|
Build.BuildId | Globally unique, monotonically increasing integer per organization |
Build.BuildNumber | The formatted run name defined by name: — this is what you version artifacts with |
Build.SourceVersion | The 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_number | GitHub Actions: run count for this workflow (resets if the workflow is renamed/recreated) |
github.run_id | GitHub Actions: globally unique, stable run identifier |
github.sha | GitHub 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
- 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'
- 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 teams | Azure Artifacts feed |
| Version and distribute arbitrary files (ML model, assets) | Universal Package (Azure Artifacts) |
| Share container images in a GitHub workflow | ghcr.io (GitHub Container Registry) |
| Builds must survive nuget.org being unavailable | Upstream source on the feed (caches a copy) |
| Promote testing → stable without new version numbers | Feed views (@prerelease → @release) |
| Production must only restore approved packages | Point the restore at the @release view URL |
| Prevent a public package shadowing an internal one | Single feed with upstream sources + scoped/reserved names |
| Breaking change to a public library API | Bump MAJOR (SemVer) |
| Tooling released on a monthly train | CalVer |
| Same commit must always produce the same binaries | Commit 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 commit | Stamp the artifact with Build.SourceVersion |