13 minute read

A few years ago, I wrote about publishing a NuGet package with CircleCI. The essential steps have not changed much: restore the repository, build and test the code, create a package, and publish it when a release is ready.

The surrounding tooling has changed considerably.

A modern GitHub Actions workflow can derive package versions from Git tags, annotate failed tests directly in the workflow UI, collect coverage, avoid rebuilding during publication, and authenticate to NuGet.org without storing a long-lived API key.

The interesting part is no longer the final dotnet nuget push command. It is building a trustworthy path from a source commit to the exact package eventually published.

In this post, we will build that path using:

  • MinVer for tag-based package versions;
  • reproducible builds and Source Link information;
  • NuGet and .NET tool caching;
  • dotnet format for style validation;
  • GitHub-native test diagnostics;
  • dotnet-coverage for coverage collection;
  • workflow artifacts to pass packages between jobs;
  • NuGet trusted publishing through GitHub OIDC.

The starting repository

I will assume the repository already contains a library and a test project:

src/
  SampleLibrary/
tests/
  SampleLibrary.Tests/
.github/
  workflows/
.config/
  dotnet-tools.json
Directory.Build.props
Directory.Packages.props
global.json
SampleLibrary.slnx

The project names are intentionally generic. The same workflow can be adapted to a library containing one package or to a repository that packs several related projects.

Deriving versions from Git tags

The package version should come from the repository history rather than from a value duplicated in the workflow.

MinVer integrates with MSBuild and derives a version from Git tags. Add it as a private dependency:

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

With Central Package Management, its version can live in Directory.Packages.props:

<ItemGroup>
  <PackageVersion Include="MinVer" Version="7.0.0" />
</ItemGroup>

Configure the convention in Directory.Build.props:

<PropertyGroup>
  <MinVerTagPrefix>v</MinVerTagPrefix>
  <MinVerDefaultPreReleaseIdentifiers>preview</MinVerDefaultPreReleaseIdentifiers>
  <MinVerAutoIncrement>minor</MinVerAutoIncrement>
</PropertyGroup>

A tag such as v1.2.0 produces package version 1.2.0. Commits after that tag receive a derived prerelease version until the next release tag is created.

MinVer needs the repository tags, so the workflow must fetch the full history:

- uses: actions/checkout@v6
  with:
    fetch-depth: 0

A shallow checkout is faster, but it removes the information the versioning strategy depends on.

Configuring reproducible package builds

Versioning is only one part of producing a trustworthy package. The build should also preserve enough information to trace the compiled assembly back to its source and to debug it after installation.

The sample references DotNet.ReproducibleBuilds. Together with the .NET SDK, the package applies the wider build conventions needed for reproducibility across development machines and CI environments.

Keep the package next to the MinVer convention in Directory.Build.props:

<Project>

  <PropertyGroup>
    <MinVerTagPrefix>v</MinVerTagPrefix>
    <MinVerDefaultPreReleaseIdentifiers>preview</MinVerDefaultPreReleaseIdentifiers>
    <MinVerAutoIncrement>minor</MinVerAutoIncrement>

    <DebugType>portable</DebugType>
    <IncludeSymbols>true</IncludeSymbols>
    <SymbolPackageFormat>snupkg</SymbolPackageFormat>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="DotNet.ReproducibleBuilds"
                      Version="2.0.5"
                      PrivateAssets="all" />
    <PackageReference Include="MinVer"
                      Version="7.0.0"
                      PrivateAssets="all" />
  </ItemGroup>

</Project>

We only override the symbol-related settings deliberately:

  • DotNet.ReproducibleBuilds defaults to embedded symbols;
  • DebugType=portable creates a separate portable PDB;
  • IncludeSymbols and SymbolPackageFormat=snupkg place that PDB in the symbol package accepted by NuGet.org.

The SDK and DotNet.ReproducibleBuilds take care of the other important build settings, including deterministic compilation, CI path normalization, and repository metadata. Repeating those properties in the project would obscure which component owns the configuration.

Source Link support has been included in the .NET SDK since .NET 8. When the repository is built with a recent SDK, the library can target an earlier runtime without needing a separate Microsoft.SourceLink.GitHub package. The SDK uses the repository information from the checkout and embeds the Source Link map in the portable PDB. Consumers can then step into the exact source revision represented by the package.

Package-specific metadata remains in the library project itself:

<PropertyGroup>
  <PackageId>Example.SampleLibrary</PackageId>
  <Description>A short description of the library.</Description>
  <Authors>Your Name</Authors>
  <PackageTags>dotnet;sample-library</PackageTags>
  <PackageReadmeFile>README.md</PackageReadmeFile>
  <PackageLicenseExpression>MIT</PackageLicenseExpression>
  <RepositoryUrl>https://github.com/example/SampleLibrary</RepositoryUrl>
  <RepositoryType>git</RepositoryType>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
  <EnablePackageValidation>true</EnablePackageValidation>
</PropertyGroup>

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

GenerateDocumentationFile includes the XML documentation alongside the assembly. EnablePackageValidation makes the SDK inspect the package produced by the build. It checks that the package exposes a consistent public API across its target frameworks and that its assemblies are compatible with the frameworks they claim to support. Once a stable version has been published, PackageValidationBaselineVersion can also compare the new package with that earlier release and report source- or binary-incompatible API changes before publication.

Metadata such as the package description, tags, README, license, and repository URL is not build machinery, but it is part of publishing a usable package. NuGet.org surfaces it to consumers, while the repository information and portable symbols connect the package back to the source that produced it.

Declaring local tools

We will use dotnet-coverage as a repository-local tool rather than installing it globally in every workflow run:

dotnet new tool-manifest
dotnet tool install dotnet-coverage

The dotnet new tool-manifest command creates .config/dotnet-tools.json. Keeping the manifest in the repository makes the tool version explicit and lets contributors run the same command locally.

Packages used by the setup

The publishing setup adds three dependencies that are independent of the library’s actual behavior:

  • MinVer derives package versions from Git tags during the MSBuild process;
  • DotNet.ReproducibleBuilds applies reproducible-build and repository-information conventions;
  • GitHubActionsTestLogger turns test failures into GitHub Actions annotations.

dotnet-coverage serves the same supporting role, but it is installed as a repository-local .NET tool rather than as a PackageReference. The test framework and test SDK are deliberately omitted because they depend on the application’s testing choices rather than on the publishing setup. None of the packages above is part of the library’s runtime API, and build-only dependencies such as MinVer and DotNet.ReproducibleBuilds are marked with PrivateAssets="all" so they do not flow to consumers.

Creating the workflow

Create .github/workflows/publish.yml.

The workflow validates every pull request and every push to the default branch. Publishing only happens when a GitHub release is published:


name: Build and publish

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]
  release:
    types: [published]

concurrency:
  group: nuget-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name != 'release' }}

Cancelling an older CI run is useful when a branch receives another commit. A release run is different: once publication starts, a newer run should not silently cancel it.

Caching packages and tools

Restoring dependencies is necessary for correctness, but downloading the same packages on every run is not.

Cache the global NuGet package directory and build the key from files that can change the dependency graph:


- name: Cache NuGet packages
  uses: actions/cache@v5
  with:
    path: ~/.nuget/packages
    key: >-
      ${{ runner.os }}-nuget-
      ${{ hashFiles(
        'global.json',
        'NuGet.config',
        'Directory.Packages.props',
        'Directory.Build.props',
        '.config/dotnet-tools.json',
        '**/*.csproj'
      ) }}
    restore-keys: |
      ${{ runner.os }}-nuget-

Both ordinary package restore and local tool restore use NuGet packages, so they benefit from the same cache.

The cache remains an optimization. The workflow must still restore explicitly and work correctly after a cache miss:

- name: Restore tools
  run: dotnet tool restore

- name: Restore packages
  run: dotnet restore

Formatting, building, and testing

Run formatting as its own validation step:

- name: Verify formatting
  run: dotnet format --verify-no-changes --no-restore

Then build once in Release configuration:

- name: Build
  run: >
    dotnet build
    --configuration Release
    --no-restore
    --warnaserror

The explicit restore and build phases keep later steps from doing hidden work. Tests and packing will reuse this build output.

Add GitHubActionsTestLogger to the test projects:

<ItemGroup>
  <PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
</ItemGroup>

The logger creates workflow annotations for failed tests. Run the tests through dotnet-coverage so one test execution produces both diagnostics and coverage:

- name: Test and collect coverage
  run: >
    dotnet tool run dotnet-coverage collect
    "dotnet test --configuration Release --no-build --logger GitHubActions"
    --output-format cobertura
    --output ./artifacts/coverage.cobertura.xml

Upload the report so it remains available after the runner is discarded:

- name: Upload coverage report
  uses: actions/upload-artifact@v7
  with:
    name: coverage
    path: ./artifacts/coverage.cobertura.xml
    if-no-files-found: error

An external coverage service can be added later. It is not required to build a dependable package-publishing workflow.

Packing the library once

After validation succeeds, create the NuGet package:

- name: Pack
  run: >
    dotnet pack
    ./src/SampleLibrary/SampleLibrary.csproj
    --configuration Release
    --no-build
    --output ./artifacts/packages

MinVer supplies the package version from the checked-out Git history. The project configuration supplies the package metadata, reproducible-build conventions, portable symbols, Source Link information, and package validation.

Upload both package files as a workflow artifact:

- name: Upload packages
  uses: actions/upload-artifact@v7
  with:
    name: nuget-packages
    path: |
      ./artifacts/packages/*.nupkg
      ./artifacts/packages/*.snupkg
    if-no-files-found: error

The workflow now has a concrete handoff point:

source commit
     ↓
restore → format → build → test → coverage → pack
                                           ↓
                                workflow artifact
                                           ↓
                              publish to NuGet.org

The publishing job will download these files. It will not rebuild or repack the repository.

Validating the release

A GitHub release provides a deliberate publication boundary, but the workflow should still verify that its metadata is coherent.

The publishing job starts only for release events and depends on the successful build job:

publish:
  name: Publish
  if: github.event_name == 'release'
  needs: build
  runs-on: ubuntu-latest
  permissions:
    contents: write
    id-token: write

Before publishing, validate the tag:

- uses: actions/checkout@v6
  with:
    fetch-depth: 0

- name: Validate release metadata
  env:
    RELEASE_TAG: $
    RELEASE_IS_PRERELEASE: $
  run: |
    if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
      echo "Unsupported release tag: $RELEASE_TAG" >&2
      exit 1
    fi

    release_commit="$(git rev-parse "$RELEASE_TAG^{commit}")"
    head_commit="$(git rev-parse HEAD)"

    if [[ "$release_commit" != "$head_commit" ]]; then
      echo "The release tag does not point to the checked-out commit." >&2
      exit 1
    fi

    if [[ "$RELEASE_TAG" == *-* ]]; then
      test "$RELEASE_IS_PRERELEASE" = "true"
    else
      test "$RELEASE_IS_PRERELEASE" = "false"
    fi

This prevents a stable tag from being published as a GitHub prerelease, a prerelease tag from being presented as stable, or a release from unexpectedly targeting another commit.

Downloading the validated package

Download the package produced by the build job:

- name: Download packages
  uses: actions/download-artifact@v8
  with:
    name: nuget-packages
    path: ./artifacts

The files in ./artifacts are the files that already passed through the validation job.

Publishing without a long-lived API key

The traditional setup stores a NuGet API key as a GitHub secret. That works, but the credential is long-lived and must be protected and rotated.

NuGet trusted publishing uses GitHub’s OpenID Connect identity instead. NuGet.org trusts a specific repository and workflow, and the workflow exchanges its identity token for a short-lived credential.

After configuring the trusted publishing policy on NuGet.org, authenticate with nuget/login:

- name: Login to NuGet.org
  id: nuget-login
  uses: nuget/login@v1
  with:
    user: YourNuGetUser

The publishing job needs id-token: write so the action can request the OIDC token. No permanent NuGet API key is stored in the repository.

Push the downloaded package:

- name: Publish to NuGet.org
  run: >
    dotnet nuget push
    "./artifacts/*.nupkg"
    --source https://api.nuget.org/v3/index.json
    --api-key "$"
    --skip-duplicate

dotnet nuget push still calls the argument --api-key, but the value is the temporary credential returned by the login action.

Attaching the same files to the GitHub release

The packages can also be attached to the release that triggered the workflow:

- name: Attach packages to the GitHub release
  env:
    GH_TOKEN: $
  run: |
    gh release upload "$" \
      ./artifacts/*.nupkg \
      ./artifacts/*.snupkg \
      --repo "$"

NuGet.org and the GitHub release now receive the same package files.

The complete workflow

Putting everything together gives us:


name: Build and publish

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]
  release:
    types: [published]

concurrency:
  group: nuget-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name != 'release' }}

jobs:
  build:
    name: Build, test, and pack
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Setup .NET
        uses: actions/setup-dotnet@v5
        with:
          dotnet-version: '10.0.x'

      - name: Cache NuGet packages
        uses: actions/cache@v5
        with:
          path: ~/.nuget/packages
          key: >-
            ${{ runner.os }}-nuget-
            ${{ hashFiles(
              'global.json',
              'NuGet.config',
              'Directory.Packages.props',
              'Directory.Build.props',
              '.config/dotnet-tools.json',
              '**/*.csproj'
            ) }}
          restore-keys: |
            ${{ runner.os }}-nuget-

      - name: Restore tools
        run: dotnet tool restore

      - name: Restore packages
        run: dotnet restore

      - name: Verify formatting
        run: dotnet format --verify-no-changes --no-restore

      - name: Build
        run: >
          dotnet build
          --configuration Release
          --no-restore
          --warnaserror

      - name: Test and collect coverage
        run: >
          dotnet tool run dotnet-coverage collect
          "dotnet test --configuration Release --no-build --logger GitHubActions"
          --output-format cobertura
          --output ./artifacts/coverage.cobertura.xml

      - name: Pack
        run: >
          dotnet pack
          ./src/SampleLibrary/SampleLibrary.csproj
          --configuration Release
          --no-build
          --output ./artifacts/packages

      - name: Upload coverage report
        uses: actions/upload-artifact@v7
        with:
          name: coverage
          path: ./artifacts/coverage.cobertura.xml
          if-no-files-found: error

      - name: Upload packages
        uses: actions/upload-artifact@v7
        with:
          name: nuget-packages
          path: |
            ./artifacts/packages/*.nupkg
            ./artifacts/packages/*.snupkg
          if-no-files-found: error

  publish:
    name: Publish
    if: github.event_name == 'release'
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write

    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Validate release metadata
        env:
          RELEASE_TAG: ${{ github.event.release.tag_name }}
          RELEASE_IS_PRERELEASE: ${{ github.event.release.prerelease }}
        run: |
          if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
            echo "Unsupported release tag: $RELEASE_TAG" >&2
            exit 1
          fi

          release_commit="$(git rev-parse "$RELEASE_TAG^{commit}")"
          head_commit="$(git rev-parse HEAD)"

          if [[ "$release_commit" != "$head_commit" ]]; then
            echo "The release tag does not point to the checked-out commit." >&2
            exit 1
          fi

          if [[ "$RELEASE_TAG" == *-* ]]; then
            test "$RELEASE_IS_PRERELEASE" = "true"
          else
            test "$RELEASE_IS_PRERELEASE" = "false"
          fi

      - name: Download packages
        uses: actions/download-artifact@v8
        with:
          name: nuget-packages
          path: ./artifacts

      - name: Login to NuGet.org
        id: nuget-login
        uses: nuget/login@v1
        with:
          user: YourNuGetUser

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

      - name: Attach packages to the GitHub release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release upload "${{ github.event.release.tag_name }}" \
            ./artifacts/*.nupkg \
            ./artifacts/*.snupkg \
            --repo "${{ github.repository }}"

Publishing a release

The human part of the release process remains small:

  1. Merge the intended changes into the default branch.
  2. Create a tag such as v1.2.0.
  3. Create and publish a GitHub release for that tag.
  4. Let the workflow validate the release and publish the package.

A prerelease tag can include a suffix:

v1.3.0-preview.1

It should be published as a GitHub prerelease. The workflow rejects a mismatch between the tag and the release type.

Optional: manually publishing a preview package

Sometimes it is useful to consume a package from a real feed before creating a public release.

Add workflow_dispatch to the triggers. A manually dispatched run has no exact release tag, so MinVer produces a derived prerelease version from the repository history.

GitHub Packages can be used as the preview destination:

preview:
  name: Publish preview
  if: github.event_name == 'workflow_dispatch'
  needs: build
  runs-on: ubuntu-latest
  permissions:
    contents: read
    packages: write

  steps:
    - name: Download packages
      uses: actions/download-artifact@v8
      with:
        name: nuget-packages
        path: ./artifacts

    - name: Publish to GitHub Packages
      run: |
        dotnet nuget push "./artifacts/*.nupkg" \
          --source "https://nuget.pkg.github.com/$/index.json" \
          --api-key "$" \
          --skip-duplicate

This keeps manual previews separate from public NuGet.org releases. It also preserves the same build-once rule: the preview job downloads the package produced by the validation job instead of packing it again.

The final publishing command is still straightforward. Most of the value comes from the path leading to it: Git tags determine the version; the SDK and DotNet.ReproducibleBuilds establish reproducible-build and Source Link conventions; formatting, compilation, tests, coverage, package validation, and portable symbols validate and describe the source; and the package is created only once.

That exact .nupkg and .snupkg pair is passed between jobs as a workflow artifact, published to NuGet.org with a short-lived OIDC credential, and attached to the GitHub release. Manual preview publishing follows the same build-once rule but targets GitHub Packages instead. The result is a release process in which every published file can be traced back to the commit that produced and validated it.

What changed since the CircleCI workflow

The older workflow and this one solve the same problem, but the defaults have moved:

Earlier approach 2026 approach
CircleCI GitHub Actions
Explicit version calculation in CI MinVer integrated with MSBuild
Basic package output Reproducible builds, Source Link, portable symbols, and package validation
Permanent NuGet API key Trusted publishing through GitHub OIDC
Test output in logs GitHub-native test annotations
Coverage as an external addition Coverage produced by the workflow
Rebuild during publication Package once and publish the same artifact
Download every dependency on every run NuGet package and tool caching
Tag alone as the release boundary Validated GitHub release
GitHub Packages as a default second destination Optional manual preview feed

Recap

In this post, we started from an ordinary library repository and built a complete GitHub Actions release path around it. We used MinVer to derive versions from Git tags, configured reproducible packages with Source Link and portable symbols, pinned local tooling, cached restores, and validated formatting, compilation, tests, coverage, and package compatibility.

We then packed the library once, transferred the resulting files between jobs as a workflow artifact, validated the GitHub release, and published to NuGet.org through trusted publishing instead of a permanent API key. Finally, we reused the same package files for the GitHub release and added an optional manual preview path through GitHub Packages.

That is the workflow I have gradually converged on while modernising my own open-source .NET projects.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!