CI Release Workflows
|
This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its 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
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:
-
ContinuousIntegrationBuildcomes fromDirectory.Build.props, which sets it whenGITHUB_ACTIONSistrue; package validation runs insidedotnet packbecause the project setsEnablePackageValidation(Package Validation). -
${GITHUB_REF_NAME#v}turns the tagv1.1.0into1.1.0with shell parameter expansion (bash onubuntu-latest), and writingVERSION=...to$GITHUB_ENVmakes it an environment variable for every later step of the job (GitHub Docs: Setting an environment variable). Bothdotnet buildanddotnet packget-p:Version=$VERSION:pack --no-buildpacks the assemblies thatbuildalready compiled, so a version given only topacknames the package1.1.0while the DLLs inside keep the default version: tried locally with SDK 10.0.402,Ledgerline.1.1.0.nupkgheld aLedgerline.dllwith assembly and file version1.0.0.0and product version1.0.0+<commit>. MinVer or Nerdbank.GitVersioning can derive the version instead, which sets it for both steps without any property. -
--skip-duplicatemakes a re-run of a half-finished publish job succeed; the.snupkgfiles 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.
Source: Azure Pipelines: Publish to a feed in the same organization, Publish to a feed in another organization, NuGetAuthenticate@1: Within your organization, Outside your organization, Sources that use ApiKey, V0 to V1 and 401, DotNetCoreCLI@2: Push to internal feed, Push to external feed, Pack a symbol package, Why should I check in a NuGet.Config?.
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).
Source: ContinuousIntegrationBuild.
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 restorewithout--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.