Skip to content

ADR 0002 — Central Package Management for NuGet#

Status Accepted
Date 2026-05-20
Authors Ruben Knuijver
Supersedes
Superseded by

Context#

NuGet package versions across the .NET 10 solution were maintained per-project. A survey of the 22 .csproj files in CommandCenter.sln produced:

  • 473 <PackageReference> items, every one carrying a Version attribute
  • 197 unique package IDs (plus Nerdbank.GitVersioning injected via the root Directory.Packages.props as a legacy central <PackageReference>)
  • Zero cross-project version conflicts at survey time — the solution was lucky, not disciplined

Directory.Packages.props existed but was set to <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>. The file's name implied centralization that was not actually in effect; it carried only build properties (TreatWarningsAsErrors, AnalysisModeSecurity=All, Nullable=enable) plus the lone Nerdbank.GitVersioning reference.

Two concrete problems made the per-project model expensive in practice:

  1. Bump amplification. Routine dotnet outdated -u -t -r runs rewrote Version attributes across many .csproj files at once. Hand-reviewing 21+ csproj diffs for a single dependency bump was slow and error-prone, and git blame on a <PackageReference> told you when the line moved, not when the version was last reviewed.
  2. License-locked dependencies were invisible. MassTransit is locked at 8.5.x — the license changed in 9+. Commit 6d68f4c ("Upgrade deps, migrate FleetDashboard to BMO, cleanup legacy") quietly bumped the family to 9.1.0 across five projects. The .claude/hooks/warn-masstransit-upgrade.js pre-tool hook should have caught it on each .csproj write, but the bump went through dotnet outdated which the hook does not intercept, and once landed it was indistinguishable from the rest of the dependency noise.

The user asked to centralize versions while keeping PackageReference ownership in the consuming projects (i.e. each project still declares which packages it depends on, but the version is decided in one place). This ADR codifies the migration that the May 2026 dependency-centralization sweep introduced.

Decision#

1. Enable Central Package Management#

Flip Directory.Packages.props:

<PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <!-- existing build properties unchanged -->
</PropertyGroup>

Every version that used to live as a Version attribute on <PackageReference> now lives as a <PackageVersion> entry in Directory.Packages.props:

<ItemGroup>
    <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
    <PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
    <!-- 195 more -->
</ItemGroup>

Project files declare <PackageReference Include="…" /> without Version. All other PackageReference metadata (PrivateAssets, IncludeAssets, conditions, child <Version> elements when present as metadata, multi-line bodies) is preserved unchanged.

Inventory after migration: 197 <PackageVersion> entries — full coverage of every referenced package, verified by re-running the inventory script and diffing against the new central list.

2. Nerdbank.GitVersioning via <GlobalPackageReference>, not per-project#

The legacy central <PackageReference Include="Nerdbank.GitVersioning" Version="3.9.50" PrivateAssets="all" /> is the only "pre-CPM" idiom for "this package is needed by every project in the repository." CPM has a first-class replacement: <GlobalPackageReference>. It lives in Directory.Packages.props, applies to every project the file imports, and carries IncludeAssets=Runtime;Build;Native;contentFiles;Analyzers semantics by default.

<ItemGroup>
    <GlobalPackageReference Include="Nerdbank.GitVersioning" Version="3.9.50" PrivateAssets="all" Condition="!Exists('packages.config')" />
</ItemGroup>

The Condition="!Exists('packages.config')" clause preserves the original guard against any future project that opts into the legacy packages.config model.

This satisfies the constraint "centralize versions only; do not centralize PackageReference ownership" because <GlobalPackageReference> is the CPM-native idiom — it is a version statement, not a project-level dependency declaration that any individual project owns or maintains.

3. CommandCenter.CodeAnalysis.Analyzers opts out of CPM#

The analyzer project targets netstandard2.0 and pins a stable set of Roslyn analyzer-stack packages (Microsoft.CodeAnalysis.* 5.3.0, System.Collections.Immutable 10.0.7, System.Memory 4.6.3, …). Bringing it into CPM would couple those analyzer pins to the main solution's central version list, where a future Roslyn bump could ripple in unintentionally.

The csproj keeps the opt-out:

<PropertyGroup>
    <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>

<GlobalPackageReference> still reaches this project — verified by checking the produced CommandCenter.CodeAnalysis.Analyzers.dll, whose ProductVersion carries the Nerdbank.GitVersioning commit-SHA suffix (1.0.0+0e10e1face…) that only NB.GV emits.

4. Restore the MassTransit 8.5.x cap at the central level#

Directory.Packages.props pins the entire MassTransit family at the documented 8.5.9 cap:

<PackageVersion Include="MassTransit" Version="8.5.9" />
<PackageVersion Include="MassTransit.Abstractions" Version="8.5.9" />
<PackageVersion Include="MassTransit.Azure.ServiceBus.Core" Version="8.5.9" />
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.5.9" />
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.9" />

This reverts the 9.1.0 sprawl introduced in 6d68f4c and re-aligns the codebase with the policy in backend/CLAUDE.md and the hook. Build and tests pass on 8.5.9 with no source-level changes required.

5. Extend the MassTransit hook to watch Directory.Packages.props#

Under CPM, MassTransit versions move from .csproj to <PackageVersion> entries in Directory.Packages.props. The pre-tool hook now inspects both files:

const isCsproj = filePath.endsWith('.csproj');
const isCentralProps = basename === 'Directory.Packages.props';
if (!isCsproj && !isCentralProps) process.exit(0);

const pattern = /<(?:PackageReference|PackageVersion)\s+Include="(MassTransit[^"]*)"\s+Version="([^"]+)"/gi;

Without this change the hook would have been silently blind for the central case.

6. Canonical update flow via cc deps update#

The hook only fires for tool-driven file edits. dotnet outdated -u rewrites Directory.Packages.props directly with no Claude-tool interception point, so the runtime guard is a wrapper:

  • scripts/update-deps.ps1 — PowerShell wrapper that invokes dotnet outdated CommandCenter.sln -t -r -vl None --exclude MassTransit (and -u unless -DryRun). Auto-installs dotnet-outdated-tool on first run.
  • scripts/cc-deps — Commander subcommand that exposes the wrapper as pnpm exec cc deps update.

The --exclude flag of dotnet-outdated is a case-insensitive substring match, so a single MassTransit exclusion covers every package whose ID contains that string. The team's documented update command is now:

pnpm exec cc deps update            # apply updates, MassTransit excluded
pnpm exec cc deps update --dry-run  # report-only

Raw dotnet outdated -u is no longer the documented flow.

Consequences#

Positive

  • One file (Directory.Packages.props) is the source of truth for every NuGet version in the .NET solution. Routine dependency bumps touch one line, not 22 .csproj files.
  • git blame Directory.Packages.props now answers "when did this version last move?" precisely.
  • The MassTransit 8.5.x cap is enforced at three layers: docs, the pre-tool hook (covering both .csproj and Directory.Packages.props), and the runtime wrapper that fronts dotnet outdated. Future drift requires bypassing all three.
  • New projects only declare which packages they depend on; they inherit versions automatically and cannot accidentally introduce a conflict.
  • <GlobalPackageReference> for Nerdbank.GitVersioning is the idiomatic CPM pattern and removes the legacy "central PackageReference" anti-pattern from the props file.

Negative / tracked

  • Analyzer opt-out is now the lone exception. CommandCenter.CodeAnalysis.Analyzers keeps inline Version attributes, so its dependencies can drift from the rest of the solution if updated by hand. The risk is small (the analyzer touches only the Roslyn analyzer stack), but it is the single file that bypasses the central guard. Revisit if the analyzer ever grows non-analyzer dependencies.
  • dotnet add package <id> without --no-restore will fail under CPM if the package is not already centralized: NuGet emits NU1008 ("PackageReference items cannot define a value for Version"). The replacement workflow is to add a <PackageVersion> entry to Directory.Packages.props first, then a bare <PackageReference> to the consuming project. Documented in the path-scoped backend rules.
  • NuGet audit-as-error still applies. TreatWarningsAsErrors=true + AnalysisModeSecurity=All still mean transitive NU1902/NU1903 will fail restore. CPM doesn't change that surface area — but a single central bump now fixes the issue across the whole solution instead of N projects.

Breaking

  • External tooling that scrapes Version= attributes from .csproj files will see nothing for the 21 in-scope projects. Any internal dashboards or audit scripts that walk csproj files for versions must now also read Directory.Packages.props (and, for the lone opt-out, the analyzer csproj). The inventory PowerShell snippet under "References" handles both cases.
  • dotnet outdated invocations across the team must move to the wrapper. Direct dotnet outdated -u runs would re-introduce the MassTransit 9.x bump silently.

Maintenance posture

  • Routine workflow: pnpm exec cc deps update --dry-run → review → pnpm exec cc deps update → commit Directory.Packages.props.
  • Adding a new package: add <PackageVersion Include="X" Version="Y" /> to Directory.Packages.props, then <PackageReference Include="X" /> to the consuming project. No version in the project file.
  • A project that genuinely needs to deviate from the central version uses <PackageReference Include="X" VersionOverride="Y" /> as the escape hatch (CPM-native; preserves central tracking).
  • The MassTransit hook covers .csproj and Directory.Packages.props. If a future need arises to extend a similar lock to another package family, add it to the same hook rather than copying.

Alternatives considered#

  • Keep per-project versions. Rejected. The 9.1.0 sprawl was the proximate trigger, but the deeper problem is that there is no place to express "this is the version this solution uses." Per-project versions are 22 places to look and 22 places to drift.
  • Centralize PackageReference ownership too (e.g. a single Directory.Packages.props that lists every package every project references). Rejected. That trades version drift for dependency-graph drift: every project would pick up every package in the central list, defeating the existing IncludeAssets/PrivateAssets discipline and bloating restore graphs.
  • Bring the analyzer into CPM as well. Considered seriously — no version conflicts existed at survey time. Rejected on isolation grounds: the analyzer's netstandard2.0 Roslyn-stack pins are tighter than the rest of the solution needs, and the VersionOverride escape hatch would have produced the same outcome with more ceremony. The opt-out is one file, well-commented, and easy to audit.
  • Use a legacy central <PackageReference> for Nerdbank.GitVersioning instead of <GlobalPackageReference>. Rejected. The legacy pattern is unrelated to CPM and confusing in a CPM-enabled props file; <GlobalPackageReference> is the documented replacement, requires no per-project declaration, and applies cleanly across the opt-out boundary.
  • Rely solely on the hook to prevent the MassTransit drift. Rejected — the hook is a Claude Code pre-tool guard. It does not run inside dotnet outdated, the .NET CLI, or any non-AI editor. The wrapper at scripts/update-deps.ps1 is the runtime guard for the actual update workflow.
  • Pin MassTransit at the 8.5.* floating range instead of 8.5.9. Rejected. The hook expects a starts-with match on 8.5., which floating ranges satisfy, but Directory.Packages.props is the source of truth for which exact version is in production — floating defeats reproducibility. Patches inside 8.5.x are intentional bumps, not implicit.

References#

Inventory snippet (verification)#

Re-run after any migration to confirm coverage. Yields zero output when Directory.Packages.props covers every referenced ID (except those handled by the analyzer opt-out, whose IDs happen to also be in the central list):

$xml = [xml](Get-Content -Raw -Path .\Directory.Packages.props)
$central = @{}
$xml.SelectNodes('//PackageVersion')          | ForEach-Object { $central[$_.Include] = $true }
$xml.SelectNodes('//GlobalPackageReference')  | ForEach-Object { $central[$_.Include] = $true }

Get-ChildItem -Recurse -Filter *.csproj | ForEach-Object {
    $px = [xml](Get-Content -Raw $_.FullName)
    $isCentral = -not ($px.SelectNodes('//ManagePackageVersionsCentrally') | Where-Object { $_.InnerText -eq 'false' })
    if (-not $isCentral) { return }   # skip opt-outs
    foreach ($n in $px.SelectNodes('//PackageReference')) {
        if ($n.Include -and -not $central.ContainsKey($n.Include)) {
            "MISSING: $($n.Include)  in  $($_.Name)"
        }
    }
}