tasks/dotnet.tasks.ps1
# Control flags $CleanBuild = $false $EnableCoverage = $true $SkipSolutionPackages = $false $SkipNuspecPackages = $false $SkipProjectPublishPackages = $false # Options $SolutionToBuild = $false $ProjectsToPublish = @() $NuSpecFilesToPackage = @() $FoldersToClean = @("bin", "obj", "TestResults", "_codeCoverage", "_packages") # Logging Options $_defaultDotNetTestLogger = "console;verbosity=$LogLevel" # we store this so we can tell whether 'DotNetTestLogger' has been customised $DotNetTestLogger = $_defaultDotNetTestLogger # Provides a backwards-compatible mechanism for supporting multiple test loggers $DotNetTestLoggers = @() $DotNetFileLoggerVerbosity = "detailed" $DotNetCompileLogFile = "dotnet-build.log" $DotNetCompileFileLoggerProps = "/flp:verbosity=$DotNetFileLoggerVerbosity;logfile=$DotNetCompileLogFile" $DotNetTestLogFile = "dotnet-test.log" $DotNetTestFileLoggerProps = "/flp:verbosity=$DotNetFileLoggerVerbosity;logfile=$DotNetTestLogFile" $DotNetPackageLogFile = "dotnet-package.log" $DotNetPackageFileLoggerProps = "/flp:verbosity=$DotNetFileLoggerVerbosity;logfile=$DotNetPackageLogFile" $DotNetPackageNuSpecLogFile = "dotnet-package-nuspec.log" $DotNetPackageNuSpecFileLoggerProps = "/flp:verbosity=$DotNetFileLoggerVerbosity;logfile=$DotNetPackageNuSpecLogFile;append" $DotNetPublishLogFile = "dotnet-publish.log" $DotNetPublishFileLoggerProps = "/flp:verbosity=$DotNetFileLoggerVerbosity;logfile=$DotNetPublishLogFile;append" # Testing & Coverage Options $CodeCoverageFilenameGlob = "coverage.*cobertura.xml" # ensure we can find TFM-specific coverage files (e.g. coverage.net8.0.cobertura.xml) $AdditionalTestArgs = @() $TargetFrameworkMoniker = "" $UseCoverlet = $false # when true, uses the 'legacy' RunTestWithCoverlet task that uses Coverlet via 'dotnet test' # when false, uses the 'dotnet-coverage' tool to collect code coverage data, whilst running 'dotnet test' $IncludeFilesInCodeCoverage = "" # used by 'dotnet-coverage' tooling $ExcludeFilesFromCodeCoverage = "" # DEPRECATED: a comma-separated list of file paths to exclude from code coverage analysis. Only used when $UseCoverlet is $true $GenerateTestReport = $true # when true, runs the 'dotnet-reportgenerator-globaltool' to generate an XML test report $GenerateMarkdownCodeCoverageSummary = $true # when true, runs the 'CodeCoverageSummary' global tool to generate a Markdown code coverage summary $ReportGeneratorToolVersion = "5.3.8" $TestReportTypes ??= "HtmlInline" # NuGet Publishing Options $NugetPublishSource = "$here/_local-nuget-feed" $NugetPublishSymbolSource = "" $NugetPublishSkipDuplicates = $true # By default the build will publish all NuGet packages it finds with the current version number $NugetPackageNamesToPublishGlob = "*" # Uses a non-interpolated string to ensure lazy evaluation of the GitVersion variable $NugetPackagesToPublishGlobSuffix = '.$(($script:GitVersion).SemVer).nupkg' # Template project file used when building .nuspec files with no associated project $templateProjectForNuSpecBuild = @" <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <NuspecFile>{0}</NuspecFile> <NuspecProperties></NuspecProperties> <NuspecBasePath></NuspecBasePath> <NoBuild>true</NoBuild> <GeneratePackageOnBuild>true</GeneratePackageOnBuild> <SkipCompilerExecution>true</SkipCompilerExecution> <CopyBuildOutputToOutputDirectory>false</CopyBuildOutputToOutputDirectory> <NoWarn>NU5110;NU5111</NoWarn> </PropertyGroup> </Project> "@ # Synopsis: Clean .NET solution task CleanSolution -If {$CleanBuild -and $SolutionToBuild} { exec { dotnet clean $SolutionToBuild ` --configuration $Configuration ` --verbosity $LogLevel } # Delete output folders Write-Build White "Deleting output folders..." $FoldersToClean | ForEach-Object { Get-ChildItem -Path (Split-Path -Parent $SolutionToBuild) ` -Filter $_ ` -Recurse ` | Where-Object { $_.PSIsContainer } } | Remove-Item -Recurse -Force } # Synopsis: Build .NET solution task BuildSolution -If {$SolutionToBuild} Version,RestorePackages,{ exec { try { dotnet build $SolutionToBuild ` --no-restore ` --configuration $Configuration ` /p:Version="$(($script:GitVersion).SemVer)" ` /p:EndjinRepositoryUrl="$BuildRepositoryUri" ` --verbosity $LogLevel ` $($DotNetCompileFileLoggerProps ? $DotNetCompileFileLoggerProps : "/fl") } finally { if ((Test-Path $DotNetCompileLogFile) -and $IsAzureDevOps) { Write-Host "##vso[artifact.upload artifactname=logs]$((Resolve-Path $DotNetCompileLogFile).Path)" } } } } # Synopsis: Restore .NET Solution Packages task RestorePackages -If {$SolutionToBuild} CleanSolution,{ exec { dotnet restore $SolutionToBuild ` --verbosity $LogLevel } } # Synopsis: Build .NET solution packages task BuildSolutionPackages -If {!$SkipSolutionPackages -and $SolutionToBuild} Version,EnsurePackagesDir,{ exec { try { # Change use of '--output' - ref: https://github.com/dotnet/sdk/issues/30624#issuecomment-1432118204 dotnet pack $SolutionToBuild ` --configuration $Configuration ` --no-build ` --no-restore ` /p:PackageOutputPath="$PackagesDir" ` /p:EndjinRepositoryUrl="$BuildRepositoryUri" ` /p:PackageVersion="$(($script:GitVersion).SemVer)" ` --verbosity $LogLevel ` $($DotNetPackageFileLoggerProps ? $DotNetPackageFileLoggerProps : "/fl") } finally { if ((Test-Path $DotNetPackageLogFile) -and $IsAzureDevOps) { Write-Host "##vso[artifact.upload artifactname=logs]$((Resolve-Path $DotNetPackageLogFile).Path)" } } } } # Synopsis: Run .NET solution tests task RunTestsWithCoverlet -If {!$SkipTest -and $SolutionToBuild -and $UseCoverlet} { Write-Warning @" The use of Coverlet for code coverage is deprecated and will be removed in a future release. Remove the 'UseCoverlet' build option or set it to '`$false' to switch the the 'dotent-coverage' tooling. Unless you are using the '`$ExcludeFilesFromCodeCoverage' option, the behaviour is identical. "@ # Only setup the default CI/CD platform test loggers if they haven't already been customised if ($DotNetTestLoggers.Count -eq 0 -and $DotNetTestLogger -eq $_defaultDotNetTestLogger) { if ($script:IsAzureDevOps) { Write-Build Green "Configuring Azure Pipelines test logger" $DotNetTestLogger = "AzurePipelines" } elseif ($script:IsGitHubActions) { Write-Build Green "Configuring GitHub Actions test logger" $DotNetTestLogger = "GitHubActions" } } # Setup the arguments we need to pass to 'dotnet test' $dotnetTestArgs = @( "--configuration", $Configuration "--no-build" "--no-restore" '/p:CollectCoverage="{0}"' -f $EnableCoverage "/p:CoverletOutputFormat=cobertura" '/p:ExcludeByFile="{0}"' -f $ExcludeFilesFromCodeCoverage.Replace(",","%2C") "--verbosity", $LogLevel "--test-adapter-path", "$PSScriptRoot/../bin" ($DotNetTestFileLoggerProps ? $DotNetTestFileLoggerProps : "/fl") ) # If multiple test loggers have been specified then use that newer config property if ($DotNetTestLoggers.Count -gt 0) { $DotNetTestLoggers | ForEach-Object { $dotnetTestArgs += @("--logger", $_) } } # Otherwise fallback to the original behaviour so we are backwards-compatible else { $dotnetTestArgs += @("--logger", $DotNetTestLogger) } Write-Build Magenta "CmdLine: dotnet test $SolutionToBuild $dotnetTestArgs" try { exec { dotnet test $SolutionToBuild @dotnetTestArgs } } finally { if ((Test-Path $DotNetTestLogFile) -and $IsAzureDevOps) { Write-Host "##vso[artifact.upload artifactname=logs]$((Resolve-Path $DotNetTestLogFile).Path)" } # Generate test report file if (!$SkipTestReport) { if ($GenerateTestReport) { Write-Build White "Generating XML test report" _GenerateTestReport } if ($GenerateMarkdownCodeCoverageSummary) { Write-Build White "Generating Markdown code coverage summary" _GenerateCodeCoverageMarkdownReport } } } } task RunTestsWithDotNetCoverage -If {!$SkipTest -and $SolutionToBuild -and !$UseCoverlet} { # Only setup the default CI/CD platform test loggers if they haven't already been customised if ($DotNetTestLoggers.Count -eq 0 -and $DotNetTestLogger -eq $_defaultDotNetTestLogger) { if ($script:IsAzureDevOps) { Write-Build Green "Configuring Azure Pipelines test logger" $DotNetTestLogger = "AzurePipelines" } elseif ($script:IsGitHubActions) { Write-Build Green "Configuring GitHub Actions test logger" $DotNetTestLogger = "GitHubActions" } } # Use InvoekBuild's built-in $Task variable to know where this file is installed and use it to # derive where the root of the module must be. This method will work when this module has # been directly imported as well as when it is used as an extension via 'endjin-devops'. $moduleDir = Split-Path -Parent (Split-Path -Parent $Task.InvocationInfo.ScriptName) Write-Build Magenta "ModuleDir: $moduleDir" # Setup the arguments we need to pass to 'dotnet test' $dotnetTestArgs = @( "--configuration", $Configuration "--no-build" "--no-restore" "--verbosity", $LogLevel "--test-adapter-path", "$moduleDir/bin" ($DotNetTestFileLoggerProps ? $DotNetTestFileLoggerProps : "/fl") ) # If multiple test loggers have been specified then use that newer config property if ($DotNetTestLoggers.Count -gt 0) { $DotNetTestLoggers | ForEach-Object { $dotnetTestArgs += @("--logger", $_) } } # Otherwise fallback to the original behaviour so we are backwards-compatible else { $dotnetTestArgs += @("--logger", $DotNetTestLogger) } if ($TargetFrameworkMoniker) { $dotnetTestArgs += @("--framework", $TargetFrameworkMoniker) } if ($AdditionalTestArgs) { $dotnetTestArgs += $AdditionalTestArgs } # Ensure the dotnet-coverage global tool is installed, as we need it to collect the code coverage data Install-DotNetTool -Name "dotnet-coverage" -Global $coverageOutput = "coverage{0}.cobertura.xml" -f ($TargetFrameworkMoniker ? ".$TargetFrameworkMoniker" : "") $dotnetCoverageArgs = @( "collect" "-o", $coverageOutput "-f", "cobertura" ) if ($IncludeFilesInCodeCoverage) { $dotnetCoverageArgs += @("--include-files", $IncludeFilesInCodeCoverage) } Write-Build Magenta "CmdLine: dotnet-coverage $dotnetCoverageArgs dotnet test $SolutionToBuild $dotnetTestArgs" try { exec { dotnet-coverage @dotnetCoverageArgs dotnet test $SolutionToBuild @dotnetTestArgs } } finally { if ((Test-Path $DotNetTestLogFile) -and $IsAzureDevOps) { Write-Host "##vso[artifact.upload artifactname=logs]$((Resolve-Path $DotNetTestLogFile).Path)" } # Generate test report file if (!$SkipTestReport) { if ($GenerateTestReport) { Write-Build White "Generating additional test reports: $TestReportTypes" _GenerateTestReport } if ($GenerateMarkdownCodeCoverageSummary) { Write-Build White "Generating Markdown code coverage summary" _GenerateCodeCoverageMarkdownReport } } } } task RunTests RunTestsWithDotNetCoverage,RunTestsWithCoverlet # Synopsis: Build publish packages for selected projects task BuildProjectPublishPackages -If {!$SkipProjectPublishPackages -and $ProjectsToPublish} Version,EnsurePackagesDir,{ # Remove the existing log, since we append to it for each project being published Get-Item $DotNetPublishLogFile -ErrorAction Ignore | Remove-Item -Force # Check each entry to see whether it is using the older or newer configuration style $projectPublishingTasks = $ProjectsToPublish | % { if ($_ -is [Hashtable]) { # New style config: just use whatever has been specified $_ } else { # Old style config: generate a configuration that will mimic the previous behaviour @{ Project = $_; RuntimeIdentifiers = @('NOT_SPECIFIED'); SelfContained = $false; Trimmed = $false; ReadyToRun = $false } } } try { foreach ($task in $projectPublishingTasks) { foreach ($runtime in $task.RuntimeIdentifiers) { $optionalCmdArgs = @() if ($task.ContainsKey("Trimmed") -and $task.Trimmed -eq $true) { $optionalCmdArgs += "-p:PublishTrimmed=true" } if ($task.ContainsKey("ReadyToRun") -and $task.ReadyToRun -eq $true) { $optionalCmdArgs += "-p:PublishReadyToRun=true" } if ($task.ContainsKey("SingleFile") -and $task.SingleFile -eq $true) { $optionalCmdArgs += "-p:PublishSingleFile=true" } if ($runtime -eq "NOT_SPECIFIED") { # If no runtime is specified then we can skip the build $optionalCmdArgs += "--no-build" } else { # Specify the required runtime $optionalCmdArgs += "--runtime",$runtime # When specifying a runtime, you need to explicitly flag it as self-contained or not $optionalCmdArgs += (($task.ContainsKey("SelfContained") -and $task.SelfContained -eq $true) ? "--self-contained" : "--no-self-contained") } Write-Build Green "Publishing Project: $($task.Project) [$($runtime)] [SelfContained=$($task.SelfContained)] [SingleFile=$($task.SingleFile)] [Trimmed=$($task.Trimmed)] [ReadyToRun=$($task.ReadyToRun)]" $packageOutputDir = Join-Path $PackagesDir $(Split-Path -LeafBase $task.Project) ($runtime -eq "NOT_SPECIFIED" ? "" : $runtime) exec { # Change use of '--output' - ref: https://github.com/dotnet/sdk/issues/30624#issuecomment-1432118204 dotnet publish $task.Project ` --nologo ` --configuration $Configuration ` --no-restore ` /p:PublishDir="$packageOutputDir" ` /p:EndjinRepositoryUrl="$BuildRepositoryUri" ` /p:PackageVersion="$(($script:GitVersion).SemVer)" ` --verbosity $LogLevel ` @optionalCmdArgs ` $($DotNetPublishFileLoggerProps ? $DotNetPublishFileLoggerProps : "/fl") } } } } finally { if ((Test-Path $DotNetPublishLogFile) -and $IsAzureDevOps) { Write-Host "##vso[artifact.upload artifactname=logs]$((Resolve-Path $DotNetPublishLogFile).Path)" } } } # Synopsis: Publish any built NuGet packages task PublishSolutionPackages -If {!$SkipSolutionPackages -and ($SolutionToBuild -or $NuSpecFilesToPackage) -and $NugetPackageNamesToPublishGlob} Version,EnsurePackagesDir,{ # Force the wildcard expression to be evaluated now that GitVersion has been run $evaluatedNugetPackagesToPublishGlob = Invoke-Expression "`"$($NugetPackageNamesToPublishGlob)$($NugetPackagesToPublishGlobSuffix)`"" Write-Verbose "EvaluatedNugetPackagesToPublishGlob: $evaluatedNugetPackagesToPublishGlob" $nugetPackagesToPublish = Get-ChildItem -Path "$here/_packages" -Filter $evaluatedNugetPackagesToPublishGlob Write-Verbose "NugetPackagesToPublish: $nugetPackagesToPublish" # Derive the NuGet API key to use - this also makes it easier to mask later on # NOTE: Where NuGet auth has been setup beforehand (e.g. via a SOURCE), an API key still needs to be specified but it can be any value $nugetApiKey = $env:NUGET_API_KEY ? $env:NUGET_API_KEY : "no-key" # Setup the 'dotnet nuget push' command-line parameters that will be the same for each package $nugetPushArgs = @( "-s" $NugetPublishSource "--api-key" $nugetApiKey ) if ($NugetPublishSkipDuplicates) { $nugetPushArgs += @( "--skip-duplicate" ) } # Ensure that the path exists when using a file-system based NuGet source if ((Test-Path $NugetPublishSource -IsValid) -and !(Test-Path $NugetPublishSource)) { Write-Build White "Creating NuGet publish source directory: $NugetPublishSource" New-Item -ItemType Directory $NugetPublishSource | Out-Null } if ($NugetPublishSymbolSource -and (Test-Path $NugetPublishSymbolSource -IsValid) -and !(Test-Path $NugetPublishSymbolSource)) { Write-Build White "Creating NuGet publish symbol source directory: $NugetPublishSymbolSource" New-Item -ItemType Directory $NugetPublishSymbolSource | Out-Null } # Remove the existing log, since we append to it for each project being packaged via a NuSpec file Get-Item $DotNetPackageNuSpecLogFile -ErrorAction Ignore | Remove-Item -Force try { foreach ($nugetPackage in $nugetPackagesToPublish) { Write-Build Green "Publishing package: $nugetPackage" # Ensure any NuGet API key is masked in the debug logging Write-Verbose ("dotnet nuget push $nugetPackage $nugetPushArgs".Replace($nugetApiKey, "*****")) exec { & dotnet nuget push $nugetPackage $nugetPushArgs } } } finally { if ((Test-Path $DotNetPackageNuSpecLogFile) -and $IsAzureDevOps) { Write-Host "##vso[artifact.upload artifactname=logs]$((Resolve-Path $DotNetPackageNuSpecLogFile).Path)" } } } # Synopsis: Build any .nuspec based NuGet Packages task BuildNuSpecPackages -If {!$SkipNuspecPackages -and $NuspecFilesToPackage} Version,EnsurePackagesDir,{ foreach ($nuspec in $NuSpecFilesToPackage) { # Assumes a convention that the .nuspec file is alongside the .csproj file with a matching name $nuspecFilePath = [IO.Path]::IsPathRooted($nuspec) ? $nuspec : (Join-Path $here $nuspec) $projectFilePath = $nuspecFilePath.Replace(".nuspec", ".csproj") $generatedTempProjectFile = $false if (!(Test-Path $projectFilePath)) { Write-Build White "Generating temporary project file for NuSpec: $nuspecFilePath" Set-Content -Path $projectFilePath -Value ($templateProjectForNuSpecBuild -f (Split-Path -Leaf $nuspec)) $generatedTempProjectFile = $true } Write-Build Green "Packaging NuSpec: $nuspecFilePath [Project=$projectFilePath]" $packArgs = @( "--nologo" $projectFilePath "--configuration" $Configuration # ref: https://github.com/dotnet/sdk/issues/30624#issuecomment-1432118204 "-p:PackageOutputPath=$PackagesDir" # this property needs to be overridden as its default value should be 'false', to ensure that the project # is not built by the 'PublishSolutionPackages' task. "-p:IsPackable=true" "-p:NuspecFile=$nuspecFilePath" "-p:NuspecProperties=version=`"$(($script:GitVersion).SemVer)`"" "--verbosity" $LogLevel $($DotNetPackageNuSpecFileLoggerProps ? $DotNetPackageNuSpecFileLoggerProps : "/fl") $DotNetPackageNuSpecFileLoggerProps ) # When building a .nuspec file using the temporary generated project file, we need to ensure that # the project is restored & built, as it won't have been done by the earlier tasks. if (!$generatedTempProjectFile) { $packArgs += "--no-build" $packArgs += "--no-restore" } Write-Verbose "dotnet pack $packArgs" exec { & dotnet pack $packArgs } if ($generatedTempProjectFile) { Write-Build White "Removing temporary project file" Remove-Item -Path $projectFilePath } } } # # Supporting functions # # Synopsis: Uses the dotnet-reportgenerator global tool to generate a code coverage report from the Cobertura XML files function _GenerateTestReport { [CmdletBinding()] param ( [Parameter()] [string] $ReportTypes = $TestReportTypes, [Parameter()] [string] $OutputPath = $CoverageDir ) Install-DotNetTool -Name "dotnet-reportgenerator-globaltool" -Version $ReportGeneratorToolVersion $testReportGlob = "$SourcesDir/**/**/$CodeCoverageFilenameGlob" if (!(Get-ChildItem -Path $SourceDir -Filter $CodeCoverageFilenameGlob -Recurse)) { Write-Warning "No code coverage reports found for the file pattern '$testReportGlob' - skipping test report" } else { exec { reportgenerator "-reports:$testReportGlob" ` "-targetdir:$OutputPath" ` "-reporttypes:$ReportTypes" } } } # Synopsis: Uses the CodeCoverageSummary global tool to generate a Markdown code coverage report from the Cobertura XML files function _GenerateCodeCoverageMarkdownReport { # Use the ReportGenerator tool to produce a Markdown summary of the code coverage $markdownReportType = $IsGitHubActions ? "MarkdownSummaryGitHub" : "MarkdownSummary" $markdownReportFilename = $IsGitHubActions ? "SummaryGithub.md" : "Summary.md" _GenerateTestReport $markdownReportType # Update the title so we can distinguish between reports across multiple test runs, # when they are published to GitHub as PR comments. $generatedReportPath = Join-Path -Resolve $CoverageDir $markdownReportFilename $generatedContent = Get-Content -Raw -Path $generatedReportPath $testRunOs = if ($IsLinux) { "Linux" } elseif ($IsMacOS) { "MacOS" } else { "Windows" } $tfmLabel = $TargetFrameworkMoniker ? $TargetFrameworkMoniker : "No TFM" $reportTitle = "# Code Coverage Summary Report - $testRunOs ($tfmLabel)" $retitledReport = $generatedContent -replace "^# Summary", $reportTitle Write-Build White "Updating generated code coverage report with title: $reportTitle" Set-Content -Path $generatedReportPath -Value $retitledReport -Encoding UTF8 } |