Public/Publish-NSPModule.ps1

function Publish-NSPModule {
    <#
    .SYNOPSIS
        Checks, packages, and publishes an NSP module repository to the PowerShell Gallery.
    .DESCRIPTION
        The release routine shared by every NSP module repository (it replaces each repo's own
        tools\Publish-ToGallery.ps1). Every check runs before anything is published:

        1. Manifest: exactly one module manifest at the root; Test-ModuleManifest passes; when a
           Public\ folder exists, FunctionsToExport lists exactly its files.
        2. Changelog: CHANGELOG.md (when present) has a heading for this version that is not
           marked unreleased.
        3. Git: no uncommitted or untracked files, so the package is exactly a commit.
        4. Version: higher than the newest version already in the repository.
        5. Client references: Find-NSPClientReference over tracked and untracked files and all of
           history, with your client token list (an empty list fails: only patterns would run).
           Prints locations and rules only.
        6. The repository's own tools\Test-Repo.ps1, in a separate process; it must exit 0.
        7. Package: tracked files only (ignored client data can never ship), without the
           developer files (Tests\, tools\, dot files, CLAUDE.md, PLAN.md, analyzer settings),
           staged under the module's name. The staged manifest must pass Test-ModuleManifest and
           import under Windows PowerShell 5.1.

        Then, after confirmation, Publish-PSResource (Microsoft.PowerShell.PSResourceGet) with the
        API key from Get-NSPSecret (NSP.Bootstrap), a check that the version is really live, and a
        local annotated tag v<version> (not pushed; -NoTag skips it).

        -WhatIf runs every check and the staging, lists the package, and publishes nothing.

        A tracked .nsp-repotools.psd1 can adjust the package:
            @{ Publish = @{ Exclude = @('Legacy/*', 'docs/*'); Include = @() } }
        Exclude adds wildcards on the repo-relative path (forward slashes). Include, when set,
        limits the package to those top-level files and folders.
    .EXAMPLE
        Publish-NSPModule -Path . -WhatIf
    .EXAMPLE
        Publish-NSPModule -Path C:\GitRepo\NSP-PoSHToolkits\NSP-Console
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive release routine: progress is for the person publishing.')]
    param(
        [string]$Path = '.',
        [string]$Repository = 'PSGallery',
        [string]$SecretName = 'MS.PSGallery.ApiKey',
        [string[]]$TokenPath = @(Get-NSPClientTokenPath),
        [switch]$NoTag
    )

    $ErrorActionPreference = 'Stop'
    $root = (Invoke-NSPGit -RepoRoot (Resolve-Path -LiteralPath $Path).ProviderPath -Arguments 'rev-parse', '--show-toplevel' | Select-Object -First 1)
    if (-not $root) { throw "$Path is not inside a Git repository." }
    $root = $root.Replace('/', '\')
    $config = Get-NSPPublishConfig -RepoRoot $root
    $failures = [System.Collections.Generic.List[string]]::new()
    $step = { param([string]$Name) Write-Host "`n=== $Name ===" -ForegroundColor Cyan }
    $fail = { param([string]$Message) $failures.Add($Message); Write-Host " $Message" -ForegroundColor Red }
    $pass = { param([string]$Message) Write-Host " $Message" -ForegroundColor Green }

    # 1. Manifest
    & $step 'Manifest'
    $manifests = @(Get-ChildItem -LiteralPath $root -Filter '*.psd1' -File | Where-Object { $_.Name -ne 'PSScriptAnalyzerSettings.psd1' -and -not $_.Name.StartsWith('.') } |
            Where-Object { (Import-PowerShellDataFile -LiteralPath $_.FullName).ContainsKey('ModuleVersion') })
    if ($manifests.Count -ne 1) { throw "Expected one module manifest at $root, found $($manifests.Count)." }
    $manifestFile = $manifests[0]
    $name = $manifestFile.BaseName
    $manifest = Import-PowerShellDataFile -LiteralPath $manifestFile.FullName
    $version = [version]$manifest.ModuleVersion
    $null = Test-ModuleManifest -Path $manifestFile.FullName
    & $pass "$name $version"
    $publicFolder = Join-Path $root 'Public'
    if (Test-Path -LiteralPath $publicFolder) {
        $public = @(Get-ChildItem -LiteralPath $publicFolder -Filter '*.ps1' -File -Recurse | ForEach-Object { $_.BaseName } | Sort-Object)
        $exported = @($manifest.FunctionsToExport | Sort-Object)
        if ($exported -contains '*') { & $fail 'FunctionsToExport is a wildcard: list the Public\ functions.' }
        elseif (@(Compare-Object $public $exported).Count) { & $fail "FunctionsToExport does not match Public\: $(@(Compare-Object $public $exported | ForEach-Object { '{0}{1}' -f $(if ($_.SideIndicator -eq '<=') { 'not exported: ' } else { 'no file: ' }), $_.InputObject }) -join '; ')" }
        else { & $pass "FunctionsToExport matches Public\ ($($public.Count))" }
    }

    # 2. Changelog
    $changelog = Join-Path $root 'CHANGELOG.md'
    if (Test-Path -LiteralPath $changelog) {
        $heading = @(Get-Content -LiteralPath $changelog | Where-Object { $_ -match ('^#+\s.*(?<![\d.])' + [regex]::Escape([string]$version) + '(?![\d.])') }) | Select-Object -First 1
        if (-not $heading) { & $fail "CHANGELOG.md has no heading for $version." }
        elseif ($heading -match '(?i)unreleased') { & $fail "CHANGELOG.md still marks $version unreleased: $heading" }
        else { & $pass "CHANGELOG.md: $heading" }
    }

    # 3. Git
    & $step 'Git'
    $dirty = @(Invoke-NSPGit -RepoRoot $root -Arguments 'status', '--porcelain' | Where-Object { $_ })
    if ($dirty.Count) { & $fail "$($dirty.Count) uncommitted or untracked file(s): commit or ignore them first." }
    $commit = Invoke-NSPGit -RepoRoot $root -Arguments 'rev-parse', '--short', 'HEAD' | Select-Object -First 1
    $tagName = "v$version"
    if (@(Invoke-NSPGit -RepoRoot $root -Arguments 'tag', '--list', $tagName | Where-Object { $_ }).Count) {
        & $fail "Tag $tagName already exists: was $version released already?"
    }
    if (-not $dirty.Count) { & $pass "clean at $commit" }

    # 4. Version
    & $step "Version on $Repository"
    try {
        $published = Get-NSPPublishedVersion -Name $name -Repository $Repository
        if ($published -and $published -ge $version) { & $fail "$Repository already has $name $published; raise ModuleVersion above it." }
        elseif ($published) { & $pass "$Repository has $published; publishing $version" }
        else { & $pass "$name is not on $Repository yet" }
    } catch {
        if ($WhatIfPreference) { Write-Warning "Could not check $Repository ($_). A real run stops here." }
        else { & $fail "Could not check $Repository`: $_" }
    }

    if ($failures.Count) { throw "Not published. Fix first:`n- $($failures -join "`n- ")" }

    # 5. Client references, history included. Locations and rules only.
    & $step 'Client references (working tree, untracked, history)'
    if (-not @(Get-NSPClientToken -Path $TokenPath).Count) {
        & $fail "Your client token list is empty ($($TokenPath -join ', ')): only the pattern rules would run. Add tokens with Add-NSPClientToken."
    } else {
        $hits = @(Find-NSPClientReference -Path $root -TokenPath $TokenPath -IncludeUntracked -History -WarningAction SilentlyContinue)
        if ($hits.Count) {
            foreach ($hit in $hits) { & $fail "$($hit.Scope) $($hit.File):$($hit.Line)$(if ($hit.Commit) { " @$($hit.Commit)" }) [$($hit.Rule)]" }
            Write-Host ' Review with: Find-NSPClientReference -Path . -IncludeUntracked -History' -ForegroundColor Red
        } else { & $pass 'clean' }
    }

    # 6. The repository's own checks
    $testRepo = Join-Path $root 'tools\Test-Repo.ps1'
    if (Test-Path -LiteralPath $testRepo) {
        & $step 'tools\Test-Repo.ps1'
        $hostExe = (Get-Process -Id $PID).Path
        & $hostExe -NoLogo -NoProfile -ExecutionPolicy Bypass -File $testRepo | Out-Host
        if ($LASTEXITCODE -ne 0) { & $fail "tools\Test-Repo.ps1 failed (exit code $LASTEXITCODE)." }
    } else {
        Write-Warning 'No tools\Test-Repo.ps1: only the checks above ran.'
    }

    if ($failures.Count) { throw "Not published. Fix first:`n- $($failures -join "`n- ")" }

    # 7. Package: tracked files only, staged under the module's name.
    & $step 'Package'
    $files = @(Get-NSPPublishFile -RepoRoot $root -Config $config)
    if ($files -notcontains $manifestFile.Name) { throw "The package would not include $($manifestFile.Name): check Publish.Include and Publish.Exclude." }
    $tempBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')
    $stageRoot = Join-Path $tempBase ('NSPPublish_' + [guid]::NewGuid().ToString('N'))
    $stage = Join-Path $stageRoot $name
    $result = [pscustomobject]@{ Name = $name; Version = $version; Repository = $Repository; Commit = $commit; Files = $files; Published = $false; Tag = $null }
    try {
        foreach ($file in $files) {
            $target = Join-Path $stage $file
            $null = New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force -WhatIf:$false -Confirm:$false
            Copy-Item -LiteralPath (Join-Path $root $file) -Destination $target -WhatIf:$false -Confirm:$false
        }
        $top = @($files | ForEach-Object { ($_ -split '/')[0] } | Select-Object -Unique)
        & $pass "$($files.Count) files: $($top -join ', ')"
        $null = Test-ModuleManifest -Path (Join-Path $stage $manifestFile.Name)
        $windowsPowerShell = Get-Command 'powershell.exe' -ErrorAction SilentlyContinue
        if ($windowsPowerShell) {
            $stagedManifest = Join-Path $stage $manifestFile.Name
            & $windowsPowerShell.Source -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "`$ErrorActionPreference='Stop'; Import-Module '$stagedManifest' -Force" 2>&1 | Out-Host
            if ($LASTEXITCODE -ne 0) { throw 'The staged package does not import under Windows PowerShell 5.1: a file it needs is missing from the package.' }
            & $pass 'package imports under Windows PowerShell 5.1'
        }

        if ($PSCmdlet.ShouldProcess("$name $version from $commit -> $Repository", 'Publish')) {
            & $step "Publish to $Repository"
            Invoke-NSPPublishResource -Path $stage -Repository $Repository -SecretName $SecretName
            $live = $null
            foreach ($attempt in 1..6) {
                Start-Sleep -Seconds 10
                try { $live = Get-NSPPublishedVersion -Name $name -Repository $Repository } catch { $live = $null }
                if ($live -ge $version) { break }
            }
            if ($live -ge $version) {
                $result.Published = $true
                & $pass "$name $version is live on $Repository."
            } else {
                throw "Publish-PSResource reported success, but $Repository does not list $name $version yet. Check before publishing again: it may be indexing lag."
            }
            if (-not $NoTag) {
                Invoke-NSPGit -RepoRoot $root -Arguments '-c', 'tag.gpgsign=false', 'tag', '-a', $tagName, '-m', "$name $version" | Out-Null
                $result.Tag = $tagName
                & $pass "Tagged $tagName at $commit (local). Push it with: git push origin $tagName"
            }
        }
    } finally {
        Remove-Item -LiteralPath $stageRoot -Recurse -Force -WhatIf:$false -Confirm:$false -ErrorAction SilentlyContinue
    }
    $result
}