CI Release Workflows

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

A release should be a pipeline, not a laptop session: the same commands every time, from a tagged commit, with the version derived from the tag and the credentials held by the CI system. This page writes the release pipeline of Ledgerline twice — GitHub Actions and Azure Pipelines. Neither was run here (Ledgerline is never published); each step matches a command run elsewhere in this section with .NET SDK 10.0.402, and each workflow element is checked against the documentation. General CI setup — caching, test reports, matrix builds — is on CI Builds and Caching.

The shape of a release

flowchart LR T["git tag v1.1.0
git push --tags"] --> B subgraph B["Job: build (contents: read)"] direction TB B1["checkout, full history"] --> B2["setup-dotnet from global.json"] B2 --> B3["restore --locked-mode"] B3 --> B4["test"] B4 --> B5["pack: validation,
ContinuousIntegrationBuild,
.nupkg + .snupkg"] end B -- "artifact: packages" --> P subgraph P["Job: publish (environment: nuget, id-token: write)"] direction TB P1["download artifact"] --> P2["NuGet/login: temporary key"] P2 --> P3["dotnet nuget push --skip-duplicate"] end P --> N[("nuget.org")]

Why two jobs. The build job runs code from the repository — tests, MSBuild targets, packages' build logic — so it should hold no publishing rights. The publish job runs no project code at all: it downloads the built files and pushes them. Only that job gets id-token: write (the right to mint the OIDC token that Trusted Publishing exchanges for a key), and it can be guarded by an environment with required reviewers. The packages that were tested are exactly the ones pushed, because they travel as an artifact instead of being rebuilt.

GitHub Actions

# .github/workflows/release.yml
name: release
on:
  push:
    tags: [ 'v*' ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      DOTNET_NOLOGO: true
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0                      # MinVer/Nerdbank need tags and history
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json       # SDK 10.0.402, rollForward latestPatch
      - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"   # v1.1.0 -> 1.1.0, for later steps
      - run: dotnet restore --locked-mode     # packages.lock.json must match
      - run: dotnet build --no-restore -c Release -p:Version=$VERSION
      - run: dotnet test --no-build -c Release
      - run: >
          dotnet pack --no-build -c Release -o artifacts
          -p:Version=$VERSION
          -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg
      - uses: actions/upload-artifact@v7
        with:
          name: packages
          path: |
            artifacts/*.nupkg
            artifacts/*.snupkg
          if-no-files-found: error

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: nuget                        # required reviewers, branch/tag rules
    permissions:
      id-token: write                         # only this job can mint an OIDC token
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: packages
          path: artifacts
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x
      - uses: NuGet/login@v1
        id: login
        with:
          user: ${{ secrets.NUGET_USER }}
      - run: >
          dotnet nuget push "artifacts/*.nupkg"
          --api-key "${{ steps.login.outputs.NUGET_API_KEY }}"
          --source https://api.nuget.org/v3/index.json
          --skip-duplicate

Notes on the pieces:

  • ContinuousIntegrationBuild comes from Directory.Build.props, which sets it when GITHUB_ACTIONS is true; package validation runs inside dotnet pack because the project sets EnablePackageValidation (Package Validation).

  • ${GITHUB_REF_NAME#v} turns the tag v1.1.0 into 1.1.0 with shell parameter expansion (bash on ubuntu-latest), and writing VERSION=... to $GITHUB_ENV makes it an environment variable for every later step of the job (GitHub Docs: Setting an environment variable). Both dotnet build and dotnet pack get -p:Version=$VERSION: pack --no-build packs the assemblies that build already compiled, so a version given only to pack names the package 1.1.0 while the DLLs inside keep the default version: tried locally with SDK 10.0.402, Ledgerline.1.1.0.nupkg held a Ledgerline.dll with assembly and file version 1.0.0.0 and product version 1.0.0+<commit>. MinVer or Nerdbank.GitVersioning can derive the version instead, which sets it for both steps without any property.

  • --skip-duplicate makes a re-run of a half-finished publish job succeed; the .snupkg files go with their packages.

  • Versions of the actions: actions/setup-dotnet@v6 (current major; v6.0.0 here), actions/checkout@v7, actions/upload-artifact@v7, actions/download-artifact@v8 — the latest major tags on 2026-10-23.

The documentation’s examples are older: Microsoft’s publish GitHub Action quickstart (2022) uses actions/setup-dotnet@v3 with SDK 6.0.401, and GitHub’s .NET tutorial uses @v4. Use @v6 (or pin a commit SHA, which is safer still).

Variant: GitHub Packages

With packages: write on the publish job instead of id-token: write, the built-in token is enough:

      - run: >
          dotnet nuget push "artifacts/*.nupkg"
          --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json"
          --api-key "${{ secrets.GITHUB_TOKEN }}"
          --skip-duplicate

Azure Pipelines

# azure-pipelines.yml
trigger:
  tags:
    include: [ 'v*' ]

pool:
  vmImage: ubuntu-latest

steps:
- task: UseDotNet@2
  inputs:
    useGlobalJson: true

- script: dotnet restore --locked-mode
- script: dotnet test -c Release

- task: DotNetCoreCLI@2
  displayName: Pack with symbols
  inputs:
    command: pack
    packagesToPack: src/**/*.csproj
    outputDir: $(Build.ArtifactStagingDirectory)
    includesymbols: true
    versioningScheme: byEnvVar
    versionEnvVar: PACKAGE_VERSION

# Internal feed in the same organization: authenticated automatically.
- task: DotNetCoreCLI@2
  displayName: Push to internal feed
  inputs:
    command: push
    searchPatternPush: '$(Build.ArtifactStagingDirectory)/*.nupkg;!$(Build.ArtifactStagingDirectory)/*.Tests.nupkg'
    feedPublish: 'Ledgerline/ledgerline-internal'

PACKAGE_VERSION would be set from the tag in an earlier step; TF_BUILD switches on ContinuousIntegrationBuild the same way GITHUB_ACTIONS does. For a feed in another organization or a third-party feed, NuGetAuthenticate@1 with nuGetServiceConnections authenticates the sources in nuget.config whose URLs match the service connections. nuget.org is different: it uses API keys, which NuGetAuthenticate cannot hold, so the documented way is a secret pipeline variable and dotnet nuget push --api-key $(NuGetApiKey) (since 2026-08-17 a key lasts at most 30 days; Azure Pipelines is not a Trusted Publishing provider). Two more documented details: NuGetAuthenticate@1 needs a .NET 6+ runtime on the agent (upgrading from V0 otherwise fails with 401), and the task docs recommend checking in a nuget.config so that the pipeline and developers restore from the same sources.

Rehearsing locally

Every step except the push is a plain command; run them in a clean clone before tagging:

git clone . /tmp/ledgerline-release && cd /tmp/ledgerline-release
dotnet restore --locked-mode && dotnet test -c Release
GITHUB_ACTIONS=true dotnet pack -c Release -o artifacts -p:Version=1.1.0 -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg
dotnet nuget push "artifacts/*.nupkg" -s ledgerline-bagetter -k "$BAGETTER_API_KEY" --skip-duplicate

Setting GITHUB_ACTIONS=true turns on the same ContinuousIntegrationBuild as in CI, and the BaGetter push rehearses the final step (Publishing to nuget.org).

Common mistakes

  • One job that builds and pushes with id-token: write — any build step could publish.

  • Rebuilding in the publish job — the pushed binaries are not the tested ones.

  • A shallow checkout with tag-based versioning — wrong versions.

  • dotnet restore without --locked-mode — the release may resolve different dependencies than the last test run.

  • Long-lived nuget.org keys in pipeline secrets — they now expire within 30 days; prefer Trusted Publishing where available.