Packaging a reusable library with dotnet pack: required and recommended metadata, SemVer versioning from CI, README and licence, symbols and Source Link, deterministic builds, package validation, signing and pushing to a feed.
dotnet pack builds the project and produces a .nupkg whose metadata comes from MSBuild properties in the project: PackageId, Version, Authors, Description, a licence expression, a repository URL and a README. I set the version from CI using Semantic Versioning, so a breaking change means a new major version. I publish symbols as a .snupkg with Source Link, so consumers can step into the exact source. I turn on package validation to catch accidental breaking changes. Then dotnet nuget push uploads to nuget.org or an internal feed, and organisations that need it can sign packages with dotnet nuget sign.
"
dotnet packbuilds the project and produces a.nupkgwhose metadata comes from MSBuild properties:PackageId,Version,Authors,Description, a licence expression, the repository URL and a README throughPackageReadmeFile. Since .NET 8 it defaults to Release. It's for libraries; shipping a runnable app isdotnet publish, and a console app can even be packed as a .NET tool.I use Semantic Versioning and set the version from CI with
-p:Version, derived from tags or a tool like GitVersion, never by hand, so a breaking change means a new major version. I publish symbols as a.snupkgwith Source Link and deterministic CI builds, so consumers can step into the exact source. I turn on package validation against the last shipped version to catch accidental breaking changes, I multi-target only the frameworks I support, and I keep dependencies minimal, because everyPackageReferencebecomes a dependency of every consumer.Then
dotnet nuget pushuploads to nuget.org or an internal feed with an API key or credential provider, and the consuming pipelines restore from that same feed. nuget.org repository-signs packages; organisations that need author signatures usedotnet nuget sign. The mistake I see is breaking the public API in a minor version, and because versions are immutable you can't take it back, only ship a fix. A package is a public API with a support promise, so I treat packaging as a release process."
Directory.Build.props (authors, company, licence, repository) and package-specific metadata in the project (PackageId, Description, PackageTags).README.md with PackageReadmeFile so the feed shows usage.PackageLicenseExpression with an SPDX expression (for example MIT or Apache-2.0) is preferred, because tools can read it. For a custom or commercial licence, ship the text in the package with PackageLicenseFile (and include the file with Pack="true"). The older PackageLicenseUrl is deprecated.dotnet pack -c Release -o artifacts -p:Version=1.4.0. Since .NET 8, dotnet pack already defaults to Release (as does dotnet publish), but being explicit in CI costs nothing. dotnet build still defaults to Debug.1.5.0-beta.3.version.json file plus the Git height (number of commits), so every commit gets a unique, reproducible version. A simple alternative is -p:Version=$(git describe ...) from a release tag.AssemblyVersion affects binding; many libraries keep it at MAJOR.0.0.0 to reduce binding conflicts in .NET Framework consumers, while Version/FileVersion carry the full number.Every PackageReference in your library becomes a dependency of every consumer. Keep the list short: prefer the BCL, depend on abstractions packages (such as Microsoft.Extensions.Logging.Abstractions) rather than implementations, use the lowest version you actually need, and mark build-only packages (analysers, Source Link) with PrivateAssets="all".
IncludeSymbols with SymbolPackageFormat=snupkg produces a symbol package that nuget.org and Azure Artifacts serve to debuggers. Source Link (included in the .NET 8+ SDK for common Git hosts) records the commit, and ContinuousIntegrationBuild=true on CI makes builds deterministic and path-independent.
EnablePackageValidation checks that the package is consistent across target frameworks, and with PackageValidationBaselineVersion it compares against the previously shipped version to catch breaking changes before consumers do.
dotnet nuget push artifacts/*.nupkg --source <feed> --api-key <key> uploads the package (and the matching .snupkg).dotnet nuget sign with a code-signing certificate and enforce it with trustedSigners in consumers' nuget.config.A platform team shipped an internal Contoso.Abn validation library by copying DLLs between repos. Moving to dotnet pack in CI with versions from Git tags, symbols with Source Link, package validation and an Azure Artifacts feed meant consumers could upgrade with a normal PackageReference, see release notes, and debug into the exact source. Package validation later blocked a release that accidentally removed a public overload.
The interviewer is checking whether you can ship reusable code responsibly.
Green flags
Red flags
your library project
| dotnet pack -c Release -p:Version=1.4.0
v
build each target framework (net8.0, net10.0)
|
v
generate .nuspec from MSBuild properties and PackageReferences
|
v
Contoso.Abn.1.4.0.nupkg + Contoso.Abn.1.4.0.snupkg
| (optional) dotnet nuget sign
| dotnet nuget push
v
feed (nuget.org / Azure Artifacts / GitHub Packages)
|
v
consumer: <PackageReference Include="Contoso.Abn" Version="1.4.0" />
| restore: read .nuspec dependencies, pick the nearest lib/<tfm>
v
consumer build compiles against lib/net10.0/Contoso.Abn.dll
|
v
consumer debugger: downloads the .pdb from the .snupkg, then source via Source Link
A .nupkg is a zip file. Open one with any zip tool to check what you are shipping.
Contoso.Abn.1.4.0.nupkg
+-- Contoso.Abn.nuspec manifest: id, version, licence, dependencies per TFM
+-- lib/
| +-- net8.0/Contoso.Abn.dll used by consumers on net8.0 and net9.0
| +-- net10.0/Contoso.Abn.dll used by consumers on net10.0 and later
+-- README.md shown on the feed's package page
+-- icon.png (optional) PackageIcon
+-- build/ or buildTransitive/ (optional) .props/.targets imported into consumer builds
+-- analyzers/dotnet/cs/ (optional) Roslyn analysers and source generators
+-- [Content_Types].xml, _rels/, package/ zip bookkeeping
Contoso.Abn.1.4.0.snupkg separate symbol package
+-- lib/net8.0/Contoso.Abn.pdb
+-- lib/net10.0/Contoso.Abn.pdb
When a consumer restores, NuGet picks the nearest compatible lib/<tfm> folder for the consumer's target framework. That is why multi-targeting net8.0;net10.0 lets you use newer APIs on .NET 10 while still supporting .NET 8 consumers.
| Property | Purpose | Notes |
|---|---|---|
PackageId |
The package name on the feed | Defaults to the assembly name; reserve a prefix on nuget.org |
Version |
Package version | Set from CI, never by hand |
Authors, Company |
Shown on the feed | Put shared values in Directory.Build.props |
Description |
Search results and the package page | Say what it does in one sentence |
PackageTags |
Search | |
PackageLicenseExpression |
SPDX licence such as MIT |
Preferred for standard licences |
PackageLicenseFile |
Licence text in the package | For custom or commercial licences |
PackageReadmeFile |
README shown on the feed | Include the file with Pack="true" |
PackageIcon |
Icon file in the package | PackageIconUrl is deprecated |
PackageProjectUrl |
Documentation site | |
PublishRepositoryUrl |
Records the repository URL | With Source Link, also the commit |
PackageReleaseNotes |
Notes for this version | Or link to a changelog |
1 . 4 . 0 - beta.3
| | | |
| | | +-- prerelease label: sorts BEFORE 1.4.0
| | +------ PATCH: bug fixes only
| +---------- MINOR: new features, backwards compatible
+-------------- MAJOR: breaking changes
| Change | Version bump |
|---|---|
| Fix a bug, no API change | 1.4.0 -> 1.4.1 |
| Add a new public method | 1.4.1 -> 1.5.0 |
| Remove or rename a public member, change a signature | 1.5.0 -> 2.0.0 |
| Change behaviour consumers rely on | Usually MAJOR, even if the signature is the same |
| Raise the minimum target framework | MAJOR (consumers on the old framework can't upgrade) |
Prerelease ordering follows SemVer 2 rules: beta.10 sorts after beta.2 because dot-separated numbers compare numerically, but beta10 sorts before beta2 because it compares as text. Use dotted labels.
Versions on nuget.org are immutable: you can't replace 1.4.0 with different content. You can only unlist it, deprecate it, or ship 1.4.1.
| Approach | How the version is calculated |
|---|---|
| Tag-based script | -p:Version=${TAG#v} from the release tag |
| GitVersion | From branches and tags using a configurable branching strategy |
| Nerdbank.GitVersioning | From version.json plus the Git height (commits since the version was set), so every commit gets a unique, reproducible version |
| MinVer | From the latest tag plus the height |
The three version properties:
| Property | Used for | Common practice |
|---|---|---|
Version / PackageVersion |
The package version | Full SemVer, such as 1.4.0 |
AssemblyVersion |
Binding in .NET Framework consumers | Often MAJOR.0.0.0 to reduce binding redirects |
FileVersion |
Shown in file properties | Full number, often with the build number |
| Property | Effect |
|---|---|
IncludeSymbols + SymbolPackageFormat=snupkg |
Produces the .snupkg that nuget.org and Azure Artifacts serve as a symbol server |
| Source Link (built into the .NET 8+ SDK for GitHub, Azure Repos, GitLab, Bitbucket) | Records the repository and commit in the PDB |
EmbedUntrackedSources |
Embeds generated files, such as source generator output |
ContinuousIntegrationBuild=true (on CI only) |
Deterministic, path-mapped builds |
dotnet nuget push pushes the matching .snupkg automatically when it is next to the .nupkg.
EnablePackageValidation = true
|
+-- compatible frameworks check: does every TFM expose the same API?
| (a method only in lib/net10.0 breaks net8.0 consumers who upgrade TFM)
|
+-- with PackageValidationBaselineVersion = 1.3.0:
download 1.3.0, compare its public API with this build
removed or changed public members --> error, pack fails
When a break is intentional (a new major version), update the baseline, or record suppressions in CompatibilitySuppressions.xml (generated with -p:ApiCompatGenerateSuppressionFile=true) so the decision is reviewed in a pull request.
Every PackageReference in your library becomes a dependency of every consumer:
| Practice | Why |
|---|---|
| Prefer the BCL | Fewer version conflicts for consumers |
Depend on abstractions (Microsoft.Extensions.Logging.Abstractions) |
Consumers choose the implementation |
| Use the lowest version you actually need | Consumers on older versions can still use you |
PrivateAssets="all" on analysers, Source Link and build-only packages |
Keeps them out of the .nuspec |
| Multi-target only frameworks you support and test | Each TFM multiplies build and test effort |
dotnet nuget push "artifacts/*.nupkg" --source <feed> --api-key "$KEY" --skip-duplicate
| Feed | Authentication |
|---|---|
| nuget.org | API key scoped to the package ids, with an expiry, from the secret store |
| Azure Artifacts | NuGetAuthenticate@1 in Azure Pipelines; any string as --api-key |
| GitHub Packages | GITHUB_TOKEN with write:packages |
--skip-duplicate lets a re-run pipeline succeed when the version already exists.
| Signature | Who adds it | Proves |
|---|---|---|
| Repository signature | nuget.org, automatically | The package came from nuget.org unchanged |
| Author signature | You, with dotnet nuget sign and a code-signing certificate |
The package came from you |
dotnet nuget sign artifacts/Contoso.Abn.1.4.0.nupkg \
--certificate-path signing.pfx --certificate-password "$CERT_PASSWORD" \
--timestamper http://timestamp.digicert.com
dotnet nuget verify --all artifacts/Contoso.Abn.1.4.0.nupkg
Consumers enforce signatures with trustedSigners and signatureValidationMode=require in nuget.config. Always timestamp, so the signature stays valid after the certificate expires.
.nupkg before the first release of a new package and check for secrets, test data or config files that slipped in.Packing is stable. Watch the .NET 11 SDK notes for changes to package validation and NuGet defaults before relying on them.
dotnet pack -c Release -o artifacts -p:Version=1.0.0 and open the .nupkg as a zip. Read the generated .nuspec.PackageReference to an analyser without PrivateAssets="all", pack, and see it appear as a dependency in the .nuspec.EnablePackageValidation and PackageValidationBaselineVersion=1.0.0 (push 1.0.0 to a local folder feed first), remove a public method, pack 1.1.0 and read the error.dotnet nuget push artifacts/*.nupkg --source ./local-feed, then consume it from another project with a nuget.config pointing at that folder.1.5.0-beta.2 and 1.5.0-beta.10 and check which one the feed lists as newer.src/Contoso.Abn/Contoso.Abn.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PackageId>Contoso.Abn</PackageId>
<Description>Validation and formatting for Australian Business Numbers (ABNs).</Description>
<Authors>Contoso Platform Team</Authors>
<PackageTags>abn;australia;validation</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>1.3.0</PackageValidationBaselineVersion>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>
src/Contoso.Abn/Abn.cs:
namespace Contoso.Abn;
public static class Abn
{
private static readonly int[] Weights = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19];
/// <summary>Checks an ABN using the ATO's modulus 89 algorithm.</summary>
public static bool IsValid(string? abn)
{
if (abn is null) return false;
var digits = abn.Where(char.IsDigit).Select(c => c - '0').ToArray();
if (digits.Length != 11) return false;
digits[0] -= 1;
var sum = digits.Select((d, i) => d * Weights[i]).Sum();
return sum % 89 == 0;
}
}
CI commands:
dotnet pack src/Contoso.Abn -c Release -o artifacts -p:Version=1.4.0 -p:ContinuousIntegrationBuild=true
dotnet nuget push "artifacts/*.nupkg" --source "https://api.nuget.org/v3/index.json" --api-key "$NUGET_API_KEY"
Mistake: hand-editing versions.
Two different builds end up with the same version, or a release goes out with last month's number, and nuget.org won't accept a duplicate.
Instead: derive the version from source control in CI (tags, GitVersion, Nerdbank.GitVersioning or MinVer) and pass it with -p:Version=....
Mistake: breaking the public API in a minor or patch version.
Consumers who take the update get compile errors or MissingMethodException at run time.
Instead: enable package validation with a baseline version, and bump MAJOR for intentional breaks.
Mistake: removing APIs without warning.
Consuming teams find out when their build breaks.
Instead: mark members [Obsolete("Use X instead")] in a minor version and remove them in the next major, with a changelog entry.
Mistake: missing README, licence or repository metadata.
The package page is empty, licence scanners flag it, and nobody can tell where the source lives.
Instead: set PackageReadmeFile, PackageLicenseExpression (or PackageLicenseFile) and PublishRepositoryUrl.
Mistake: no symbols or Source Link.
Consumers can't step into the package, so they clone the repository and guess the version.
Instead: publish a .snupkg and build with ContinuousIntegrationBuild on CI.
Mistake: leaking analyser or tooling dependencies to consumers.
Without PrivateAssets="all", every consumer inherits your analysers and build-only packages.
Instead: mark build-only references with PrivateAssets="all" and check the generated .nuspec.
Mistake: targeting many frameworks "just in case". Each TFM multiplies build time, test effort and the chance of behaviour differences. Instead: target the frameworks your consumers actually use, typically the current LTS and maybe the previous one.
Mistake: trying to republish a version after a bad release. Versions on nuget.org are immutable, and overwriting on internal feeds breaks caches and lock files. Instead: deprecate or unlist the bad version with a message, and ship a new patch version.
Mistake: committing the API key, or using a non-expiring key for all packages. A leaked key lets anyone publish malicious versions of every package you own. Instead: keep keys in the secret store, scope them to specific package ids, and set an expiry.
Mistake: signing without a timestamp.
The signature becomes invalid when the certificate expires, and consumers that require signatures can no longer restore old versions.
Instead: always pass --timestamper to dotnet nuget sign.
A package is a public API with a support promise. A senior engineer treats packaging as a release process: versions derived from source control, automated validation against the last shipped version, symbols and Source Link for debuggability, and minimal dependencies.
The part that is easy to miss is change management after release:
[Obsolete("Use X instead")] in a minor version, remove them only in the next major, and announce the timeline to consuming teams.Inside an organisation they publish through one feed with upstream sources and retention rules, so teams consume shared code the same way they consume open-source packages, which is far safer than copied DLLs or Git submodules.
By default dotnet publish gives a framework-dependent folder that needs the .NET runtime on the server. Self-contained (--self-contained -r linux-x64) bundles the runtime, so nothing needs installing, but the output is bigger and you patch the runtime by redeploying. On top of that: single-file packs everything into one executable, trimming removes unused code, ReadyToRun precompiles to speed up start-up, and Native AOT compiles to a native binary with very fast start-up and low memory but no dynamic code generation. The SDK can also build a container image directly. I choose based on where it runs, start-up and memory needs, and whether the libraries are trim- and AOT-safe, and I measure the result.
First I read the exact error. NU1301 means a source couldn't be reached, 401 or 403 means authentication, and NU1101 means the package isn't on any configured source. It works on my machine because I have cached packages and stored credentials, while CI starts clean. Then I check which nuget.config CI actually uses, since config files are merged from the repo upwards. I make the repo's config explicit with <clear />, and I authenticate CI the supported way, for example the Azure Artifacts credential provider or a token from the pipeline's secret store, never a committed password. I also add package source mapping and lock files so restores are reproducible and protected against dependency confusion.
A .NET tool is a console app shipped as a special NuGet package. Global tools (dotnet tool install -g) are installed per user and land on your PATH. Local tools are pinned per repository in a manifest, .config/dotnet-tools.json, created with dotnet new tool-manifest. Anyone who clones the repo runs dotnet tool restore and gets exactly the same versions. For team and CI work I prefer local tools, because the version lives in source control next to the code that depends on it.
Last updated 1 Nov 2026.