Mert Özen Further With Every Line
DevOps Versioning and Release Management — #05

Publishing a NuGet Package: dotnet pack, Package Metadata, Package Validation and nuget.org Trusted Publishing

Mert Özen Nov 14, 2026 12 min 2 views
Publishing a NuGet Package: dotnet pack, Package Metadata, Package Validation and nuget.org Trusted Publishing

Build a NuGet package with metadata, README, license and a symbol package, catch accidental breaking changes during pack with EnablePackageValidation against the previous version, and publish to nuget.org with trusted publishing, which issues a one-hour key through OIDC instead of a long-lived API key. On Bookstore.Contracts we show an optional parameter's binary break as CP0002, a dropped framework as PKV006 and a second push of the same version as 409 Conflict on a local server.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.402), MinVer 8.0.0, BaGetter (a local NuGet server in Docker) and actionlint 1.7.12. The trusted publishing workflow was validated with actionlint; it was not run on GitHub or nuget.org.

Guess First

The Bookstore.Contracts package of Bookstore, the online bookstore used as the shared example across this site's series, is at 2.0.0. Emre wants to add publisher information to books, and to avoid breaking existing users he makes the new parameter optional:

// 2.0.0
public sealed record BookDto(string Isbn, string Title, string Author, Money ListPrice);

// next release: an optional parameter
public sealed record BookDto(string Isbn, string Title, string Author, Money ListPrice, string? Publisher = null);

Old code is written as new BookDto(isbn, title, author, price); recompiled, it gets no error. Can this change ship as 2.1.0? Let's create the package with package validation, using 2.0.0 as the baseline:

$ dotnet pack src/Bookstore.Contracts -c Release -o artifacts \
    -p:PackageValidationBaselineVersion=2.0.0 \
    -p:RestoreAdditionalProjectSources=$PWD/artifacts/feed
  Successfully created package 'artifacts/Bookstore.Contracts.2.0.1-alpha.0.2.nupkg'.
error CP0002: Member 'Bookstore.Contracts.BookDto.BookDto(string, string, string, Bookstore.Contracts.Money)' exists on [Baseline] lib/net10.0/Bookstore.Contracts.dll but not on lib/net10.0/Bookstore.Contracts.dll
error CP0002: Member 'void Bookstore.Contracts.BookDto.Deconstruct(out string, out string, out string, out Bookstore.Contracts.Money)' exists on [Baseline] lib/net10.0/Bookstore.Contracts.dll but not on lib/net10.0/Bookstore.Contracts.dll
error : API breaking changes found. If those are intentional, the APICompat suppression file can be updated by rebuilding with '/p:ApiCompatGenerateSuppressionFile=true'

It cannot. An optional parameter is compatible at the source level but not at the binary level: the compiler writes the default value into the caller, and an assembly compiled against 2.0.0 looks for the four-parameter constructor that no longer exists. An application running with 2.1.0 without recompiling gets a MissingMethodException. The Deconstruct the record generates changed for the same reason. In this post we look at building the package with the right metadata, catching breaks like this before release, and publishing to nuget.org without storing an API key.

Package Design: Metadata, README, License and Symbol Package

In the "Organizing C# Code: Namespaces, global using, Solutions and NuGet" post of the C# Fundamentals series we looked at NuGet packages from the consuming side. For the publishing side, the package page, search results and trust signals of what dotnet pack produces all come from properties in the csproj:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <!-- Version comes from tags such as contracts/v1.2.0 -->
    <MinVerTagPrefix>contracts/v</MinVerTagPrefix>
  </PropertyGroup>

  <PropertyGroup>
    <PackageId>Bookstore.Contracts</PackageId>
    <Description>Shared DTOs of the Bookstore API (books, prices) for .NET clients.</Description>
    <Authors>Bookstore Team</Authors>
    <PackageTags>bookstore;dto;contracts</PackageTags>
    <PackageLicenseExpression>MIT</PackageLicenseExpression>
    <PackageReadmeFile>README.md</PackageReadmeFile>
    <PackageProjectUrl>https://github.com/example/bookstore</PackageProjectUrl>
    <PublishRepositoryUrl>true</PublishRepositoryUrl>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <IncludeSymbols>true</IncludeSymbols>
    <SymbolPackageFormat>snupkg</SymbolPackageFormat>
    <EnablePackageValidation>true</EnablePackageValidation>
  </PropertyGroup>

  <ItemGroup>
    <None Include="README.md" Pack="true" PackagePath="/" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="MinVer" Version="8.0.0" PrivateAssets="all" />
  </ItemGroup>

</Project>

Every line has a reason. PackageId is the package's permanent identity; once taken on nuget.org, nobody else can use it. Description and PackageTags show up in search. PackageLicenseExpression is an SPDX expression; nuget.org recognizes the license from it. PackageReadmeFile puts the README shown on the package page into the package; the file has to be added to the package root with a None item. PackageIcon works the same way for a package icon; Bookstore has no icon for now. PublishRepositoryUrl writes the repository URL and commit that Source Link found into the nuspec. GenerateDocumentationFile adds the XML documentation to the package, so users see descriptions in IDE tooltips. IncludeSymbols and SymbolPackageFormat put the pdbs into a separate .snupkg; nuget.org keeps it on its symbol server, and together with the Source Link information from the earlier posts the debugger can step into the source.

$ dotnet pack src/Bookstore.Contracts -c Release -o artifacts
  Successfully created package 'artifacts/Bookstore.Contracts.2.0.1-alpha.0.1.nupkg'.
  Successfully created package 'artifacts/Bookstore.Contracts.2.0.1-alpha.0.1.snupkg'.
$ unzip -l artifacts/Bookstore.Contracts.2.0.1-alpha.0.1.nupkg
  Length      Date    Time    Name
---------  ---------- -----   ----
      482  11-14-2026 07:12   _rels/.rels
      839  11-14-2026 07:12   Bookstore.Contracts.nuspec
      390  11-14-2026 04:12   README.md
     8704  11-14-2026 04:12   lib/net10.0/Bookstore.Contracts.dll
     2497  11-14-2026 04:12   lib/net10.0/Bookstore.Contracts.xml
      582  11-14-2026 07:12   [Content_Types].xml
      701  11-14-2026 07:12   package/services/metadata/core-properties/nuget.psmdcp

The generated nuspec shows the metadata really made it into the package:

<metadata>
  <id>Bookstore.Contracts</id>
  <version>2.0.1-alpha.0.1</version>
  <authors>Bookstore Team</authors>
  <license type="expression">MIT</license>
  <licenseUrl>https://licenses.nuget.org/MIT</licenseUrl>
  <readme>README.md</readme>
  <projectUrl>https://github.com/example/bookstore</projectUrl>
  <description>Shared DTOs of the Bookstore API (books, prices) for .NET clients.</description>
  <tags>bookstore dto contracts</tags>
  <repository type="git" url="https://github.com/example/bookstore.git" branch="refs/heads/main" commit="a257ef9e6e37abf55f4353277420a3657dde20da" />
  <dependencies>
    <group targetFramework="net10.0" />
  </dependencies>
</metadata>

The commit in the repository line shows everyone on nuget.org which code the package was built from. The version is 2.0.1-alpha.0.1 because there is one commit after the last tag; we saw this MinVer behavior in the third post.

Choosing Target Frameworks

Which frameworks a package targets is also a promise to its users. Support for .NET 8 and .NET 9 ended on November 10, 2026; the supported versions are .NET 10 (LTS) and .NET 11. That is why Bookstore.Contracts targets only net10.0. If you had to support an institutional client still on .NET 8, you could build for both with <TargetFrameworks>net8.0;net10.0</TargetFrameworks>; if you also need .NET Framework you have to add netstandard2.0, but features such as records and init need extra definitions there.

There is a silent trap here. We tried the NETSDK1138 warning the SDK gives for out-of-support frameworks: SDK 10.0.402 raised it only for executable projects, and its list contained net7.0 but not net8.0. A library targeting net8.0 got no warning at all; in the same test the .NET 11 SDK stayed silent too. Tracking which frameworks are out of support is your job.

Adding a framework is a minor change, removing one is a breaking change: users on that framework cannot move to the new version. Package validation catches this as well. In a separate test, with a 2.1.0 targeting net8.0;net10.0 as the baseline and a 2.2.0 targeting only net10.0:

error PKV006: Target framework net8.0 is no longer supported in the latest version.

Package Validation: Catching Breaks in the Build with a Baseline

With EnablePackageValidation on, dotnet pack runs several checks after creating the package. Even without a baseline it checks consistency inside the package itself, for example whether the public APIs of the assemblies built for different frameworks are compatible with each other. The really valuable check starts when PackageValidationBaselineVersion is set: the SDK downloads that version from the feed and compares every framework of the new package with its counterpart in the baseline at the API level. Microsoft.DotNet.ApiCompat's rules run behind it; there is also a separate Microsoft.DotNet.ApiCompat.Tool to run the same checks outside the build.

We did not write the baseline version into the csproj; we pass it on the command line: it changes with every release, and if it lived in the csproj, every build that cannot find the baseline in a feed would fail at restore. Locally we used a folder feed instead of nuget.org; to put 2.0.0 there:

$ dotnet pack src/Bookstore.Contracts -c Release -o artifacts/feed -p:MinVerVersionOverride=2.0.0
  Successfully created package 'artifacts/feed/Bookstore.Contracts.2.0.0.nupkg'.
  Successfully created package 'artifacts/feed/Bookstore.Contracts.2.0.0.snupkg'.

The command in the guess section passes this folder as an extra source through RestoreAdditionalProjectSources. In CI the baseline comes straight from nuget.org or the company feed. The fix for the error is to put the new information into an init property instead of the constructor:

namespace Bookstore.Contracts;

/// <summary>A book as returned by the Bookstore API.</summary>
/// <param name="Isbn">ISBN-13 of the book.</param>
/// <param name="Title">Title of the book.</param>
/// <param name="Author">Author name.</param>
/// <param name="ListPrice">Current list price with currency.</param>
public sealed record BookDto(string Isbn, string Title, string Author, Money ListPrice)
{
    /// <summary>Publisher name, if known.</summary>
    public string? Publisher { get; init; }
}

In this form validation passes and the change really is a minor: the old constructor and Deconstruct are in place, and the new property is optional. If the break is deliberate (you are preparing a major), you can generate a suppression file with -p:ApiCompatGenerateSuppressionFile=true and commit it to the repository; the file becomes the written record of which breaks you accepted. One thing to watch: when validation fails, dotnet pack exits with 1 but the package file stays in the folder. If the next CI step runs with if: always() or through a script that ignores the error, the broken package can still be published.

Pushing to a Local Feed: A Published Version Cannot Change

Before moving to nuget.org we tried publishing behavior on a local NuGet server. BaGetter is a small server that speaks the NuGet protocol; it starts in Docker with one command:

docker run -d --name relseries-nuget -p 127.0.0.1:18555:8080 \
  -e ApiKey= -e Storage__Type=FileSystem -e Storage__Path=/data \
  -e Database__Type=Sqlite -e "Database__ConnectionString=Data Source=/data/db.sqlite" \
  bagetter/bagetter:latest

NuGet in SDK 10 refused to push to a source served over HTTP and asked for the source to be marked explicitly with allowInsecureConnections. A nuget.config that allows it only for the local server:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="bookstore-local" value="http://localhost:18555/v3/index.json" allowInsecureConnections="true" />
  </packageSources>
</configuration>

Let's send the same package twice (we removed the API key warning lines):

$ dotnet nuget push Bookstore.Contracts.2.1.0.nupkg --source bookstore-local
Pushing Bookstore.Contracts.2.1.0.nupkg to 'http://localhost:18555/api/v2/package'...
  PUT http://localhost:18555/api/v2/package/
  Created http://localhost:18555/api/v2/package/ 230ms
Your package was pushed.
Pushing Bookstore.Contracts.2.1.0.snupkg to 'http://localhost:18555/api/v2/symbol'...
  PUT http://localhost:18555/api/v2/symbol/
  Created http://localhost:18555/api/v2/symbol/ 60ms
Your package was pushed.

$ dotnet nuget push Bookstore.Contracts.2.1.0.nupkg --source bookstore-local
Pushing Bookstore.Contracts.2.1.0.nupkg to 'http://localhost:18555/api/v2/package'...
  PUT http://localhost:18555/api/v2/package/
  Conflict http://localhost:18555/api/v2/package/ 18ms
To skip already published packages, use the option --skip-duplicate
error: Response status code does not indicate success: 409 (Conflict).

$ dotnet nuget push Bookstore.Contracts.2.1.0.nupkg --source bookstore-local --skip-duplicate
  Conflict http://localhost:18555/api/v2/package/ 17ms
Package '.../Bookstore.Contracts.2.1.0.nupkg' already exists at feed 'http://localhost:18555/api/v2/package'.

The first push is Created, the second 409 Conflict. nuget.org behaves the same way: once a version is published its contents cannot change and normally it cannot be deleted; the fix for a broken 2.1.0 ships as 2.1.1. --skip-duplicate is for re-runs of the same workflow: it skips a package that already exists without treating it as an error. Not every feed gives this guarantee. When we sent the same version to a folder feed twice:

$ dotnet nuget push artifacts/Bookstore.Contracts.2.1.0.nupkg --source "$PWD/artifacts/feed"
Pushing Bookstore.Contracts.2.1.0.nupkg to 'artifacts/feed'...
Your package was pushed.
$ dotnet nuget push artifacts/Bookstore.Contracts.2.1.0.nupkg --source "$PWD/artifacts/feed"
Pushing Bookstore.Contracts.2.1.0.nupkg to 'artifacts/feed'...
Your package was pushed.

The second push overwrote the file. Folder feeds are handy for testing, but used as a feed shared by the team, "2.1.0" can have two different contents and two developers' machines run different code. A shared feed should be a server that keeps versions immutable.

Trusted Publishing to nuget.org

The classic way is to create an API key on nuget.org and put it into CI as a secret. That key is usually long-lived; if it leaks, someone can publish packages in your name. Trusted publishing removes the key: while the workflow runs, GitHub issues a short-lived OIDC token, nuget.org validates it and compares it with the policy you defined, and if they match it hands out a temporary API key valid for one hour. Each token can be used to obtain only one key.

On the nuget.org side the policy contains the repository owner, the repository name, the workflow file name (file name only, publish-contracts.yml) and optionally an environment. The publishing workflow for Bookstore.Contracts:

name: publish-contracts

on:
  push:
    tags: ['contracts/v*']

permissions:
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: nuget
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json

      - name: Find the previous release as baseline
        id: baseline
        run: |
          previous=$(git describe --tags --abbrev=0 --match 'contracts/v*' "${GITHUB_REF_NAME}^" 2>/dev/null || true)
          echo "version=${previous#contracts/v}" >> "$GITHUB_OUTPUT"

      - name: Pack with package validation
        env:
          BASELINE: ${{ steps.baseline.outputs.version }}
        run: |
          args=()
          if [ -n "$BASELINE" ]; then args+=("-p:PackageValidationBaselineVersion=$BASELINE"); fi
          dotnet pack src/Bookstore.Contracts -c Release -o artifacts "${args[@]}"

      - name: NuGet login (OIDC -> short-lived API key)
        uses: NuGet/login@v1
        id: login
        with:
          user: ${{ secrets.NUGET_USER }}

      - name: Push to nuget.org
        env:
          NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}
        run: dotnet nuget push "artifacts/*.nupkg" --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json

The workflow runs when a contracts/v* tag is pushed; the release step from the previous post creates that tag. First it finds the baseline: git describe takes the last contracts/v* tag before this one, and the first release has no baseline. Then a validated pack, a temporary key through NuGet/login, and the push. The job-level id-token: write permission lets GitHub issue an OIDC token for this job; the rest of the workflow runs with read permission only. The environment: nuget line makes it possible to gate publishing on a human approval by adding a protection rule to that environment on GitHub; if you put the environment into the policy, no other job can publish from the same workflow. According to the documentation, policies created for private repositories stay temporarily active for seven days until the first successful publish. We covered the general mechanics of OIDC in GitHub Actions in the "Deploying with GitHub Actions 1: Azure App Service with OIDC and Secretless Cloud Access" post of the GitHub Actions series. This workflow was validated with actionlint; it was not run on GitHub or nuget.org.

Prerelease, Unlist, Deprecate and Vulnerabilities

After publishing, four tools manage the package's lifecycle. Prerelease versions (2.2.0-rc.1) are not picked unless the user explicitly asks for them; in Visual Studio they do not show up unless "Include prerelease" is checked. Shipping a breaking major as an rc first and trying it with a few internal users is a good habit.

Unlisting hides a version from search results but does not delete it: projects that reference that exact version keep restoring it. Deprecating informs the version's users. On nuget.org you can pick one of three reasons: no longer maintained (Legacy), has critical bugs, and other; you can also recommend an alternate package. Users see this on Visual Studio's Installed tab or in the output of dotnet list package --deprecated. The two can be combined: unlisting and deprecating a version with a critical bug keeps new users from choosing it and tells existing users why.

Vulnerability information reaches nuget.org from the GitHub Advisory Database. When you find a vulnerability in your package, you publish an advisory; NuGet's audit during restore then raises a warning between NU1901 and NU1904, depending on severity, in every project that uses an affected version. When you ship a security patch, this information showing the right version range is the answer to the user's question "am I affected?".

In Production

Protect the package identity. Open an organization account on nuget.org and publish packages under it; a package tied to one person's account is left without an owner when that person leaves. If you apply for ID prefix reservation for a prefix (for example Bookstore.), nobody else can publish packages with that prefix, and your packages show the verified mark.

Publish through one path only. Packages should come only from the workflow triggered by a tag; a developer being able to run dotnet nuget push from their own machine means skipping every check of the release process (validation, approval, changelog). Trusted publishing enforces this naturally, because the right to publish belongs to a specific workflow rather than a person.

Find the baseline automatically. Updating the baseline by hand gets forgotten, and validation keeps comparing against an old version. The git describe step in the workflow derives it from the tag.

Check the symbol package and Source Link. With a tool such as NuGet Package Explorer or on nuget.info, verify that the package's repository information and commit are filled in and that the pdb is in the snupkg; if not, users' debuggers cannot step into the source.

Common Pitfalls

Trying to push the same version again. The 409 error is not a bug, it is a guarantee. The way to fix a broken package is a new patch version, and unlisting or deprecating the old one.

Thinking an optional parameter is compatible. As we saw in the guess section, it is source compatible but binary breaking. Instead of adding to constructor and method signatures, add an overload or a property; if you are not sure, ask package validation.

Choosing between version ranges and fixed versions without thinking. In a PackageReference, Version="2.0.0" means "at least 2.0.0", and NuGet picks the lowest version that satisfies it; a floating version such as 2.* takes the newest one on every restore and makes the build unrepeatable. In applications use fixed versions and lock files; in libraries a lower bound on dependencies is enough, and the NuGet documentation recommends not putting upper bounds on packages you do not own.

Accidentally sending a private package to nuget.org. If you do not pass --source to dotnet nuget push, the default push source is used; depending on the machine's config, that can be nuget.org. For internal packages always write --source explicitly, set defaultPushSource to the internal feed in the repository's nuget.config, and give the right to publish to nuget.org only to workflows with a trusted publishing policy.

Publishing the package left behind after a validation error. Even when dotnet pack fails, the .nupkg stays in the folder; run the push step only if pack succeeded.

A Small Experiment

★☆☆ Pack Bookstore.Contracts and open the .snupkg. Where is the pdb file, and is the Source Link JSON inside it?

★★☆ Rename Money's Currency property to CurrencyCode and pack with 2.1.0 as the baseline. Which error codes do you see? Then generate a suppression file with ApiCompatGenerateSuppressionFile and look at its contents.

★★★ Start the BaGetter container and bind the Bookstore API to Contracts 2.1.0 with a PackageReference instead of a ProjectReference. After putting 2.1.1 into the feed, check with dotnet list package which version resolves for Version="2.1.0", Version="[2.1.0]" and Version="2.*".

The Contracts package is now published validated and without keys. In the next post we do the same on the frontend side: we extract an Angular library from the shared components in bookstore-web, package it with ng-packagr, and publish it with peerDependencies, dist-tags and npm trusted publishing.