Publish-Packages.psm1

<#
.Synopsis
   Returns all published versions of a package on a feed
#>

function Get-FeedPackageVersions {
    param ([string] $Id, [string] $Feed)
    $json = dotnet package search $Id --exact-match --prerelease --source $Feed --format json | Out-String
    $exitCode = $LASTEXITCODE
    $result = try { $json | ConvertFrom-Json } catch { $null }
    if ($null -eq $result) { throw "dotnet package search failed for $Id on $Feed`n$json" }
    #errors (unknown feed, auth, network) are reported per source with exit code 0 - treat any problem as failure,
    #otherwise an unreachable feed would look like "version not on feed" and the package would be pushed
    $problems = @($result.problems) + @($result.searchResult | ForEach-Object { $_.problems }) | Where-Object { $null -ne $_ }
    if ($problems.Count -gt 0) { throw "dotnet package search failed for $Id on ${Feed}: $(($problems | ForEach-Object { $_.text }) -join '; ')" }
    if ($exitCode -ne 0) { throw "dotnet package search failed for $Id on $Feed`n$json" }
    if (@($result.searchResult).Count -eq 0) { throw "dotnet package search returned no result for $Id on $Feed" }
    @($result.searchResult.packages | Where-Object { $_.id -eq $Id } | ForEach-Object { $_.version })
}

<#
.Synopsis
   Automatic Publish Tool (APT)
.DESCRIPTION
   Publish nugets to a feed.
   Only packages whose id+version is not on the feed yet are pushed; an existing version is never sent.
   The current directory is never changed: packages are only read from -Path, while dotnet keeps running in the
   directory the command was called from, so the nuget.config of that directory (and of its parents) is used.
   -Feed is a source name from 'dotnet nuget list source'. Credentials come from nuget.config. Lomtec.Packages
   (Azure Artifacts) gets the api key 'az', other feeds (Nuaktiv.GitLab) no key.
   -WhatIf runs every step (file names, version lookup on the feed) except the push itself.
.EXAMPLE
   Publish-Packages -Path D:\Projects\Framework\ActiveOffice.NET\developer\publish\ -Feed Lomtec.Packages
.EXAMPLE
   Publish-Packages -Path D:\Projects\Framework\ActiveOffice.NET\developer\publish\ -Feed Nuaktiv.GitLab
.EXAMPLE
   Publish-Packages -Path D:\Projects\Framework\ActiveOffice.NET\developer\publish\ -Feed Nuaktiv.GitLab -WhatIf
 .NOTES
   Author: Imrich Szolik
#>

function Publish-Packages {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        #Packages path
        [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$false, HelpMessage="Packages path")]
        [string]
        $Path,
        #Package feed name (dotnet nuget list source), e.g. Lomtec.Packages or Nuaktiv.GitLab
        [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, HelpMessage="Packages feed name (dotnet nuget list source).")]
        [string]
        $Feed = "Lomtec.Packages"
    )
    begin {
        if ($null -eq (Get-Command dotnet -ErrorAction SilentlyContinue)) { throw "Please install .NET SDK 8.0.200 or newer (dotnet package search)." }
    }
    process {
        #no Push-Location: the packages directory is only read from, every dotnet command keeps the caller's
        #current directory, so the nuget.config (sources + credentials) of that directory stays in effect
        if (-not (Test-Path -LiteralPath $Path -PathType Container)) { throw "Packages path not found: $Path" }
        #validate feed: must be an enabled source in 'dotnet nuget list source'
        $sourceList = dotnet nuget list source
        if ($LASTEXITCODE -ne 0) { throw "dotnet nuget list source failed:`n$($sourceList -join "`n")" }
        $sources = @($sourceList | ForEach-Object {
            if ($_ -match '^\s*\d+\.\s+(?<name>.+?)\s+\[(?<state>[^\]]+)\]\s*$') {
                [pscustomobject]@{ Name = $Matches['name']; Enabled = ($Matches['state'] -eq 'Enabled') }
            }
        })
        $source = $sources | Where-Object { $_.Name -eq $Feed } | Select-Object -First 1
        if ($null -eq $source) {
            throw "Feed '$Feed' not found in NuGet sources (nuget.config searched from the current directory $((Get-Location).Path)). Available: $(@($sources | Where-Object { $_.Enabled }).Name -join ', ')"
        }
        if (-not $source.Enabled) { throw "Feed '$Feed' is disabled in NuGet sources." }
        $Feed = $source.Name
        #
        #legacy *.symbols.nupkg have the same id+version as the package, never push them as packages
        $files = Get-ChildItem -LiteralPath $Path -Filter *.nupkg -File | Where-Object { $_.Name -notlike '*.symbols.nupkg' }
        if(($files |  Measure-Object).Count -eq 0 ){
            Write-Host "#### APT NUGET deploy into $Feed aborted. Not found any packages in $Path ####"
            return
        }
        #
        Write-Host "#### APT NUGET deploy of $Path into $Feed ####"
        #
        $pushed = 0
        $skipped = 0
        $notPushed = 0
        #
        foreach($file in $files){
            #id and version from the file name: <id>.<major>.<minor>.<patch>[-prerelease].nupkg
            $package = [regex]::Match($file.Name, '^(?<id>.+?)\.(?<version>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.nupkg$')
            if (-not $package.Success) { throw "Cannot read id/version from file name: $($file.Name)" }
            $id = $package.Groups['id'].Value
            $version = $package.Groups['version'].Value
            $name = "$id.$version"
            #
            #versions already on the feed are never pushed
            $feedVersions = Get-FeedPackageVersions -Id $id -Feed $Feed
            if ($feedVersions -contains $version) {
                Write-Verbose "Found $name, not deployed."
                $skipped++
                continue
            }
            #
            if ($PSCmdlet.ShouldProcess($name, "Push to $Feed")) {
                Write-Host "#### APT NUGET deploy: $name ####"
                $pushArgs = @('nuget', 'push', $file.FullName, '--source', $Feed, '--skip-duplicate')
                #Azure Artifacts requires an api key (any value), GitLab authenticates with nuget.config credentials only
                if ($Feed -eq 'Lomtec.Packages') { $pushArgs += @('--api-key', 'az') }
                dotnet @pushArgs
                if ($LASTEXITCODE -ne 0) { throw "Push of $name to $Feed failed." }
                $pushed++
            }
            else {
                #-WhatIf (or -Confirm answered No): everything checked, only the push is not sent
                $notPushed++
            }
        }
        #
        $summary = "Pushed: $pushed, already on feed: $skipped"
        if ($notPushed -gt 0) { $summary += ", would push (not sent): $notPushed" }
        Write-Host "#### APT NUGET deploy into $Feed done. $summary ####"
    }
    end {
    }
}
Export-ModuleMember -Function Publish-Packages

# SIG # Begin signature block
# MIIIEwYJKoZIhvcNAQcCoIIIBDCCCAACAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBmGvThfwhCt9Vi
# f64LHV4GVj9Qzgms8XgAoZ/hlxrp7qCCBQ8wggULMIIC86ADAgECAgIApTANBgkq
# 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
# AgELMQ4wDAYKKwYBBAGCNwIBFjAvBgkqhkiG9w0BCQQxIgQgv+tQE3Sk3d0JkYL+
# nuxZIHK7BZfs3xmOVft7K3t6sIMwDQYJKoZIhvcNAQEBBQAEggEArDM8bFhoncgf
# 0b27FKFAK5vUh54ORI2sp4T7WAYSlKNcqTv1YDNmLHhyUUKSpz/LUVIyBFlHC7Jc
# Gep+v2dHPDApWJHAAPO43cpR67kKCI0jdPpl5h8nEsXaSmK5UaLaicoa/yijtUHo
# zzFt7Eg6UY7UUBgeZeFhMFbEsu5JQ+J/DwVGApNyC77qd/sELtn7YZkkvLlizq7j
# WjXPpEXykLetwquW9Bf8LlEDCiHOmGJ4JdMdb5ePDuP1Jq1ZpnAv9em670USWvGo
# wRwdXOuJIiF/1GQInY8lGRFXWI5PbBM2jcHfSeq+C7Uv+08+sWkk5+L4eV/YtFeq
# Gdy0Md1KnA==
# SIG # End signature block