NuGet and Central Package Management

Manage package versions for a whole solution with Directory.Packages.props, lock restores in CI and fail builds on vulnerable dependencies.

Syntax<!-- Directory.Packages.props --> <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" /> <!-- Orders.Api.csproj --> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />

A PackageReference declares a dependency; dotnet restore (run implicitly by build, test and publish) resolves the whole graph, downloads packages into the global packages folder (~/.nuget/packages) and writes obj/project.assets.json, which the compiler reads. Version="10.0.3" means "10.0.3 or higher", and NuGet picks the lowest version that satisfies every constraint. When two paths need the same package, the reference nearest the project wins, so a direct reference can override a transitive one. If a direct reference is lower than a transitive requirement you get NU1605 (package downgrade), an error by default. Floating versions such as 10.0.* make two restores of the same commit differ; avoid them for services.

Central Package Management

With many projects, versions drift: the API on one EF Core patch, the worker on another. Directory.Packages.props at the repository root with ManagePackageVersionsCentrally=true holds every version as a PackageVersion item; projects keep PackageReference without a Version. A version on a reference is error NU1008. VersionOverride on a reference allows a deliberate exception, and GlobalPackageReference adds a package (an analyzer, for example) to every project.

ApproachWhere versions liveChoose when
Per-project VersionEach .csprojA single project
Central Package ManagementDirectory.Packages.propsAny solution with two or more projects
CPM with transitive pinningSame file, also pins transitive packagesYou must force a patched transitive dependency

CentralPackageTransitivePinningEnabled=true makes a PackageVersion for a package you never reference directly raise its transitive version, which is how you pull in a security fix before the parent package ships one.

Directory.Build.props

Shared MSBuild properties (target framework, nullable, audit settings) belong in Directory.Build.props, imported automatically into every project below it. MSBuild imports only the nearest one walking up from the project; a nested file must import its parent explicitly. Directory.Packages.props follows the same nearest-file rule.

Lock files

RestorePackagesWithLockFile=true writes packages.lock.json per project with exact versions and content hashes. Commit it and run dotnet restore --locked-mode in CI: restore fails if the graph would change, instead of quietly building something nobody reviewed.

Vulnerability audit

Restore checks every package against known advisories and warns NU1901 (low), NU1902 (moderate), NU1903 (high) or NU1904 (critical). Since .NET 9 transitive packages are audited by default (NuGetAuditMode=all). Warnings scroll past in CI logs, so promote high and critical (NU1903, NU1904) to errors in the scheduled audit and main-branch builds, through a property only those jobs set, and keep them as warnings in pull-request and local builds so an advisory published overnight does not block unrelated changes (see Quality Gates). A private feed that mirrors nuget.org without advisory data audits nothing; configure an audit source. Report the current state with dotnet package list --vulnerable --include-transitive.

Example

Example · xml
<!-- Directory.Packages.props at the repository root -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>

  <ItemGroup Label="Web">
    <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
    <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
    <PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.11.0" />
  </ItemGroup>

  <ItemGroup Label="Data">
    <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13" />
    <!-- Transitive via the EF Core provider; pinned here so a patch reaches every project at once -->
    <PackageVersion Include="Npgsql" Version="10.0.3" />
  </ItemGroup>

  <ItemGroup Label="Tests">
    <PackageVersion Include="xunit.v3" Version="4.0.2" />
    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
    <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
    <PackageVersion Include="NSubstitute" Version="6.2.0" />
    <PackageVersion Include="Testcontainers.PostgreSql" Version="4.16.0" />
  </ItemGroup>
</Project>

When to use it

  • Move every project to a new EF Core patch by changing one PackageVersion line instead of editing nine .csproj files.
  • Fail the main-branch and nightly audit builds when restore reports NU1903 or NU1904 in any transitive package.
  • Pull a patched transitive dependency into all projects with one pinned PackageVersion while the parent package catches up.

More examples

A project with versionless references

The project says which packages it needs; the central file says which versions. Upgrading EF Core becomes a one-line pull request that every project picks up together, and reviewers see the version change in one place. <code>TargetFramework</code> and <code>Nullable</code> are missing here on purpose: they come from <code>Directory.Build.props</code>.

Example · xml
<!-- src/Orders.Api/Orders.Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
    <!-- Design-time only: PrivateAssets keeps it out of anything that references this project -->
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Orders.Core\Orders.Core.csproj" />
  </ItemGroup>

</Project>

Directory.Build.props with audit gates

<code>NuGetAuditLevel</code> sets the lowest severity reported at all. <code>WarningsNotAsErrors</code> keeps every advisory a warning even under <code>TreatWarningsAsErrors</code>, so a pull request is never blocked by an advisory published overnight; only the jobs that set <code>StrictAudit</code> (the scheduled audit and the main-branch build) turn NU1903 and NU1904 into errors. Appending keeps values set before this file, such as the SDK's <code>NU1605</code> default. <code>Directory.Build.props</code> is imported before the project body, so a .csproj that later assigns <code>WarningsAsErrors</code> without <code>$(WarningsAsErrors)</code> overwrites these gates: every project must append too (<code>$(WarningsAsErrors);nullable</code>), or put <code>nullable</code> here as well. With lock files on, CI should run <code>dotnet restore --locked-mode</code> so an unreviewed dependency change fails instead of being built.

Example · xml
<!-- Directory.Build.props at the repository root: imported by every project below it -->
<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>

    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>

    <NuGetAudit>true</NuGetAudit>
    <NuGetAuditMode>all</NuGetAuditMode>
    <NuGetAuditLevel>moderate</NuGetAuditLevel>
    <!-- Advisories stay warnings in pull-request and local builds -->
    <WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
  </PropertyGroup>

  <!-- Only the scheduled audit and main-branch builds pass -p:StrictAudit=true:
       there high and critical advisories fail the build -->
  <PropertyGroup Condition="'$(StrictAudit)' == 'true'">
    <WarningsAsErrors>$(WarningsAsErrors);NU1903;NU1904</WarningsAsErrors>
  </PropertyGroup>
</Project>

nuget.config with package source mapping

Without mapping, NuGet asks every source for every package, so someone publishing <code>Shop.Payments.Client</code> on nuget.org with a higher version could be picked over your internal one (dependency confusion). The most specific pattern wins: <code>Shop.*</code> comes only from the internal feed. <code>&lt;clear /&gt;</code> stops machine-level sources leaking in. Feed credentials come from the CI secret store, never from this committed file.

Example · xml
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
    <add key="shop-internal" value="https://pkgs.example.com/shop/nuget/v3/index.json" />
  </packageSources>
  <packageSourceMapping>
    <packageSource key="nuget.org">
      <package pattern="*" />
    </packageSource>
    <packageSource key="shop-internal">
      <package pattern="Shop.*" />
    </packageSource>
  </packageSourceMapping>
</configuration>

Discussion

  • Be the first to comment on this lesson.