blog post image
Andrew Lock avatar

Andrew Lock

~7 min read

Publishing NuGet packages from GitHub actions the easy way with Trusted Publishing

Share on:

In this post I describe how you can use nuget.org's new Trusted Publishing feature to publish NuGet packages from a GitHub Actions workflow, without having to generate and store API keys, while simultaneously benefiting from improved security.

How do you push NuGet packages today?

If you're someone that creates NuGet packages and pushes them to nuget.org today, then you're probably doing so in one of several ways:

  • Building the packages locally and manually uploading them to nuget.org.
  • Building the packages in CI, downloading them, and manually uploading them to nuget.org.
  • Building the packages in CI, and pushing them directly to nuget.org from CI.

Each of these approaches is progressively "better" in that they generally improve the consistency of your builds and reduce the number of manual steps you require. However, pushing your .nupkg files to NuGet from CI is also somewhat "harder" than just uploading them locally, via the nuget.org website.

Instead of just dragging-and-dropping your package onto the web page, you now need to:

  • Generate an API key
  • Store it securely in GitHub Actions (e.g. as a Secret)
  • Pass the secret in your GitHub Actions workflow
  • Rotate the secret whenever it changes
  • Make sure others in your organisation can do the same (if you're publishing packages for an org)

None of that is insurmountable, but managing the lifecycle of long-lived secrets is notoriously difficult. And that's where Trusted Publishing steps in.

What is Trusted Publishing?

Trusted Publishing is an initiative that's been in place for many ecosystems for a while. For example Python has it for PyPI, Ruby has it for RubyGems.org, and JavaScript has it for npm. And as of last week, .NET has Trusted Publishing for nuget.org 🎉

Trusted Publishing is an approach that uses existing authentication standards (OpenID Connect) to connect CI infrastructure provides (such as GitHub Actions or GitLab Pipelines) with public package repositories (like PyPI and nuget.org). Instead of needing to store and manage API keys so that the two systems can talk to each other, you use OpenID Connect to retrieve short-lived authentication tokens, which you can then use to push packages to the package repository.

Exactly how this works will vary by provider and package repository, but the overall flow is the same. For GitHub actions and nuget.org, it looks something like this:

  • The user configures a "trust policy" on nuget.org.
  • In a CI workflow, you retrieve an OpenID Connect token for the job from GitHub.
    • In this example, GitHub is the Identity Provider (IdP).
    • The token is a signed JSON Web Token (JWT), which includes details about the repository and the workflow that's running.
  • You send the token to nuget.org, exchanging the token for an API key.
    • NuGet.org verifies the contents of the JWT against the configured trust policy.
    • If everything matches up, nuget.org issues a short-lived API token that can be used to publish packages.
  • The CI workflow uses the API token to push the .nupkg packages to nuget.org.
Diagram of Trusted Publishers flow. From Trusted Publishers for All Package Repositories.

Because the trust-policy is configured ahead of time, and you're already authenticated with GitHub (due to running in their infrastructure) it's pretty easy to get up and running.

Configuring Trusted Publishing on NuGet.org

Once I saw the blog post, I decided to give it a try for my new sleep-pc tool, seeing as I hadn't set up CI for it yet. I'll start by showing the workflow without NuGet publishing, to provide the baseline, and then show the steps I took to add publishing via Trusted Publishing.

The workflow starting point

The initial workflow, without publishing, is shown below. This is stored in the file .github/workflows/BuildAndPack.yml:

name: BuildAndPack
on:
  push:
    branches: [main]
    tags: ['*']
  pull_request:

jobs:
  build-and-publish:
    permissions:
      contents: read
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x

      - name: dotnet pack
        run: |
          dotnet pack
          dotnet pack -r win-x64

This just does 3 things:

  • Checkout the repo
  • Install .NET 10
  • Run dotnet pack

It's worth nothing that I added the permissions entry to limit the permissions to reading from the repository as a security best practice. This isn't required, but I added it mostly to show what needs to change when we use trusted publishing.

Updating the workflow to add Trusted Publishing support

To add trusted publishing, we need to do three things:

  • Add the id-token: write permission.
  • Use NuGet/login@v1 to exchange an OIDC token for a NuGet API key.
  • Use the generated API key to push the packages to NuGet

The following shows the updated workflow, with comments highlighting the differences:

name: BuildAndPack
on:
  push:
    branches: [main]
    tags: ['*']
  pull_request:

jobs:
  build-and-publish:
    permissions:
      contents: read
      id-token: write  # enable GitHub OIDC token issuance for this job

    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x

      - name: dotnet pack
        run: |
          dotnet pack
          dotnet pack -r win-x64

      # Use the ambient GitHub token to login to NuGet and retrieve an API key
      - name: NuGet login (OIDC → temp API key)
        uses: NuGet/login@v1
        id: login
        with:
          # Secret is your NuGet username, e.g. andrewlock
          user: ${{ secrets.NUGET_USER }}

      - name: push to NuGet
        # Only push to NuGet if we're building a tag (optional)
        if: startsWith(github.ref, 'refs/tags/')
        shell: pwsh
        # Loop through all the packages in the output folder and push them to
        # nuget.org, using the NUGET_API_KEY generated by the previous login step
        run: |
          Get-ChildItem artifacts/package/release -Filter *.nupkg | ForEach-Object {
            dotnet nuget push $_.FullName `
              --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" `
              --source https://api.nuget.org/v3/index.json
          }

In this workflow, the only secret you need to configure is the NUGET_USER secret, which should be set to your nuget.org username (not your email address). Given that it's public information anyway, storing it in a secret seems a tad overkill, but why not 😄

Add the NUGET_USER name to your repo

If you run this workflow now, the NuGet login step will fail with an error like the following:

Requesting GitHub OIDC token from: https://run-actions-1-azure-eastus.actions.githubusercontent.com/100//idtoken/aae150a5-757c-411a-b2b0-f82a9b26401f/1943e299-9d47-50e7-8d56-90681c654f24?api-version=2.0&audience=https%3A%2F%2Fwww.nuget.org
Error: Token exchange failed (401): No matching trust policy owned by user '***' was found.

As you can see from the message, the stage failed because we haven't yet configured a trust policy on nuget.org, so that's our next job.

Configuring a trust policy on NuGet.org

Configuring a trust policy is very easy. We start by signing in at nuget.org and navigating to the Trusted Publishing page:

The Trusted Publishing page is available in the account menu

Click Create to create a new policy, and fill in all the required fields:

  • A name for the policy. I think it makes sense to be the name of the package/github repo.
  • The package owner (whether it's an individual account or an organisation)
  • The GitHub repository details (owner and repository)
  • The workflow file that will be pushing to Nuget

You can see how I completed this for my sleep-pc tool below:

The configured details for the sleep-pc tool

Once you've created the trust policy, it exists in a "partially" active state until you use the policy

The partially active policy

Now if you run the workflow again the login step is successful:

Requesting GitHub OIDC token from: https://run-actions-1-azure-eastus.actions.githubusercontent.com/73//idtoken/21d84c5d-b6a5-49e0-bc04-e46694e083df/b3fac26f-8847-5e96-b7ae-47d656a98f51?api-version=2.0&audience=https%3A%2F%2Fwww.nuget.org
Successfully exchanged OIDC token for NuGet API key.

and the policy has changed to be fully active:

The fully active policy

Assuming everything else goes to plan, in the next step your package will be published to NuGet, no long-lived API keys required 🎉

Wrapping up

As far as I can tell, as of today, there's no additional benefits from using trusted publishing on nuget.org, aside from the ease of publishing, and the lack of long-lived credentials. I can certainly foresee additional benefits in the future, with packages pushed via trusted publishing having additional verification marks. For example, maybe there will be some tie-in to provenance attestations in the future? I guess we'll wait and see!

Summary

In this post I discussed Trusted Publishing and how it can help by avoiding the need to store long-lived credentials in your GitHub repositories. I then showed how I configured publishing of my sleep-pc package from my GitHub repository to nuget.org using Trusted Publishing.

  • Buy Me A Coffee
  • Donate with PayPal
Andrew Lock | .Net Escapades
Want an email when
there's new posts?