CSharp Central Package Management (CPM)
August 13, 2026 · View on GitHub
Scope: Sprint-0006/0007. How the four .NET-bearing repos (ATAP.Utilities,
AceCommander, ATAP.IAC, SharedVSCode) centralize NuGet package versions through
Directory.Packages.props, including the floating-version strategy for internal
ATAP.Utilities dependencies consumed by AceCommander.
Strategy update (sprint-0007 — Immutable Build). Under the immutable-build strategy, the package being consumed at any tier is the promoted instance of the same
(PackageId, Version, SHA-256)— not a tier-specific rebuild. CPM's job is therefore to express "AceCommander at Integration consumes the version ofATAP.Utilities.Xthat has been promoted tonuget-integration." The pinning rules in §6.1 below are exactly this: floating0.*-*is allowed at Experimental and Development (where rapid iteration matters) and pinned versions (resolved bySet-AceCommanderPackagePins) are required at Integration, QA, and Production (where reproducibility matters). The pinned version is the same one that was promoted into the target feed; the consumer does not get a "different build" of that version. See Immutable-Build-Strategy.md.
Audience: Developers who need to add, upgrade, or pin a NuGet dependency; anyone investigating NU1507 / NU1008 errors; release engineers promoting package tiers.
Status: Authoritative for sprint-0006. This document supersedes the scattered
notes in Building.md and the older Packaging.md drafts regarding package
version management. The files referenced in §3 are the source of truth.
Not in this doc:
- How versions are generated on produced packages → see CSharp-Packages-Versioning.md.
- How packages are packed and pushed → see CSharp-Packages-Pack-and-Push.md.
- Which ProGet feed is mapped to which prerelease label → see BuildMaster-ProGet-CSharp-Package-Pipeline.md.
- NuGet feed authentication / API keys → same doc as above.
1. What CPM is and why we use it
Central Package Management (CPM) is a NuGet feature (NuGet 6.2+, .NET SDK 7+)
that lets a solution declare every package version once in a single
Directory.Packages.props file at or above the solution root. Individual
.csproj files then reference packages without a Version= attribute:
<!-- project.csproj -->
<ItemGroup>
<PackageReference Include="Serilog" />
<PackageReference Include="xunit" />
</ItemGroup>
The single source of truth lives in Directory.Packages.props:
<ItemGroup Label="Logging">
<PackageVersion Include="Serilog" Version="4.2.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
</ItemGroup>
We adopted CPM in sprint-0004 for the following reasons:
- Version drift elimination — with 30+ projects per solution, duplicate
<PackageReference Version>entries routinely fell out of sync. - Auditable upgrades — a single PR touching
Directory.Packages.propsshows the full blast radius of a dependency change. - Floating-version support — CPM is the only place where
CentralPackageFloatingVersionsEnabledhas meaning; this is critical for AceCommander consuming the internal ATAP.Utilities packages (see §6).
2. Enabling CPM — the two properties
CPM is enabled by Directory.Build.props (or Directory.Packages.props itself)
with a single MSBuild property:
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
The optional companion property is set only in AceCommander:
<PropertyGroup>
<CentralPackageFloatingVersionsEnabled>true</CentralPackageFloatingVersionsEnabled>
</PropertyGroup>
This second property allows <PackageVersion Version="0.*-*" /> in the central
file. Without it, floating ranges in CPM raise NU1011. ATAP.Utilities does
not enable floating versions because it is a producer — it pins every
dependency to a concrete version.
3. File locations
| Repo | File | Floating enabled |
|---|---|---|
| ATAP.Utilities | Directory.Packages.props (solution root) | No |
| AceCommander | Directory.Packages.props (solution root) | Yes |
| ATAP.IAC | (no CPM — PowerShell-centric repo) | n/a |
| SharedVSCode | (no CPM — no .csproj files) | n/a |
Directory.Packages.props is picked up automatically by MSBuild when it sits
at or above every .csproj in the repo. There is intentionally no per-project
override.
4. Structure: label-grouped ItemGroups
Both CPM files organize <PackageVersion> entries into <ItemGroup Label="...">
blocks. The label is not semantic to NuGet — it only serves as a reading aid in
the file. ATAP.Utilities uses roughly 20 labels; AceCommander uses ~10.
Representative ATAP.Utilities groups (abbreviated):
<ItemGroup Label="Security Patches">
<PackageVersion Include="System.Text.Json" Version="9.0.0" />
</ItemGroup>
<ItemGroup Label="ATAP.Utilities.Configuration Family">
<PackageVersion Include="ATAP.Utilities.Configuration" Version="0.1.0-Alpha-009" />
<PackageVersion Include="ATAP.Utilities.Configuration.Extensions" Version="0.1.0-Alpha-009" />
</ItemGroup>
<ItemGroup Label="xUnit Testing Suite">
<PackageVersion Include="xunit" Version="2.4.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageVersion Include="coverlet.collector" Version="3.1.2" />
</ItemGroup>
<ItemGroup Label="MSBuild Custom Tasks">
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.9.50" />
</ItemGroup>
Representative AceCommander groups (full list is shorter):
<ItemGroup Label="ATAP.Utilities (floating)">
<PackageVersion Include="ATAP.Utilities.Philote" Version="0.*-*" />
<PackageVersion Include="ATAP.Utilities.Configuration" Version="0.*-*" />
<PackageVersion Include="ATAP.Utilities.ETW" Version="0.*-*" />
<!-- all internal ATAP.Utilities packages use 0.*-* -->
</ItemGroup>
<ItemGroup Label="Blazor / ASP.NET Core">
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
</ItemGroup>
<ItemGroup Label="Syncfusion Blazor">
<PackageVersion Include="Syncfusion.Blazor" Version="32.2.7" />
</ItemGroup>
<ItemGroup Label="Testing">
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="bunit" Version="1.39.5" />
<PackageVersion Include="Microsoft.Playwright.MSTest" Version="1.49.0" />
</ItemGroup>
5. Floating versions — 0.*-*
This pattern only works in AceCommander and only because
CentralPackageFloatingVersionsEnabled=true is set.
Syntax: 0.*-* means "the highest 0.x.y.z version, including any
prerelease label." The first wildcard floats the numeric components; the
second wildcard (-*) opts into prerelease versions.
Effect during restore:
dotnet restorequeries the configured ProGet feed(s).- The feed returns every available version of
ATAP.Utilities.Philote. - NuGet picks the highest one matching
0.*-*, which is typically the freshestSprintprerelease built minutes ago on the developer's machine and pushed to the Experimental feed.
Why we want this: AceCommander is a consumer of the internal ATAP.Utilities
packages. During active development we want every dotnet build to pull the
latest sprint build without editing Directory.Packages.props.
Why this is dangerous in CI: restore is non-deterministic by definition.
Two consecutive CI runs can resolve different versions of the same floating
reference. Mitigation: packages.lock.json (see §8).
6. The two-repo consumer contract
The contract between ATAP.Utilities (producer) and AceCommander (consumer) is:
-
ATAP.Utilities builds a set of packages with version
0.{major}.{minor}-Sprint.{height}(see CSharp-Packages-Versioning.md §4). -
The packages are pushed to ProGet's Experimental feed (see CSharp-Packages-Pack-and-Push.md §7).
-
AceCommander's floating
0.*-*restore picks up the new version on nextdotnet restore. -
If the new version breaks AceCommander, the fix is to pin the specific offending package in AceCommander's
Directory.Packages.propstemporarily:<PackageVersion Include="ATAP.Utilities.Philote" Version="0.1.0-Sprint.42" />and file a follow-up to unpick it once the upstream issue is resolved.
6.1 Version-pinning rule at the Integration tier and above
Rule: Floating version patterns (0.*-*) are only permitted at the
Experimental and Development tiers. At the Integration, QA, and
Stable/Production tiers, every ATAP.* entry in AceCommander's
Directory.Packages.props must be pinned to a concrete version before
dotnet restore is called.
| Tier | Feed | Floating 0.*-* allowed? |
|---|---|---|
| Experimental | nuget-experimental | Yes — default working-copy state |
| Development | nuget-development | Yes — resolves latest Alpha build |
| Integration | nuget-integration | No — must be pinned |
| QA | nuget-qa | No — must be pinned |
| Stable | nuget-stable | No — must be pinned |
Why: Non-deterministic restores at the Integration tier and above undermine the purpose of integration gating. Two consecutive QA builds could consume different package versions, making failures unreproducible.
Ownership: The pin-the-floating-versions mechanic is owned by
ATAP.Utilities.BuildTooling.PowerShell via the generic, repository-agnostic
cmdlet Set-FloatingPackagePins (-PackageIdPrefix selects the package
family to pin; defaults to ATAP.). Consumers keep a thin wrapper that
supplies their own defaults and delegates to the engine — they do not
re-implement the resolution/rewrite logic. AceCommander's
Set-AceCommanderPackagePins.ps1 is that consumer wrapper (it passes
-PackageIdPrefix 'ATAP.'). See
Package-Pinning-Ownership-Decision.md
for the decision and rationale (task V4-D06).
How (in CI): The BuildMaster QA stage runs
Set-AceCommanderPackagePins.ps1 as its first step. The wrapper delegates to
Set-FloatingPackagePins, which resolves each floating ATAP.* entry to the
highest concrete version available in the target feed and rewrites
Directory.Packages.props in the agent workspace before dotnet restore /
dotnet build are called. The working-copy file retains its floating
patterns — only the CI agent copy is mutated. (The AceCommander plan already
imports ATAP.Utilities.BuildTooling.PowerShell, so the engine is on the
agent's module path.)
How (manually): A developer promoting a branch to Integration or QA may run the consumer wrapper:
Set-AceCommanderPackagePins `
-ProGetUrl 'http://proget.local:50000' `
-FeedName 'nuget-integration'
or call the engine directly for any repo / package family:
Set-FloatingPackagePins `
-PackagePropsPath 'C:\src\AceCommander\Directory.Packages.props' `
-ProGetUrl 'http://proget.local:50000' `
-FeedName 'nuget-integration' `
-PackageIdPrefix 'ATAP.'
and commit the pinned Directory.Packages.props to the promotion branch.
6.2 Resolving "latest in feed X" under immutable build
Under the immutable-build strategy a promoted artifact is visible in every feed it has reached, so a floating reference does not distinguish "pushed here" from "promoted here":
Under immutable build, "latest in feed X" means "highest version visible through feed X's resolution chain." A floating
0.*-*reference will always pick up the highest version, regardless of whether that version was originally pushed to feed X or promoted into it. This is intentional — once promoted, the artifact has feed-X identity. Consumers who want "the latest version that has not yet been promoted out of feed X" must filter by prerelease label (e.g.0.*-Sprint*).
See Immutable-Build-Strategy.md §6.3 for the producer-side statement of the same rule.
7. Package source mapping (NU1507)
NuGet 6.x requires packageSourceMapping in NuGet.config whenever multiple
feeds are configured. Without mapping, restore raises NU1507: "There are
N package sources defined. Please map the package sources."
The mapping is declared in each repo's NuGet.config (not in
Directory.Packages.props):
<packageSourceMapping>
<packageSource key="nuget-experimental">
<package pattern="ATAP.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
CPM does not interact directly with this — but the pairing matters:
- Every
PackageVersionInclude inDirectory.Packages.propsmust resolve to exactly one feed viapackageSourceMapping. - The
ATAP.*pattern claims everyATAP.Utilities.*name from the internal feed;*catches everything else from nuget.org.
See BuildMaster-ProGet-CSharp-Package-Pipeline.md
§5 for the full NuGet.config reference.
8. Interaction with packages.lock.json
CPM and lock files compose but are not automatic. To enable reproducible restore:
<!-- Directory.Build.props -->
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<RestoreLockedMode Condition="'$(ContinuousIntegrationBuild)' == 'true'">true</RestoreLockedMode>
</PropertyGroup>
Current status:
- ATAP.Utilities: lock files enabled per project — committed to git.
- AceCommander: lock files not yet enabled (a known gap — tracked in
_Planning/TASKS.md). Floating versions without lock files mean CI restores are non-deterministic.
Local automation guard:
Assert-LockFilesCleaninATAP.Utilities.BuildTooling.PowerShellis the reusable preflight forpackages.lock.jsondrift.Invoke-GitCommitcalls it before staging changes. The explicit bypass is-SkipLockFileGuard, and the caller must record why lock drift is safe.Complete-PlanningSessioncalls it before the commit/PR step. The explicit bypass is also-SkipLockFileGuard.Test-SprintPrerequisitescalls it for every discovered sprint worktree so SprintStartAgent and SprintEndAgent preflight can fail before dirty or missing lock files are carried across sprint boundaries. Use-SkipLockFileGuardonly when the sprint notes document the reason.- For production-filter validation, call
Assert-LockFilesClean -CheckSolutionFilter -SolutionFilterPath .\ATAP.Utilities.Production.slnfand pass any documented facade/aggregator exceptions through-AllowedMissingLockFileProjectPaths.
8.1 ATAP.Utilities production-filter lock-file exceptions
Task 14.91 revalidated the five recovered V4-D02 exceptions. Each project is
an intentionally source-free facade/aggregator with
<EnableDefaultItems>false</EnableDefaultItems> and only project references
declared in its own project file. The V4-D02 force-evaluated production-filter
restore completed without NuGet emitting a sibling lock file for these nodes;
Task 14.91 revalidated the current five-path set with Assert-LockFilesClean.
The referenced implementation projects remain independently lock-file
governed.
| Project | Owner |
|---|---|
src/ATAP.Utilities.ComputerInventory/ATAP.Utilities.ComputerInventory.csproj | ATAP.Utilities ComputerInventory maintainers |
src/ATAP.Utilities.ComputerInventory/Hardware/ATAP.Utilities.ComputerInventory.Hardware.csproj | ATAP.Utilities ComputerInventory maintainers |
src/ATAP.Utilities.ComputerInventory/ProcessInfo/ATAP.Utilities.ComputerInventory.ProcessInfo.csproj | ATAP.Utilities ComputerInventory maintainers |
src/ATAP.Utilities.ComputerInventory/Software/ATAP.Utilities.ComputerInventory.Software.csproj | ATAP.Utilities ComputerInventory maintainers |
src/ATAP.Utilities.IAC.Ansible/ATAP.Utilities.IAC.Ansible.csproj | ATAP.Utilities IAC/Ansible maintainers |
Use this exact guard invocation. Adding or removing an exception requires the owning maintainers to repeat the restore and update this list; the ATAP.Utilities BuildTooling maintainers own the guard behavior.
$allowedMissing = @(
'src/ATAP.Utilities.ComputerInventory/ATAP.Utilities.ComputerInventory.csproj',
'src/ATAP.Utilities.ComputerInventory/Hardware/ATAP.Utilities.ComputerInventory.Hardware.csproj',
'src/ATAP.Utilities.ComputerInventory/ProcessInfo/ATAP.Utilities.ComputerInventory.ProcessInfo.csproj',
'src/ATAP.Utilities.ComputerInventory/Software/ATAP.Utilities.ComputerInventory.Software.csproj',
'src/ATAP.Utilities.IAC.Ansible/ATAP.Utilities.IAC.Ansible.csproj'
)
Assert-LockFilesClean `
-RepoPath (Get-Location).Path `
-CheckSolutionFilter `
-SolutionFilterPath '.\ATAP.Utilities.Production.slnf' `
-AllowedMissingLockFileProjectPaths $allowedMissing
8.2 AutoDoc central package management
ATAP.Utilities.AutoDoc declares versionless PackageReference items. Its
docfx.console version and Microsoft.Build.Utilities.Core version are owned
by Directory.Packages.props, and direct force-evaluated restore governs its
checked-in packages.lock.json.
9. Interaction with ConstrainATAPPackageDependencyVersionRange
Directory.Build.targets in ATAP.Utilities contains a custom target
ConstrainATAPPackageDependencyVersionRange that rewrites the $version$ token
emitted into .nuspec dependency entries during dotnet pack. It replaces the
concrete consumer version (e.g., 0.1.0-Sprint.42) with a range expression
like [0.1.0, 1.0.0).
CPM does not override this behavior. The consumer's resolved version feeds
the target; the target then emits the range into the produced package's
.nuspec. This is how we decouple "what AceCommander restored during build"
from "what the published ATAP.Utilities package declares as its dependency."
See CSharp-Packages-Pack-and-Push.md §8 for the full pack-time rewriting flow.
10. Migrating a project into CPM
When a new .csproj is added to either repo:
- Confirm
Directory.Packages.propsexists at or above the project path. - Write
<PackageReference Include="X" />— noVersion=attribute. - If the package is not yet in
Directory.Packages.props, add a<PackageVersion Include="X" Version="..." />to the appropriate labeledItemGroup. - Run
dotnet restore— anyNU1008("Projects that use central package version management should not define the version on the PackageReference items") indicates a forgottenVersion=attribute in the.csproj.
11. Known drift (sprint-0006)
-
Test-framework version skew — ATAP.Utilities pins
xunitat2.4.1andMicrosoft.NET.Test.Sdkat17.0.0. AceCommander usesxunit2.9.3andMicrosoft.NET.Test.Sdk17.12.0. The older versions in ATAP.Utilities are load-bearing for the legacyMakeBuildcustom task path and have not been upgraded because the upgrade requires regenerating test fixtures underATAP.Utilities.Testing. -
AceCommander lacks lock files — floating
0.*-*without a lock file means no two CI restores are guaranteed identical. -
Some
.csprojstill carryVersion=attributes — mostly in older projects undertests/that predate CPM adoption. A one-shot cleanup is pending (tracked as sprint-0006 follow-up). -
Security-patch group is out of order —
System.Text.Json9.0.0 is pinned in ATAP.Utilities for the transitive-dependency CVE fix even though the library itself targetsnet8.0. This is deliberate and should not be "fixed" to match the target framework. -
ATAP.Utilities.Configurationfamily pinned, not floating — inside ATAP.Utilities itself, the Configuration sub-packages are pinned to0.1.0-Alpha-009rather than using same-repo ProjectReferences. This is a known anti-pattern carried from the pre-CPM era; replacing these with<ProjectReference>is a tracked cleanup item.
12. Common failures and remedies
| Error | Cause | Fix |
|---|---|---|
| NU1008 | <PackageReference Version="..."/> present alongside CPM | Remove Version= from the .csproj; add to Directory.Packages.props |
| NU1011 | Floating version used without CentralPackageFloatingVersionsEnabled | Only valid in AceCommander; pin the version in ATAP.Utilities instead |
| NU1507 | Multiple sources in NuGet.config, no packageSourceMapping | Add a packageSourceMapping entry for every source |
| NU1601 | Restore resolved a version outside the range declared in CPM | Update the PackageVersion entry in Directory.Packages.props |
| NU1603 | Floating reference resolved a higher version than requested | Expected when 0.*-* is used; not an error in AceCommander |
PackageVersion not found | New PackageReference added to .csproj without CPM entry | Add matching <PackageVersion> in Directory.Packages.props |
13. Quick reference
Add a new third-party package:
<!-- Directory.Packages.props -->
<ItemGroup Label="{choose or create label}">
<PackageVersion Include="Foo.Bar" Version="1.2.3" />
</ItemGroup>
<!-- MyProject.csproj -->
<ItemGroup>
<PackageReference Include="Foo.Bar" />
</ItemGroup>
Add a new internal ATAP.Utilities package reference from AceCommander:
<!-- AceCommander/Directory.Packages.props -->
<ItemGroup Label="ATAP.Utilities (floating)">
<PackageVersion Include="ATAP.Utilities.{NewName}" Version="0.*-*" />
</ItemGroup>
Pin an ATAP.Utilities package temporarily in AceCommander:
<!-- AceCommander/Directory.Packages.props -->
<PackageVersion Include="ATAP.Utilities.Philote" Version="0.1.0-Sprint.42" />
Related Documents
- Production-and-Tooling-Overview.md — index.
- CSharp-Packages-Versioning.md — how producer versions are generated.
- CSharp-Packages-Pack-and-Push.md — how packages are packed and pushed to ProGet.
- CSharp-Packages-Test-Process.md — how C# tests are structured and executed.
- BuildMaster-ProGet-CSharp-Package-Pipeline.md — CI/CD promotion through 5 ProGet feed tiers.