Publish-PSLocalBase.ps1
|
<# .SYNOPSIS Packages and publishes PSLocalBase to the PowerShell Gallery (PSGallery). .DESCRIPTION Creates a clean, production-only staging package containing exclusively the core database engine and metadata files (PSLocalBase.psd1, PSLocalBase.psm1, PSLocalBase.Cmdlets.ps1, InitWinSqlite3Native.ps1, LICENSE, README.md, CHANGELOG.md). The cmdlet adapter remains a separate script, matching the source layout. Explicitly excludes development assets, tests, documentation, and the Playground visual GUI explorer. .PARAMETER NuGetApiKey The PowerShell Gallery API key. If omitted, the script checks the PSGALLERY_API_KEY environment variable, or prompts securely for it. .PARAMETER DryRun Creates staging, builds a local NuGet archive, and verifies its actual contents without uploading to PSGallery. .PARAMETER KeepStaging Retains the staged module for inspection or isolated testing after completion. .EXAMPLE # Test packaging without uploading: .\Publish-PSLocalBase.ps1 -DryRun .EXAMPLE # Publish by passing API key directly: .\Publish-PSLocalBase.ps1 -NuGetApiKey "oy2a..." .EXAMPLE # Publish using environment variable: $env:PSGALLERY_API_KEY = "oy2a..." .\Publish-PSLocalBase.ps1 #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Position = 0)] [string]$NuGetApiKey, [switch]$DryRun, [switch]$KeepStaging ) $ErrorActionPreference = 'Stop' # Ensure TLS 1.2 is enabled for PowerShell Gallery (required for Windows PowerShell 5.1) try { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 } catch { } # 1. Resolve root repository path $repoRoot = $PSScriptRoot if (-not $repoRoot) { $repoRoot = (Get-Location).Path } $manifestPath = Join-Path $repoRoot "PSLocalBase.psd1" if (-not (Test-Path $manifestPath)) { throw "Cannot find PSLocalBase.psd1 at '$repoRoot'. Run this script from the repository root." } # 2. Validate manifest syntax before staging $manifest = Test-ModuleManifest -Path $manifestPath $version = $manifest.Version.ToString() Write-Host "==========================================================" -ForegroundColor Cyan Write-Host "Publisher script: $PSCommandPath" -ForegroundColor Gray Write-Host " Preparing to Publish PSLocalBase v$version" -ForegroundColor Cyan Write-Host "==========================================================" -ForegroundColor Cyan # 3. Resolve API Key $resolvedKey = $NuGetApiKey if ([string]::IsNullOrWhiteSpace($resolvedKey)) { $resolvedKey = $env:PSGALLERY_API_KEY } if (-not $DryRun -and [string]::IsNullOrWhiteSpace($resolvedKey)) { Write-Host "No API key was passed as parameter or found in `$env:PSGALLERY_API_KEY." -ForegroundColor Yellow $secureKey = Read-Host -Prompt "Enter your PowerShell Gallery API Key" -AsSecureString $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureKey) try { $resolvedKey = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } if (-not $DryRun -and [string]::IsNullOrWhiteSpace($resolvedKey)) { throw "A valid PowerShell Gallery API key is required to publish. Use -DryRun to test packaging." } # 4. Prepare Staging Directory (Excluding Playground, _TESTS, _DOCS, .git) $stagingId = [System.Guid]::NewGuid().ToString("N").Substring(0, 8) # Note: PowerShellGet requires the leaf folder name to match the module name exactly ('PSLocalBase') $stagingRoot = Join-Path ([System.IO.Path]::GetTempPath()) "PSLocalBase_Staging_$stagingId" $stagingDir = Join-Path $stagingRoot "PSLocalBase" $resolvedTempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\', '/') $resolvedStagingRoot = [System.IO.Path]::GetFullPath($stagingRoot) if ([System.IO.Path]::GetDirectoryName($resolvedStagingRoot) -ne $resolvedTempRoot -or [System.IO.Path]::GetFileName($resolvedStagingRoot) -notmatch '\APSLocalBase_Staging_[a-f0-9]{8}\z') { throw "Staging directory must be a dedicated child of the system temporary directory." } try { if (Test-Path -LiteralPath $stagingRoot) { throw "Staging directory already exists: '$stagingRoot'." } New-Item -ItemType Directory -Path $stagingDir | Out-Null Write-Host "Staging production files..." -ForegroundColor Gray $filesToPackage = @( "PSLocalBase.psd1", "PSLocalBase.psm1", "PSLocalBase.Cmdlets.ps1", "InitWinSqlite3Native.ps1", "LICENSE", "README.md", "CHANGELOG.md" ) foreach ($file in $filesToPackage) { $sourcePath = Join-Path $repoRoot $file if (-not (Test-Path $sourcePath)) { throw "Required package file not found: '$sourcePath'" } Copy-Item -Path $sourcePath -Destination $stagingDir -Force Write-Host " + Included: $file" -ForegroundColor DarkGreen } # Normalize README.md links in staging to prevent broken links # (replaces relative links to Playground, _DOCS, _TESTS with absolute GitHub URLs) $stagedReadme = Join-Path $stagingDir "README.md" if (Test-Path $stagedReadme) { $readmeText = [System.IO.File]::ReadAllText($stagedReadme, [System.Text.Encoding]::UTF8) # 1. Images: Playground/screenshots/... -> raw.githubusercontent.com $readmeText = $readmeText -replace '\(Playground/screenshots/([^\)]+)\)', '(https://raw.githubusercontent.com/cmartineztech/PSLocalBase/main/Playground/screenshots/$1)' # 2. Folders: (_DOCS/) or (_DOCS/#section) -> tree/main/_DOCS $readmeText = $readmeText -replace '\(_DOCS/(#[^\)]*)?\)', '(https://github.com/cmartineztech/PSLocalBase/tree/main/_DOCS/$1)' # 3. Specific documents in _DOCS, _TESTS, Playground $readmeText = $readmeText -replace '\(_DOCS/([a-zA-Z0-9_\-\.]+\.md)(#[^\)]*)?\)', '(https://github.com/cmartineztech/PSLocalBase/blob/main/_DOCS/$1$2)' $readmeText = $readmeText -replace '\(_TESTS/([^\)]+)\)', '(https://github.com/cmartineztech/PSLocalBase/blob/main/_TESTS/$1)' $readmeText = $readmeText -replace '\(Playground/([^\)]+)\)', '(https://github.com/cmartineztech/PSLocalBase/blob/main/Playground/$1)' $readmeText = $readmeText -replace '\(SECURITY\.md\)', '(https://github.com/cmartineztech/PSLocalBase/blob/main/SECURITY.md)' [System.IO.File]::WriteAllText($stagedReadme, $readmeText, [System.Text.Encoding]::UTF8) Write-Host " * Normalized staged README.md relative links to absolute GitHub URLs (no broken links)." -ForegroundColor DarkGreen } # Verify staging manifest $stagedManifestPath = Join-Path $stagingDir "PSLocalBase.psd1" $stagedManifest = Test-ModuleManifest -Path $stagedManifestPath $actualFiles = @(Get-ChildItem -LiteralPath $stagingDir -File | Select-Object -ExpandProperty Name) if (@(Compare-Object $filesToPackage $actualFiles).Count -ne 0 -or @(Get-ChildItem -LiteralPath $stagingDir -Directory).Count -ne 0 -or @(Compare-Object $filesToPackage @($stagedManifest.FileList | ForEach-Object { Split-Path $_ -Leaf })).Count -ne 0) { throw 'Staged package contents do not match the seven-file allowlist.' } Write-Host "[OK] Staged module manifest verified successfully (v$($stagedManifest.Version))." -ForegroundColor Green # Display package contents Write-Host "`nStaged Package Contents ($stagingDir):" -ForegroundColor Cyan Get-ChildItem -Path $stagingDir | Select-Object Name, Length | Format-Table -AutoSize | Out-String | Write-Host Write-Host "Excluded from package:" -ForegroundColor DarkGray Write-Host " - Playground/ (GUI explorer)" -ForegroundColor DarkGray Write-Host " - _TESTS/ (Unit and stress test suites)" -ForegroundColor DarkGray Write-Host " - _DOCS/ (Extended technical specifications)" -ForegroundColor DarkGray Write-Host " - .github/ (CI/CD workflows)" -ForegroundColor DarkGray # Build the actual NuGet archive locally, then inspect it before any upload. # Publish-Module is deliberately restricted to this temporary local repository. $packageFeed = Join-Path $stagingRoot 'package' $localRepository = 'PSLocalBasePackageCheck_' + [System.Guid]::NewGuid().ToString('N') New-Item -ItemType Directory -Path $packageFeed | Out-Null $registeredLocalRepository = $false try { PowerShellGet\Register-PSRepository -Name $localRepository -SourceLocation $packageFeed -PublishLocation $packageFeed -InstallationPolicy Trusted -ErrorAction Stop $registeredLocalRepository = $true PowerShellGet\Publish-Module -Path $stagingDir -Repository $localRepository -WhatIf:$false -ErrorAction Stop } finally { if ($registeredLocalRepository) { PowerShellGet\Unregister-PSRepository -Name $localRepository -ErrorAction Stop } } $packagePath = Join-Path $packageFeed "PSLocalBase.$version.nupkg" if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) { throw "Expected NuGet archive not generated: '$packagePath'." } Add-Type -AssemblyName System.IO.Compression.FileSystem $archive = [System.IO.Compression.ZipFile]::OpenRead($packagePath) try { $archiveEntries = @($archive.Entries | ForEach-Object { $_.FullName.Replace('\', '/') }) $packagePayload = @($archiveEntries | Where-Object { $_ -ne '_rels/.rels' -and $_ -ne '[Content_Types].xml' -and $_ -notmatch '\Apackage/services/metadata/core-properties/[^/]+\.psmdcp\z' }) $expectedPayload = @($filesToPackage) + 'PSLocalBase.nuspec' if ($packagePayload.Count -ne $expectedPayload.Count -or @(Compare-Object $expectedPayload $packagePayload).Count -ne 0) { throw "UPLOAD BLOCKED: actual .nupkg contains unexpected or missing files: $($packagePayload -join ', ')" } } finally { $archive.Dispose() } $verifiedHash = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash Write-Host "[OK] Actual .nupkg verified: seven module files plus generated nuspec." -ForegroundColor Green Write-Host "Verified archive: $packagePath" -ForegroundColor Cyan Write-Host "SHA256: $verifiedHash" -ForegroundColor Gray # 5. Execute Publish or Dry Run if ($DryRun) { Write-Host "`n[DRY RUN COMPLETE] Package is valid and ready. No upload was performed." -ForegroundColor Yellow Write-Host "To publish for real, run:" -ForegroundColor Yellow Write-Host " .\Publish-PSLocalBase.ps1 -NuGetApiKey 'TU_API_KEY'" -ForegroundColor White } else { Write-Host "`nPublishing PSLocalBase v$version to PowerShell Gallery (PSGallery)..." -ForegroundColor Cyan # Push the inspected archive itself; never rebuild from the repository root. $nugetCandidates = @( (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\PowerShell\PowerShellGet\NuGet.exe'), (Join-Path $env:ProgramData 'Microsoft\Windows\PowerShell\PowerShellGet\NuGet.exe') ) $nugetCommand = Get-Command NuGet.exe -CommandType Application -ErrorAction SilentlyContinue if ($nugetCommand) { $nugetCandidates += $nugetCommand.Source } $nugetExe = $nugetCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 if (-not $nugetExe) { throw 'NuGet.exe was not found. No upload was attempted.' } if ($PSCmdlet.ShouldProcess("PSGallery / PSLocalBase $version", 'Upload verified seven-file NuGet archive')) { if ((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash -ne $verifiedHash) { throw 'UPLOAD BLOCKED: archive changed after verification.' } & $nugetExe push $packagePath -Source 'https://www.powershellgallery.com/api/v2/package' -ApiKey $resolvedKey -NonInteractive if ($LASTEXITCODE -ne 0) { throw "NuGet push failed with exit code $LASTEXITCODE." } Write-Host "`n[SUCCESS] PSLocalBase v$version was published successfully to PowerShell Gallery!" -ForegroundColor Green Write-Host "View your published module at: https://www.powershellgallery.com/packages/PSLocalBase/$version" -ForegroundColor Cyan Write-Host "Install with: Install-Module -Name PSLocalBase" -ForegroundColor White } } } finally { if ($KeepStaging) { Write-Host "Staged module retained at: $stagingDir" -ForegroundColor Cyan if ($packagePath) { Write-Host "Verified archive retained at: $packagePath" -ForegroundColor Cyan } } elseif (Test-Path -LiteralPath $resolvedStagingRoot) { Remove-Item -LiteralPath $resolvedStagingRoot -Recurse -Force -ErrorAction SilentlyContinue } } |