Versioning and Packages
Semantic versions, one central file for package versions, a lock file for repeatable restore, and how to pack and push a library.
<PackageVersion Include="Npgsql" Version="9.0.3" /> <!-- in Directory.Packages.props -->A version number is a promise about compatibility. Semantic versioning writes it as MAJOR.MINOR.PATCH: raise the patch for a fix, the minor for something added that old callers still compile against, and the major when you break them. A prerelease suffix such as 1.4.0-beta.1 sorts before the plain 1.4.0. In a project, <Version>1.4.0</Version> sets the package version and the assembly versions together.
Versions in one place
A PackageReference can carry its own Version, but across several projects those drift. Central package management fixes the versions once: put Directory.Packages.props at the repository root, set ManagePackageVersionsCentrally to true, declare each package as <PackageVersion>, and leave the Version attribute off every PackageReference. Both forms are the same reference; only the place the number lives changes.
Repeatable restore
A requested version is a floor, not a pin, so two restores can resolve differently. Set RestorePackagesWithLockFile to true, commit the generated packages.lock.json, and have CI run dotnet restore --locked-mode, which fails rather than quietly resolving something new. Restore also audits your packages against known advisories, raising NU1901 to NU1904; for projects targeting net10.0, NuGetAuditMode defaults to all, so transitive packages are checked too.
Publishing a library
dotnet pack produces a .nupkg, in Release by default from the .NET 8 SDK onwards, and dotnet nuget push uploads it.
Example
// Version numbers compare as numbers, not as text. Getting this
// wrong is why some tools think 1.4.0 is newer than 1.10.0.
Version installed = new Version(1, 4, 0);
Version latest = Version.Parse("1.10.0");
Console.WriteLine($"text says newer: {string.CompareOrdinal("1.10.0", "1.4.0") > 0}");
Console.WriteLine($"version says newer: {latest > installed}");
Console.WriteLine($"same major, so no breaking change: {latest.Major == installed.Major}");
Console.WriteLine($"latest: {latest}");
// System.Version understands numbers only. It has no idea that
// 1.10.0-beta.1 comes before 1.10.0; NuGet's own version type does.
// Output:
// text says newer: False
// version says newer: True
// same major, so no breaking change: True
// latest: 1.10.0When to use it
- Upgrade a package across twenty projects by editing one line.
- Make CI fail when a dependency resolves to a version nobody reviewed.
- Publish an internal client library so other teams can reference it by version.
- Decide whether a change you are about to release needs a minor or a major bump.
More examples
Central package management, two files
Reach for this as soon as a repository has more than one project, because it turns "upgrade EF Core everywhere" into a one-line change and makes a mismatch impossible. Only the nearest Directory.Packages.props is read, so a second one deeper in the tree replaces the root rather than adding to it unless you import the parent yourself.
<!-- Directory.Packages.props, at the repository root -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!-- Let a version here override one a dependency asked for. -->
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageVersion Include="xunit.v3" Version="3.0.0" />
</ItemGroup>
</Project>
<!-- src/Shipping.Api/Shipping.Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- No Version attribute: it comes from the file above. -->
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
</Project>Lock file and audit settings for CI
In the pipeline, restore with `dotnet restore --locked-mode` (the MSBuild equivalent is -p:RestoreLockedMode=true). It fails the build if the lock file and the project files disagree, which is what you want: a dependency change should arrive as a reviewed commit, not as a surprise during a release. Do not commit lock files for library projects, because a consumer's resolution overrides them anyway.
<PropertyGroup>
<!-- Write packages.lock.json next to the project, and commit it. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<!-- Audit is on by default. Report moderate and worse only. -->
<NuGetAudit>true</NuGetAudit>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
</PropertyGroup>Pack and push a library
Use this for an internal client library that several of your own services consume, as well as for public packages. Versions on nuget.org cannot be replaced, only unlisted, so treat the push as irreversible: build the package in CI from a tagged commit rather than from a laptop.
# Release is the default from the .NET 8 SDK onwards.
dotnet pack -c Release -p:Version=1.4.0
# Prerelease: same number with a suffix, sorts before 1.4.0.
dotnet pack -c Release -p:Version=1.4.0-beta.1
# Push. Never paste the key itself into a script or a log.
dotnet nuget push bin/Release/Shipping.Client.1.4.0.nupkg \
--api-key "$NUGET_API_KEY" \
--source https://api.nuget.org/v3/index.json
Discussion