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 aVersionattribute - 197 unique package IDs (plus
Nerdbank.GitVersioninginjected via the rootDirectory.Packages.propsas 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:
- Bump amplification. Routine
dotnet outdated -u -t -rruns rewroteVersionattributes across many.csprojfiles at once. Hand-reviewing 21+ csproj diffs for a single dependency bump was slow and error-prone, andgit blameon a<PackageReference>told you when the line moved, not when the version was last reviewed. - 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.jspre-tool hook should have caught it on each.csprojwrite, but the bump went throughdotnet outdatedwhich 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 invokesdotnet outdated CommandCenter.sln -t -r -vl None --exclude MassTransit(and-uunless-DryRun). Auto-installsdotnet-outdated-toolon first run.scripts/cc-deps— Commander subcommand that exposes the wrapper aspnpm 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.csprojfiles. git blame Directory.Packages.propsnow 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
.csprojandDirectory.Packages.props), and the runtime wrapper that frontsdotnet 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 "centralPackageReference" anti-pattern from the props file.
Negative / tracked
- Analyzer opt-out is now the lone exception.
CommandCenter.CodeAnalysis.Analyzerskeeps inlineVersionattributes, 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-restorewill 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 toDirectory.Packages.propsfirst, then a bare<PackageReference>to the consuming project. Documented in the path-scoped backend rules.- NuGet audit-as-error still applies.
TreatWarningsAsErrors=true+AnalysisModeSecurity=Allstill mean transitiveNU1902/NU1903will 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.csprojfiles will see nothing for the 21 in-scope projects. Any internal dashboards or audit scripts that walk csproj files for versions must now also readDirectory.Packages.props(and, for the lone opt-out, the analyzer csproj). The inventory PowerShell snippet under "References" handles both cases. dotnet outdatedinvocations across the team must move to the wrapper. Directdotnet outdated -uruns 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→ commitDirectory.Packages.props. - Adding a new package: add
<PackageVersion Include="X" Version="Y" />toDirectory.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
.csprojandDirectory.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
PackageReferenceownership too (e.g. a singleDirectory.Packages.propsthat 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 existingIncludeAssets/PrivateAssetsdiscipline 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.0Roslyn-stack pins are tighter than the rest of the solution needs, and theVersionOverrideescape 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 atscripts/update-deps.ps1is the runtime guard for the actual update workflow. - Pin
MassTransitat the8.5.*floating range instead of8.5.9. Rejected. The hook expects a starts-with match on8.5., which floating ranges satisfy, butDirectory.Packages.propsis the source of truth for which exact version is in production — floating defeats reproducibility. Patches inside8.5.xare intentional bumps, not implicit.
References#
- Implementation:
Directory.Packages.props,.claude/hooks/warn-masstransit-upgrade.js,scripts/update-deps.ps1,scripts/cc-deps,scripts/cc. - Commits:
328d183(CPM migration + MassTransit revert + hook scope),85b8ced(cc deps updatewrapper),3615418(AI-instruction docs sync). - Repo guides:
CLAUDE.md(Hard constraints),backend/CLAUDE.md(MassTransit policy),.claude/rules/backend.md(Gotchas),.claude/rules/scripts.md(ccCLI +update-deps.ps1). - Microsoft Learn:
- Tooling:
dotnet-outdated—--excludeis a case-insensitive substring match.
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)"
}
}
}