Publish-DotnetApplication.psm1

#
# Publish-DotnetApplication
#
# The dotnet CLI only counterpart of Publish-Application. Same parameters, same lomtec/nuaktiv.json
# configuration files, same outputs - but every build goes through `dotnet build` / `dotnet publish`,
# so it needs the .NET SDK and nothing from Visual Studio.
#
# How a run goes:
# 1. plan every config file next to a csproj becomes a plan (publish entry or nuget package)
# 2. validate all plans are checked BEFORE the first build: runtime looks like a RID, no two
# publishes writing to the same folder. Every problem is reported at once, nothing
# is built. A nuget config without its nuspec is only a warning: the project is
# still built, no package is produced for it.
# 3. build nuget projects are built with ONE solution build per configuration through a
# generated solution filter (.slnf) that lists only those projects - anything else in
# the solution (sqlproj, tests, ...) is never loaded. Falls back to per project builds
# when a project is not one the dotnet CLI can build.
# 4. publish every publish entry runs `dotnet publish` with its own runtime and properties
# 5. pack `dotnet pack --no-build` for every nuget plan, in parallel, driven by the nuspec
# 6. cleanup pdb / config leftovers in the publish output, the .slnf files, the build servers
#
# What is different to Publish-Application:
# - "Build.Command": "msbuild" in a config is accepted but ignored (with a warning). There is no
# MSBuild.exe path in here.
#
# A note on the word "MSBuild" in this file: `dotnet build`, `dotnet publish` and `dotnet pack` ARE
# MSBuild - the dotnet CLI hosts the MSBuild engine (MSBuild.dll running on .NET). That engine is what
# -m (build nodes), node reuse, the binary logs (.binlog) and the build server shutdown refer to.
# What this module never uses is MSBuild.exe, the .NET Framework host that ships with Visual Studio.
# Same engine, different host.
# - No separate restore pass. `dotnet build` and `dotnet publish` restore implicitly, and the
# solution filter build restores every nuget project in one go.
# - No per project obj/bin wipe. Clear-Solution gives the clean slate, and the nuspec files list
# their content explicitly, so nothing stale can end up in a package. The wipe used to force a
# full restore and a rebuild of the whole ProjectReference chain for every single config.
# - -ShowTiming prints the per step timing report at any verbosity, -BinaryLog writes one binary
# build log (.binlog) per build step, readable with the MSBuild Structured Log Viewer.
# - -Verbosity quiet still shows progress: the engine runs at minimal, so it prints one line per project
# as it completes (its terminal logger's line in a terminal, "Project -> output" in a log), and every
# package gets its own "#### APT PACK <file>.nupkg ####" line.
#
# Packages are still defined by the hand written .nuspec files, not by the csproj: `dotnet pack` is
# given the nuspec through NuspecFile, which makes it authoritative for id, version, dependencies and
# the explicit file list, and NuspecProperties feeds the $configuration$ token exactly like
# `nuget pack -properties` did. Same package content, no nuget.exe - the .NET SDK is the only tool
# this module needs.
#
# Only what the json configs describe gets built. A project without a config is never loaded, unless
# it is a ProjectReference of one that has - then it is built as a requirement, nothing more.
#

#
function Get-Absolute-Path([string] $path){
    if($path.StartsWith(".")){
        $path = [IO.Path]::Combine((Get-Location).Path, $path)
    }
    return [IO.Path]::GetFullPath($path)
}

#
function Get-Relative-Path([string] $from, [string] $to){
    # $to relative to directory $from, with backslashes. Fast path for the usual case (project below
    # the solution), Uri based otherwise. [IO.Path]::GetRelativePath does not exist on PowerShell 5.1.
    $root = $from.TrimEnd('\', '/') + '\'
    if($to.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)){
        return $to.Substring($root.Length)
    }
    $relative = (New-Object System.Uri($root)).MakeRelativeUri((New-Object System.Uri($to))).ToString()
    return [Uri]::UnescapeDataString($relative).Replace('/', '\')
}

#
function Get-Custom-Property([pscustomobject] $property){
    # One "/p:Name=Value" argument per property, meant to be splatted (@customProperties) so dotnet
    # gets each one as its own argument. Joined into a single string, a second property would end up
    # inside the first property's value.
    #
    # Callers MUST wrap the result in @( ): PowerShell unrolls a one element array coming out of a
    # function into a plain string, and splatting a string hands the native command its CHARACTERS
    # ("/", "p", ":", ...) - MSBuild then fails with MSB1001 Unknown switch "/".
    if($null -eq $property){ return @() }
    return @($property.PSObject.Properties | ForEach-Object { "/p:$($_.Name)=$($_.Value)" })
}

#
function Get-Binlog-Argument([string] $directory, [int] $step, [string] $name){
    # "-bl:<directory>\<step>-<name>.binlog" or nothing. One file per build step - the build engine
    # overwrites a binlog it is pointed at twice. Wrap the result in @( ), see Get-Custom-Property.
    if([string]::IsNullOrEmpty($directory)){ return @() }
    $safe = $name -replace '[^A-Za-z0-9._-]', '_'
    return @("-bl:$([IO.Path]::Combine($directory, ('{0:D2}-{1}.binlog' -f $step, $safe)))")
}

#
function Get-Max-Cpu-Count(){
    # dotnet build/publish defaults to ALL logical processors. Memory scales with the node count, so
    # this is the knob to turn on a small machine: APT_MAXCPUCOUNT overrides the detected value.
    $cores = [Environment]::ProcessorCount
    if(-not [string]::IsNullOrEmpty($env:APT_MAXCPUCOUNT)){
        return [math]::Max(1, [math]::Min([int] $env:APT_MAXCPUCOUNT, $cores))
    }
    # clamped to the declared MaxCpuCount range - ValidateRange is re-checked on assignment
    return [math]::Max(1, [math]::Min($cores, 64))
}

#
function Test-Projects-DotnetBuildable([string[]] $projects){
    # The dotnet CLI cannot build everything MSBuild.exe can:
    # - legacy non SDK csproj is the old project format, whatever it targets (net48 included)
    # - packages.config needs classic restore, dotnet restore only does PackageReference
    # The projects here are csproj by construction (a config only counts next to a csproj), so the
    # sqlproj / vcxproj family never reaches this point - the solution filter leaves them out.
    $sdkStyle = '<Project[^>]*\sSdk\s*=|<Sdk\s+Name\s*=|<Import[^>]*\sSdk\s*='
    $blocked = [System.Collections.Generic.List[string]]::new()
    foreach($project in $projects){
        $name = Split-Path -Leaf $project
        if($project -notlike '*.csproj'){
            $blocked.Add("$name [needs MSBuild.exe]")
            Continue
        }
        if(-not (Test-Path -Path $project -PathType Leaf)){
            $blocked.Add("$name [not found]")
            Continue
        }
        if((Get-Content -Path $project -Raw) -notmatch $sdkStyle){
            $blocked.Add("$name [legacy project format]")
            Continue
        }
        if(Test-Path -Path ([IO.Path]::Combine((Split-Path -Parent $project), 'packages.config')) -PathType Leaf){
            $blocked.Add("$name [packages.config]")
        }
    }
    if($blocked.Count -gt 0){
        Write-Host "#### APT BUILD per project, not dotnet buildable: $($blocked -join ', ') ####"
        return $false
    }
    return $true
}

#
function New-Solution-Filter([string] $solution, [string[]] $projects, [string] $configuration){
    # A solution filter is a json file next to the solution naming the projects to load from it.
    # `dotnet build x.slnf` behaves like a solution build (SolutionDir etc. are set, one restore for
    # everything) but only for the listed projects - ProjectReferences still build transitively.
    $solutionPath = Split-Path -Parent $solution
    $name = [IO.Path]::GetFileNameWithoutExtension($solution)
    $filter = [IO.Path]::Combine($solutionPath, "$name.apt.$configuration.slnf")
    [string[]] $relative = @($projects | ForEach-Object { Get-Relative-Path $solutionPath $_ } | Sort-Object -Unique)
    $content = @{ solution = @{ path = (Split-Path -Leaf $solution); projects = $relative } } | ConvertTo-Json -Depth 4
    Set-Content -Path $filter -Value $content -Encoding UTF8
    return $filter
}

#
function Add-Build-Timing($timings, [string] $name, [string] $phase, $stopwatch){
    $stopwatch.Stop()
    $timings.Add([pscustomobject]@{ Seconds = [math]::Round($stopwatch.Elapsed.TotalSeconds, 1); Phase = $phase; Project = $name })
}

#
function Write-Build-Timing($timings){
    if($timings.Count -eq 0){ return }
    $total = ($timings | Measure-Object -Property Seconds -Sum).Sum
    Write-Host "#### APT TIMING slowest steps ####"
    ($timings | Sort-Object -Property Seconds -Descending | Select-Object -First 12 | Format-Table -AutoSize | Out-String).Trim() | Write-Host
    Write-Host "#### APT TIMING by phase ####"
    ($timings | Group-Object -Property Phase |
        Select-Object @{N='Phase';E={$_.Name}}, @{N='Count';E={$_.Count}}, @{N='Seconds';E={[math]::Round(($_.Group | Measure-Object -Property Seconds -Sum).Sum, 1)}} |
        Sort-Object -Property Seconds -Descending | Format-Table -AutoSize | Out-String).Trim() | Write-Host
    Write-Host ("#### APT TIMING measured {0:N1}s across {1} steps ####" -f $total, $timings.Count)
}

#
function Get-Default-Settings($debug, $configuration, [string] $publishPath, [string] $project){
    # Fills a config section (Publish entry or Nuget section) up with defaults. Note that the
    # Configuration in the json is only honoured together with -EnableDebug; a normal run is Release.
    $setting = @{}
    #
    $setting.Enabled = $true
    if($null -ne $configuration.Enabled){
        $setting.Enabled = $configuration.Enabled
    }
    #
    $setting.Configuration = "Release"
    if($debug -and ($null -ne $configuration.Configuration)){
        $setting.Configuration = $configuration.Configuration
    }
    #
    $setting.Runtime = "linux-x64"
    if($null -ne $configuration.Runtime){
        $setting.Runtime = $configuration.Runtime
    }
    #
    $setting.SelfContained = $false
    if($null -ne $configuration.SelfContained){
        $setting.SelfContained = $configuration.SelfContained
    }
    #
    $setting.SingleFile = $false
    if($null -ne $configuration.SingleFile){
        $setting.SingleFile = $configuration.SingleFile
    }
    # publish output: config Path, else the project name, else (nuget) the publish root itself
    $path = $configuration.Path
    if([string]::IsNullOrEmpty($path)){
        $path = ""
    }
    if([string]::IsNullOrEmpty($path) -and (-not [string]::IsNullOrEmpty($project))){
        $path = [IO.Path]::GetFileNameWithoutExtension($project)
    }
    $setting.Path = [IO.Path]::Combine($publishPath, $path)
    #
    $setting.Properties = $configuration.Properties
    return $setting
}

<#
.Synopsis
   Automatic Publish Tool (APT)
.DESCRIPTION
   Publish a Visual Studio solution with the dotnet CLI only (no MSBuild.exe, no Visual Studio needed).
   Drop in compatible with Publish-Application: same parameters, same lomtec/nuaktiv.json files.
.EXAMPLE
   Publish-DotnetApplication D:\Projects\project.sln D:\Project\publish\admin
.EXAMPLE
   Publish-DotnetApplication D:\Projects\project.sln D:\Project\publish -ShowTiming -BinaryLog D:\Temp\binlog
.NOTES
   Author: Imrich Szolik
#>

function Publish-DotnetApplication
{
    [CmdletBinding()]
    Param(
        # Solution file (sln)
        [string]
        [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$false, HelpMessage="Solution file.")]
        $Solution,

        #Publish path.
        [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$false, HelpMessage="Publish path.")]
        [string]
        $PublishPath,

        #config
        [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, HelpMessage="Publish json config")]
        [string]
        $JsonConfig = "(lomtec|nuaktiv)\.json",

        #verbosity
        [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, HelpMessage="Build verbosity. quiet still prints one line per project as it is built (the engine runs at minimal)")]
        [ValidateSet('quiet','minimal','normal','detailed', 'diagnostic')]
        [string]
        $Verbosity = "quiet",

        #platform - filters Publish configs by their Runtime (linux* / win*). 'all' publishes everything.
        [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, HelpMessage="Build platform")]
        [ValidateSet('all','linux','windows')]
        [string]
        $Platform = "all",

        #max cpu
        [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, HelpMessage="Maximum parallel MSBuild nodes (0 = all processors available to this machine)")]
        [ValidateRange(0,64)]
        [int]
        $MaxCpuCount = 0,

        #enable debug mode
        [switch]
        [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, HelpMessage="Enable Debug compilation")]
        $EnableDebug,

        #keep the roslyn compiler server alive
        [switch]
        [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, HelpMessage="Reuse the Roslyn compiler server (faster, but one long lived process holds memory until the build server shutdown)")]
        $EnableSharedCompilation,

        #timing report at any verbosity
        [switch]
        [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, HelpMessage="Print the per step timing report (always printed at -Verbosity diagnostic)")]
        $ShowTiming,

        #binary build logs (the .binlog format of the build engine inside the dotnet CLI - no MSBuild.exe involved)
        [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, HelpMessage="Directory for binary build logs, one .binlog per build step (open with the MSBuild Structured Log Viewer)")]
        [string]
        $BinaryLog
    )
    Begin{
        #required - the dotnet SDK, and nothing else
        if ($null -eq (Get-Command dotnet -ErrorAction SilentlyContinue)) { throw "Please install dotnet SDK. [ Download https://dotnet.microsoft.com/en-us/download ]" }

        #solution
        if( (-not (Test-Path -Path $Solution -PathType Leaf)) -or (-not ( $Solution -like '*.sln')) ){ throw "$Solution is not valid" }
        $solution = ( Resolve-Path $Solution ).Path
        $solutionPath = Split-Path -Parent $solution
        #
        $PublishPath = Get-Absolute-Path $PublishPath
        #
        if(-not [string]::IsNullOrEmpty($BinaryLog)){
            $BinaryLog = Get-Absolute-Path $BinaryLog
            New-Item -ItemType Directory -Path $BinaryLog -Force | Out-Null
        }

        #max cpu (dotnet defaults to ALL logical cores, so this always has to be set)
        if($MaxCpuCount -le 0){
            $MaxCpuCount = Get-Max-Cpu-Count
        }

        #shared compilation is off by default: the roslyn server is one more process holding memory for
        #the whole build. On a single solution build it is worth about 25% of the compile time.
        $sharedCompilation = "-p:UseSharedCompilation=$($EnableSharedCompilation.IsPresent)"
        Write-Host "#### APT BUILD dotnet CLI, parallel nodes [-m:$MaxCpuCount], shared compilation [$($EnableSharedCompilation.IsPresent)] ####"

        #the engine's verbosity. At 'quiet' the engine prints nothing at all while a 30 second filter build
        #runs, so quiet is mapped to minimal: one line per project as it completes ("Project -> output.dll"),
        #warnings stay visible. Every other verbosity is passed through as given. The timing report gate
        #below still looks at the verbosity that was asked for.
        $engineVerbosity = if($Verbosity -eq 'quiet'){ 'minimal' } else { $Verbosity }
    }
    Process{
    $timings = [System.Collections.Generic.List[object]]::new()
    $filters = [System.Collections.Generic.List[string]]::new()
    try{
        $binlogStep = 0

        #### 1. plan ####################################################################################
        #config files - anything matching $JsonConfig that sits next to a csproj
        $lomtecConfigs = @(Get-ChildItem -Path $solutionPath -Recurse -File -Filter *.json |
            Where-Object { ($_.Name -Match $JsonConfig) -and ($_.FullName -notmatch '[\\/](node_modules|bin|obj)[\\/]') } |
            Select-Object -ExpandProperty FullName)
        if($lomtecConfigs.Count -eq 0){ throw "Not found any build configuration [$JsonConfig]!" }
        #
        $plans = [System.Collections.Generic.List[object]]::new()
        foreach($lomtec in $lomtecConfigs){
            #the project the config belongs to
            $project = Get-ChildItem (Split-Path -Parent $lomtec) -Filter "*.csproj" | Select-Object -ExpandProperty FullName -First 1
            if($null -eq $project){
                Write-Verbose "Missing csproj file for [$lomtec]"
                Continue
            }
            $projectName = Split-Path -Leaf $project

            #json
            $json = Get-Content $lomtec | ConvertFrom-Json
            if($null -eq $json.Build){
                Write-Warning "Missing Build section for [$lomtec]"
                Continue
            }
            #no MSBuild.exe in here - say so once per config that asks for it, then build with dotnet
            if($json.Build.Command -eq 'msbuild'){
                Write-Warning "[$projectName] asks for msbuild, Publish-DotnetApplication builds it with the dotnet CLI"
            }

            #publish entries
            if($null -ne $json.Build.Publish -and @($json.Build.Publish).Count -gt 0){
                foreach($config in $json.Build.Publish){
                    $setting = Get-Default-Settings $EnableDebug.IsPresent $config $PublishPath $project
                    Write-Verbose " Configuration $($setting | ConvertTo-Json -Compress)"
                    #platform filter
                    if($Platform -eq 'linux'   -and $setting.Runtime -notlike "linux*") { Continue }
                    if($Platform -eq 'windows' -and $setting.Runtime -notlike "win*")   { Continue }
                    #
                    if($setting.Enabled -eq $false){
                        Write-Warning "#### APT PUBLISH $projectName [$(Split-Path -Leaf $setting.Path)] is not enabled ####"
                        Continue
                    }
                    $plans.Add([pscustomobject]@{ Kind = 'publish'; Project = $project; Name = $projectName; Setting = $setting; Config = $lomtec; Nuspec = $null })
                }
            }
            #nuget package
            elseif($null -ne $json.Build.Nuget){
                #no project name in the path, every package lands in the publish root
                $setting = Get-Default-Settings $EnableDebug.IsPresent $json.Build.Nuget $PublishPath
                Write-Verbose " Configuration $($setting | ConvertTo-Json -Compress)"
                if($setting.Enabled -eq $false){
                    Write-Warning "Build/Nuget is disabled for $projectName"
                    Continue
                }
                $plans.Add([pscustomobject]@{ Kind = 'nuget'; Project = $project; Name = $projectName; Setting = $setting; Config = $lomtec; Nuspec = $project.Replace("csproj", "nuspec") })
            }
            else{
                Write-Warning "Missing Build/Publish or Build/Nuget section for [$lomtec]!"
            }
        }
        if($plans.Count -eq 0){
            Write-Warning "Nothing to build - every configuration is disabled or filtered out"
            return
        }

        #### 2. validate ################################################################################
        #everything that can be checked without building is checked now, and reported in one go
        $problems = [System.Collections.Generic.List[string]]::new()
        foreach($plan in $plans){
            if([string]::IsNullOrWhiteSpace($plan.Setting.Configuration)){
                $problems.Add("$($plan.Name): empty Configuration [$($plan.Config)]")
            }
            #a missing nuspec is a warning, not an error: the project still builds, it is just not packed
            if($plan.Kind -eq 'nuget' -and -not (Test-Path -Path $plan.Nuspec -PathType Leaf)){
                Write-Warning "$($plan.Name): Build/Nuget is configured but $($plan.Nuspec) is missing - the project is built, no package is produced"
                $plan.Nuspec = $null
            }
            if($plan.Kind -eq 'publish' -and $plan.Setting.Runtime -notmatch '^[a-z][a-z0-9.]*(-[a-z0-9.]+)*$'){
                $problems.Add("$($plan.Name): '$($plan.Setting.Runtime)' does not look like a runtime identifier [$($plan.Config)]")
            }
        }
        #two publishes into the same folder would overwrite each other
        $plans | Where-Object Kind -eq 'publish' | Group-Object { $_.Setting.Path.ToLowerInvariant() } | Where-Object Count -gt 1 | ForEach-Object {
            $problems.Add("publish path used more than once: $($_.Group[0].Setting.Path) <- $(($_.Group | ForEach-Object { $_.Name }) -join ', ')")
        }
        if($problems.Count -gt 0){
            throw "APT configuration is not valid, nothing was built:`n - $($problems -join "`n - ")"
        }
        Write-Host "#### APT PLAN $(@($plans | Where-Object { $_.Kind -eq 'nuget' -and $null -ne $_.Nuspec }).Count) package(s), $(@($plans | Where-Object Kind -eq 'publish').Count) publish target(s) ####"

        #### 3. build ###################################################################################
        #nuget projects without custom properties share one solution filter build per configuration. A
        #plan with custom properties builds on its own, because those properties only apply to it.
        $solutionBuilt = @{}
        $shared = @($plans | Where-Object { $_.Kind -eq 'nuget' -and @(Get-Custom-Property $_.Setting.Properties).Count -eq 0 })
        if($shared.Count -gt 0 -and (Test-Projects-DotnetBuildable @($shared | ForEach-Object { $_.Project } | Sort-Object -Unique))){
            foreach($group in ($shared | Group-Object { $_.Setting.Configuration })){
                $configuration = $group.Name
                $filter = New-Solution-Filter $solution @($group.Group | ForEach-Object { $_.Project }) $configuration
                $filters.Add($filter)
                Write-Host "#### APT BUILD $($group.Count) project(s) [$configuration] via $(Split-Path -Leaf $filter) [dotnet] ####"
                $binlog = @(Get-Binlog-Argument $BinaryLog (++$binlogStep) "build-$configuration")
                $sw = [Diagnostics.Stopwatch]::StartNew()
                dotnet build -nologo /nodeReuse:false -m:$MaxCpuCount $sharedCompilation --verbosity $engineVerbosity --configuration $configuration @binlog $filter
                if($LastExitCode) { throw "DOTNET BUILD $(Split-Path -Leaf $filter) failed" }
                Add-Build-Timing $timings (Split-Path -Leaf $filter) 'build solution' $sw
                $solutionBuilt[$configuration] = $true
            }
        }
        #whatever the solution filter did not cover builds on its own
        foreach($plan in ($plans | Where-Object Kind -eq 'nuget')){
            $customProperties = @(Get-Custom-Property $plan.Setting.Properties)
            if(($customProperties.Count -eq 0) -and $solutionBuilt.ContainsKey($plan.Setting.Configuration)){ Continue }
            Write-Host "#### APT BUILD $($plan.Name) [$($plan.Setting.Configuration)] [dotnet] ####"
            $binlog = @(Get-Binlog-Argument $BinaryLog (++$binlogStep) "build-$($plan.Name)")
            $sw = [Diagnostics.Stopwatch]::StartNew()
            dotnet build -nologo /nodeReuse:false -m:$MaxCpuCount $sharedCompilation --verbosity $engineVerbosity --configuration $plan.Setting.Configuration @customProperties @binlog $plan.Project
            if($LastExitCode) { throw "DOTNET BUILD $($plan.Name) failed" }
            Add-Build-Timing $timings $plan.Name 'build' $sw
        }

        #### 4. publish #################################################################################
        #publish restores implicitly - it needs the runtime specific (RID) restore anyway
        foreach($plan in ($plans | Where-Object Kind -eq 'publish')){
            $setting = $plan.Setting
            $customProperties = @(Get-Custom-Property $setting.Properties)
            Write-Host "#### APT PUBLISH $($plan.Name) to $($setting.Path) [dotnet] ####"
            $binlog = @(Get-Binlog-Argument $BinaryLog (++$binlogStep) "publish-$(Split-Path -Leaf $setting.Path)")
            $sw = [Diagnostics.Stopwatch]::StartNew()
            dotnet publish -nologo /nodeReuse:false -m:$MaxCpuCount $sharedCompilation --verbosity $engineVerbosity --runtime $setting.Runtime --configuration $setting.Configuration --self-contained $setting.SelfContained -p:PublishSingleFile=$($setting.SingleFile) @customProperties @binlog $plan.Project --output $setting.Path
            if($LastExitCode) { throw "DOTNET PUBLISH $($plan.Name) failed" }
            Add-Build-Timing $timings $plan.Name 'publish' $sw
        }

        #### 5. pack ####################################################################################
        #dotnet pack driven by the nuspec: NuspecFile makes the nuspec authoritative (id, version,
        #dependencies, files - the csproj metadata is not used) and NuspecProperties fills in the
        #$configuration$ token. --no-build, everything is built already. Each pack is one project, so it
        #gets one msbuild node (-m:1); the parallelism comes from running the packs side by side.
        #Everything the pack block needs travels inside the queue item, so the same block works both
        #in ForEach-Object -Parallel (where only $using: would reach outer variables) and sequentially.
        $packQueue = @($plans | Where-Object { $_.Kind -eq 'nuget' -and $null -ne $_.Nuspec } | ForEach-Object {
            [pscustomobject]@{
                Name = $_.Name; Project = $_.Project; Nuspec = $_.Nuspec; Output = $_.Setting.Path
                Configuration = $_.Setting.Configuration; Verbosity = $engineVerbosity; BinaryLog = $BinaryLog
            } })
        if($packQueue.Count -gt 0){
            Write-Host "#### APT PACK $($packQueue.Count) package(s) to $PublishPath [dotnet] ####"
            $sw = [Diagnostics.Stopwatch]::StartNew()
            $pack = {
                $item = $_
                $binlog = @()
                if(-not [string]::IsNullOrEmpty($item.BinaryLog)){
                    $binlog = @("-bl:$([IO.Path]::Combine($item.BinaryLog, ('pack-{0}.binlog' -f ($item.Name -replace '[^A-Za-z0-9._-]', '_'))))")
                }
                $output = dotnet pack $item.Project --no-build --nologo /nodeReuse:false -m:1 --verbosity $item.Verbosity --configuration $item.Configuration --output $item.Output "-p:NuspecFile=$($item.Nuspec)" "-p:NuspecBasePath=$(Split-Path -Parent $item.Project)" "-p:NuspecProperties=configuration=$($item.Configuration)" -p:IsPackable=true @binlog 2>&1
                if($LASTEXITCODE){
                    return "$($item.Name) [dotnet pack exit $LASTEXITCODE] $((@($output) | Select-Object -Last 3) -join ' | ')"
                }
                #the output is captured for the failure message above, so show the one line worth seeing -
                #as "Successfully created [<file>.nupkg]." with the file name only, no path
                @($output) | Where-Object { "$_" -match "Successfully created package '(?<path>[^']+)'" } | ForEach-Object {
                    [void]("$_" -match "Successfully created package '(?<path>[^']+)'")
                    Write-Host "#### APT PACK $(Split-Path -Leaf $Matches.path) ####"
                }
                #exit 0 is not proof of a package: a project that is not packable is skipped silently
                $manifest = New-Object System.Xml.XmlDocument
                $manifest.Load($item.Nuspec)
                $expected = [IO.Path]::Combine($item.Output, "$($manifest.package.metadata.id).$($manifest.package.metadata.version).nupkg")
                if(-not (Test-Path -Path $expected -PathType Leaf)){
                    return "$($item.Name) [$(Split-Path -Leaf $expected) was not produced]"
                }
            }
            #ForEach-Object -Parallel needs PowerShell 7, keep a sequential path for 5.1
            if($PSVersionTable.PSVersion.Major -ge 7){
                $packFailed = $packQueue | ForEach-Object -ThrottleLimit $MaxCpuCount -Parallel $pack
            }
            else{
                $packFailed = $packQueue | ForEach-Object $pack
            }
            Add-Build-Timing $timings "$($packQueue.Count) package(s)" 'pack' $sw
            if(@($packFailed).Count -gt 0){ throw "DOTNET PACK failed:`n - $(@($packFailed) -join "`n - ")" }
        }

        #### 6. cleanup #################################################################################
        #nothing from the build environment ships in the publish output
        Write-Verbose "Publish cleanup [$PublishPath]"
        Get-ChildItem -Path $PublishPath -Recurse -Filter *.pdb -File -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
        Get-ChildItem -Path $PublishPath -Recurse -Filter $JsonConfig -File -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
        Get-ChildItem -Path $PublishPath -Recurse -Filter appsettings.*.json -File -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
        Get-ChildItem -Path $PublishPath -Recurse -Filter web.*.config -File -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
    }
    finally{
        #timing report
        if($ShowTiming.IsPresent -or ($Verbosity -eq 'diagnostic')){ Write-Build-Timing $timings }
        #the generated solution filters are build artefacts, not source
        foreach($filter in $filters){ Remove-Item -Path $filter -Force -ErrorAction SilentlyContinue }
        #release build servers (msbuild nodes, roslyn compiler, razor) from memory
        Write-Verbose "Build server shutdown"
        dotnet build-server shutdown | Out-Null
        $global:LASTEXITCODE = 0
    }
    }
    End{
    }
}
Export-ModuleMember -Function Publish-DotnetApplication

# SIG # Begin signature block
# MIIIEwYJKoZIhvcNAQcCoIIIBDCCCAACAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCA1nG9ZErTxmsH7
# 9dkmo1qEOXQ0PxpS+uGMo2AH+Ng0DaCCBQ8wggULMIIC86ADAgECAgIApTANBgkq
# hkiG9w0BAQsFADCBoDELMAkGA1UEBhMCU0sxETAPBgNVBAgTCFNsb3Zha2lhMRMw
# EQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpMb210ZWMuY29tMSswKQYDVQQD
# EyJMb210ZWMuY29tIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MScwJQYJKoZIhvcN
# AQkBFhhJbXJpY2guU3pvbGlrQGxvbXRlYy5jb20wHhcNMTkwMzI2MTUwNDQzWhcN
# MzAwMTAxMDAwMDAwWjCBkzELMAkGA1UEBhMCU0sxETAPBgNVBAgTCFNsb3Zha2lh
# MRMwEQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpMb210ZWMuY29tMR4wHAYD
# VQQDExVMb210ZWMuY29tIFBvd2Vyc2hlbGwxJzAlBgkqhkiG9w0BCQEWGEltcmlj
# aC5Tem9saWtAbG9tdGVjLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
# ggEBAL4n4nCHbkdrf09IHNFQ2P/z6I43GKScsFJOQHUMkRD3ALoUFL/URC9sW2fY
# rqG5VkFAKhnM0VxeiICR53cAshNShjFf58PhaS973jtCoJcaugIVBVoFIuQ+gnNY
# Jp2VdBbIPKMW4JjjZOxBEkHpMWmfitXKWGKeU68Qcn3oI6PO+YSztfXLe1NU+1GI
# O3fA7E0vHVPUf/qWZXMYU5ElLQVm9AXbfX79mTgl76A57+OC6j+Aehkd2OPfUl4w
# snox3fOyUAUA8iojeWh97PpXd/s+RkuxWdgsC4YSWDUjhZSzBkml9uerYqo9a+XA
# b39dvkpK9TPl3q5HNBQMkCfp8bkCAwEAAaNaMFgwCQYDVR0TBAIwADALBgNVHQ8E
# BAMCB4AwKwYDVR0lBCQwIgYIKwYBBQUHAwMGCisGAQQBgjcCARUGCisGAQQBgjcC
# ARYwEQYJYIZIAYb4QgEBBAQDAgSwMA0GCSqGSIb3DQEBCwUAA4ICAQAJJapv9skY
# jh5HTsJnqDdtqh7YOOXuA8g+DKBj+5gDEZE5V9VhAFVp8UJ9RoITGGIRTVId0lqc
# LJiVSTHx305VW9aID8vo77kfrTyXvPXNIsTtHnPkkwH47+CoiY3IpPQLjUA/Q6Qd
# qwINvvwom7/Wc+OoIqlPdJH5DbBrIy85dr6M/Lm3Rw2BolcTRwXTB3xAhweth78B
# P6pbcAd32FdymkRopLIihuNs7g7ZR/Q/5803G+OiQIMRGyTvaQ+aQjJgFpkzp7NI
# whzougfCOV47Sc89jEpUqw16i2UFfz2ywOlWUyYtue1S1PjM1ljgJfRo+e0wUnFp
# gFQzXGF1bTYVaQ4e3nJleADfvqeXoH2AYeBTbz9BcogkkfURAC3iiob1bNs5jE1C
# brEDCw6m/03k0oOmm3xQksXyAhBYuUkRwu9jd4y3FwZ/syDGLz3b6cY8o1YyINOO
# A3B2r92shNt0rWhJu3v+qcIVmFQ0aKlhNNRoiVlQgJ7NgO0UV+vU2lgiscpUSxEt
# xKN+450r49su06NA6zsyn3CELUmVkPyjx5fyizwt9KxVuYOUSEb32Y7QCffHJ1qt
# F6SuQrKbgb/24y7cCDW6PDVRvPOySUlKu9sPykICDjvzXBvjYEILM7AmtYCIBGQg
# imBsQvTEoXJXiwAOi1XNz3LxcjQHQ/ZM4zGCAlowggJWAgEBMIGnMIGgMQswCQYD
# VQQGEwJTSzERMA8GA1UECBMIU2xvdmFraWExEzARBgNVBAcTCkJyYXRpc2xhdmEx
# EzARBgNVBAoTCkxvbXRlYy5jb20xKzApBgNVBAMTIkxvbXRlYy5jb20gQ2VydGlm
# aWNhdGlvbiBBdXRob3JpdHkxJzAlBgkqhkiG9w0BCQEWGEltcmljaC5Tem9saWtA
# bG9tdGVjLmNvbQICAKUwDQYJYIZIAWUDBAIBBQCggYQwGAYKKwYBBAGCNwIBDDEK
# MAigAoAAoQKAADAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3
# AgELMQ4wDAYKKwYBBAGCNwIBFjAvBgkqhkiG9w0BCQQxIgQgstawU8zRsMNlD4wU
# TtRZnMDbJ/k5fPcybilN5nAMfjQwDQYJKoZIhvcNAQEBBQAEggEAT7AOoBVoLJkP
# sgyxPzKnxzlGPtl0phUjfmnZp6iHMvH+zCOHwItWPFGJl3zdgqq4ygvHJOamlivX
# 1yWGR8RdmYrEj0QJ95Cy0Vmo+bMY1+mxVMV4KUg/OQ80G3PIkY9yR3Q2bdvcQf+9
# qUGqCCBZUoWfelZrvhDB+JycsfCBt7MW0TZdbfYKk1Nra65mvAvS7BYX555T/DxV
# 0XKsq5I02DnxfvXGoIHUsBKWrQHc1peQPEmhVrMafb8Q092B0rcI19XOBSJyDg2U
# +32qJMb95VgdBxdpSqAMrnL7/IfpsSbKA3RjQjacUyIvph6BvNGFrLuaiYifrGc+
# s4/7ifefIw==
# SIG # End signature block