dotnet-ado-build-test

SKILL.md

dotnet-ado-build-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.

Scope

  • DotNetCoreCLI@2 task for build, test, pack, and custom commands
  • NuGet restore with Azure Artifacts feeds (NuGetAuthenticate@1)
  • Test result publishing with PublishTestResults@2 (TRX, JUnit)
  • Code coverage with PublishCodeCoverageResults@2 (Cobertura)
  • Multi-TFM matrix strategy across TFMs and operating systems

Out of scope

  • Starter CI templates -- see [skill:dotnet-add-ci]
  • Test architecture and strategy -- see [skill:dotnet-testing-strategy]
  • Benchmark regression detection in CI -- see [skill:dotnet-ci-benchmarking]
  • Publishing and deployment -- see [skill:dotnet-ado-publish] and [skill:dotnet-ado-unique]
  • GitHub Actions build/test workflows -- see [skill:dotnet-gha-build-test]

Cross-references: [skill:dotnet-add-ci] for starter build/test templates, [skill:dotnet-testing-strategy] for test architecture guidance, [skill:dotnet-ci-benchmarking] for benchmark CI integration.


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'

```bash

### Test

```yaml

- 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-results

```csharp

### Pack

```yaml

- task: DotNetCoreCLI@2
  displayName: 'Pack NuGet packages'
  inputs:
    command: 'pack'
    packagesToPack: 'src/**/*.csproj'
    configuration: 'Release'
    outputDir: '$(Build.ArtifactStagingDirectory)/nupkgs'
    nobuild: true

```csharp

### Custom Command

For commands not directly supported by the task (e.g., `dotnet tool install`):

```yaml

- task: DotNetCoreCLI@2
  displayName: 'Install dotnet tools'
  inputs:
    command: 'custom'
    custom: 'tool'
    arguments: 'restore'

```bash

### Multi-Version SDK Install

Install multiple SDK versions for multi-TFM builds:

```yaml

- 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'

```text

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

```yaml

steps:
  - task: NuGetAuthenticate@1
    displayName: 'Authenticate NuGet feeds'

  - task: DotNetCoreCLI@2
    displayName: 'Restore'
    inputs:
      command: 'restore'
      projects: 'MyApp.sln'
      feedsToUse: 'config'
      nugetConfigPath: 'nuget.config'

```bash

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:

```yaml

- task: DotNetCoreCLI@2
  displayName: 'Restore with Azure Artifacts'
  inputs:
    command: 'restore'
    projects: 'MyApp.sln'
    feedsToUse: 'select'
    vstsFeed: 'MyProject/MyFeed'
    includeNuGetOrg: true

```text

### Upstream 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:

```xml

<!-- 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>

```json

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:

```yaml

- 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'

```bash

---

## Test Result Publishing

### `PublishTestResults@2` with TRX Format

```yaml

- 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'

```text

**Key decisions:**

- `continueOnError: true` on the test task ensures the publish step always runs, even on test failures
- `condition: always()` on the publish task runs regardless of previous step outcome
- `mergeTestResults: true` combines results from multiple test projects into a single test run
- `testRunTitle` provides a descriptive name in the Azure DevOps Test tab

### JUnit Format

Some third-party test frameworks output JUnit XML. Use the `JUnit` format:

```yaml

- task: PublishTestResults@2
  displayName: 'Publish JUnit results'
  condition: always()
  inputs:
    testResultsFormat: 'JUnit'
    testResultsFiles: '**/junit-results.xml'
    mergeTestResults: true

```xml

### Test Results with Attachments

Attach screenshots or logs to test results for debugging failed tests:

```yaml

- 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

```text

---

## Code Coverage

### `PublishCodeCoverageResults@2` with Cobertura

```yaml

- 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'

```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:

```yaml

- 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'

```text

### Coverage Thresholds

Enforce minimum coverage by parsing the Cobertura XML in a script step:

```yaml

- 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'

```text

---

## Multi-TFM Matrix Strategy

### Matrix Build Across TFMs and Operating Systems

```yaml

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)'

```text

### Installing Multiple SDKs for Multi-TFM in a Single Job

When running all TFMs in one job (instead of matrix), install all required SDKs:

```yaml

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'

```bash

Without the matching SDK installed, `dotnet test` cannot build for that TFM and fails with `NETSDK1045`.

### Template-Based Matrix for Reusability

```yaml

# 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 }}'

```bash

---

## 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.
Weekly Installs
1
First Seen
11 days ago
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1