Skip to content

Commit 68696b6

Browse files
CopilotMariusStorhaugMarius StorhaugCopilot
authored
🪲 [Fix]: New module versions publish to the PowerShell Gallery (#529)
A module version that has never been published to the PowerShell Gallery can now be published. Any first release, and every subsequent new version, previously failed the Publish-Module stage before the upload was attempted. ## Fixed: New module versions publish to the PowerShell Gallery Publishing a version that is not yet on the PowerShell Gallery now succeeds. The stage checks whether the version already exists so an interrupted run can resume, and treats an absent version as the expected result for a new release rather than an error. Before this change the Publish-Module job failed with the following, and no module was ever uploaded: ```text Find-PSResource: Package with name 'MyModule', version '1.3.1' could not be found in repository 'PSGallery'. Error: Process completed with exit code 1. ``` No repository configuration changes are needed. A workflow run that previously failed at this point succeeds on re-run. Resuming an interrupted publication is unchanged: when the version is already on the Gallery, the run skips the upload and continues to GitHub release creation. --- <details> <summary>Technical details</summary> - `.github/actions/Publish-PSModule/src/publish.ps1` — the Gallery existence probe in the `Publish to PSGallery` region ran `Find-PSResource` with `-ErrorAction Stop`. `Microsoft.PowerShell.PSResourceGet` raises `PackageNotFound,Microsoft.PowerShell.PSResourceGet.Cmdlets.FindPSResource` when the requested version does not exist, which `-ErrorAction Stop` turns into a throw, so the probe made a missing version fatal instead of returning `$null`. The probe now keeps `-ErrorAction Stop` and catches only `PackageNotFound`, treating that one error as 'not yet published' and letting the `if ($publishedPackage)` branch decide the outcome. Every other error stays fatal, so a transient Gallery failure cannot be misread as 'version absent' and cause a re-upload of a version that already exists. - The probe was introduced in #512 to make Gallery publication idempotent for the default-branch push release path. That path replaced a `try`/`catch` around `Publish-PSResource`, which is why the regression reached `main` without an existing test catching it. - `.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1` — the harness could not observe whether publication happened, so its assertions were vacuous: `Publish-PSResource` was shimmed to set `$script:publishInvoked`, but `publish.ps1` runs in its own scope via `&`, so the flag never propagated and stayed `$false` regardless. Replaced with a hashtable captured by `GetNewClosure()`, which is shared by reference. A second variant wrote a marker file under `$env:GITHUB_WORKSPACE`; that is process-wide and races between parallel Pester runspaces, so the marker could land in another test file's `TestDrive`. The not-found shim also used `$PSCmdlet.ThrowTerminatingError(...)`, which ignores `-ErrorAction` and therefore threw under both `Stop` and `SilentlyContinue` — unable to distinguish the fix from the defect. It now uses `Write-Error` with the real `PackageNotFound` error ID, matching how the cmdlet actually behaves. Added a case asserting a non-`PackageNotFound` lookup failure stays fatal and does not publish. Each test was verified to fail against the specific defect it guards. - `.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1` and `Publish-PSModule.Recovery.Tests.ps1` — shim teardown used `Remove-Item -Path function:global:X`. `Set-Item` accepts that path and creates `X` in the global scope, but `Remove-Item` and `Get-Item` do not resolve it back, and fail silently rather than erroring, so the cleanup was a no-op. The shims survived `AfterAll` and shadowed the real commands for later test files, which is what made `Test-Actions` fail with `A parameter cannot be found that matches parameter name 'Prerelease'` in `Get-NextPrereleaseNumber`. Teardown now removes by name. - Validated end to end in `MariusStorhaug/MariusTestModule` ([PR #63](MariusStorhaug/MariusTestModule#63)) with the caller pointed at this branch. A new version published successfully ([run 33597824748](https://github.com/MariusStorhaug/MariusTestModule/actions/runs/33597824748/job/100145585604)), and re-running the same job with the version present skipped the upload via the resume path ([re-run](https://github.com/MariusStorhaug/MariusTestModule/actions/runs/33597824748/job/100146802704)). Both branches of the probe are confirmed against the live Gallery. - Out of scope, found while reproducing: a `workflow_dispatch` on the default branch resolves no associated pull request, because pull request association in `.github/actions/Get-PSModuleSettings/src/main.ps1` is gated on `$isPush`. A manual recovery run therefore discards the merged pull request's version label and silently resolves a Patch bump. This is a separate defect in version resolution and is recorded in the analysis on #528; it is not addressed here. - Also out of scope: `.github/workflows/Test-Actions.yml` builds a Pester configuration with `Run.Parallel` and `Run.Shuffle`, asserts the options applied, then discards it and creates a fresh `New-PesterConfiguration` for the actual run. Parallel and shuffle are validated but never used, which is why the `GITHUB_WORKSPACE` race above could not surface in CI. The suite now passes both sequentially and under the intended parallel configuration, so enabling it should be safe. | Changed surface | Standards checked | Framework docs checked | Result | | --- | --- | --- | --- | | `.github/actions/Publish-PSModule/src/**` (PowerShell) | Coding standards, error handling | Publish stage contract | Aligned | | `.github/actions/Publish-PSModule/tests/**` (Pester) | Pester test standards | Action test layout | Aligned | | `.github/actions/Release-PSModule/tests/**` (Pester) | Pester test standards | Action test layout | Aligned | </details> <details> <summary>Relevant issues (or links)</summary> - Resolves #528 ### Related work - References #512 </details> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MariusStorhaug <17722253+MariusStorhaug@users.noreply.github.com> Co-authored-by: Marius Storhaug <Marius.Storhaug@dnb.no> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 59e056c commit 68696b6

3 files changed

Lines changed: 73 additions & 10 deletions

File tree

.github/actions/Publish-PSModule/src/publish.ps1

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,18 @@ LogGroup 'Publish to PSGallery' {
145145
if ($whatIf) {
146146
Write-Host "Publish-PSResource -Path $modulePath -Repository PSGallery -ApiKey ***"
147147
} else {
148-
$publishedPackage = Find-PSResource -Name $name -Version $publishPSVersion -Repository PSGallery -ErrorAction Stop
148+
# A version that is not on the Gallery is the expected state for a new release, but PSResourceGet
149+
# reports it as a PackageNotFound error, which -ErrorAction Stop turns into a throw. Only that error
150+
# may be treated as 'not published'; any other failure (for example a Gallery outage) must stay fatal,
151+
# otherwise a version that already exists would be re-published and fail the upload with a conflict.
152+
$publishedPackage = $null
153+
try {
154+
$publishedPackage = Find-PSResource -Name $name -Version $publishPSVersion -Repository PSGallery -ErrorAction Stop
155+
} catch {
156+
if ($_.FullyQualifiedErrorId -notlike 'PackageNotFound,*') { throw }
157+
Write-Host "$name $publishPSVersion is not on the PowerShell Gallery yet."
158+
}
159+
149160
if ($publishedPackage) {
150161
Write-Host (
151162
"::notice title=♻️ Resuming Gallery-only publication::$name $publishPSVersion is already " +

.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,13 @@ AfterAll {
2929
foreach ($name in $script:environmentVariableNames) {
3030
[System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name])
3131
}
32-
Remove-Item -Path function:global:Find-PSResource -ErrorAction SilentlyContinue
33-
Remove-Item -Path function:global:Publish-PSResource -ErrorAction SilentlyContinue
34-
Remove-Item -Path function:global:Resolve-PSModuleDependency -ErrorAction SilentlyContinue
32+
# Set-Item accepts 'function:global:X' and creates a function named 'X' in the global scope, but
33+
# Remove-Item and Get-Item do not resolve that same path back to it, and fail silently rather than
34+
# erroring. Removing by name is what actually deletes the shims; leaving them behind would shadow the
35+
# real cmdlets for every test file that runs later in the session.
36+
Remove-Item -Path 'Function:\Find-PSResource' -ErrorAction SilentlyContinue
37+
Remove-Item -Path 'Function:\Publish-PSResource' -ErrorAction SilentlyContinue
38+
Remove-Item -Path 'Function:\Resolve-PSModuleDependency' -ErrorAction SilentlyContinue
3539
}
3640

3741
Describe 'Publish-PSModule recovery' {
@@ -68,20 +72,64 @@ Describe 'Publish-PSModule recovery' {
6872
$env:PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY = 'test-key'
6973
$env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = ''
7074
$env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'false'
71-
$script:publishInvoked = $false
75+
# The publish script runs in its own scope, so a $script: flag set inside a shim never reaches the
76+
# test. A hashtable captured by GetNewClosure() is shared by reference and records the call reliably.
77+
# The closure captures local variables only, so $calls must be local here, not $script:-qualified.
78+
$calls = @{ PublishInvoked = $false }
79+
$script:calls = $calls
7280

7381
Set-Item -Path function:global:Resolve-PSModuleDependency -Value {}
7482
Set-Item -Path function:global:Find-PSResource -Value {
7583
[PSCustomObject]@{ Name = 'TestModule'; Version = '1.2.4' }
7684
}
7785
Set-Item -Path function:global:Publish-PSResource -Value {
78-
$script:publishInvoked = $true
79-
}
86+
$calls.PublishInvoked = $true
87+
}.GetNewClosure()
8088
}
8189

8290
It 'skips Gallery publication when the resolved version already exists' {
8391
{ & $script:publishScriptPath } | Should -Not -Throw
8492

85-
$script:publishInvoked | Should -BeFalse
93+
$script:calls.PublishInvoked | Should -BeFalse
94+
}
95+
96+
It 'publishes when the resolved version is not in the Gallery' {
97+
Set-Item -Path function:global:Find-PSResource -Value {
98+
[CmdletBinding()]
99+
param(
100+
[string] $Name,
101+
[string] $Version,
102+
[string] $Repository
103+
)
104+
105+
# Mirrors how PSResourceGet reports an absent version: an error with the PackageNotFound error ID
106+
# that honours -ErrorAction, so the shim reacts to -ErrorAction the same way the real cmdlet does.
107+
Write-Error -Message "Package with name '$Name', version '$Version' could not be found in repository '$Repository'." `
108+
-ErrorId 'PackageNotFound' -Category ObjectNotFound -TargetObject $Name
109+
}
110+
111+
{ & $script:publishScriptPath } | Should -Not -Throw
112+
113+
$script:calls.PublishInvoked | Should -BeTrue
114+
}
115+
116+
It 'fails without publishing when the Gallery lookup errors for another reason' {
117+
Set-Item -Path function:global:Find-PSResource -Value {
118+
[CmdletBinding()]
119+
param(
120+
[string] $Name,
121+
[string] $Version,
122+
[string] $Repository
123+
)
124+
125+
# A transient Gallery failure carries a different error ID and must not be mistaken for
126+
# 'version not published', otherwise an already-published version would be re-uploaded.
127+
Write-Error -Message "Failed to find '$Name' '$Version' in repository '$Repository': Service Unavailable" `
128+
-ErrorId 'HttpRequestCallFailure' -Category ResourceUnavailable -TargetObject $Name
129+
}
130+
131+
{ & $script:publishScriptPath } | Should -Throw
132+
133+
$script:calls.PublishInvoked | Should -BeFalse
86134
}
87135
}

.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,12 @@ AfterAll {
3535
foreach ($name in $script:environmentVariableNames) {
3636
[System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name])
3737
}
38-
Remove-Item -Path function:global:gh -ErrorAction SilentlyContinue
39-
Remove-Item -Path function:global:git -ErrorAction SilentlyContinue
38+
# Set-Item accepts 'function:global:X' and creates a function named 'X' in the global scope, but
39+
# Remove-Item and Get-Item do not resolve that same path back to it, and fail silently rather than
40+
# erroring. Removing by name is what actually deletes the shims; leaving them behind would shadow the
41+
# real commands for every test file that runs later in the session.
42+
Remove-Item -Path 'Function:\gh' -ErrorAction SilentlyContinue
43+
Remove-Item -Path 'Function:\git' -ErrorAction SilentlyContinue
4044
}
4145

4246
Describe 'Release-PSModule WhatIf' {

0 commit comments

Comments
 (0)