Eigenverft.Manifested.Drydock.Publishing.ps1

function Publish-PowerShellModuleRelease {
    <#
    .SYNOPSIS
        Publishes one PowerShell module release to one target.
 
    .DESCRIPTION
        Provides a single-target publishing abstraction for CI/CD scripts. Each invocation publishes
        exactly one module directory to exactly one target. Call the function repeatedly with separate
        parameter hashtables when a release must be published to multiple destinations.
 
        Supported targets:
        - Local: creates a local PowerShell repository, publishes to it, and removes the registration.
        - PowerShellRepository: registers and publishes to a caller-defined PowerShellGet repository.
        - GitHubPackages: configures both dotnet NuGet and PowerShellGet for GitHub Packages, publishes,
          and removes both temporary registrations.
        - PSGallery: publishes directly to the registered PSGallery repository.
 
        GitHub Packages uses an explicit PSCredential by default. The legacy implicit-credential path
        is available only through -UseLegacyGitHubRegistration because its behavior differs between
        PowerShell host versions.
 
    .PARAMETER Path
        Directory containing the PowerShell module manifest to publish.
 
    .PARAMETER Target
        Exactly one publish target: Local, PowerShellRepository, GitHubPackages, or PSGallery.
 
    .PARAMETER RepositoryName
        Repository name used by Local, PowerShellRepository, or GitHubPackages. Target-specific defaults
        are LocalPowershellGallery and github.
 
    .PARAMETER RepositoryPath
        Local repository directory for Target Local. Default: $HOME/source/LocalPowershellGallery.
 
    .PARAMETER SourceLocation
        Source endpoint for Target PowerShellRepository.
 
    .PARAMETER PublishLocation
        Publish endpoint for Target PowerShellRepository. Defaults to SourceLocation.
 
    .PARAMETER InstallationPolicy
        PowerShellGet installation policy used for temporary repository registrations.
 
    .PARAMETER RepositoryCredential
        Optional credential used while registering Target PowerShellRepository.
 
    .PARAMETER ApiKey
        NuGet API key used by Target PowerShellRepository or PSGallery.
 
    .PARAMETER GitHubOwner
        GitHub organization or user owning the package feed.
 
    .PARAMETER GitHubToken
        GitHub token used both for the NuGet source and module publication.
 
    .PARAMETER UseLegacyGitHubRegistration
        Omits -Credential from Register-PSRepository for GitHub Packages. This reproduces the historical
        implicit behavior and should only be used for controlled compatibility diagnostics.
 
    .PARAMETER KeepRepositoryRegistration
        Keeps temporary PowerShellGet and dotnet NuGet registrations after publication. By default,
        temporary registrations are removed in a finally block.
 
    .PARAMETER PassThru
        Returns one result object. Without PassThru, the function emits no pipeline object.
 
    .EXAMPLE
        $publishParametersTargetLocal = @{
            Path = $manifestFile.DirectoryName
            Target = 'Local'
            RepositoryName = 'LocalPowershellGallery'
        }
        Publish-PowerShellModuleRelease @publishParametersTargetLocal
 
    .EXAMPLE
        $publishParametersTargetGitHub = @{
            Path = $manifestFile.DirectoryName
            Target = 'GitHubPackages'
            RepositoryName = 'github'
            GitHubOwner = 'eigenverft'
            GitHubToken = $NuGetGitHubPush
        }
        Publish-PowerShellModuleRelease @publishParametersTargetGitHub
 
    .EXAMPLE
        $publishParametersTargetPsGallery = @{
            Path = $manifestFile.DirectoryName
            Target = 'PSGallery'
            ApiKey = $PsGalleryApiKey
        }
        Publish-PowerShellModuleRelease @publishParametersTargetPsGallery
 
    .EXAMPLE
        $publishParametersTargetTestGallery = @{
            Path = $manifestFile.DirectoryName
            Target = 'PowerShellRepository'
            RepositoryName = 'PSGalleryTest'
            SourceLocation = $testGallerySource
            PublishLocation = $testGalleryPublish
            ApiKey = $testGalleryApiKey
        }
        Publish-PowerShellModuleRelease @publishParametersTargetTestGallery
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [ValidateSet('Local', 'PowerShellRepository', 'GitHubPackages', 'PSGallery')]
        [string]$Target,

        [Parameter()]
        [string]$RepositoryName,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$RepositoryPath = "$HOME/source/LocalPowershellGallery",

        [Parameter()]
        [string]$SourceLocation,

        [Parameter()]
        [string]$PublishLocation,

        [Parameter()]
        [ValidateSet('Trusted', 'Untrusted')]
        [string]$InstallationPolicy = 'Trusted',

        [Parameter()]
        [AllowNull()]
        [System.Management.Automation.PSCredential]$RepositoryCredential,

        [Parameter()]
        [string]$ApiKey,

        [Parameter()]
        [string]$GitHubOwner,

        [Parameter()]
        [string]$GitHubToken,

        [Parameter()]
        [switch]$UseLegacyGitHubRegistration,

        [Parameter()]
        [switch]$KeepRepositoryRegistration,

        [Parameter()]
        [switch]$PassThru
    )

    function local:Write-PublishReleaseLog {
        [Diagnostics.CodeAnalysis.SuppressMessage('PSUseApprovedVerbs', '')]
        [CmdletBinding()]
        param(
            [Parameter(Mandatory = $true)]
            [string]$Message,

            [Parameter()]
            [ValidateSet('DBG', 'INF', 'WRN')]
            [string]$Level = 'INF'
        )

        if (Get-Command -Name 'Write-ConsoleLog' -ErrorAction SilentlyContinue) {
            Write-ConsoleLog -Message $Message -Level $Level
        }
        else {
            Write-Host "[$Level] $Message"
        }
    }

    function local:Assert-PublishReleaseString {
        [Diagnostics.CodeAnalysis.SuppressMessage('PSUseApprovedVerbs', '')]
        [CmdletBinding()]
        param(
            [Parameter(Mandatory = $true)]
            [AllowNull()]
            [AllowEmptyString()]
            [string]$Value,

            [Parameter(Mandatory = $true)]
            [ValidateNotNullOrEmpty()]
            [string]$ParameterName,

            [Parameter(Mandatory = $true)]
            [ValidateNotNullOrEmpty()]
            [string]$TargetName
        )

        if ([string]::IsNullOrWhiteSpace($Value)) {
            throw "Target '$TargetName' requires parameter '$ParameterName'."
        }
    }

    $moduleDirectory = $null
    try {
        $moduleDirectory = (Get-Item -LiteralPath $Path -ErrorAction Stop).FullName
    }
    catch {
        throw "Module path '$Path' could not be resolved. $($_.Exception.Message)"
    }

    if (-not (Test-Path -LiteralPath $moduleDirectory -PathType Container)) {
        throw "Module path '$moduleDirectory' is not a directory."
    }

    $manifestFile = Get-ChildItem -LiteralPath $moduleDirectory -Filter '*.psd1' -File -ErrorAction Stop |
        Select-Object -First 1

    if ($null -eq $manifestFile) {
        throw "No PowerShell module manifest was found in '$moduleDirectory'."
    }

    $manifestInfo = Test-ModuleManifest -Path $manifestFile.FullName -ErrorAction Stop
    $moduleName = [string]$manifestInfo.Name
    $moduleVersion = [string]$manifestInfo.Version

    if ([string]::IsNullOrWhiteSpace($moduleName)) {
        $moduleName = [string]$manifestFile.BaseName
    }

    if ($UseLegacyGitHubRegistration -and $Target -ne 'GitHubPackages') {
        throw '-UseLegacyGitHubRegistration is valid only for Target GitHubPackages.'
    }

    $effectiveRepositoryName = $RepositoryName
    $effectiveSourceLocation = $SourceLocation
    $effectivePublishLocation = $PublishLocation
    $authenticationMode = 'None'
    $registeredPsRepository = $false
    $registeredDotnetSource = $false
    $published = $false
    $cleanupRequested = -not $KeepRepositoryRegistration
    $cleanupCompleted = $true
    $operationError = $null

    $result = [ordered]@{
        Target                 = $Target
        ModuleName             = $moduleName
        ModuleVersion          = $moduleVersion
        ModulePath             = $moduleDirectory
        RepositoryName         = $null
        SourceLocation         = $null
        AuthenticationMode     = $authenticationMode
        Published              = $false
        CleanupRequested       = $cleanupRequested
        CleanupCompleted       = $false
        PowerShellVersion      = [string]$PSVersionTable.PSVersion
    }

    try {
        switch ($Target) {
            'Local' {
                if ([string]::IsNullOrWhiteSpace($effectiveRepositoryName)) {
                    $effectiveRepositoryName = 'LocalPowershellGallery'
                }

                $effectiveSourceLocation = $RepositoryPath
                $result.RepositoryName = $effectiveRepositoryName
                $result.SourceLocation = $effectiveSourceLocation

                if ($PSCmdlet.ShouldProcess(
                    "$moduleName $moduleVersion -> $effectiveRepositoryName",
                    'Publish PowerShell module release to local repository')) {

                    Write-PublishReleaseLog -Message (
                        "Publishing module '{0}' version '{1}' to local repository '{2}'." -f
                        $moduleName, $moduleVersion, $effectiveRepositoryName
                    )

                    $effectiveRepositoryName = Register-LocalPSGalleryRepository `
                        -RepositoryPath $RepositoryPath `
                        -RepositoryName $effectiveRepositoryName `
                        -InstallationPolicy $InstallationPolicy

                    $registeredPsRepository = $true
                    $result.RepositoryName = $effectiveRepositoryName

                    Publish-Module `
                        -Path $moduleDirectory `
                        -Repository $effectiveRepositoryName `
                        -ErrorAction Stop | Out-Null

                    $published = $true
                }
            }

            'PowerShellRepository' {
                Assert-PublishReleaseString -Value $effectiveRepositoryName -ParameterName 'RepositoryName' -TargetName $Target
                Assert-PublishReleaseString -Value $effectiveSourceLocation -ParameterName 'SourceLocation' -TargetName $Target

                if ([string]::IsNullOrWhiteSpace($effectivePublishLocation)) {
                    $effectivePublishLocation = $effectiveSourceLocation
                }

                $result.RepositoryName = $effectiveRepositoryName
                $result.SourceLocation = $effectiveSourceLocation
                if ($null -ne $RepositoryCredential) {
                    $authenticationMode = 'ExplicitCredential'
                }
                elseif (-not [string]::IsNullOrWhiteSpace($ApiKey)) {
                    $authenticationMode = 'ApiKey'
                }
                $result.AuthenticationMode = $authenticationMode

                if ($PSCmdlet.ShouldProcess(
                    "$moduleName $moduleVersion -> $effectiveRepositoryName",
                    'Publish PowerShell module release to custom PowerShell repository')) {

                    $existingRepository = Get-PSRepository -Name $effectiveRepositoryName -ErrorAction SilentlyContinue
                    if ($null -ne $existingRepository) {
                        Write-PublishReleaseLog -Message (
                            "Removing existing PowerShell repository registration '{0}'." -f $effectiveRepositoryName
                        ) -Level 'WRN'
                        Unregister-PSRepository -Name $effectiveRepositoryName -ErrorAction Stop | Out-Null
                    }

                    $repositoryRegistration = @{
                        Name                  = $effectiveRepositoryName
                        SourceLocation        = $effectiveSourceLocation
                        PublishLocation       = $effectivePublishLocation
                        ScriptSourceLocation  = $effectiveSourceLocation
                        ScriptPublishLocation = $effectivePublishLocation
                        InstallationPolicy    = $InstallationPolicy
                        ErrorAction           = 'Stop'
                    }

                    if ($null -ne $RepositoryCredential) {
                        $repositoryRegistration.Credential = $RepositoryCredential
                    }

                    Register-PSRepository @repositoryRegistration | Out-Null
                    $registeredPsRepository = $true

                    $publishParameters = @{
                        Path        = $moduleDirectory
                        Repository  = $effectiveRepositoryName
                        ErrorAction = 'Stop'
                    }
                    if (-not [string]::IsNullOrWhiteSpace($ApiKey)) {
                        $publishParameters.NuGetApiKey = $ApiKey
                    }

                    Publish-Module @publishParameters | Out-Null
                    $published = $true
                }
            }

            'GitHubPackages' {
                Assert-PublishReleaseString -Value $GitHubOwner -ParameterName 'GitHubOwner' -TargetName $Target
                Assert-PublishReleaseString -Value $GitHubToken -ParameterName 'GitHubToken' -TargetName $Target

                if ([string]::IsNullOrWhiteSpace($effectiveRepositoryName)) {
                    $effectiveRepositoryName = 'github'
                }

                $effectiveSourceLocation = "https://nuget.pkg.github.com/$GitHubOwner/index.json"
                $effectivePublishLocation = $effectiveSourceLocation
                $authenticationMode = if ($UseLegacyGitHubRegistration) {
                    'LegacyImplicitCredential'
                }
                else {
                    'ExplicitCredential'
                }

                $result.RepositoryName = $effectiveRepositoryName
                $result.SourceLocation = $effectiveSourceLocation
                $result.AuthenticationMode = $authenticationMode

                if ($PSCmdlet.ShouldProcess(
                    "$moduleName $moduleVersion -> GitHub Packages/$GitHubOwner",
                    'Publish PowerShell module release to GitHub Packages')) {

                    if (-not (Get-Command -Name 'dotnet' -ErrorAction SilentlyContinue)) {
                        throw "Target '$Target' requires the dotnet CLI."
                    }

                    $existingRepository = Get-PSRepository -Name $effectiveRepositoryName -ErrorAction SilentlyContinue
                    if ($null -ne $existingRepository) {
                        Write-PublishReleaseLog -Message (
                            "Removing existing PowerShell repository registration '{0}'." -f $effectiveRepositoryName
                        ) -Level 'WRN'
                        Unregister-PSRepository -Name $effectiveRepositoryName -ErrorAction Stop | Out-Null
                    }

                    Unregister-LocalNuGetDotNetPackageSource -SourceName $effectiveRepositoryName

                    Invoke-ProcessTyped `
                        -Executable 'dotnet' `
                        -Arguments @(
                            'nuget', 'add', 'source',
                            '--username', $GitHubOwner,
                            '--password', $GitHubToken,
                            '--store-password-in-clear-text',
                            '--name', $effectiveRepositoryName,
                            $effectiveSourceLocation
                        ) `
                        -CaptureOutput $false `
                        -CaptureOutputDump $false `
                        -HideValues @($GitHubToken) | Out-Null

                    $registeredDotnetSource = $true

                    $repositoryRegistration = @{
                        Name                  = $effectiveRepositoryName
                        SourceLocation        = $effectiveSourceLocation
                        PublishLocation       = $effectivePublishLocation
                        ScriptSourceLocation  = $effectiveSourceLocation
                        ScriptPublishLocation = $effectivePublishLocation
                        InstallationPolicy    = $InstallationPolicy
                        ErrorAction           = 'Stop'
                    }

                    if ($UseLegacyGitHubRegistration) {
                        Write-PublishReleaseLog -Message (
                            'Legacy implicit GitHub repository authentication is enabled. ' +
                            'This behavior is host-version dependent and is known to differ between PowerShell 7.4 and 7.6.'
                        ) -Level 'WRN'
                    }
                    else {
                        $secureToken = ConvertTo-SecureString $GitHubToken -AsPlainText -Force
                        $githubCredential = [System.Management.Automation.PSCredential]::new(
                            $GitHubOwner,
                            $secureToken
                        )
                        $repositoryRegistration.Credential = $githubCredential
                    }

                    Register-PSRepository @repositoryRegistration | Out-Null
                    $registeredPsRepository = $true

                    Publish-Module `
                        -Path $moduleDirectory `
                        -Repository $effectiveRepositoryName `
                        -NuGetApiKey $GitHubToken `
                        -ErrorAction Stop | Out-Null

                    $published = $true
                }
            }

            'PSGallery' {
                Assert-PublishReleaseString -Value $ApiKey -ParameterName 'ApiKey' -TargetName $Target

                $effectiveRepositoryName = 'PSGallery'
                $authenticationMode = 'ApiKey'
                $result.RepositoryName = $effectiveRepositoryName
                $result.SourceLocation = 'PSGallery'
                $result.AuthenticationMode = $authenticationMode

                if ($PSCmdlet.ShouldProcess(
                    "$moduleName $moduleVersion -> PSGallery",
                    'Publish PowerShell module release to PSGallery')) {

                    Publish-Module `
                        -Path $moduleDirectory `
                        -Repository 'PSGallery' `
                        -NuGetApiKey $ApiKey `
                        -ErrorAction Stop | Out-Null

                    $published = $true
                }
            }
        }
    }
    catch {
        $operationError = $_
    }
    finally {
        if ($cleanupRequested) {
            if ($registeredPsRepository) {
                try {
                    $registeredRepository = Get-PSRepository -Name $effectiveRepositoryName -ErrorAction SilentlyContinue
                    if ($null -ne $registeredRepository) {
                        Write-PublishReleaseLog -Message (
                            "Removing temporary PowerShell repository registration '{0}'." -f $effectiveRepositoryName
                        ) -Level 'DBG'
                        Unregister-PSRepository -Name $effectiveRepositoryName -ErrorAction Stop | Out-Null
                    }
                }
                catch {
                    $cleanupCompleted = $false
                    Write-PublishReleaseLog -Message (
                        "Failed to remove PowerShell repository registration '{0}': {1}" -f
                        $effectiveRepositoryName, $_.Exception.Message
                    ) -Level 'WRN'
                }
            }

            if ($registeredDotnetSource) {
                try {
                    Unregister-LocalNuGetDotNetPackageSource -SourceName $effectiveRepositoryName
                }
                catch {
                    $cleanupCompleted = $false
                    Write-PublishReleaseLog -Message (
                        "Failed to remove dotnet NuGet source '{0}': {1}" -f
                        $effectiveRepositoryName, $_.Exception.Message
                    ) -Level 'WRN'
                }
            }
        }
    }

    $result.Published = $published
    $result.CleanupCompleted = if ($cleanupRequested) { $cleanupCompleted } else { $null }

    if ($null -ne $operationError) {
        # Emit the original target-specific message as the public error contract after cleanup.
        throw $operationError.Exception.Message
    }

    if ($cleanupRequested -and -not $cleanupCompleted) {
        throw "Module publication completed, but cleanup of temporary repository registrations failed for '$effectiveRepositoryName'."
    }

    if ($published) {
        Write-PublishReleaseLog -Message (
            "Published module '{0}' version '{1}' to target '{2}' using repository '{3}'." -f
            $moduleName, $moduleVersion, $Target, $effectiveRepositoryName
        )
    }

    if ($PassThru) {
        return [pscustomobject]$result
    }
}