📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do you create and publish a NuGet package with dotnet pack, including versioning, package metadata, symbols and signing?

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.

Intermediate Theory Intermediate dotnet-pack nuget package-signing semver source-link

🎯 Key points

  • `dotnet pack` builds the project and creates a .nupkg from project properties; Release by default since .NET 8
  • Metadata comes from MSBuild properties: PackageId, Version, Authors, Description, PackageLicenseExpression, RepositoryUrl, PackageReadmeFile
  • Use Semantic Versioning, set the version from CI (-p:Version=...), and treat breaking changes as major versions
  • Publish symbols as .snupkg with Source Link and deterministic CI builds so consumers can debug into the package
  • EnablePackageValidation checks for accidental breaking changes against a baseline version
  • `dotnet nuget push` uploads to a feed with an API key or credential provider; `dotnet nuget sign` adds an author signature
  • Multi-target only the frameworks you support, and keep dependencies minimal

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"dotnet pack builds the project and produces a .nupkg whose metadata comes from MSBuild properties: PackageId, Version, Authors, Description, a licence expression, the repository URL and a README through PackageReadmeFile. Since .NET 8 it defaults to Release. It's for libraries; shipping a runnable app is dotnet 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 .snupkg with 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 every PackageReference becomes a dependency of every consumer.

Then dotnet nuget push uploads 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 use dotnet 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."


Minimum viable package

  1. Put shared metadata in Directory.Build.props (authors, company, licence, repository) and package-specific metadata in the project (PackageId, Description, PackageTags).
  2. Include a README.md with PackageReadmeFile so the feed shows usage.
  3. Declare the licence. For a standard open-source licence, 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.
  4. 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.

Versioning

  • SemVer: MAJOR for breaking API changes, MINOR for new features, PATCH for fixes; prerelease suffixes like 1.5.0-beta.3.
  • Set the version in CI, never by hand-editing the project. GitVersion calculates the version from branches and tags using a configurable strategy; Nerdbank.GitVersioning derives it from a 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.

Dependencies

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".

Symbols, Source Link and determinism

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.

Package validation

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.

Pushing and signing

  • dotnet nuget push artifacts/*.nupkg --source <feed> --api-key <key> uploads the package (and the matching .snupkg).
  • nuget.org repository-signs every package; organisations that require author signatures use dotnet nuget sign with a code-signing certificate and enforce it with trustedSigners in consumers' nuget.config.

Production example

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.

🔍 What the interviewer is checking

The interviewer is checking whether you can ship reusable code responsibly.

Green flags

  • Knows the key metadata properties and SemVer.
  • Mentions symbols, Source Link, deterministic builds and package validation.
  • Handles API keys securely and understands signing.

Red flags

  • Copies DLLs between repositories.
  • Thinks a package version can be reused.
  • No idea how consumers would debug into the package.

📚 Learn in depth

1. The lifecycle of a package

 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

2. What is inside a .nupkg

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.

3. Metadata properties

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

4. Semantic Versioning in practice

 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.

5. Versioning from source control

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

6. Symbols, Source Link and deterministic builds

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.

7. Package validation

 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.

8. Dependencies you pass on

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

9. Pushing

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.

10. Signing

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.

11. Security

  • Keep API keys and signing certificates in the pipeline's secret store or a key vault, scoped and expiring.
  • Open the .nupkg before the first release of a new package and check for secrets, test data or config files that slipped in.
  • Reserve your ID prefix on nuget.org, and use package source mapping internally, to reduce dependency confusion risk.

12. .NET 11 awareness

Packing is stable. Watch the .NET 11 SDK notes for changes to package validation and NuGet defaults before relying on them.

13. Try it yourself

  1. Create a class library and add the properties from the code example.
  2. Run dotnet pack -c Release -o artifacts -p:Version=1.0.0 and open the .nupkg as a zip. Read the generated .nuspec.
  3. Add a PackageReference to an analyser without PrivateAssets="all", pack, and see it appear as a dependency in the .nuspec.
  4. Add 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.
  5. Push to a local folder feed: dotnet nuget push artifacts/*.nupkg --source ./local-feed, then consume it from another project with a nuget.config pointing at that folder.
  6. Pack 1.5.0-beta.2 and 1.5.0-beta.10 and check which one the feed lists as newer.

💻 Code

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"

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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:

  • Keep a changelog and release notes per version, linked from the README, so consumers know what changed and why.
  • Deprecate in steps: mark APIs [Obsolete("Use X instead")] in a minor version, remove them only in the next major, and announce the timeline to consuming teams.
  • Deprecate or unlist whole package versions on the feed (nuget.org and Azure Artifacts both support deprecation messages) when a version has a serious bug or vulnerability, pointing to the fixed version.
  • Agree a support window, for example the current and previous major versions get security fixes.

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate is clear on pack and the interviewer wants the app-side equivalent. Why: Contrasts packaging a library with publishing a runnable application.
    How to answer

    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.

  2. When: Candidate pushes packages to an internal feed. Why: Checks that the candidate can consume the packages they push to a private feed from CI.
    How to answer

    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.

  3. When: Candidate mentions packing a console app. Why: A tool is a special package; tests whether the candidate knows PackAsTool.
    How to answer

    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.

↩ Usually asked after

More in .NET CLI

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me