|
| 1 | +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
| 2 | + 'PSUseDeclaredVarsMoreThanAssignments', '', |
| 3 | + Justification = 'Pester setup variables are used by test blocks and command replacements.' |
| 4 | +)] |
| 5 | +[CmdletBinding()] |
| 6 | +param() |
| 7 | + |
| 8 | +$ErrorActionPreference = 'Stop' |
| 9 | + |
| 10 | +BeforeAll { |
| 11 | + $script:environmentMutex = [Threading.Mutex]::new($false, "PSModule-ActionTests-Environment-$PID") |
| 12 | + if (-not $script:environmentMutex.WaitOne([TimeSpan]::FromMinutes(5))) { |
| 13 | + throw 'Cannot acquire the process environment lock for action tests.' |
| 14 | + } |
| 15 | + Import-Module -Name 'PSModule' -Force |
| 16 | + |
| 17 | + $script:publishScriptPath = [IO.Path]::GetFullPath( |
| 18 | + [IO.Path]::Combine($PSScriptRoot, '..', 'src', 'publish.ps1') |
| 19 | + ) |
| 20 | + $script:environmentVariableNames = @( |
| 21 | + 'GITHUB_EVENT_PATH' |
| 22 | + 'GITHUB_REPOSITORY' |
| 23 | + 'GITHUB_REPOSITORY_NAME' |
| 24 | + 'GITHUB_SHA' |
| 25 | + 'GITHUB_WORKSPACE' |
| 26 | + 'PSMODULE_PUBLISH_PSMODULE_INPUT_Name' |
| 27 | + 'PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath' |
| 28 | + 'PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY' |
| 29 | + 'PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest' |
| 30 | + 'PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag' |
| 31 | + 'PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha' |
| 32 | + 'PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf' |
| 33 | + ) |
| 34 | + $script:originalEnvironment = @{} |
| 35 | + foreach ($name in $script:environmentVariableNames) { |
| 36 | + $script:originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) |
| 37 | + } |
| 38 | + $script:replacedCommands = @('gh', 'Find-PSResource', 'Publish-PSResource', 'Invoke-WebRequest') |
| 39 | + $script:originalFunctions = @{} |
| 40 | + foreach ($name in $script:replacedCommands) { |
| 41 | + $function = Get-Item -LiteralPath "Function:\$name" -ErrorAction SilentlyContinue |
| 42 | + $script:originalFunctions[$name] = $function.ScriptBlock |
| 43 | + } |
| 44 | +} |
| 45 | + |
| 46 | +AfterAll { |
| 47 | + try { |
| 48 | + foreach ($name in $script:environmentVariableNames) { |
| 49 | + [Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) |
| 50 | + } |
| 51 | + foreach ($name in $script:replacedCommands) { |
| 52 | + Remove-Item -LiteralPath "Function:\$name" -ErrorAction SilentlyContinue |
| 53 | + if ($null -ne $script:originalFunctions[$name]) { |
| 54 | + Set-Item -Path "Function:global:$name" -Value $script:originalFunctions[$name] |
| 55 | + } |
| 56 | + } |
| 57 | + } finally { |
| 58 | + $script:environmentMutex.ReleaseMutex() |
| 59 | + $script:environmentMutex.Dispose() |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +Describe 'Publish-PSModule source-bound preflight' { |
| 64 | + BeforeEach { |
| 65 | + $script:modulePath = Join-Path -Path $TestDrive -ChildPath 'workspace' -AdditionalChildPath '.PSModule', 'module', 'TestModule' |
| 66 | + $null = New-Item -Path $script:modulePath -ItemType Directory -Force |
| 67 | + Set-Content -LiteralPath (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1') -Value '# Tested bytes.' |
| 68 | + Set-Content -LiteralPath (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psd1') -Value @' |
| 69 | +@{ |
| 70 | + RootModule = 'TestModule.psm1' |
| 71 | + ModuleVersion = '1.2.4' |
| 72 | + GUID = '3c0f8d73-60b7-4f38-b3cf-9386db4982a4' |
| 73 | + Author = 'PSModule' |
| 74 | + Description = 'Anonymous publication fixture' |
| 75 | + PrivateData = @{ |
| 76 | + ConsumerMetadata = 'Preserve this value.' |
| 77 | + PSData = @{ Prerelease = '' } |
| 78 | + } |
| 79 | +} |
| 80 | +'@ |
| 81 | + $eventPath = Join-Path -Path $TestDrive -ChildPath 'event.json' |
| 82 | + @{ head_commit = @{ message = 'Anonymous release notes.' } } | |
| 83 | + ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $eventPath |
| 84 | + |
| 85 | + $env:GITHUB_EVENT_PATH = $eventPath |
| 86 | + $env:GITHUB_REPOSITORY = 'PSModule/TestModule' |
| 87 | + $env:GITHUB_SHA = '1111111111111111111111111111111111111111' |
| 88 | + $env:GITHUB_WORKSPACE = Join-Path -Path $TestDrive -ChildPath 'workspace' |
| 89 | + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_Name = 'TestModule' |
| 90 | + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath = '.PSModule/module' |
| 91 | + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY = 'anonymous-fixture' |
| 92 | + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = '' |
| 93 | + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag = 'v1.2.4' |
| 94 | + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA |
| 95 | + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'false' |
| 96 | + |
| 97 | + $state = @{ |
| 98 | + Calls = [Collections.Generic.List[string]]::new() |
| 99 | + GalleryWrites = 0 |
| 100 | + GitHubWrites = 0 |
| 101 | + Tag = $null |
| 102 | + TagObject = $null |
| 103 | + Release = $null |
| 104 | + Package = $null |
| 105 | + ApiError = '' |
| 106 | + } |
| 107 | + $script:state = $state |
| 108 | + |
| 109 | + Set-Item -Path function:global:Find-PSResource -Value { |
| 110 | + [CmdletBinding()] |
| 111 | + param( |
| 112 | + [string] $Name, |
| 113 | + [string] $Version, |
| 114 | + [string] $Repository |
| 115 | + ) |
| 116 | + if ($Name -cne 'TestModule' -or $Version -cne '1.2.4' -or $Repository -cne 'PSGallery') { |
| 117 | + throw "Unexpected exact package lookup: [$Name] [$Version] [$Repository]." |
| 118 | + } |
| 119 | + $state.Calls.Add('gallery-read') |
| 120 | + if ($null -ne $state.Package) { |
| 121 | + return $state.Package |
| 122 | + } |
| 123 | + Write-Error -Message 'The exact fixture package is absent.' -ErrorId PackageNotFound -Category ObjectNotFound |
| 124 | + }.GetNewClosure() |
| 125 | + |
| 126 | + Set-Item -Path function:global:Publish-PSResource -Value { |
| 127 | + $state.Calls.Add('gallery-write') |
| 128 | + $state.GalleryWrites++ |
| 129 | + }.GetNewClosure() |
| 130 | + |
| 131 | + Set-Item -Path function:global:Invoke-WebRequest -Value { |
| 132 | + throw 'Package bytes must come from an explicitly injected local fixture, never a network request.' |
| 133 | + } |
| 134 | + |
| 135 | + Set-Item -Path function:global:gh -Value { |
| 136 | + [CmdletBinding()] |
| 137 | + param( |
| 138 | + [Parameter(ValueFromRemainingArguments)] |
| 139 | + [string[]] $Arguments |
| 140 | + ) |
| 141 | + $state.Calls.Add("github: $($Arguments -join ' ')") |
| 142 | + $global:LASTEXITCODE = 0 |
| 143 | + if ($Arguments[0] -eq 'api' -and $Arguments -notcontains 'POST' -and $Arguments -notcontains 'PATCH') { |
| 144 | + if ($state.ApiError) { |
| 145 | + $global:LASTEXITCODE = 1 |
| 146 | + return $state.ApiError |
| 147 | + } |
| 148 | + if ($Arguments[1] -like '*/git/ref/tags/*') { |
| 149 | + if ($null -ne $state.Tag) { |
| 150 | + return (@{ object = $state.Tag } | ConvertTo-Json -Depth 10 -Compress) |
| 151 | + } |
| 152 | + } elseif ($Arguments[1] -like '*/git/tags/*') { |
| 153 | + return ($state.TagObject | ConvertTo-Json -Depth 10 -Compress) |
| 154 | + } elseif ($Arguments[1] -like '*/releases/tags/*') { |
| 155 | + if ($null -ne $state.Release) { |
| 156 | + return ($state.Release | ConvertTo-Json -Depth 10 -Compress) |
| 157 | + } |
| 158 | + } else { |
| 159 | + throw "Unexpected read in publication fixture: $($Arguments -join ' ')." |
| 160 | + } |
| 161 | + $global:LASTEXITCODE = 1 |
| 162 | + return 'gh: Not Found (HTTP 404)' |
| 163 | + } |
| 164 | + $state.GitHubWrites++ |
| 165 | + throw "Unexpected mutation in a rejected publication fixture: $($Arguments -join ' ')." |
| 166 | + }.GetNewClosure() |
| 167 | + } |
| 168 | + |
| 169 | + It 'rejects a conflicting standalone tag before a Gallery write or a GitHub mutation' { |
| 170 | + $script:state.Tag = @{ |
| 171 | + type = 'commit' |
| 172 | + sha = '2222222222222222222222222222222222222222' |
| 173 | + } |
| 174 | + |
| 175 | + $failure = $null |
| 176 | + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } |
| 177 | + |
| 178 | + $script:state.GalleryWrites | Should -Be 0 |
| 179 | + $script:state.GitHubWrites | Should -Be 0 |
| 180 | + $failure | Should -Not -BeNullOrEmpty |
| 181 | + $failure.Exception.Message | Should -Match 'source|belongs|ownership' |
| 182 | + } |
| 183 | + |
| 184 | + It 'rejects different release asset bytes before a Gallery write or a GitHub mutation' { |
| 185 | + $script:state.Tag = @{ type = 'commit'; sha = $env:GITHUB_SHA } |
| 186 | + $asset = @{ |
| 187 | + name = 'TestModule-1.2.4.zip' |
| 188 | + digest = 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' |
| 189 | + } |
| 190 | + $script:state.Release = @{ |
| 191 | + id = 10 |
| 192 | + tag_name = 'v1.2.4' |
| 193 | + html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' |
| 194 | + prerelease = $false |
| 195 | + assets = @($asset) |
| 196 | + } |
| 197 | + |
| 198 | + $failure = $null |
| 199 | + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } |
| 200 | + |
| 201 | + $script:state.GalleryWrites | Should -Be 0 |
| 202 | + $script:state.GitHubWrites | Should -Be 0 |
| 203 | + $failure | Should -Not -BeNullOrEmpty |
| 204 | + $failure.Exception.Message | Should -Match 'asset|contents|proof|ownership' |
| 205 | + } |
| 206 | + |
| 207 | + It 'does not confuse GitHub API failure with an absent release or tag' { |
| 208 | + $script:state.ApiError = 'gh: Service Unavailable (HTTP 503)' |
| 209 | + |
| 210 | + $failure = $null |
| 211 | + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } |
| 212 | + |
| 213 | + $script:state.GalleryWrites | Should -Be 0 |
| 214 | + $script:state.GitHubWrites | Should -Be 0 |
| 215 | + $failure | Should -Not -BeNullOrEmpty |
| 216 | + $failure.Exception.Message | Should -Match '503|API|read|lookup' |
| 217 | + } |
| 218 | + |
| 219 | + It 'rejects a Gallery-only package with no source and artifact proof' { |
| 220 | + $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } |
| 221 | + |
| 222 | + $failure = $null |
| 223 | + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } |
| 224 | + |
| 225 | + $script:state.GalleryWrites | Should -Be 0 |
| 226 | + $script:state.GitHubWrites | Should -Be 0 |
| 227 | + $failure | Should -Not -BeNullOrEmpty |
| 228 | + $failure.Exception.Message | Should -Match 'proof|ownership|unverifiable' |
| 229 | + } |
| 230 | + |
| 231 | + It 'rejects a same-source tag without artifact and release-context proof' { |
| 232 | + $script:state.Tag = @{ type = 'commit'; sha = $env:GITHUB_SHA } |
| 233 | + |
| 234 | + $failure = $null |
| 235 | + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } |
| 236 | + |
| 237 | + $script:state.GalleryWrites | Should -Be 0 |
| 238 | + $script:state.GitHubWrites | Should -Be 0 |
| 239 | + $failure | Should -Not -BeNullOrEmpty |
| 240 | + $failure.Exception.Message | Should -Match 'proof|ownership|unverifiable' |
| 241 | + } |
| 242 | + |
| 243 | + It 'rejects an unverifiable annotated tag rather than trusting its release target hint' { |
| 244 | + $script:state.Tag = @{ type = 'tag'; sha = '3333333333333333333333333333333333333333' } |
| 245 | + $script:state.TagObject = @{ |
| 246 | + object = @{ type = 'tree'; sha = $env:GITHUB_SHA } |
| 247 | + } |
| 248 | + |
| 249 | + $failure = $null |
| 250 | + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } |
| 251 | + |
| 252 | + $script:state.GalleryWrites | Should -Be 0 |
| 253 | + $script:state.GitHubWrites | Should -Be 0 |
| 254 | + $failure | Should -Not -BeNullOrEmpty |
| 255 | + $failure.Exception.Message | Should -Match 'commit|immutable|tag' |
| 256 | + } |
| 257 | + |
| 258 | + It 'rejects a missing immutable source before all external mutations' { |
| 259 | + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha = '' |
| 260 | + $env:GITHUB_SHA = '' |
| 261 | + |
| 262 | + $failure = $null |
| 263 | + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } |
| 264 | + |
| 265 | + $script:state.GalleryWrites | Should -Be 0 |
| 266 | + $script:state.GitHubWrites | Should -Be 0 |
| 267 | + $failure | Should -Not -BeNullOrEmpty |
| 268 | + $failure.Exception.Message | Should -Match 'source|CommitSha|GITHUB_SHA' |
| 269 | + } |
| 270 | + |
| 271 | + It 'keeps WhatIf free of Gallery and GitHub calls' { |
| 272 | + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'true' |
| 273 | + |
| 274 | + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw |
| 275 | + |
| 276 | + $script:state.GalleryWrites | Should -Be 0 |
| 277 | + $script:state.GitHubWrites | Should -Be 0 |
| 278 | + $script:state.Calls.Count | Should -Be 0 |
| 279 | + } |
| 280 | +} |
0 commit comments