scripts/internal/invoke-module-release.ps1
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'SigningCertPassword', Justification = 'GitHub Actions and local release tests supply secrets as strings; the script converts them before use.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'Password', Justification = 'Internal helper parameters are immediately converted to SecureString values for certificate import/export.')] [CmdletBinding()] param( [string]$RepositoryRoot = (Resolve-Path (Join-Path -Path $PSScriptRoot -ChildPath '..\..')).Path, [ValidateSet('dry-run', 'publish-prerelease', 'publish-stable', 'promote-prerelease')] [string]$ReleaseMode = 'dry-run', [AllowEmptyString()] [string]$GitRefType = '', [AllowEmptyString()] [string]$GitRefName = '', [AllowEmptyString()] [string]$SigningCertBase64 = $env:SIGNING_CERT_BASE64, [AllowEmptyString()] [string]$SigningCertPassword = $env:SIGNING_CERT_PASSWORD, [AllowEmptyString()] [string]$PSGalleryApiKey = $env:PSGALLERY_API_KEY, [string]$SummaryPath, [switch]$AllowEphemeralSigningCertificate ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Write-ReleaseInfo { param([string]$Message) Write-Host "[release] $Message" -ForegroundColor Cyan } function Get-SpecrewVersionFromConfig { param([Parameter(Mandatory = $true)][string]$ConfigPath) if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { throw "Missing Specrew config '$ConfigPath'." } foreach ($line in Get-Content -LiteralPath $ConfigPath -Encoding UTF8) { if ($line -match '^\s*specrew_version:\s*"?(?<version>[^"#]+?)"?\s*$') { return $Matches.version.Trim() } } throw "Could not read 'specrew_version' from '$ConfigPath'." } function Set-SpecrewManifestReleaseMetadata { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param( [Parameter(Mandatory = $true)][string]$ManifestPath, [Parameter(Mandatory = $true)][string]$Version, [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Prerelease ) if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { throw "Missing module manifest '$ManifestPath'." } $content = Get-Content -LiteralPath $ManifestPath -Raw -Encoding UTF8 $updated = [regex]::Replace($content, "(?m)^(\s*ModuleVersion\s*=\s*)'[^']*'\s*$", ('$1''{0}''' -f $Version), 1) if ($updated -eq $content) { throw "Could not locate ModuleVersion in '$ManifestPath'." } $withPrerelease = [regex]::Replace($updated, "(?m)^(\s*Prerelease\s*=\s*)'[^']*'\s*$", ('$1''{0}''' -f $Prerelease), 1) if ($withPrerelease -eq $updated) { throw "Could not locate PrivateData.PSData.Prerelease in '$ManifestPath'." } if ($PSCmdlet.ShouldProcess($ManifestPath, ("Set ModuleVersion to {0} with Prerelease '{1}'" -f $Version, $Prerelease))) { [System.IO.File]::WriteAllText($ManifestPath, $withPrerelease, [System.Text.UTF8Encoding]::new($false)) } } function Get-SpecrewManifestReleaseInfo { param([Parameter(Mandatory = $true)][string]$ManifestPath) $manifest = Import-PowerShellDataFile -Path $ManifestPath $prerelease = '' if ( $manifest.ContainsKey('PrivateData') -and $manifest.PrivateData -and $manifest.PrivateData.ContainsKey('PSData') -and $manifest.PrivateData.PSData -and $manifest.PrivateData.PSData.ContainsKey('Prerelease') -and $null -ne $manifest.PrivateData.PSData['Prerelease'] ) { $prerelease = [string]$manifest.PrivateData.PSData['Prerelease'] } return [pscustomobject]@{ ModuleVersion = [string]$manifest.ModuleVersion Prerelease = $prerelease } } function ConvertTo-ManifestPrerelease { param([AllowEmptyString()][string]$TagPrerelease) if ([string]::IsNullOrWhiteSpace($TagPrerelease)) { return '' } return ($TagPrerelease -replace '[.+]', '') } function Resolve-ReleaseStamp { param( [Parameter(Mandatory = $true)][string]$ReleaseMode, [AllowEmptyString()][string]$GitRefType, [AllowEmptyString()][string]$GitRefName, [Parameter(Mandatory = $true)][string]$ExpectedVersion ) if ([string]::IsNullOrWhiteSpace($GitRefType) -or [string]::IsNullOrWhiteSpace($GitRefName)) { if ($ReleaseMode -ne 'dry-run') { throw ("Release mode '{0}' requires a v*.* tag ref or workflow_dispatch release_tag input." -f $ReleaseMode) } return [pscustomobject]@{ ModuleVersion = $ExpectedVersion ManifestPrerelease = '' SourcePrereleaseTag = '' EffectiveVersion = $ExpectedVersion } } if ($GitRefType -ne 'tag') { if ($ReleaseMode -ne 'dry-run') { throw ("Release mode '{0}' requires a tag ref, but the workflow is running against ref type '{1}'." -f $ReleaseMode, $GitRefType) } return [pscustomobject]@{ ModuleVersion = $ExpectedVersion ManifestPrerelease = '' SourcePrereleaseTag = '' EffectiveVersion = $ExpectedVersion } } if ($GitRefName -notmatch '^v(?<version>\d+\.\d+\.\d+)(?:-(?<prerelease>[0-9A-Za-z][0-9A-Za-z.-]*))?$') { throw ("Tag '{0}' does not follow the required v*.* format." -f $GitRefName) } $tagVersion = $Matches.version $tagPrerelease = if ($Matches.ContainsKey('prerelease') -and -not [string]::IsNullOrWhiteSpace($Matches.prerelease)) { $Matches.prerelease } else { '' } if ($tagVersion -ne $ExpectedVersion) { throw ("Tag version '{0}' does not match .specrew/config.yml specrew_version '{1}'." -f $tagVersion, $ExpectedVersion) } $normalizedTagPrerelease = ConvertTo-ManifestPrerelease -TagPrerelease $tagPrerelease $manifestPrerelease = switch ($ReleaseMode) { 'dry-run' { $normalizedTagPrerelease } 'publish-prerelease' { if ([string]::IsNullOrWhiteSpace($tagPrerelease)) { throw ("Release mode '{0}' requires a prerelease tag like v{1}-beta.1." -f $ReleaseMode, $ExpectedVersion) } $normalizedTagPrerelease } 'publish-stable' { if (-not [string]::IsNullOrWhiteSpace($tagPrerelease)) { throw ("Release mode '{0}' requires a stable tag with no prerelease suffix." -f $ReleaseMode) } '' } 'promote-prerelease' { if ([string]::IsNullOrWhiteSpace($tagPrerelease)) { throw ("Release mode '{0}' requires a prerelease tag to promote from." -f $ReleaseMode) } '' } default { throw ("Unsupported release mode '{0}'." -f $ReleaseMode) } } $effectiveVersion = if ([string]::IsNullOrWhiteSpace($manifestPrerelease)) { $ExpectedVersion } else { '{0}-{1}' -f $ExpectedVersion, $manifestPrerelease } return [pscustomobject]@{ ModuleVersion = $ExpectedVersion ManifestPrerelease = $manifestPrerelease SourcePrereleaseTag = $tagPrerelease EffectiveVersion = $effectiveVersion } } function Test-SigningCertificateValidity { param([Parameter(Mandatory = $true)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) $validityDays = ($Certificate.NotAfter.ToUniversalTime() - $Certificate.NotBefore.ToUniversalTime()).TotalDays if ($validityDays -gt 370) { throw ("Signing certificate validity ({0:N1} days) exceeds the approved 1-year model." -f $validityDays) } } function New-ReleaseScratchRoot { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param([Parameter(Mandatory = $true)][string]$RepositoryRoot) $scratchRoot = Join-Path -Path $RepositoryRoot -ChildPath '.scratch\module-release' if (Test-Path -LiteralPath $scratchRoot) { if ($PSCmdlet.ShouldProcess($scratchRoot, 'Reset release scratch root')) { Remove-Item -LiteralPath $scratchRoot -Recurse -Force } } if ($PSCmdlet.ShouldProcess($scratchRoot, 'Create release scratch root')) { $null = New-Item -Path $scratchRoot -ItemType Directory -Force } return $scratchRoot } function Copy-ReleaseFile { param( [Parameter(Mandatory = $true)][string]$RepositoryRoot, [Parameter(Mandatory = $true)][string]$StageRoot, [Parameter(Mandatory = $true)][string]$RelativePath ) $sourcePath = Join-Path -Path $RepositoryRoot -ChildPath $RelativePath if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { throw "Missing release file '$RelativePath'." } $destinationPath = Join-Path -Path $StageRoot -ChildPath $RelativePath $destinationDirectory = Split-Path -Path $destinationPath -Parent if (-not [string]::IsNullOrWhiteSpace($destinationDirectory) -and -not (Test-Path -LiteralPath $destinationDirectory)) { $null = New-Item -Path $destinationDirectory -ItemType Directory -Force } Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -Force } function New-ReleaseStageRoot { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param( [Parameter(Mandatory = $true)][string]$RepositoryRoot, [Parameter(Mandatory = $true)][string]$ScratchRoot, [Parameter(Mandatory = $true)][string]$ManifestPath ) $stageRoot = Join-Path -Path $ScratchRoot -ChildPath 'Specrew' if ($PSCmdlet.ShouldProcess($stageRoot, 'Create staged module release root')) { $null = New-Item -Path $stageRoot -ItemType Directory -Force } $manifest = Import-PowerShellDataFile -Path $ManifestPath $filesToStage = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($relativePath in @($manifest.FileList)) { if ([string]::IsNullOrWhiteSpace($relativePath)) { continue } if ($filesToStage.Add($relativePath)) { Copy-ReleaseFile -RepositoryRoot $RepositoryRoot -StageRoot $stageRoot -RelativePath $relativePath } } foreach ($optionalPath in @('README.md', 'CHANGELOG.md', 'LICENSE', 'NOTICE.md')) { $sourcePath = Join-Path -Path $RepositoryRoot -ChildPath $optionalPath if ((Test-Path -LiteralPath $sourcePath -PathType Leaf) -and $filesToStage.Add($optionalPath)) { Copy-ReleaseFile -RepositoryRoot $RepositoryRoot -StageRoot $stageRoot -RelativePath $optionalPath } } return $stageRoot } function Import-SecretSigningCertificate { param( [Parameter(Mandatory = $true)][string]$ScratchRoot, [Parameter(Mandatory = $true)][string]$Base64, [Parameter(Mandatory = $true)][string]$Password ) $pfxPath = Join-Path -Path $ScratchRoot -ChildPath 'specrew-signing-cert.pfx' [System.IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($Base64)) $securePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force $certificate = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation 'Cert:\CurrentUser\My' -Password $securePassword -Exportable if ($null -eq $certificate -or -not $certificate.HasPrivateKey) { throw 'Failed to import signing certificate with a private key.' } Test-SigningCertificateValidity -Certificate $certificate return [pscustomobject]@{ Certificate = $certificate CleanupPath = $pfxPath Source = 'github-secret' } } function New-DryRunSigningCertificate { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param([Parameter(Mandatory = $true)][string]$ScratchRoot) $certificate = $null if ($PSCmdlet.ShouldProcess($ScratchRoot, 'Create dry-run self-signed code-signing certificate')) { $certificate = New-SelfSignedCertificate ` -Subject 'CN=Specrew Module Signing (Dry Run)' ` -Type CodeSigningCert ` -CertStoreLocation 'Cert:\CurrentUser\My' ` -NotAfter (Get-Date).AddYears(1) } Test-SigningCertificateValidity -Certificate $certificate return [pscustomobject]@{ Certificate = $certificate CleanupPath = $null Source = 'ephemeral-dry-run' } } function Get-ReleaseSigningCertificate { param( [Parameter(Mandatory = $true)][string]$ScratchRoot, [Parameter(Mandatory = $true)][string]$ReleaseMode, [AllowEmptyString()][string]$Base64, [AllowEmptyString()][string]$Password, [bool]$AllowEphemeralFallback ) if (-not [string]::IsNullOrWhiteSpace($Base64) -and -not [string]::IsNullOrWhiteSpace($Password)) { return Import-SecretSigningCertificate -ScratchRoot $ScratchRoot -Base64 $Base64 -Password $Password } if ($ReleaseMode -ne 'dry-run') { throw 'Live publish requires SIGNING_CERT_BASE64 and SIGNING_CERT_PASSWORD to be configured.' } if (-not $AllowEphemeralFallback) { throw 'Dry-run release requires signing secrets or -AllowEphemeralSigningCertificate.' } return New-DryRunSigningCertificate -ScratchRoot $ScratchRoot } function Set-ReleaseSignature { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param( [Parameter(Mandatory = $true)][string[]]$FilePaths, [Parameter(Mandatory = $true)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate ) $results = @() foreach ($filePath in $FilePaths) { if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { throw "Missing file to sign '$filePath'." } if (-not $PSCmdlet.ShouldProcess($filePath, 'Apply Authenticode signature')) { continue } $null = Set-AuthenticodeSignature -FilePath $filePath -Certificate $Certificate -HashAlgorithm SHA256 $verifiedSignature = Get-AuthenticodeSignature -FilePath $filePath if ($null -eq $verifiedSignature.SignerCertificate -or $verifiedSignature.SignerCertificate.Thumbprint -ne $Certificate.Thumbprint) { throw ("Signature verification for '{0}' did not match the expected signing certificate." -f $filePath) } if ($verifiedSignature.Status -in @('NotSigned', 'HashMismatch', 'NotSupportedFileFormat')) { throw ("Authenticode signing failed for '{0}' with status '{1}'." -f $filePath, $verifiedSignature.Status) } $results += [pscustomobject]@{ Path = $filePath Status = [string]$verifiedSignature.Status } } return $results } function Write-ReleaseSummary { param( [AllowEmptyString()][string]$SummaryPath, [Parameter(Mandatory = $true)][string]$ReleaseMode, [Parameter(Mandatory = $true)][string]$ModuleVersion, [Parameter(Mandatory = $true)][AllowEmptyString()][string]$ManifestPrerelease, [Parameter(Mandatory = $true)][string]$EffectiveVersion, [AllowEmptyString()][string]$GitRefType, [AllowEmptyString()][string]$GitRefName, [Parameter(Mandatory = $true)][string]$SigningSource, [Parameter(Mandatory = $true)][object[]]$SignatureResults ) if ([string]::IsNullOrWhiteSpace($SummaryPath)) { return } $lines = @( '# Specrew module release summary', '', ('- Mode: `{0}`' -f $ReleaseMode), ('- Module version: `{0}`' -f $ModuleVersion), ('- Published version: `{0}`' -f $EffectiveVersion), ('- Manifest prerelease field: `{0}`' -f $(if ([string]::IsNullOrWhiteSpace($ManifestPrerelease)) { '(empty)' } else { $ManifestPrerelease })), ('- Git ref: `{0}:{1}`' -f $(if ([string]::IsNullOrWhiteSpace($GitRefType)) { 'n/a' } else { $GitRefType }), $(if ([string]::IsNullOrWhiteSpace($GitRefName)) { 'n/a' } else { $GitRefName })), ('- Signing source: `{0}`' -f $SigningSource), ('- Publish action: `{0}`' -f $(if ($ReleaseMode -eq 'dry-run') { 'Publish-Module -WhatIf only' } elseif ($ReleaseMode -eq 'promote-prerelease') { 'live Publish-Module (stable promotion)' } else { 'live Publish-Module' })), '' ) foreach ($signatureResult in $SignatureResults) { $lines += ('- Signature `{0}` → `{1}`' -f ([System.IO.Path]::GetFileName($signatureResult.Path)), $signatureResult.Status) } $content = ($lines -join [Environment]::NewLine) + [Environment]::NewLine [System.IO.File]::WriteAllText($SummaryPath, $content, [System.Text.UTF8Encoding]::new($false)) } $resolvedRepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path $configPath = Join-Path -Path $resolvedRepositoryRoot -ChildPath '.specrew\config.yml' $manifestPath = Join-Path -Path $resolvedRepositoryRoot -ChildPath 'Specrew.psd1' $scratchRoot = $null $stageRoot = $null $certificateHandle = $null $signatureResults = @() try { $scratchRoot = New-ReleaseScratchRoot -RepositoryRoot $resolvedRepositoryRoot $moduleVersion = Get-SpecrewVersionFromConfig -ConfigPath $configPath Write-ReleaseInfo ("Resolved module version {0} from .specrew/config.yml." -f $moduleVersion) $releaseStamp = Resolve-ReleaseStamp -ReleaseMode $ReleaseMode -GitRefType $GitRefType -GitRefName $GitRefName -ExpectedVersion $moduleVersion Write-ReleaseInfo ("Resolved release mode '{0}' to publish version {1}." -f $ReleaseMode, $releaseStamp.EffectiveVersion) if ( -not [string]::IsNullOrWhiteSpace($releaseStamp.SourcePrereleaseTag) -and $releaseStamp.SourcePrereleaseTag -ne $releaseStamp.ManifestPrerelease ) { Write-ReleaseInfo ("Normalized prerelease tag suffix '{0}' to PowerShellGet manifest value '{1}'." -f $releaseStamp.SourcePrereleaseTag, $releaseStamp.ManifestPrerelease) } $stageRoot = New-ReleaseStageRoot -RepositoryRoot $resolvedRepositoryRoot -ScratchRoot $scratchRoot -ManifestPath $manifestPath $stagedManifestPath = Join-Path -Path $stageRoot -ChildPath 'Specrew.psd1' $stagedModulePath = Join-Path -Path $stageRoot -ChildPath 'Specrew.psm1' Set-SpecrewManifestReleaseMetadata -ManifestPath $stagedManifestPath -Version $moduleVersion -Prerelease $releaseStamp.ManifestPrerelease $stampedManifestInfo = Get-SpecrewManifestReleaseInfo -ManifestPath $stagedManifestPath if ($stampedManifestInfo.ModuleVersion -ne $moduleVersion) { throw ("Stamped manifest version '{0}' did not match config version '{1}'." -f $stampedManifestInfo.ModuleVersion, $moduleVersion) } if ($stampedManifestInfo.Prerelease -ne $releaseStamp.ManifestPrerelease) { throw ("Stamped manifest prerelease '{0}' did not match expected prerelease '{1}'." -f $stampedManifestInfo.Prerelease, $releaseStamp.ManifestPrerelease) } Write-ReleaseInfo ("Stamped staged Specrew.psd1 to version {0} with prerelease '{1}'." -f $moduleVersion, $releaseStamp.ManifestPrerelease) $null = Test-ModuleManifest -Path $stagedManifestPath -ErrorAction Stop Write-ReleaseInfo 'Test-ModuleManifest succeeded after stamping.' $certificateHandle = Get-ReleaseSigningCertificate ` -ScratchRoot $scratchRoot ` -ReleaseMode $ReleaseMode ` -Base64 $SigningCertBase64 ` -Password $SigningCertPassword ` -AllowEphemeralFallback $AllowEphemeralSigningCertificate.IsPresent Write-ReleaseInfo ("Using signing certificate source '{0}'." -f $certificateHandle.Source) $signatureResults = Set-ReleaseSignature -FilePaths @($stagedManifestPath, $stagedModulePath) -Certificate $certificateHandle.Certificate foreach ($signatureResult in $signatureResults) { Write-ReleaseInfo ("Signed {0} ({1})." -f ([System.IO.Path]::GetFileName($signatureResult.Path)), $signatureResult.Status) } $publishParameters = @{ Path = $stageRoot Repository = 'PSGallery' ErrorAction = 'Stop' Verbose = $true NuGetApiKey = $(if ([string]::IsNullOrWhiteSpace($PSGalleryApiKey)) { 'DRY-RUN-NO-LIVE-KEY' } else { $PSGalleryApiKey }) } if ($ReleaseMode -ne 'dry-run') { if ([string]::IsNullOrWhiteSpace($PSGalleryApiKey)) { throw 'Live publish requires the PSGALLERY_API_KEY secret.' } Write-ReleaseInfo ("Running live Publish-Module to PSGallery for {0}." -f $releaseStamp.EffectiveVersion) Publish-Module @publishParameters } else { Write-ReleaseInfo ("Running Publish-Module -WhatIf dry-run for {0} (no live publish)." -f $releaseStamp.EffectiveVersion) Publish-Module @publishParameters -WhatIf } Write-ReleaseSummary ` -SummaryPath $SummaryPath ` -ReleaseMode $ReleaseMode ` -ModuleVersion $moduleVersion ` -ManifestPrerelease $releaseStamp.ManifestPrerelease ` -EffectiveVersion $releaseStamp.EffectiveVersion ` -GitRefType $GitRefType ` -GitRefName $GitRefName ` -SigningSource $certificateHandle.Source ` -SignatureResults $signatureResults [pscustomobject]@{ ReleaseMode = $ReleaseMode ModuleVersion = $moduleVersion EffectiveVersion = $releaseStamp.EffectiveVersion Prerelease = $releaseStamp.ManifestPrerelease GitRefType = $GitRefType GitRefName = $GitRefName SigningSource = $certificateHandle.Source SignatureResults = $signatureResults } } catch { Write-Error ("Module release failed: {0}" -f $_.Exception.Message) throw } finally { if ($null -ne $certificateHandle -and $null -ne $certificateHandle.Certificate) { $certificatePath = 'Cert:\CurrentUser\My\{0}' -f $certificateHandle.Certificate.Thumbprint if (Test-Path -LiteralPath $certificatePath) { Remove-Item -LiteralPath $certificatePath -Force -ErrorAction SilentlyContinue } } if ($null -ne $certificateHandle -and -not [string]::IsNullOrWhiteSpace($certificateHandle.CleanupPath) -and (Test-Path -LiteralPath $certificateHandle.CleanupPath)) { Remove-Item -LiteralPath $certificateHandle.CleanupPath -Force -ErrorAction SilentlyContinue } if (-not [string]::IsNullOrWhiteSpace($scratchRoot) -and (Test-Path -LiteralPath $scratchRoot)) { Remove-Item -LiteralPath $scratchRoot -Recurse -Force -ErrorAction SilentlyContinue } } |