
Dotnet Devops
- 88 installs
- 228 repo stars
- Updated August 3, 2026
- novotnyllc/dotnet-artisan
Helps with devops & ci/cd tasks.
About
dotnet-devops is a Claude Code skill for devops & ci/cd. It helps solo builders move faster with AI-assisted coding.
- dotnet-devops
- DevOps & CI/CD
- AI-coding skill
Dotnet Devops by the numbers
- 88 all-time installs (skills.sh)
- +4 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #571 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/novotnyllc/dotnet-artisan --skill dotnet-devopsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 88 |
|---|---|
| repo stars | ★ 228 |
| Last updated | August 3, 2026 |
| Repository | novotnyllc/dotnet-artisan ↗ |
What it does
Helps with devops & ci/cd tasks.
Files
dotnet-devops
Overview
CI/CD, packaging, release management, and operational tooling for .NET. This consolidated skill spans 18 topic areas. Load the appropriate companion file from references/ based on the routing table below.
Routing Table
| Topic | Keywords | Description | Companion File |
|---|---|---|---|
| GHA build/test | setup-dotnet, NuGet cache, reporting | GitHub Actions .NET build/test (setup-dotnet, NuGet cache, reporting) | references/gha-build-test.md |
| GHA deploy | Azure Web Apps, GitHub Pages, containers | GitHub Actions deployment (Azure Web Apps, GitHub Pages, containers) | references/gha-deploy.md |
| GHA publish | NuGet push, container images, signing, SBOM | GitHub Actions publishing (NuGet push, container images, signing, SBOM) | references/gha-publish.md |
| GHA patterns | reusable workflows, composite, matrix, cache | GitHub Actions composition (reusable workflows, composite, matrix, cache) | references/gha-patterns.md |
| ADO build/test | DotNetCoreCLI, Artifacts, test results | Azure DevOps .NET build/test (DotNetCoreCLI, Artifacts, test results) | references/ado-build-test.md |
| ADO publish | NuGet push, containers to ACR | Azure DevOps publishing (NuGet push, containers to ACR) | references/ado-publish.md |
| ADO patterns | templates, variable groups, multi-stage | Azure DevOps composition (templates, variable groups, multi-stage) | references/ado-patterns.md |
| ADO unique | environments, approvals, service connections | Azure DevOps exclusive features (environments, approvals, service connections) | references/ado-unique.md |
| Containers | multi-stage Dockerfiles, SDK publish, rootless | .NET containerization (multi-stage Dockerfiles, SDK publish, rootless) | references/containers.md |
| Container deployment | Compose, health probes, CI/CD pipelines | Container deployment (Compose, health probes, CI/CD pipelines) | references/container-deployment.md |
| NuGet authoring | SDK-style, source generators, multi-TFM | NuGet package authoring (SDK-style, source generators, multi-TFM) | references/nuget-authoring.md |
| MSIX | creation, signing, Store, sideload, auto-update | MSIX packaging (creation, signing, Store, sideload, auto-update) | references/msix.md |
| GitHub Releases | creation, assets, notes, pre-release | GitHub Releases (creation, assets, notes, pre-release) | references/github-releases.md |
| Release management | NBGV, SemVer, changelogs, branching | Release lifecycle (NBGV, SemVer, changelogs, branching) | references/release-management.md |
| Observability | OpenTelemetry, health checks, custom metrics | Observability (OpenTelemetry, health checks, custom metrics) | references/observability.md |
| Structured logging | aggregation, sampling, PII, correlation | Log pipelines (aggregation, sampling, PII, correlation) | references/structured-logging.md |
| Add CI | CI/CD scaffold, GHA vs ADO detection | CI/CD scaffolding (GHA vs ADO detection, workflow templates) | references/add-ci.md |
| GitHub docs | README badges, CONTRIBUTING, templates | GitHub documentation (README badges, CONTRIBUTING, templates) | references/github-docs.md |
Scope
- GitHub Actions workflows (build, test, deploy, publish)
- Azure DevOps pipelines (build, test, publish, environments)
- Container builds and deployment (Docker, Compose)
- NuGet and MSIX packaging
- Release management (NBGV, SemVer, changelogs)
- Observability and structured logging (OpenTelemetry)
- GitHub repository documentation and CI scaffolding
Out of scope
- API/backend code patterns -> [skill:dotnet-api]
- Build system authoring -> [skill:dotnet-tooling]
- Test authoring -> [skill:dotnet-testing]
interface:
display_name: "dotnet-devops"
short_description: "CI/CD, packaging, releases, and observability"
default_prompt: "Use $dotnet-advisor to route this pipeline task, then load $dotnet-devops for CI/CD and release guidance."
policy:
allow_implicit_invocation: true
Add CI
Add starter CI/CD workflows to an existing .NET project. Detects the hosting platform (GitHub Actions or Azure DevOps) and generates an appropriate starter workflow for build, test, and pack.
Platform Detection
Detect the CI platform from existing repo indicators:
| Indicator | Platform |
|---|---|
.github/ directory exists | GitHub Actions |
azure-pipelines.yml exists | Azure DevOps |
.github/workflows/ has YAML files | GitHub Actions (already configured) |
| Neither | Ask the user which platform to target |
---
GitHub Actions Starter Workflow
Create .github/workflows/build.yml:
name: Build and Test
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Restore
run: dotnet restore --locked-mode
- name: Build
run: dotnet build --no-restore -c Release
- name: Test
run: dotnet test --no-build -c Release --logger trx --results-directory TestResults
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: TestResults/**/*.trxKey Decisions Explained
- `global-json-file` -- uses the repo's
global.jsonto install the exact SDK version. If the project has noglobal.json, replace withdotnet-version: '10.0.x'(or the appropriate version) - `--locked-mode` -- ensures
packages.lock.jsonfiles are respected; fails if they're out of date. If the project doesn't use lock files, replace with plaindotnet restore - `-c Release` -- builds in Release mode so
ContinuousIntegrationBuildtakes effect - `permissions: contents: read` -- principle of least privilege
- Environment variables -- suppress .NET CLI noise in logs
Adding NuGet Pack (Libraries)
For projects that publish to NuGet, add a pack step:
- name: Pack
run: dotnet pack --no-build -c Release -o artifacts
- name: Upload packages
uses: actions/upload-artifact@v4
with:
name: nuget-packages
path: artifacts/*.nupkg---
Azure DevOps Starter Pipeline
Create azure-pipelines.yml at the repo root:
trigger:
branches:
include:
- main
pr:
branches:
include:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
buildConfiguration: 'Release'
steps:
- task: UseDotNet@2
displayName: 'Setup .NET SDK'
inputs:
useGlobalJson: true
- script: dotnet restore --locked-mode
displayName: 'Restore'
- script: dotnet build --no-restore -c $(buildConfiguration)
displayName: 'Build'
- task: DotNetCoreCLI@2
displayName: 'Test'
inputs:
command: 'test'
arguments: '--no-build -c $(buildConfiguration) --logger trx'
publishTestResults: trueAdding NuGet Pack (Libraries)
- script: dotnet pack --no-build -c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)
displayName: 'Pack'
- task: PublishBuildArtifacts@1
displayName: 'Publish NuGet packages'
inputs:
pathToPublish: '$(Build.ArtifactStagingDirectory)'
artifactName: 'nuget-packages'---
Adapting the Starter Workflow
Multi-TFM Projects
If the project multi-targets, the default workflow works without changes -- dotnet build and dotnet test handle all TFMs automatically. No matrix is needed for the starter.
Windows-Only Projects (MAUI, WPF, WinForms)
Change the runner:
# GitHub Actions
runs-on: windows-latest
# Azure DevOps
pool:
vmImage: 'windows-latest'Solution Filter
If the repo has multiple solutions or uses solution filters:
- name: Build
run: dotnet build MyApp.slnf --no-restore -c Release---
Verification
After adding the workflow, verify locally:
# GitHub Actions -- validate YAML syntax
# Install: gh extension install moritztomasi/gh-workflow-validator
gh workflow-validator .github/workflows/build.yml
# Or simply verify the build steps work locally
dotnet restore --locked-mode
dotnet build --no-restore -c Release
dotnet test --no-build -c ReleasePush a branch and open a PR to trigger the workflow.
---
What's Next
This starter covers build-test-pack. For advanced scenarios, see the CI/CD depth skills:
- Reusable composite actions and workflow templates
- Matrix builds across OS/TFM combinations
- Deployment pipelines with environment gates
- NuGet publishing with signing
- Container image builds
- Code coverage reporting and enforcement
---
References
ADO Build and Test
.NET build and test pipeline patterns for Azure DevOps: DotNetCoreCLI@2 task for build, test, and pack operations, NuGet restore with Azure Artifacts feeds using NuGetAuthenticate@1, test result publishing with PublishTestResults@2 for TRX and JUnit formats, code coverage with PublishCodeCoverageResults@2 for Cobertura and JaCoCo formats, and multi-TFM matrix strategy across net8.0 and net9.0.
Version assumptions: DotNetCoreCLI@2 task (current). UseDotNet@2 for SDK installation. NuGetAuthenticate@1 for Azure Artifacts. PublishTestResults@2 and PublishCodeCoverageResults@2 for reporting.
DotNetCoreCLI@2 Task
Build
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
packageType: 'sdk'
version: '8.0.x'
- task: DotNetCoreCLI@2
displayName: 'Restore'
inputs:
command: 'restore'
projects: 'MyApp.sln'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: 'MyApp.sln'
arguments: '-c Release --no-restore'Test
- task: DotNetCoreCLI@2
displayName: 'Run tests'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: >-
-c Release
--logger "trx;LogFileName=test-results.trx"
--results-directory $(Build.ArtifactStagingDirectory)/test-resultsPack
- task: DotNetCoreCLI@2
displayName: 'Pack NuGet packages'
inputs:
command: 'pack'
packagesToPack: 'src/**/*.csproj'
configuration: 'Release'
outputDir: '$(Build.ArtifactStagingDirectory)/nupkgs'
nobuild: trueCustom Command
For commands not directly supported by the task (e.g., dotnet tool install):
- task: DotNetCoreCLI@2
displayName: 'Install dotnet tools'
inputs:
command: 'custom'
custom: 'tool'
arguments: 'restore'Multi-Version SDK Install
Install multiple SDK versions for multi-TFM builds:
- task: UseDotNet@2
displayName: 'Install .NET 8'
inputs:
packageType: 'sdk'
version: '8.0.x'
- task: UseDotNet@2
displayName: 'Install .NET 9'
inputs:
packageType: 'sdk'
version: '9.0.x'Each UseDotNet@2 invocation adds the SDK version to PATH. The last installed version becomes the default, but all versions are available via --framework targeting.
---
NuGet Restore with Azure Artifacts Feeds
NuGetAuthenticate@1 for Feed Authentication
steps:
- task: NuGetAuthenticate@1
displayName: 'Authenticate NuGet feeds'
- task: DotNetCoreCLI@2
displayName: 'Restore'
inputs:
command: 'restore'
projects: 'MyApp.sln'
feedsToUse: 'config'
nugetConfigPath: 'nuget.config'The NuGetAuthenticate@1 task configures credentials for all Azure Artifacts feeds referenced in nuget.config. No explicit PAT or API key is needed -- the task uses the pipeline's identity.
Selecting Feeds Directly
For simple setups without a nuget.config, select feeds directly in the restore task:
- task: DotNetCoreCLI@2
displayName: 'Restore with Azure Artifacts'
inputs:
command: 'restore'
projects: 'MyApp.sln'
feedsToUse: 'select'
vstsFeed: 'MyProject/MyFeed'
includeNuGetOrg: trueUpstream Sources
Azure Artifacts feeds can proxy nuget.org as an upstream source. When configured, a single feed reference provides access to both private packages and public NuGet packages:
<!-- nuget.config with Azure Artifacts upstream -->
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="MyFeed" value="https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json" />
</packageSources>
</configuration>With upstream sources enabled on the feed, nuget.org packages are cached in the Azure Artifacts feed, providing a single authenticated source for all packages.
Cross-Organization Feed Access
For feeds in different Azure DevOps organizations, use a service connection:
- task: NuGetAuthenticate@1
displayName: 'Authenticate external feed'
inputs:
nuGetServiceConnections: 'ExternalOrgFeedConnection'
- task: DotNetCoreCLI@2
displayName: 'Restore'
inputs:
command: 'restore'
projects: 'MyApp.sln'
feedsToUse: 'config'
nugetConfigPath: 'nuget.config'---
Test Result Publishing
PublishTestResults@2 with TRX Format
- task: DotNetCoreCLI@2
displayName: 'Run tests'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: >-
-c Release
--logger "trx;LogFileName=results.trx"
--results-directory $(Common.TestResultsDirectory)
continueOnError: true
- task: PublishTestResults@2
displayName: 'Publish test results'
condition: always()
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '$(Common.TestResultsDirectory)/**/*.trx'
mergeTestResults: true
testRunTitle: '.NET Unit Tests'Key decisions:
continueOnError: trueon the test task ensures the publish step always runs, even on test failurescondition: always()on the publish task runs regardless of previous step outcomemergeTestResults: truecombines results from multiple test projects into a single test runtestRunTitleprovides a descriptive name in the Azure DevOps Test tab
JUnit Format
Some third-party test frameworks output JUnit XML. Use the JUnit format:
- task: PublishTestResults@2
displayName: 'Publish JUnit results'
condition: always()
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '**/junit-results.xml'
mergeTestResults: trueTest Results with Attachments
Attach screenshots or logs to test results for debugging failed tests:
- task: DotNetCoreCLI@2
displayName: 'Run tests with attachments'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: >-
-c Release
--logger "trx;LogFileName=results.trx"
--results-directory $(Common.TestResultsDirectory)
--collect:"XPlat Code Coverage"
continueOnError: true
- task: PublishTestResults@2
displayName: 'Publish test results'
condition: always()
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '$(Common.TestResultsDirectory)/**/*.trx'
mergeTestResults: true
testRunTitle: '.NET Tests'
publishRunAttachments: true---
Code Coverage
PublishCodeCoverageResults@2 with Cobertura
- task: DotNetCoreCLI@2
displayName: 'Test with coverage'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: >-
-c Release
--collect:"XPlat Code Coverage"
--results-directory $(Agent.TempDirectory)/coverage
- task: PublishCodeCoverageResults@2
displayName: 'Publish code coverage'
inputs:
summaryFileLocation: '$(Agent.TempDirectory)/coverage/**/coverage.cobertura.xml'The PublishCodeCoverageResults@2 task (v2) auto-generates HTML coverage reports in the Azure DevOps Build Summary tab without requiring reportgenerator.
Coverage with ReportGenerator for Detailed Reports
For custom coverage reports beyond the built-in rendering:
- task: DotNetCoreCLI@2
displayName: 'Test with coverage'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: >-
-c Release
--collect:"XPlat Code Coverage"
--results-directory $(Agent.TempDirectory)/coverage
- script: |
set -euo pipefail
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator \
-reports:$(Agent.TempDirectory)/coverage/**/coverage.cobertura.xml \
-targetdir:$(Build.ArtifactStagingDirectory)/coverage-report \
-reporttypes:HtmlInline_AzurePipelines\;Cobertura
displayName: 'Generate coverage report'
- task: PublishCodeCoverageResults@2
displayName: 'Publish coverage'
inputs:
summaryFileLocation: '$(Build.ArtifactStagingDirectory)/coverage-report/Cobertura.xml'
- task: PublishPipelineArtifact@1
displayName: 'Upload coverage report'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/coverage-report'
artifactName: 'coverage-report'Coverage Thresholds
Enforce minimum coverage by parsing the Cobertura XML in a script step:
- script: |
set -euo pipefail
COVERAGE_FILE=$(find $(Agent.TempDirectory)/coverage -name 'coverage.cobertura.xml' | head -1)
COVERAGE=$(python3 -c "
import xml.etree.ElementTree as ET
tree = ET.parse('$COVERAGE_FILE')
print(float(tree.getroot().attrib['line-rate']) * 100)
")
echo "Line coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "##vso[task.logissue type=error]Coverage ${COVERAGE}% is below 80% threshold"
exit 1
fi
displayName: 'Enforce coverage threshold'---
Multi-TFM Matrix Strategy
Matrix Build Across TFMs and Operating Systems
jobs:
- job: Test
strategy:
matrix:
Linux_net80:
vmImage: 'ubuntu-latest'
tfm: 'net8.0'
dotnetVersion: '8.0.x'
Linux_net90:
vmImage: 'ubuntu-latest'
tfm: 'net9.0'
dotnetVersion: '9.0.x'
Windows_net80:
vmImage: 'windows-latest'
tfm: 'net8.0'
dotnetVersion: '8.0.x'
Windows_net90:
vmImage: 'windows-latest'
tfm: 'net9.0'
dotnetVersion: '9.0.x'
pool:
vmImage: $(vmImage)
steps:
- task: UseDotNet@2
displayName: 'Install .NET $(dotnetVersion)'
inputs:
packageType: 'sdk'
version: $(dotnetVersion)
- task: DotNetCoreCLI@2
displayName: 'Test $(tfm) on $(vmImage)'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: >-
-c Release
--framework $(tfm)
--logger "trx;LogFileName=$(tfm)-results.trx"
--results-directory $(Common.TestResultsDirectory)
continueOnError: true
- task: PublishTestResults@2
displayName: 'Publish $(tfm) results'
condition: always()
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '$(Common.TestResultsDirectory)/**/*.trx'
testRunTitle: '$(tfm) on $(vmImage)'Installing Multiple SDKs for Multi-TFM in a Single Job
When running all TFMs in one job (instead of matrix), install all required SDKs:
steps:
- task: UseDotNet@2
displayName: 'Install .NET 8'
inputs:
packageType: 'sdk'
version: '8.0.x'
- task: UseDotNet@2
displayName: 'Install .NET 9'
inputs:
packageType: 'sdk'
version: '9.0.x'
- task: DotNetCoreCLI@2
displayName: 'Test all TFMs'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: '-c Release'Without the matching SDK installed, dotnet test cannot build for that TFM and fails with NETSDK1045.
Template-Based Matrix for Reusability
# templates/jobs/matrix-test.yml
parameters:
- name: configurations
type: object
default:
- tfm: 'net8.0'
dotnetVersion: '8.0.x'
- tfm: 'net9.0'
dotnetVersion: '9.0.x'
jobs:
- ${{ each config in parameters.configurations }}:
- job: Test_${{ replace(config.tfm, '.', '_') }}
displayName: 'Test ${{ config.tfm }}'
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: ${{ config.dotnetVersion }}
- task: DotNetCoreCLI@2
displayName: 'Test ${{ config.tfm }}'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: '-c Release --framework ${{ config.tfm }}'---
Agent Gotchas
1. Use `set -euo pipefail` in multi-line `script:` steps -- ADO script: tasks on Linux default to set -e but do not set pipefail or nounset; without pipefail, a failure in a piped command is silently swallowed. 2. Use `continueOnError: true` on the test task, not on the result publisher -- the test task must not fail the pipeline before results are published, but the publisher should reflect the actual test outcome. 3. Install all required SDK versions for multi-TFM builds -- dotnet test without the matching SDK produces NETSDK1045; add a UseDotNet@2 step for each required version. 4. `NuGetAuthenticate@1` must precede the restore step -- authentication tokens are injected into the agent's NuGet config at task execution time; restoring before authentication fails with 401. 5. Use `feedsToUse: 'config'` with `nuget.config` for complex feed setups -- feedsToUse: 'select' supports only one Azure Artifacts feed; multi-feed scenarios require a nuget.config file. 6. Coverage collection requires `--collect:"XPlat Code Coverage"` -- the default dotnet test does not produce coverage files; the XPlat Code Coverage collector is built into the .NET SDK. 7. `PublishCodeCoverageResults@2` expects Cobertura XML -- passing TRX or other formats to the coverage publisher produces no output; ensure the collector outputs Cobertura format. 8. ADO matrix syntax differs from GHA -- ADO uses named matrix entries with key-value pairs, not arrays; each entry must define all variable names used in the job. 9. Never hardcode credentials in pipeline YAML -- use variable groups linked to Azure Key Vault or pipeline-level secret variables; hardcoded secrets are visible in repository history.
ADO Patterns
Composable Azure DevOps YAML pipeline patterns for .NET projects: template references with extends, stages, jobs, and steps keywords for hierarchical pipeline composition, variable groups and variable templates for centralized configuration, conditional insertion with ${{ if }} and ${{ each }} expressions, multi-stage pipelines (build, test, deploy), and pipeline triggers for CI, PR, and scheduled runs.
Version assumptions: Azure Pipelines YAML schema. DotNetCoreCLI@2 task for .NET 8/9/10 builds. Template expressions syntax v2.
Template Composition
Step Template (reusable steps inserted into a job)
# templates/steps/dotnet-setup.yml
parameters:
- name: dotnetVersion
type: string
default: '8.0.x'
- name: nugetFeed
type: string
default: ''
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK ${{ parameters.dotnetVersion }}'
inputs:
packageType: 'sdk'
version: ${{ parameters.dotnetVersion }}
- ${{ if ne(parameters.nugetFeed, '') }}:
- task: NuGetAuthenticate@1Extends Template (enforced pipeline structure)
# templates/pipeline-policy.yml -- callers cannot bypass this structure
parameters:
- name: stages
type: stageList
default: []
stages:
- stage: SecurityScan
jobs:
- job: Scan
steps:
- script: echo "Running mandatory security scan"
- ${{ each stage in parameters.stages }}:
- ${{ stage }}# azure-pipelines.yml (caller)
extends:
template: templates/pipeline-policy.yml
parameters:
stages:
- stage: Build
jobs:
- job: BuildApp
steps:
- script: dotnet build -c Release---
Variable Groups and Templates
# templates/variables/dotnet-defaults.yml
variables:
dotnetVersion: '8.0.x'
buildConfiguration: 'Release'
# azure-pipelines.yml
variables:
- template: templates/variables/dotnet-defaults.yml
- group: 'kv-production-secrets' # Key Vault-linked for secrets
- name: projectPath
value: 'MyApp.sln'Key Vault secrets resolve at runtime via $(secret-name), not template expressions ${{ }}.
---
Pipeline Decorators
Decorators inject steps into every pipeline in an organization, enforcing policies without modifying individual pipeline files. They are defined via ADO extensions (not YAML) and cannot be overridden by callers. Debug by inspecting the expanded pipeline YAML in ADO run logs. See [skill:dotnet-devops] references/ado-unique.md for implementation details including extension manifests and deployment.
---
Conditional Insertion
parameters:
- name: environments
type: object
default:
- name: staging
approvals: false
- name: production
approvals: true
stages:
- ${{ each env in parameters.environments }}:
- stage: Deploy_${{ env.name }}
jobs:
- ${{ if eq(env.approvals, true) }}:
- job: Approve
pool: server
steps:
- task: ManualValidation@0
- deployment: DeployApp
environment: ${{ env.name }}
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to ${{ env.name }}"---
Triggers
# CI trigger
trigger:
branches:
include: [main, release/*]
paths:
include: [src/**, tests/**]
exclude: [docs/**, '*.md']
# PR trigger
pr:
branches:
include: [main]
drafts: false
# Scheduled trigger
schedules:
- cron: '0 6 * * 1-5'
branches:
include: [main]
always: false # only run if changes since last
# Pipeline resource trigger (downstream of another pipeline)
resources:
pipelines:
- pipeline: buildPipeline
source: 'MyApp-Build'
trigger:
branches:
include: [main]---
Agent Gotchas
1. Template parameter types are enforced at compile time -- passing a string where type: boolean is expected causes a validation error before the pipeline runs. 2. `extends` templates cannot be overridden -- callers cannot inject steps before or after mandatory stages. 3. Variable group secrets are not available in template expressions -- ${{ variables.mySecret }} resolves at compile time when secrets are not yet available; use $(mySecret) runtime syntax. 4. `${{ each }}` iterates at compile time -- runtime variables cannot be used as the iteration source. 5. Omitting both `trigger` and `pr` enables default CI triggering on all branches -- explicitly set trigger: none to disable. 6. Path filters use repository root-relative paths -- use src/** not ./src/**. 7. Scheduled triggers always run on the default branch first -- branches.include applies after the schedule fires. 8. Pipeline resource triggers require the source pipeline name -- use the ADO pipeline name, not the YAML file path.
---
Detailed Examples
Extended YAML pipeline examples for template references, variable groups, conditional insertion, multi-stage pipelines, and triggers.
---
Template References
Stage Templates
Stage templates define reusable pipeline stages that callers insert into their multi-stage pipeline:
# templates/stages/build-test.yml
parameters:
- name: dotnetVersion
type: string
default: '8.0.x'
- name: buildConfiguration
type: string
default: 'Release'
- name: projects
type: string
default: '**/*.sln'
stages:
- stage: Build
displayName: 'Build and Test'
jobs:
- job: BuildJob
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
packageType: 'sdk'
version: ${{ parameters.dotnetVersion }}
- task: DotNetCoreCLI@2
displayName: 'Restore'
inputs:
command: 'restore'
projects: ${{ parameters.projects }}
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: ${{ parameters.projects }}
arguments: '-c ${{ parameters.buildConfiguration }} --no-restore'Calling a Stage Template
# azure-pipelines.yml
trigger:
branches:
include:
- main
stages:
- template: templates/stages/build-test.yml
parameters:
dotnetVersion: '9.0.x'
buildConfiguration: 'Release'
projects: 'MyApp.sln'
- template: templates/stages/deploy.yml
parameters:
environment: 'staging'Job Templates
Job templates encapsulate a complete job with its pool and steps:
# templates/jobs/dotnet-build.yml
parameters:
- name: dotnetVersion
type: string
default: '8.0.x'
- name: projects
type: string
jobs:
- job: Build
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: ${{ parameters.dotnetVersion }}
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: ${{ parameters.projects }}
arguments: '-c Release'Step Templates
Step templates define reusable step sequences inserted into an existing job:
# templates/steps/dotnet-setup.yml
parameters:
- name: dotnetVersion
type: string
default: '8.0.x'
- name: nugetFeed
type: string
default: ''
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK ${{ parameters.dotnetVersion }}'
inputs:
packageType: 'sdk'
version: ${{ parameters.dotnetVersion }}
- ${{ if ne(parameters.nugetFeed, '') }}:
- task: NuGetAuthenticate@1
displayName: 'Authenticate NuGet feed'
- task: DotNetCoreCLI@2
displayName: 'Restore packages'
inputs:
command: 'restore'
projects: '**/*.sln'
${{ if ne(parameters.nugetFeed, '') }}:
feedsToUse: 'select'
vstsFeed: ${{ parameters.nugetFeed }}Using Step Templates in a Pipeline
jobs:
- job: Build
pool:
vmImage: 'ubuntu-latest'
steps:
- checkout: self
- template: templates/steps/dotnet-setup.yml
parameters:
dotnetVersion: '9.0.x'
nugetFeed: 'MyOrg/MyFeed'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
arguments: '-c Release --no-restore'Extends Templates (Enforced Pipeline Structure)
The extends keyword enforces a required pipeline structure defined by an organization template. Callers cannot bypass the structure:
# templates/pipeline-policy.yml
parameters:
- name: stages
type: stageList
default: []
stages:
- stage: SecurityScan
displayName: 'Security Scan (Required)'
jobs:
- job: Scan
pool:
vmImage: 'ubuntu-latest'
steps:
- script: echo "Running mandatory security scan"
- ${{ each stage in parameters.stages }}:
- ${{ stage }}
- stage: Compliance
displayName: 'Compliance Check (Required)'
dependsOn:
- ${{ each stage in parameters.stages }}:
- ${{ stage.stage }}
jobs:
- job: Check
pool:
vmImage: 'ubuntu-latest'
steps:
- script: echo "Running compliance checks"# azure-pipelines.yml (caller)
extends:
template: templates/pipeline-policy.yml
parameters:
stages:
- stage: Build
jobs:
- job: BuildApp
pool:
vmImage: 'ubuntu-latest'
steps:
- script: dotnet build -c ReleaseThe extends template wraps caller-defined stages with mandatory security and compliance stages that cannot be removed.
---
Variable Groups and Variable Templates
Variable Groups
Variable groups centralize configuration shared across multiple pipelines. Link them from Azure Pipelines Library:
variables:
- group: 'dotnet-build-settings'
- group: 'nuget-feed-credentials'
- name: buildConfiguration
value: 'Release'Variable Templates
Variable templates define reusable variable sets in YAML files:
# templates/variables/dotnet-defaults.yml
variables:
dotnetVersion: '8.0.x'
buildConfiguration: 'Release'
testResultsDirectory: '$(Build.ArtifactStagingDirectory)/test-results'
coverageDirectory: '$(Build.ArtifactStagingDirectory)/coverage'# azure-pipelines.yml
variables:
- template: templates/variables/dotnet-defaults.yml
- name: projectPath
value: 'MyApp.sln'Variable Group with Key Vault Integration
Link variable groups to Azure Key Vault for secret management. Secrets are fetched at pipeline runtime:
# Reference in pipeline
variables:
- group: 'kv-production-secrets' # linked to Azure Key Vault
- name: nonSecretVar
value: 'some-value'
steps:
- script: |
echo "Using secret from Key Vault"
# $(sql-connection-string) resolves at runtime from Key Vault
env:
CONNECTION_STRING: $(sql-connection-string)Key Vault-linked variable groups require a service connection with Key Vault access. Secret names in Key Vault map to variable names (hyphens become valid variable characters).
---
Pipeline Decorators
Pipeline decorators inject steps into every pipeline in an organization or project, enforcing policies without modifying individual pipeline files. Decorators are an ADO-exclusive feature with no GitHub Actions equivalent -- see [skill:dotnet-devops] references/ado-unique.md for implementation details including extension manifests, deployment guidance, and use case examples.
---
Conditional Insertion
${{ if }} Expressions
parameters:
- name: runIntegrationTests
type: boolean
default: false
- name: targetEnvironment
type: string
default: 'development'
values:
- development
- staging
- production
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- script: dotnet build -c Release
- ${{ if eq(parameters.runIntegrationTests, true) }}:
- stage: IntegrationTests
dependsOn: Build
jobs:
- job: IntegrationTestJob
steps:
- script: dotnet test --filter Category=Integration
- ${{ if eq(parameters.targetEnvironment, 'production') }}:
- stage: ApprovalGate
dependsOn: Build
jobs:
- job: WaitForApproval
pool: server
steps:
- task: ManualValidation@0
inputs:
notifyUsers: 'release-managers@example.com'
instructions: 'Approve production deployment'${{ each }} Iteration
parameters:
- name: environments
type: object
default:
- name: development
pool: 'ubuntu-latest'
approvals: false
- name: staging
pool: 'ubuntu-latest'
approvals: true
- name: production
pool: 'ubuntu-latest'
approvals: true
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- script: dotnet build -c Release
- ${{ each env in parameters.environments }}:
- stage: Deploy_${{ env.name }}
displayName: 'Deploy to ${{ env.name }}'
dependsOn: Build
jobs:
- ${{ if eq(env.approvals, true) }}:
- job: Approve
pool: server
steps:
- task: ManualValidation@0
inputs:
instructions: 'Approve deployment to ${{ env.name }}'
- deployment: DeployApp
pool:
vmImage: ${{ env.pool }}
environment: ${{ env.name }}
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to ${{ env.name }}"Conditional Step Insertion Within Templates
# templates/steps/dotnet-test.yml
parameters:
- name: collectCoverage
type: boolean
default: false
steps:
- task: DotNetCoreCLI@2
displayName: 'Run tests'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
${{ if eq(parameters.collectCoverage, true) }}:
arguments: '-c Release --collect:"XPlat Code Coverage"'
${{ else }}:
arguments: '-c Release'
- ${{ if eq(parameters.collectCoverage, true) }}:
- task: PublishCodeCoverageResults@2
displayName: 'Publish coverage'
inputs:
summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'---
Multi-Stage Pipelines
Build, Test, Deploy Pattern
trigger:
branches:
include:
- main
- release/*
stages:
- stage: Build
displayName: 'Build'
jobs:
- job: BuildJob
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '8.0.x'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: 'MyApp.sln'
arguments: '-c Release'
- task: DotNetCoreCLI@2
displayName: 'Publish'
inputs:
command: 'publish'
projects: 'src/MyApp/MyApp.csproj'
arguments: '-c Release -o $(Build.ArtifactStagingDirectory)/app'
- task: PublishPipelineArtifact@1
displayName: 'Upload artifact'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/app'
artifactName: 'app'
- stage: Test
displayName: 'Test'
dependsOn: Build
jobs:
- job: UnitTests
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '8.0.x'
- task: DotNetCoreCLI@2
displayName: 'Run tests'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: '-c Release --logger "trx;LogFileName=results.trx"'
- task: PublishTestResults@2
displayName: 'Publish test results'
condition: always()
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/results.trx'
- stage: DeployStaging
displayName: 'Deploy to Staging'
dependsOn: Test
jobs:
- deployment: DeployStaging
pool:
vmImage: 'ubuntu-latest'
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- script: echo "Deploying to staging"
- stage: DeployProduction
displayName: 'Deploy to Production'
dependsOn: DeployStaging
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployProduction
pool:
vmImage: 'ubuntu-latest'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- script: echo "Deploying to production"Stage Dependencies and Conditions
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- script: dotnet build -c Release
- stage: UnitTests
dependsOn: Build
jobs:
- job: UnitTestJob
steps:
- script: dotnet test --filter Category!=Integration
- stage: IntegrationTests
dependsOn: Build
jobs:
- job: IntegrationTestJob
steps:
- script: dotnet test --filter Category=Integration
# Deploy only if BOTH test stages succeed
- stage: Deploy
dependsOn:
- UnitTests
- IntegrationTests
condition: and(succeeded('UnitTests'), succeeded('IntegrationTests'))
jobs:
- deployment: DeployApp
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying"---
Pipeline Triggers
CI Triggers
trigger:
branches:
include:
- main
- release/*
exclude:
- feature/experimental/*
paths:
include:
- src/**
- tests/**
- '*.sln'
- Directory.Build.props
- Directory.Packages.props
exclude:
- docs/**
- '*.md'
tags:
include:
- 'v*'PR Triggers
pr:
branches:
include:
- main
- release/*
paths:
include:
- src/**
- tests/**
exclude:
- docs/**
drafts: false # do not trigger on draft PRsScheduled Triggers
schedules:
- cron: '0 6 * * 1-5'
displayName: 'Weekday nightly build'
branches:
include:
- main
always: false # only run if there are changes since last run
- cron: '0 0 * * 0'
displayName: 'Weekly full validation'
branches:
include:
- main
always: true # run even without changesPipeline Resource Triggers
Trigger a pipeline when another pipeline completes:
resources:
pipelines:
- pipeline: buildPipeline
source: 'MyApp-Build'
trigger:
branches:
include:
- main
stages:
- stage: DeployAfterBuild
jobs:
- deployment: Deploy
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- download: buildPipeline
artifact: app
- script: echo "Deploying build from upstream pipeline"---
ADO Publish
Publishing pipelines for .NET projects in Azure DevOps: NuGet package push to Azure Artifacts and nuget.org, container image build and push to Azure Container Registry (ACR) using Docker@2, artifact staging with PublishBuildArtifacts@1 and PublishPipelineArtifact@1, and pipeline artifacts for multi-stage release pipelines.
Version assumptions: DotNetCoreCLI@2 for pack/push operations. Docker@2 for container image builds. NuGetCommand@2 for NuGet push to external feeds. PublishPipelineArtifact@1 (preferred over PublishBuildArtifacts@1).
NuGet Push to Azure Artifacts
Push with DotNetCoreCLI@2
trigger:
tags:
include:
- 'v*'
stages:
- stage: Pack
jobs:
- job: PackJob
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '8.0.x'
- task: DotNetCoreCLI@2
displayName: 'Pack'
inputs:
command: 'pack'
packagesToPack: 'src/**/*.csproj'
configuration: 'Release'
outputDir: '$(Build.ArtifactStagingDirectory)/nupkgs'
versioningScheme: 'byEnvVar'
versionEnvVar: 'PACKAGE_VERSION'
env:
PACKAGE_VERSION: $(Build.SourceBranchName)
- task: PublishPipelineArtifact@1
displayName: 'Upload NuGet packages'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/nupkgs'
artifactName: 'nupkgs'
- stage: PushToFeed
dependsOn: Pack
jobs:
- job: PushJob
pool:
vmImage: 'ubuntu-latest'
steps:
- download: current
artifact: nupkgs
- task: NuGetAuthenticate@1
displayName: 'Authenticate NuGet'
- task: DotNetCoreCLI@2
displayName: 'Push to Azure Artifacts'
inputs:
command: 'push'
packagesToPush: '$(Pipeline.Workspace)/nupkgs/*.nupkg'
nuGetFeedType: 'internal'
publishVstsFeed: 'MyProject/MyFeed'Version from Git Tag
Extract the version from the triggering Git tag using a script step. Build.SourceBranch is a runtime variable, so use a script to parse it rather than compile-time template expressions:
steps:
- script: |
set -euo pipefail
if [[ "$(Build.SourceBranch)" == refs/tags/v* ]]; then
VERSION="${BUILD_SOURCEBRANCH#refs/tags/v}"
else
VERSION="0.0.0-ci.$(Build.BuildId)"
fi
echo "##vso[task.setvariable variable=packageVersion]$VERSION"
displayName: 'Extract version from tag'
- task: DotNetCoreCLI@2
displayName: 'Pack'
inputs:
command: 'pack'
packagesToPack: 'src/**/*.csproj'
configuration: 'Release'
outputDir: '$(Build.ArtifactStagingDirectory)/nupkgs'
arguments: '-p:Version=$(packageVersion)'---
NuGet Push to nuget.org
Push with NuGetCommand@2
For pushing to external NuGet feeds (nuget.org), use a service connection:
- task: NuGetCommand@2
displayName: 'Push to nuget.org'
inputs:
command: 'push'
packagesToPush: '$(Pipeline.Workspace)/nupkgs/*.nupkg'
nuGetFeedType: 'external'
publishFeedCredentials: 'NuGetOrgServiceConnection'The service connection stores the nuget.org API key securely. Create it in Project Settings > Service Connections > NuGet.
Conditional Push (Stable vs Pre-Release)
- task: NuGetCommand@2
displayName: 'Push to nuget.org (stable only)'
condition: and(succeeded(), not(contains(variables['packageVersion'], '-')))
inputs:
command: 'push'
packagesToPush: '$(Pipeline.Workspace)/nupkgs/*.nupkg'
nuGetFeedType: 'external'
publishFeedCredentials: 'NuGetOrgServiceConnection'
- task: DotNetCoreCLI@2
displayName: 'Push to Azure Artifacts (all versions)'
inputs:
command: 'push'
packagesToPush: '$(Pipeline.Workspace)/nupkgs/*.nupkg'
nuGetFeedType: 'internal'
publishVstsFeed: 'MyProject/MyFeed'Pre-release versions (containing - like 1.2.3-preview.1) go only to Azure Artifacts; stable versions go to both feeds.
Skip Duplicate Packages
- task: DotNetCoreCLI@2
displayName: 'Push (skip duplicates)'
inputs:
command: 'push'
packagesToPush: '$(Pipeline.Workspace)/nupkgs/*.nupkg'
nuGetFeedType: 'internal'
publishVstsFeed: 'MyProject/MyFeed'
continueOnError: true # Azure Artifacts returns 409 for duplicatesAzure Artifacts returns HTTP 409 for duplicate package versions. Use continueOnError: true for idempotent pipeline reruns, or configure the feed to allow overwriting pre-release versions in Feed Settings.
---
Container Image Build and Push to ACR
Docker@2 Task
Build and push a container image to Azure Container Registry. See [skill:dotnet-devops] references/containers.md for Dockerfile authoring guidance:
stages:
- stage: BuildContainer
jobs:
- job: DockerBuild
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Docker@2
displayName: 'Login to ACR'
inputs:
command: 'login'
containerRegistry: 'MyACRServiceConnection'
- task: Docker@2
displayName: 'Build and push'
inputs:
command: 'buildAndPush'
repository: 'myapp'
containerRegistry: 'MyACRServiceConnection'
dockerfile: 'src/MyApp/Dockerfile'
buildContext: '.'
tags: |
$(Build.BuildId)
latestTagging Strategy
- task: Docker@2
displayName: 'Build and push with semver tags'
inputs:
command: 'buildAndPush'
repository: 'myapp'
containerRegistry: 'MyACRServiceConnection'
dockerfile: 'src/MyApp/Dockerfile'
buildContext: '.'
tags: |
$(packageVersion)
$(Build.SourceVersion)
latestUse semantic version tags for release images and commit SHA tags for traceability. The latest tag should only be applied to stable releases.
SDK Container Publish (Dockerfile-Free)
Use .NET SDK container publish for projects without a Dockerfile. See [skill:dotnet-devops] references/containers.md for PublishContainer MSBuild configuration:
- task: Docker@2
displayName: 'Login to ACR'
inputs:
command: 'login'
containerRegistry: 'MyACRServiceConnection'
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '8.0.x'
- script: |
dotnet publish src/MyApp/MyApp.csproj \
-c Release \
-p:PublishProfile=DefaultContainer \
-p:ContainerRegistry=$(ACR_LOGIN_SERVER) \
-p:ContainerRepository=myapp \
-p:ContainerImageTags='"$(packageVersion);latest"'
displayName: 'Publish container via SDK'
env:
ACR_LOGIN_SERVER: $(acrLoginServer)Native AOT Container Publish
Publish a Native AOT binary as a container image. AOT configuration is owned by [skill:dotnet-tooling]; this shows the CI pipeline step only:
- script: |
dotnet publish src/MyApp/MyApp.csproj \
-c Release \
-r linux-x64 \
-p:PublishAot=true \
-p:PublishProfile=DefaultContainer \
-p:ContainerRegistry=$(ACR_LOGIN_SERVER) \
-p:ContainerRepository=myapp \
-p:ContainerBaseImage=mcr.microsoft.com/dotnet/runtime-deps:8.0-noble-chiseled \
-p:ContainerImageTags='"$(packageVersion)"'
displayName: 'Publish AOT container'The runtime-deps base image is sufficient for AOT binaries since they include the runtime.
---
Artifact Staging
PublishPipelineArtifact@1 (Recommended)
Pipeline artifacts are the modern replacement for build artifacts, offering faster upload/download and deduplication:
steps:
- task: DotNetCoreCLI@2
displayName: 'Publish app'
inputs:
command: 'publish'
projects: 'src/MyApp/MyApp.csproj'
arguments: '-c Release -o $(Build.ArtifactStagingDirectory)/app'
- task: PublishPipelineArtifact@1
displayName: 'Upload app artifact'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/app'
artifactName: 'app'
- task: PublishPipelineArtifact@1
displayName: 'Upload NuGet packages'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/nupkgs'
artifactName: 'nupkgs'PublishBuildArtifacts@1 (Legacy)
Use only when integrating with classic release pipelines that require build artifacts:
- task: PublishBuildArtifacts@1
displayName: 'Upload build artifact (legacy)'
inputs:
pathToPublish: '$(Build.ArtifactStagingDirectory)/app'
artifactName: 'app'
publishLocation: 'Container'Downloading Artifacts in Downstream Stages
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- script: dotnet publish -c Release -o $(Build.ArtifactStagingDirectory)/app
- task: PublishPipelineArtifact@1
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/app'
artifactName: 'app'
- stage: Deploy
dependsOn: Build
jobs:
- deployment: DeployJob
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- script: echo "Deploying from $(Pipeline.Workspace)/app"The download: current keyword downloads artifacts from the current pipeline run. Use download: pipelineName for artifacts from a different pipeline.
---
Pipeline Artifacts for Release Pipelines
Multi-Stage Release with Artifact Promotion
trigger:
tags:
include:
- 'v*'
stages:
- stage: Build
jobs:
- job: BuildAndPack
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '8.0.x'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: 'MyApp.sln'
arguments: '-c Release'
- task: DotNetCoreCLI@2
displayName: 'Publish'
inputs:
command: 'publish'
projects: 'src/MyApp/MyApp.csproj'
arguments: '-c Release -o $(Build.ArtifactStagingDirectory)/app'
- task: DotNetCoreCLI@2
displayName: 'Pack'
inputs:
command: 'pack'
packagesToPack: 'src/MyLibrary/MyLibrary.csproj'
configuration: 'Release'
outputDir: '$(Build.ArtifactStagingDirectory)/nupkgs'
- task: PublishPipelineArtifact@1
displayName: 'Upload app'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/app'
artifactName: 'app'
- task: PublishPipelineArtifact@1
displayName: 'Upload packages'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/nupkgs'
artifactName: 'nupkgs'
- stage: DeployStaging
dependsOn: Build
jobs:
- deployment: DeployStaging
environment: 'staging'
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- script: echo "Deploying to staging from $(Pipeline.Workspace)/app"
- stage: PublishPackages
dependsOn: DeployStaging
jobs:
- job: PushPackages
pool:
vmImage: 'ubuntu-latest'
steps:
- download: current
artifact: nupkgs
- task: NuGetAuthenticate@1
- task: NuGetCommand@2
displayName: 'Push to nuget.org'
inputs:
command: 'push'
packagesToPush: '$(Pipeline.Workspace)/nupkgs/*.nupkg'
nuGetFeedType: 'external'
publishFeedCredentials: 'NuGetOrgServiceConnection'
- stage: DeployProduction
dependsOn: DeployStaging
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployProduction
environment: 'production'
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- script: echo "Deploying to production from $(Pipeline.Workspace)/app"Cross-Pipeline Artifact Consumption
Consume artifacts from a different pipeline (e.g., a shared build pipeline):
resources:
pipelines:
- pipeline: buildPipeline
source: 'MyApp-Build'
trigger:
branches:
include:
- main
stages:
- stage: Deploy
jobs:
- deployment: DeployFromBuild
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- download: buildPipeline
artifact: app
- script: echo "Deploying from $(Pipeline.Workspace)/buildPipeline/app"---
Agent Gotchas
1. Use `PublishPipelineArtifact@1` over `PublishBuildArtifacts@1` -- pipeline artifacts are faster, support deduplication, and work with multi-stage YAML pipelines; build artifacts are legacy and required only for classic release pipelines. 2. Azure Artifacts returns 409 for duplicate package versions -- use continueOnError: true for idempotent reruns, or handle duplicates in feed settings by allowing pre-release version overwrites. 3. `NuGetCommand@2` with `external` feed type requires a service connection -- do not hardcode API keys in pipeline YAML; create a NuGet service connection in Project Settings that stores the key securely. 4. SDK container publish requires Docker on the agent -- dotnet publish with PublishProfile=DefaultContainer needs Docker; hosted ubuntu-latest agents include Docker, but self-hosted agents may not. 5. AOT publish requires matching RID -- dotnet publish -r linux-x64 must match the agent OS; do not use -r win-x64 on a Linux agent. 6. `download: current` uses `$(Pipeline.Workspace)` not `$(Build.ArtifactStagingDirectory)` -- artifacts downloaded in deployment jobs are at $(Pipeline.Workspace)/artifactName, not the staging directory. 7. Never hardcode registry credentials in pipeline YAML -- use Docker service connections for ACR/DockerHub authentication; service connections store credentials securely and rotate independently. 8. Tag triggers require explicit `tags.include` in the trigger section -- tags are not included by default CI triggers; add tags: include: ['v*'] to trigger on version tags.
ADO Unique Features
Azure DevOps-exclusive features not available in GitHub Actions: Environments with approvals and gates (pre-deployment checks, business hours restrictions), deployment groups vs environments (when to use each), service connections (Azure Resource Manager, Docker Registry, NuGet), classic release pipelines (legacy migration guidance to YAML), variable groups and library (linked to Azure Key Vault), pipeline decorators for organization-wide policy, and Azure Artifacts universal packages.
Version assumptions: Azure DevOps Services (cloud). YAML pipelines with multi-stage support. Classic release pipelines for legacy migration context only.
Environments with Approvals and Gates
Defining Environments in YAML
Environments are first-class Azure DevOps resources that provide deployment targeting, approval gates, and deployment history:
stages:
- stage: DeployStaging
jobs:
- deployment: DeployToStaging
pool:
vmImage: 'ubuntu-latest'
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- script: echo "Deploying to staging"
- stage: DeployProduction
dependsOn: DeployStaging
jobs:
- deployment: DeployToProduction
pool:
vmImage: 'ubuntu-latest'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- script: echo "Deploying to production"Environments are created automatically on first reference. Configure approvals and gates in Azure DevOps > Pipelines > Environments > (select environment) > Approvals and checks.
Approval Checks
| Check Type | Purpose | Configuration |
|---|---|---|
| Approvals | Manual sign-off before deployment | Assign approver users/groups |
| Branch control | Restrict deployments to specific branches | Allow only main, release/* |
| Business hours | Deploy only during allowed time windows | Define hours and timezone |
| Template validation | Require pipeline to extend a specific template | Specify required template path |
| Invoke Azure Function | Custom validation via Azure Function | Provide function URL and key |
| Invoke REST API | Custom validation via HTTP endpoint | Provide URL and success criteria |
| Required template | Enforce pipeline structure | Specify required extends template |
Configuring Approval Checks
Approval checks are configured in the Azure DevOps UI, not in YAML. The YAML pipeline references the environment, and the checks are applied:
# Pipeline YAML -- environment reference triggers checks
- deployment: DeployToProduction
environment: 'production' # checks configured in UI
strategy:
runOnce:
deploy:
steps:
- script: echo "This runs only after all checks pass"Approval configuration (UI):
- Navigate to Pipelines > Environments > production > Approvals and checks
- Add "Approvals" check: assign individuals or groups
- Set minimum number of approvers (e.g., 2 for production)
- Enable "allow approvers to approve their own runs" only if appropriate
Business Hours Gate
Restrict deployments to specific time windows to reduce risk:
- Navigate to Pipelines > Environments > production > Approvals and checks
- Add "Business Hours" check
- Configure: Monday-Friday, 09:00-17:00 (team timezone)
- Pipelines will queue and wait until the window opens
Pre-Deployment Validation with Azure Functions
# The environment's "Invoke Azure Function" check calls:
# https://myvalidation.azurewebsites.net/api/pre-deploy
# with the pipeline context as payload.
# Returns 200 to approve, non-200 to reject.
- deployment: DeployToProduction
environment: 'production' # Azure Function check configured in UI
strategy:
runOnce:
preDeploy:
steps:
- script: echo "Pre-deploy hook (in-pipeline)"
deploy:
steps:
- script: echo "Deploying"
routeTraffic:
steps:
- script: echo "Routing traffic"
postRouteTraffic:
steps:
- script: echo "Post-route validation"The preDeploy, routeTraffic, and postRouteTraffic lifecycle hooks execute within the pipeline. Environment checks (approvals, Azure Function gates) execute before the deployment job starts.
---
Deployment Groups vs Environments
When to Use Each
| Feature | Deployment Groups | Environments |
|---|---|---|
| Target | Physical/virtual machines with agents | Any target (VMs, Kubernetes, cloud services) |
| Agent model | Self-hosted agents on target machines | Pool agents or target-specific resources |
| Pipeline type | Classic release pipelines (legacy) | YAML multi-stage pipelines (modern) |
| Approvals | Per-stage in classic UI | Checks and approvals on environment |
| Rolling deployment | Built-in rolling strategy | strategy: rolling in YAML |
| Recommendation | Legacy workloads only | All new projects |
Deployment Group Example (Legacy)
Deployment groups install an agent on each target machine. Use only for existing on-premises deployments:
# Classic release pipeline (not YAML) -- for reference only
# Deployment groups are configured in Project Settings > Deployment Groups
# Each target server runs the ADO agent registered to the groupEnvironment with Kubernetes Resource
- deployment: DeployToK8s
environment: 'production.my-k8s-namespace'
strategy:
runOnce:
deploy:
steps:
- task: KubernetesManifest@1
inputs:
action: 'deploy'
manifests: 'k8s/*.yml'
containers: '$(ACR_LOGIN_SERVER)/myapp:$(Build.BuildId)'Environments can target Kubernetes clusters and namespaces. Register the cluster as a resource under the environment in the Azure DevOps UI.
Migration from Deployment Groups to Environments
1. Create environments matching existing deployment group names 2. Configure the same approval gates in the environment's Approvals and checks 3. Convert classic release pipeline stages to YAML deployment jobs targeting the new environments 4. Use strategy: rolling for incremental deployments equivalent to deployment group behavior
---
Service Connections
Azure Resource Manager (ARM)
Service connections provide authenticated access to external services. ARM connections enable Azure resource deployments:
- task: AzureWebApp@1
displayName: 'Deploy to Azure App Service'
inputs:
azureSubscription: 'MyAzureServiceConnection'
appType: 'webAppLinux'
appName: 'myapp-staging'
package: '$(Pipeline.Workspace)/app'Creating an ARM service connection:
- Navigate to Project Settings > Service Connections > New service connection > Azure Resource Manager
- Choose "Service principal (automatic)" for automatic credential management
- Select the subscription and resource group scope
- ADO creates an app registration and assigns Contributor role
Workload Identity Federation (Recommended)
Use workload identity federation for passwordless Azure authentication (no client secret):
- Navigate to Project Settings > Service Connections > New service connection > Azure Resource Manager
- Choose "Workload Identity federation (automatic)"
- This creates a federated credential that trusts Azure DevOps pipeline tokens
- No secret rotation required -- the credential uses short-lived pipeline tokens
Docker Registry Service Connection
- task: Docker@2
displayName: 'Login to ACR'
inputs:
command: 'login'
containerRegistry: 'MyACRServiceConnection'
- task: Docker@2
displayName: 'Build and push'
inputs:
command: 'buildAndPush'
containerRegistry: 'MyACRServiceConnection'
repository: 'myapp'
dockerfile: 'src/MyApp/Dockerfile'Creating a Docker registry connection:
- Project Settings > Service Connections > New service connection > Docker Registry
- For ACR: select "Azure Container Registry" and choose the registry
- For DockerHub: provide username and access token
NuGet Service Connection
For pushing to external NuGet feeds (e.g., nuget.org):
- task: NuGetCommand@2
displayName: 'Push to nuget.org'
inputs:
command: 'push'
packagesToPush: '$(Pipeline.Workspace)/nupkgs/*.nupkg'
nuGetFeedType: 'external'
publishFeedCredentials: 'NuGetOrgServiceConnection'Creating a NuGet connection:
- Project Settings > Service Connections > New service connection > NuGet
- Provide the feed URL (
https://api.nuget.org/v3/index.json) and API key
---
Classic Release Pipelines (Legacy Migration)
Why Migrate to YAML
Classic release pipelines use a visual designer and are not stored in source control. Migrate to YAML multi-stage pipelines for:
- Source control: Pipeline definitions live alongside code
- Code review: Pipeline changes go through PR review
- Branch-specific pipelines: YAML pipelines can vary by branch
- Reusability: Templates and extends for composable pipelines
- Modern features: Environments, deployment strategies, pipeline decorators
Migration Pattern
Classic release structure:
Build Pipeline -> Release Pipeline
Stage 1: Dev (auto-deploy)
Stage 2: Staging (manual approval)
Stage 3: Production (scheduled + approval)Equivalent YAML multi-stage pipeline:
trigger:
branches:
include:
- main
stages:
- stage: Build
jobs:
- job: BuildJob
pool:
vmImage: 'ubuntu-latest'
steps:
- task: DotNetCoreCLI@2
inputs:
command: 'publish'
projects: 'src/MyApp/MyApp.csproj'
arguments: '-c Release -o $(Build.ArtifactStagingDirectory)/app'
- task: PublishPipelineArtifact@1
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/app'
artifactName: 'app'
- stage: DeployDev
dependsOn: Build
jobs:
- deployment: DeployDev
environment: 'development'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- script: echo "Deploy to dev"
- stage: DeployStaging
dependsOn: DeployDev
jobs:
- deployment: DeployStaging
environment: 'staging' # approvals configured in UI
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- script: echo "Deploy to staging"
- stage: DeployProduction
dependsOn: DeployStaging
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployProduction
environment: 'production' # approvals + business hours in UI
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- script: echo "Deploy to production"Migration Checklist
1. Identify all classic release stages and map to YAML stages 2. Convert environment variables to YAML variable groups or templates 3. Replace classic approval gates with environment checks 4. Convert artifact sources to download: current or pipeline resources 5. Replace task groups with YAML step or job templates 6. Test the YAML pipeline on a non-production branch before decommissioning the classic release
---
Variable Groups and Library
Variable Groups Linked to Azure Key Vault
Variable groups can pull secrets directly from Azure Key Vault at pipeline runtime:
variables:
- group: 'kv-production-secrets'
- group: 'build-settings'
- name: buildConfiguration
value: 'Release'
steps:
- script: |
echo "Building with configuration $(buildConfiguration)"
displayName: 'Build'
env:
SQL_CONNECTION: $(sql-connection-string) # from Key Vault
API_KEY: $(api-key) # from Key VaultSetting up Key Vault-linked variable groups: 1. Navigate to Pipelines > Library > Variable Groups > New variable group 2. Enable "Link secrets from an Azure key vault as variables" 3. Select the Azure subscription (service connection) and Key Vault 4. Choose which secrets to include 5. Secrets are fetched at pipeline runtime and available as $(secret-name)
Scoping Variable Groups to Environments
Use conditional variable group references based on pipeline stage:
stages:
- stage: DeployStaging
variables:
- group: 'staging-config'
- group: 'kv-staging-secrets'
jobs:
- deployment: Deploy
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying with staging config"
env:
CONNECTION_STRING: $(sql-connection-string)
- stage: DeployProduction
variables:
- group: 'production-config'
- group: 'kv-production-secrets'
jobs:
- deployment: Deploy
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying with production config"
env:
CONNECTION_STRING: $(sql-connection-string)Secure Files in Library
Store certificates, SSH keys, and other binary secrets in the Pipelines Library:
- task: DownloadSecureFile@1
displayName: 'Download signing certificate'
name: signingCert
inputs:
secureFile: 'code-signing.pfx'
- script: |
dotnet nuget sign ./nupkgs/*.nupkg \
--certificate-path $(signingCert.secureFilePath) \
--certificate-password $(CERT_PASSWORD) \
--timestamper http://timestamp.digicert.com
displayName: 'Sign NuGet packages'---
Pipeline Decorators
Pipeline decorators inject steps into every pipeline in an organization or project without modifying individual pipeline files. They enforce organizational policies:
Decorator Use Cases
| Use Case | Implementation |
|---|---|
| Mandatory security scanning | Inject credential scanner before every job |
| Compliance audit logging | Inject telemetry step after every job |
| Required code analysis | Inject SonarQube analysis on main branch builds |
| License compliance | Inject dependency license scanner |
Decorator Definition
Decorators are packaged as Azure DevOps extensions:
# vss-extension.json (extension manifest)
{
"contributions": [
{
"id": "required-security-scan",
"type": "ms.azure-pipelines.pipeline-decorator",
"targets": ["ms.azure-pipelines-agent-job"],
"properties": {
"template": "decorator.yml",
"targetsExecutionOrder": "PreJob"
}
}
]
}# decorator.yml
steps:
- task: CredentialScanner@1
displayName: '[Policy] Credential scan'
condition: always()Deployment Limitations
- Decorators require Azure DevOps organization admin permissions to install
- They apply to all pipelines in the organization (or selected projects)
- Pipeline authors cannot override or skip decorator steps
- Decorator steps run under the pipeline's agent pool and service connection context
---
Azure Artifacts Universal Packages
Universal packages store arbitrary files (binaries, tools, datasets) in Azure Artifacts feeds, not limited to NuGet/npm/Maven formats:
Publish a Universal Package
- task: UniversalPackages@0
displayName: 'Publish universal package'
inputs:
command: 'publish'
publishDirectory: '$(Build.ArtifactStagingDirectory)/tools'
feedsToUsePublish: 'internal'
vstsFeedPublish: 'MyProject/MyFeed'
vstsFeedPackagePublish: 'my-dotnet-tool'
versionOption: 'custom'
versionPublish: '$(Build.BuildNumber)'
packagePublishDescription: '.NET CLI tool binaries'Download a Universal Package
- task: UniversalPackages@0
displayName: 'Download universal package'
inputs:
command: 'download'
feedsToUse: 'internal'
vstsFeed: 'MyProject/MyFeed'
vstsFeedPackage: 'my-dotnet-tool'
vstsPackageVersion: '*'
downloadDirectory: '$(Pipeline.Workspace)/tools'Use Cases for .NET Projects
- CLI tool distribution: Publish self-contained .NET CLI tool binaries for cross-team consumption
- Build tool caching: Store custom MSBuild tasks or analyzers used across repositories
- Test fixture data: Publish large test datasets that should not be stored in Git
- AOT binaries: Distribute pre-built Native AOT binaries for platforms where on-demand compilation is impractical
---
Agent Gotchas
1. Environment checks (approvals, gates) are configured in the UI, not YAML -- the YAML pipeline references the environment name; all checks are managed through the Azure DevOps web UI. 2. Deployment groups are legacy -- use environments for all new projects; deployment groups exist only for backward compatibility with classic release pipelines. 3. Service connection scope matters -- ARM connections scoped to a resource group cannot deploy to resources outside that group; use subscription-level scope for cross-resource-group deployments. 4. Workload identity federation is preferred over service principal secrets -- federated credentials eliminate secret rotation; use automatic federation for new connections. 5. Key Vault-linked variable groups fetch secrets at runtime -- template expressions (${{ }}) cannot access Key Vault secrets because they resolve at compile time; use runtime expressions ($()) instead. 6. Classic release pipelines are not stored in source control -- this is a primary motivation for migration; YAML pipelines enable PR review and branch-specific definitions. 7. Pipeline decorators cannot be bypassed by pipeline authors -- this is intentional for policy enforcement; test decorator changes in a separate organization or project to avoid breaking all pipelines. 8. Universal packages have a 4 GiB size limit per file -- for larger artifacts, split files or use Azure Blob Storage with a SAS token instead.
Container Deployment
Deploying .NET containers to Kubernetes and local development environments. Covers Kubernetes Deployment + Service + probe YAML, Docker Compose for local dev workflows, and CI/CD integration for building and pushing container images.
Kubernetes Deployment
Deployment Manifest
A production-ready Kubernetes Deployment for a .NET API:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
labels:
app: order-api
app.kubernetes.io/name: order-api
app.kubernetes.io/version: "1.0.0"
app.kubernetes.io/component: api
spec:
replicas: 3
selector:
matchLabels:
app: order-api
template:
metadata:
labels:
app: order-api
spec:
containers:
- name: order-api
image: ghcr.io/myorg/order-api:1.0.0
ports:
- containerPort: 8080
protocol: TCP
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-collector.monitoring:4317"
- name: OTEL_SERVICE_NAME
value: "order-api"
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: order-api-secrets
key: connection-string
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 30
securityContext:
runAsNonRoot: true
runAsUser: 1654
fsGroup: 1654
terminationGracePeriodSeconds: 30Service Manifest
Expose the Deployment within the cluster:
apiVersion: v1
kind: Service
metadata:
name: order-api
labels:
app: order-api
spec:
type: ClusterIP
selector:
app: order-api
ports:
- port: 80
targetPort: 8080
protocol: TCP
name: httpConfigMap for Non-Sensitive Configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: order-api-config
data:
ASPNETCORE_ENVIRONMENT: "Production"
Logging__LogLevel__Default: "Information"
Logging__LogLevel__Microsoft.AspNetCore: "Warning"Reference in the Deployment:
envFrom:
- configMapRef:
name: order-api-configSecrets for Sensitive Configuration
apiVersion: v1
kind: Secret
metadata:
name: order-api-secrets
type: Opaque
stringData:
connection-string: "Host=postgres;Database=orders;Username=app;Password=secret"In production, use an external secrets operator (e.g., External Secrets Operator, Sealed Secrets) rather than plain Kubernetes Secrets stored in source control.
---
Kubernetes Probes
Probes tell Kubernetes how to check application health. They map to the health check endpoints defined in your .NET application (see [skill:dotnet-devops]).
Probe Types
| Probe | Purpose | Endpoint | Failure Action |
|---|---|---|---|
| Startup | Has the app finished initializing? | /health/live | Keep waiting (up to failureThreshold * periodSeconds) |
| Liveness | Is the process healthy? | /health/live | Restart the pod |
| Readiness | Can the process serve traffic? | /health/ready | Remove from Service endpoints |
Probe Configuration Guidelines
# Startup probe: give the app time to initialize
# Total startup budget: failureThreshold * periodSeconds = 30 * 5 = 150s
startupProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 30
# Liveness probe: detect deadlocks and hangs
# Only runs after startup probe succeeds
livenessProbe:
httpGet:
path: /health/live
port: 8080
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
# Readiness probe: control traffic routing
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3Graceful Shutdown
.NET responds to SIGTERM and begins graceful shutdown. Configure terminationGracePeriodSeconds to allow in-flight requests to complete:
spec:
terminationGracePeriodSeconds: 30In your application, use IHostApplicationLifetime to handle shutdown:
app.Lifetime.ApplicationStopping.Register(() =>
{
// Perform cleanup: flush telemetry, close connections
Log.CloseAndFlush();
});Ensure the Host.ShutdownTimeout allows in-flight requests to complete:
builder.Host.ConfigureHostOptions(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(25);
});Set ShutdownTimeout to a value less than terminationGracePeriodSeconds to ensure the app shuts down before Kubernetes sends SIGKILL.
---
Docker Compose for Local Development
Docker Compose provides a local development environment that mirrors production dependencies.
Basic Compose File
# docker-compose.yml
services:
order-api:
build:
context: .
dockerfile: src/OrderApi/Dockerfile
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Host=postgres;Database=orders;Username=app;Password=devpassword
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# Note: CMD-SHELL + curl requires a base image with shell and curl installed.
# Chiseled/distroless images lack both. For chiseled images, either use a
# non-chiseled dev target in the Dockerfile or omit the healthcheck and rely
# on depends_on ordering (acceptable for local dev).
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health/live || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
postgres:
image: postgres:17
environment:
POSTGRES_DB: orders
POSTGRES_USER: app
POSTGRES_PASSWORD: devpassword
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d orders"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
postgres-data:Development Override
Use a separate override file for development-specific settings:
# docker-compose.override.yml (auto-loaded by docker compose up)
services:
order-api:
build:
target: build # Stop at build stage for faster rebuilds
volumes:
- .:/src # Mount source for hot reload
environment:
- ASPNETCORE_ENVIRONMENT=Development
- DOTNET_USE_POLLING_FILE_WATCHER=true
command: ["dotnet", "watch", "run", "--project", "src/OrderApi/OrderApi.csproj"]Observability Stack
Add an OpenTelemetry collector and Grafana for local observability:
# docker-compose.observability.yml
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otelcol-config.yaml"]
volumes:
- ./infra/otelcol-config.yaml:/etc/otelcol-config.yaml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
volumes:
- grafana-data:/var/lib/grafana
volumes:
grafana-data:Run with the observability stack:
docker compose -f docker-compose.yml -f docker-compose.observability.yml up---
CI/CD Integration
Basic CI/CD patterns for building and pushing .NET container images. Advanced CI patterns (matrix builds, environment promotion, deploy pipelines) -- see [skill:dotnet-devops] references/gha-publish.md, references/gha-deploy.md, and references/ado-publish.md.
GitHub Actions: Build and Push
# .github/workflows/docker-publish.yml
name: Build and Push Container
on:
push:
branches: [main]
tags: ["v*"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxImage Tagging Strategy
| Tag Pattern | Example | Use Case |
|---|---|---|
latest | myapi:latest | Development only -- never use in production |
| Semver | myapi:1.2.3 | Release versions -- immutable |
| Major.Minor | myapi:1.2 | Floating tag for patch updates |
| SHA | myapi:sha-abc1234 | Unique per commit -- traceability |
| Branch | myapi:main | CI builds -- latest from branch |
dotnet publish Container in CI
For projects using dotnet publish /t:PublishContainer instead of Dockerfiles:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Publish container image
run: |
dotnet publish src/OrderApi/OrderApi.csproj \
--os linux --arch x64 \
/t:PublishContainer \
-p:ContainerRegistry=${{ env.REGISTRY }} \
-p:ContainerRepository=${{ env.IMAGE_NAME }} \
-p:ContainerImageTag=${{ github.sha }}---
Key Principles
- Use startup probes to decouple initialization time from liveness detection -- without a startup probe, slow-starting apps get killed before they are ready
- Separate liveness from readiness -- liveness checks should not include dependency health (see [skill:dotnet-devops] for endpoint patterns)
- Set resource requests and limits -- without them, pods can starve other workloads or get OOM-killed unpredictably
- Run as non-root -- set
runAsNonRoot: truein the pod security context and use chiseled images (see [skill:dotnet-devops]references/containers.md) - Use `depends_on` with health checks in Docker Compose -- prevents app startup before dependencies are ready
- Keep secrets out of manifests -- use Kubernetes Secrets with external secrets operators, not plain values in source control
- Match ShutdownTimeout to terminationGracePeriodSeconds -- ensure the app finishes cleanup before Kubernetes sends SIGKILL
---
Agent Gotchas
1. Do not omit the startup probe -- without it, the liveness probe runs during initialization and may restart slow-starting apps. Calculate startup budget as failureThreshold * periodSeconds. 2. Do not include dependency checks in liveness probes -- a database outage should not restart your app. Liveness endpoints must only check the process itself. See [skill:dotnet-devops] for the liveness vs readiness pattern. 3. Do not use `latest` tag in Kubernetes manifests -- latest is mutable and imagePullPolicy: IfNotPresent may serve stale images. Use immutable tags (semver or SHA). 4. Do not hardcode connection strings in Kubernetes manifests -- use Secrets or ConfigMaps referenced via secretKeyRef/configMapRef. 5. Do not set `terminationGracePeriodSeconds` lower than `Host.ShutdownTimeout` -- the app needs time to drain in-flight requests before Kubernetes sends SIGKILL. 6. Do not forget `condition: service_healthy` in Docker Compose `depends_on` -- without the condition, Compose starts dependent services immediately without waiting for health checks.
---
References
Containers
Best practices for containerizing .NET applications. Covers multi-stage Dockerfile patterns, the dotnet publish container image feature (.NET 8+), rootless container configuration, optimized layer caching, and container health checks.
Multi-Stage Dockerfiles
Multi-stage builds separate the build environment from the runtime environment, producing minimal final images.
Standard Multi-Stage Pattern
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy project files first for layer caching
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
COPY ["src/MyApi.Core/MyApi.Core.csproj", "src/MyApi.Core/"]
COPY ["Directory.Build.props", "."]
COPY ["Directory.Packages.props", "."]
RUN dotnet restore "src/MyApi/MyApi.csproj"
# Copy everything else and build
COPY . .
WORKDIR "/src/src/MyApi"
RUN dotnet publish -c Release -o /app/publish --no-restore
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
EXPOSE 8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]Layer Caching Strategy
Order COPY instructions from least-frequently-changed to most-frequently-changed:
1. Project files and props -- change only when dependencies change 2. `dotnet restore` -- cached until project files change 3. Source code -- changes with every build 4. `dotnet publish` -- runs only when source or restore layer changes
# Good: restore layer is cached when only source changes
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
RUN dotnet restore
COPY . .
RUN dotnet publish
# Bad: restore runs on every source change
COPY . .
RUN dotnet restore
RUN dotnet publishSolution-Level Restore
For multi-project solutions, copy all .csproj files and the solution file to enable a single restore:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy solution and all project files for restore caching
COPY ["MyApp.sln", "."]
COPY ["Directory.Build.props", "."]
COPY ["Directory.Packages.props", "."]
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
COPY ["src/MyApi.Core/MyApi.Core.csproj", "src/MyApi.Core/"]
COPY ["src/MyApi.Infrastructure/MyApi.Infrastructure.csproj", "src/MyApi.Infrastructure/"]
RUN dotnet restore
COPY . .
RUN dotnet publish "src/MyApi/MyApi.csproj" -c Release -o /app/publish --no-restore---
dotnet publish Container Images (.NET 8+)
Starting with .NET 8, dotnet publish can produce OCI container images directly without a Dockerfile. This uses the Microsoft.NET.Build.Containers SDK (included in the .NET SDK).
Basic Usage
# Publish as a container image to local Docker daemon
dotnet publish --os linux --arch x64 /t:PublishContainer
# Publish to a remote registry
dotnet publish --os linux --arch x64 /t:PublishContainer \
-p:ContainerRegistry=ghcr.io \
-p:ContainerRepository=myorg/myapiMSBuild Configuration
Configure container properties in the .csproj:
<PropertyGroup>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0</ContainerBaseImage>
<ContainerImageName>myapi</ContainerImageName>
<ContainerImageTag>$(Version)</ContainerImageTag>
</PropertyGroup>
<ItemGroup>
<ContainerPort Include="8080" Type="tcp" />
</ItemGroup>Advanced Configuration
<PropertyGroup>
<!-- Use chiseled (distroless) base image for smaller attack surface -->
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled</ContainerBaseImage>
<!-- Run as non-root user (default for chiseled images) -->
<ContainerUser>app</ContainerUser>
</PropertyGroup>
<ItemGroup>
<!-- Environment variables -->
<ContainerEnvironmentVariable Include="ASPNETCORE_URLS" Value="http://+:8080" />
<ContainerEnvironmentVariable Include="DOTNET_RUNNING_IN_CONTAINER" Value="true" />
<!-- Labels -->
<ContainerLabel Include="org.opencontainers.image.source" Value="https://github.com/myorg/myapi" />
</ItemGroup>When to Use dotnet publish vs Dockerfile
| Scenario | Recommendation |
|---|---|
| Simple single-project API | dotnet publish /t:PublishContainer -- less boilerplate |
| Multi-stage build with native dependencies | Dockerfile -- full control over build environment |
Need to install OS packages (e.g., libgdiplus) | Dockerfile -- RUN apt-get install not available in SDK publish |
| CI/CD with complex build steps | Dockerfile -- explicit, reproducible |
| Quick local container testing | dotnet publish /t:PublishContainer -- fastest iteration |
---
Base Image Selection
Official .NET Container Images
| Image | Use Case | Size |
|---|---|---|
mcr.microsoft.com/dotnet/aspnet:10.0 | ASP.NET Core apps (Ubuntu) | ~220 MB |
mcr.microsoft.com/dotnet/aspnet:10.0-alpine | ASP.NET Core apps (Alpine, smaller) | ~110 MB |
mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled | Distroless (no shell, no package manager) | ~110 MB |
mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra | Chiseled + globalization + time zones | ~130 MB |
mcr.microsoft.com/dotnet/runtime:10.0 | Console apps, worker services | ~190 MB |
mcr.microsoft.com/dotnet/runtime-deps:10.0 | Self-contained/AOT apps (runtime not needed) | ~30 MB |
Choosing a Base Image
- Default: Use
aspnetfor web apps,runtimefor worker services - Minimal footprint: Use
chiseledvariants (no shell, no root user, no package manager) - Globalization needed: Use
chiseled-extraif your app uses culture-specific formatting or time zones - Self-contained or AOT: Use
runtime-deps-- the runtime is bundled in your app - Alpine: Smaller than Ubuntu but uses musl libc; test for compatibility with native dependencies
---
Rootless Containers
Running containers as non-root reduces the attack surface. .NET 8+ chiseled images run as non-root by default.
Non-Root with Standard Images
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# Create non-root user and switch to it
RUN adduser --disabled-password --gecos "" --uid 1001 appuser
USER appuser
COPY --from=build --chown=appuser:appuser /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]Non-Root with Chiseled Images
Chiseled images include a pre-configured app user (UID 1654). No additional configuration needed:
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime
WORKDIR /app
# Already runs as non-root 'app' user (UID 1654)
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]Port Configuration
Non-root users cannot bind to ports below 1024. ASP.NET Core defaults to port 8080 in containers (set via ASPNETCORE_HTTP_PORTS):
# Default in .NET 8+ container images -- no explicit config needed
# ASPNETCORE_HTTP_PORTS=8080
# If you need a different port:
ENV ASPNETCORE_HTTP_PORTS=5000
EXPOSE 5000---
Container Health Checks
Health checks allow container runtimes to monitor application readiness. The application-level health check endpoints (see [skill:dotnet-devops]) are consumed by Docker and Kubernetes probes.
Docker HEALTHCHECK
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# Health check using curl (not available in chiseled images)
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/health/live || exit 1
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]For chiseled images (no curl), use a dedicated health check binary or rely on orchestrator-level probes (Kubernetes httpGet, Docker Compose test):
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime
WORKDIR /app
# No HEALTHCHECK directive -- use orchestrator probes instead
# See [skill:dotnet-devops] references/container-deployment.md for Kubernetes probe configuration
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]Health Check Endpoints
Register health check endpoints in your application (see [skill:dotnet-devops] for full guidance):
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
.AddNpgSql(
builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "database",
tags: ["ready"]);
var app = builder.Build();
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});---
Container Optimization
.dockerignore
Always include a .dockerignore to exclude unnecessary files from the build context:
**/.git
**/.vs
**/.vscode
**/bin
**/obj
**/node_modules
**/*.user
**/*.suo
**/Dockerfile*
**/docker-compose*
**/.dockerignore
**/README.md
**/LICENSEGlobalization and Time Zones
If your app needs globalization support (culture-specific formatting, time zones), configure ICU:
# Option 1: Use the chiseled-extra image (includes ICU + tzdata)
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra
# Option 2: Disable globalization for smaller images (if not needed)
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=trueMemory Limits
Configure .NET to respect container memory limits:
# .NET automatically detects container memory limits and adjusts GC heap size.
# Override only if needed:
ENV DOTNET_GCHeapHardLimit=0x10000000 # 256 MB hard limit.NET automatically reads cgroup memory limits. The GC adjusts its heap size to stay within the container memory budget. Avoid setting DOTNET_GCHeapHardLimit unless you have a specific reason.
ReadOnlyRootFilesystem
For defense-in-depth, run with a read-only root filesystem. Ensure writable paths for temp files:
ENV DOTNET_EnableDiagnostics=0
# Or mount a tmpfs at /tmp for diagnostics support---
Key Principles
- Use multi-stage builds -- keep build tools out of the final image
- Order COPY for layer caching -- project files and restore before source code
- Prefer chiseled images for production -- no shell, no root, minimal attack surface
- Use `dotnet publish /t:PublishContainer` for simple projects -- skip Dockerfile boilerplate
- Run as non-root -- use
USERdirective or chiseled images (non-root by default) - Set health check endpoints -- enable orchestrators to monitor application state (see [skill:dotnet-devops])
- Include `.dockerignore` -- keep build context small and exclude secrets
---
Agent Gotchas
1. Do not use `mcr.microsoft.com/dotnet/sdk` as the final image -- SDK images are 800+ MB and include build tools. Always use aspnet, runtime, or runtime-deps for the final stage. 2. Do not hardcode image tags to a patch version (e.g., 10.0.1) -- use 10.0 to receive security patches. Pin to patch versions only if you have a specific compatibility requirement. 3. Do not use `HEALTHCHECK` with chiseled images -- chiseled images have no curl or shell. Use orchestrator-level probes (Kubernetes httpGet, Docker Compose test) instead. 4. Do not forget `--no-restore` on `dotnet publish` after a separate `dotnet restore` step -- without it, restore runs again and breaks layer caching. 5. Do not bind to ports below 1024 in non-root containers -- .NET defaults to port 8080 in container images. If you override ASPNETCORE_HTTP_PORTS, ensure the port is >= 1024. 6. Do not omit `.dockerignore` -- without it, the build context includes .git, bin/obj, and potentially secrets, increasing build time and image size.
---
References
GitHub Actions Build and Test
.NET build and test workflow patterns for GitHub Actions: actions/setup-dotnet@v4 configuration with multi-version installs and NuGet authentication, NuGet restore caching for fast CI, dotnet test with result publishing via dorny/test-reporter, code coverage upload to Codecov and Coveralls, multi-TFM matrix testing across net8.0 and net9.0, and test sharding strategies for large projects.
Version assumptions: actions/setup-dotnet@v4 for .NET 8/9/10 support. dorny/test-reporter@v1 for test result visualization. Codecov and Coveralls GitHub Apps for coverage reporting.
actions/setup-dotnet@v4 Configuration
Basic Setup
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'Multi-Version Install
Install multiple SDK versions for multi-TFM builds within a single job:
- name: Setup .NET SDKs
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.xThe first listed version becomes the default dotnet on PATH. All installed versions are available via --framework targeting.
NuGet Authentication for Private Feeds
Configure NuGet source authentication via actions/setup-dotnet@v4:
- name: Setup .NET with NuGet auth
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
source-url: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}For multiple private feeds, configure additional sources after setup:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Add private NuGet feed
run: |
set -euo pipefail
dotnet nuget add source https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json \
--name AzureArtifacts \
--username az \
--password ${{ secrets.AZURE_ARTIFACTS_PAT }} \
--store-password-in-clear-textThe --store-password-in-clear-text flag is required on Linux runners where DPAPI encryption is unavailable.
Global.json SDK Version Pinning
When global.json exists in the repository root, actions/setup-dotnet@v4 can read it automatically:
- name: Setup .NET from global.json
uses: actions/setup-dotnet@v4
with:
global-json-file: global.jsonThis ensures CI uses the same SDK version as local development.
---
NuGet Restore Caching
Standard Cache Configuration
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
restore-keys: |
nuget-${{ runner.os }}-
- name: Restore dependencies
run: dotnet restore MySolution.slnBuilt-in Cache with setup-dotnet
actions/setup-dotnet@v4 has built-in caching support using packages.lock.json:
- name: Setup .NET with caching
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'Generate lock files locally first: dotnet restore --use-lock-file. Commit packages.lock.json files for deterministic restore.
Cache Key Strategy
| Key Component | Purpose |
|---|---|
runner.os | Prevent cross-OS cache collisions |
hashFiles('**/*.csproj') | Invalidate when package references change |
hashFiles('**/Directory.Packages.props') | Invalidate when centrally managed versions change |
restore-keys prefix | Partial match for incremental cache reuse |
---
Test Result Publishing
dorny/test-reporter
Publish dotnet test results as GitHub Actions check annotations with inline failure details:
- name: Test
run: |
set -euo pipefail
dotnet test MySolution.sln \
--configuration Release \
--logger "trx;LogFileName=test-results.trx" \
--results-directory ./test-results
continue-on-error: true
id: test
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: '.NET Test Results'
path: 'test-results/**/*.trx'
reporter: dotnet-trx
fail-on-error: trueKey decisions:
continue-on-error: trueon the test step ensures the reporter step always runs, even on failuresif: always()on the reporter step publishes results regardless of test outcomefail-on-error: trueon the reporter marks the check as failed when tests fail
Alternative: EnricoMi/publish-unit-test-result-action
For richer PR comment integration with test counts:
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: 'test-results/**/*.trx'
check_name: 'Test Results'---
Code Coverage Upload
Codecov
- name: Test with coverage
run: |
set -euo pipefail
dotnet test MySolution.sln \
--configuration Release \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
directory: ./coverage
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}Coveralls
- name: Test with coverage
run: |
set -euo pipefail
dotnet test MySolution.sln \
--configuration Release \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage
- name: Upload coverage to Coveralls
uses: coverallsapp/github-action@v2
with:
file: coverage/**/coverage.cobertura.xml
format: cobertura
github-token: ${{ secrets.GITHUB_TOKEN }}Coverage Report Generation with ReportGenerator
Generate human-readable HTML coverage reports alongside CI upload:
- name: Generate coverage report
run: |
set -euo pipefail
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator \
-reports:coverage/**/coverage.cobertura.xml \
-targetdir:coverage-report \
-reporttypes:HtmlInline_AzurePipelines\;Cobertura
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage-report/
retention-days: 30---
Multi-TFM Matrix Testing
Matrix Strategy for TFMs
jobs:
test:
strategy:
fail-fast: false
matrix:
tfm: [net8.0, net9.0]
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
restore-keys: |
nuget-${{ runner.os }}-
- name: Test ${{ matrix.tfm }}
run: |
set -euo pipefail
dotnet test MySolution.sln \
--framework ${{ matrix.tfm }} \
--configuration Release \
--logger "trx;LogFileName=${{ matrix.tfm }}-results.trx" \
--results-directory ./test-results
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: 'Tests (${{ matrix.os }} / ${{ matrix.tfm }})'
path: 'test-results/**/*.trx'
reporter: dotnet-trxInstall All Required SDKs
When running multi-TFM tests in a single job instead of a matrix, install all required SDKs upfront:
- name: Setup .NET SDKs
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
- name: Test all TFMs
run: dotnet test MySolution.sln --configuration ReleaseWithout the matching SDK installed, dotnet test cannot build for that TFM and fails with NETSDK1045.
---
Test Sharding for Large Projects
Splitting Tests Across Parallel Jobs
For large test suites, split test projects across parallel runners to reduce total CI time:
jobs:
discover:
runs-on: ubuntu-latest
outputs:
projects: ${{ steps.find.outputs.projects }}
steps:
- uses: actions/checkout@v4
- id: find
shell: bash
run: |
set -euo pipefail
PROJECTS=$(find tests -name '*.csproj' | jq -R . | jq -sc .)
echo "projects=$PROJECTS" >> "$GITHUB_OUTPUT"
test:
needs: discover
strategy:
fail-fast: false
matrix:
project: ${{ fromJson(needs.discover.outputs.projects) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Test ${{ matrix.project }}
run: |
set -euo pipefail
dotnet test ${{ matrix.project }} \
--configuration Release \
--logger "trx;LogFileName=results.trx" \
--results-directory ./test-results
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: 'Tests - ${{ matrix.project }}'
path: 'test-results/**/*.trx'
reporter: dotnet-trxSharding by Test Class Within a Project
For a single large test project, use dotnet test --filter to split by namespace:
jobs:
test:
strategy:
fail-fast: false
matrix:
shard: ['Unit', 'Integration', 'EndToEnd']
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Test ${{ matrix.shard }}
run: |
set -euo pipefail
dotnet test tests/MyApp.Tests.csproj \
--configuration Release \
--filter "FullyQualifiedName~${{ matrix.shard }}" \
--logger "trx;LogFileName=${{ matrix.shard }}-results.trx" \
--results-directory ./test-results---
Agent Gotchas
1. Always set `set -euo pipefail` in multi-line bash `run` blocks -- without pipefail, piped commands that fail do not propagate the error, producing false-green CI. 2. Use `continue-on-error: true` on the test step, not on the reporter -- the test step must not fail the job prematurely so the reporter can publish results, but the reporter should fail the check when tests fail. 3. Include `runner.os` in NuGet cache keys -- NuGet packages have OS-specific native assets; cross-OS cache hits cause restore failures. 4. Install all required SDK versions for multi-TFM -- dotnet test without the matching SDK produces NETSDK1045; list every required version in dotnet-version. 5. Do not hardcode TFM strings in workflow files -- use matrix variables to keep workflow files in sync with project configuration; hardcoded net8.0 in CI breaks when the project moves to net9.0. 6. Coverage collection requires `--collect:"XPlat Code Coverage"` -- the default dotnet test does not produce coverage files; the XPlat Code Coverage collector is built into the .NET SDK. 7. TRX logger path must match reporter glob -- if the logger writes to test-results/results.trx, the reporter path must include that directory in its glob pattern. 8. Never commit NuGet credentials to workflow files -- use ${{ secrets.* }} references for all authentication tokens; the NUGET_AUTH_TOKEN environment variable is the standard pattern.
GitHub Actions Deploy
Deployment patterns for .NET applications in GitHub Actions: GitHub Pages deployment for documentation sites (Starlight/Docusaurus), container registry push patterns for GHCR and ACR, Azure Web Apps deployment via azure/webapps-deploy, GitHub Environments with protection rules for staged rollouts, and rollback strategies for failed deployments.
Version assumptions: GitHub Actions workflow syntax v2. azure/webapps-deploy@v3 for Azure App Service. azure/login@v2 for Azure credential management. GitHub Environments for deployment gates.
GitHub Pages Deployment for Documentation
Static Site Deployment (Starlight/Docusaurus)
Deploy a .NET project's documentation site to GitHub Pages:
name: Deploy Docs
on:
push:
branches: [main]
paths:
- 'docs/**'
- '.github/workflows/deploy-docs.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
working-directory: docs
run: npm ci
- name: Build documentation site
working-directory: docs
run: npm run build
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4Key decisions:
concurrency.cancel-in-progress: falseprevents cancelling an in-progress Pages deploymentid-token: writepermission is required for the Pages deployment token- Separate
buildanddeployjobs allow the deploy job to use thegithub-pagesenvironment with protection rules
API Documentation from XML Comments
Generate and deploy API reference documentation from .NET XML comments:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Build with XML docs
run: |
set -euo pipefail
dotnet build src/MyLibrary/MyLibrary.csproj \
-c Release \
-p:GenerateDocumentationFile=true
- name: Generate API docs with docfx
run: |
set -euo pipefail
dotnet tool install -g docfx
docfx docs/docfx.json
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/_site---
Container Registry Push Patterns
Push to GHCR with Environment Gates
jobs:
build:
runs-on: ubuntu-latest
outputs:
image-digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- name: Deploy container to staging
run: |
set -euo pipefail
echo "Deploying ghcr.io/${{ github.repository }}@${{ needs.build.outputs.image-digest }} to staging"
# Platform-specific deployment command here
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- name: Deploy container to production
run: |
set -euo pipefail
echo "Deploying ghcr.io/${{ github.repository }}@${{ needs.build.outputs.image-digest }} to production"Promote by Digest (Immutable Deployments)
Use image digest references for immutable deployments across environments:
- name: Retag for production
run: |
set -euo pipefail
# Pull by digest (immutable), retag for production
docker pull ghcr.io/${{ github.repository }}@${{ needs.build.outputs.image-digest }}
docker tag ghcr.io/${{ github.repository }}@${{ needs.build.outputs.image-digest }} \
ghcr.io/${{ github.repository }}:production
docker push ghcr.io/${{ github.repository }}:productionDigest-based promotion ensures the exact same image bytes are deployed to production, regardless of tag mutations.
---
Azure Web Apps Deployment
Deploy via azure/webapps-deploy
name: Deploy to Azure
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Publish
run: |
set -euo pipefail
dotnet publish src/MyApp/MyApp.csproj \
-c Release \
-o ./publish
- name: Upload publish artifact
uses: actions/upload-artifact@v4
with:
name: webapp
path: ./publish
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment:
name: staging
url: https://myapp-staging.azurewebsites.net
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: webapp
path: ./publish
- name: Login to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to Azure Web App
uses: azure/webapps-deploy@v3
with:
app-name: myapp-staging
package: ./publish
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.azurewebsites.net
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: webapp
path: ./publish
- name: Login to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to Azure Web App
uses: azure/webapps-deploy@v3
with:
app-name: myapp-production
package: ./publishAzure Web App with Deployment Slots
Use deployment slots for zero-downtime deployments with pre-swap validation:
- name: Deploy to staging slot
uses: azure/webapps-deploy@v3
with:
app-name: myapp-production
slot-name: staging
package: ./publish
- name: Validate staging slot
shell: bash
run: |
set -euo pipefail
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
https://myapp-production-staging.azurewebsites.net/healthz)
if [ "$HTTP_STATUS" != "200" ]; then
echo "Health check failed with status $HTTP_STATUS"
exit 1
fi
- name: Swap slots
uses: azure/cli@v2
with:
inlineScript: |
az webapp deployment slot swap \
--resource-group myapp-rg \
--name myapp-production \
--slot staging \
--target-slot productionOIDC Authentication (Federated Credentials)
Use OIDC for passwordless Azure authentication instead of service principal secrets:
- name: Login to Azure (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}OIDC requires configuring a federated credential in Azure AD that trusts the GitHub Actions OIDC provider. No client secret is stored in GitHub Secrets.
---
GitHub Environments with Protection Rules
Multi-Environment Pipeline
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: dotnet publish -c Release -o ./publish
- uses: actions/upload-artifact@v4
with:
name: app
path: ./publish
deploy-dev:
needs: build
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/download-artifact@v4
with:
name: app
- run: echo "Deploy to dev"
deploy-staging:
needs: deploy-dev
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: app
- run: echo "Deploy to staging"
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- uses: actions/download-artifact@v4
with:
name: app
- run: echo "Deploy to production"Protection Rule Configuration
Configure in GitHub Settings > Environments for each environment:
| Environment | Required Reviewers | Wait Timer | Branch Policy |
|---|---|---|---|
| development | None | None | Any branch |
| staging | 1 reviewer | None | main, release/* |
| production | 2 reviewers | 15 minutes | main only |
Environment-Specific Secrets and Variables
Each environment can override repository-level secrets:
jobs:
deploy:
environment: production
runs-on: ubuntu-latest
steps:
- name: Deploy with environment-specific config
env:
# Resolves to the production environment's secret, not the repo-level one
DB_CONNECTION: ${{ secrets.DB_CONNECTION_STRING }}
APP_URL: ${{ vars.APP_URL }}
run: |
set -euo pipefail
echo "Deploying to $APP_URL"---
Rollback Patterns
Revert Deployment
Re-deploy the previous known-good version on failure:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy new version
id: deploy
continue-on-error: true
run: |
set -euo pipefail
# Deploy logic here
./deploy.sh --version ${{ github.sha }}
- name: Health check
id: health
if: steps.deploy.outcome == 'success'
continue-on-error: true
shell: bash
run: |
set -euo pipefail
for i in {1..5}; do
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://example.com/healthz)
if [ "$HTTP_STATUS" = "200" ]; then
echo "Health check passed"
exit 0
fi
sleep 10
done
echo "Health check failed after 5 attempts"
exit 1
- name: Rollback on failure
if: steps.deploy.outcome == 'failure' || steps.health.outcome == 'failure'
run: |
set -euo pipefail
echo "Rolling back to previous version"
# Re-deploy the last known-good artifact
./deploy.sh --version ${{ github.event.before }}
- name: Fail the job if rollback was needed
if: steps.deploy.outcome == 'failure' || steps.health.outcome == 'failure'
run: exit 1Azure Deployment Slot Rollback
Swap back to the previous slot on health check failure:
- name: Swap to production
id: swap
uses: azure/cli@v2
with:
inlineScript: |
az webapp deployment slot swap \
--resource-group myapp-rg \
--name myapp-production \
--slot staging \
--target-slot production
- name: Post-swap health check
id: post-health
continue-on-error: true
shell: bash
run: |
set -euo pipefail
sleep 30 # allow swap to stabilize
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://myapp.azurewebsites.net/healthz)
if [ "$HTTP_STATUS" != "200" ]; then
echo "Post-swap health check failed"
exit 1
fi
- name: Rollback swap on failure
if: steps.post-health.outcome == 'failure'
uses: azure/cli@v2
with:
inlineScript: |
az webapp deployment slot swap \
--resource-group myapp-rg \
--name myapp-production \
--slot staging \
--target-slot production
echo "Rolled back: swapped staging back to production"Manual Rollback via workflow_dispatch
Provide a manual trigger for emergency rollbacks:
on:
workflow_dispatch:
inputs:
version:
description: 'Version to roll back to (e.g., v1.2.3)'
required: true
type: string
environment:
description: 'Target environment'
required: true
type: choice
options:
- staging
- production
jobs:
rollback:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.version }}
- name: Publish
run: |
set -euo pipefail
dotnet publish src/MyApp/MyApp.csproj -c Release -o ./publish
- name: Deploy rollback version
run: |
set -euo pipefail
echo "Rolling back ${{ inputs.environment }} to ${{ inputs.version }}"
# Platform-specific deployment---
Agent Gotchas
1. Use `set -euo pipefail` in all multi-line bash steps -- without pipefail, failures in piped commands are silently swallowed, producing false-green deployments. 2. Never use `cancel-in-progress: true` for deployment concurrency groups -- cancelling an in-progress deployment can leave infrastructure in a partially deployed state. 3. Always run health checks after deployment -- a successful deploy step does not guarantee the application is running correctly; verify with HTTP health checks. 4. Use `id-token: write` permission for OIDC Azure login -- without it, the federated credential exchange fails with a cryptic 403 error. 5. Deployment slot swaps are atomic -- if the swap fails, both slots retain their original deployments; no partial state. 6. Never hardcode Azure credentials in workflow files -- use OIDC federated credentials or environment-scoped secrets; hardcoded secrets in YAML are visible in repository history. 7. Use digest-based image references for production deployments -- tags are mutable and can be overwritten; digests are immutable and guarantee the exact image bytes. 8. Separate build and deploy jobs -- build artifacts once, deploy to multiple environments from the same artifact to ensure consistency.