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