diff --git a/.github/workflows/Action-Test.yml b/.github/workflows/Action-Test.yml index 72da746b..fb63f0c5 100644 --- a/.github/workflows/Action-Test.yml +++ b/.github/workflows/Action-Test.yml @@ -364,6 +364,103 @@ jobs: CONCLUSION: ${{ steps.action-test.conclusion }} run: tests/Show-Status.ps1 + ActionTestPesterGuidPin: + name: Action-Test - [Pester GUID Pin] + runs-on: ubuntu-latest + outputs: + Outcome: ${{ steps.action-test.outcome }} + Conclusion: ${{ steps.action-test.conclusion }} + Executed: ${{ steps.action-test.outputs.Executed }} + Result: ${{ steps.action-test.outputs.Result }} + + steps: + # Need to check out as part of the test, as it's a local action + - name: Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Action-Test [Pester GUID Pin] + uses: ./ + id: action-test + with: + WorkingDirectory: tests/4-PesterVersionConstraints/PesterGuidPin + Version: '6.0.0' + Guid: 'a699dea5-2c73-4616-a270-1f7abb777e71' + TestResult_TestSuiteName: PesterGuidPin + + - name: Status + shell: pwsh + env: + OUTCOME: ${{ steps.action-test.outcome }} + CONCLUSION: ${{ steps.action-test.conclusion }} + run: tests/Show-Status.ps1 + + ActionTestPesterGuidMatch: + name: Action-Test - [Pester GUID Match] + runs-on: ubuntu-latest + outputs: + Outcome: ${{ steps.action-test.outcome }} + Conclusion: ${{ steps.action-test.conclusion }} + Executed: ${{ steps.action-test.outputs.Executed }} + Result: ${{ steps.action-test.outputs.Result }} + + steps: + # Need to check out as part of the test, as it's a local action + - name: Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + # A matching GUID passed via the action input must let the run proceed and the tests pass. + - name: Action-Test [Pester GUID Match] + uses: ./ + id: action-test + with: + WorkingDirectory: tests/4-PesterVersionConstraints/PesterGuidMatch + Version: '6.0.0' + Guid: 'a699dea5-2c73-4616-a270-1f7abb777e71' + TestResult_TestSuiteName: PesterGuidMatch + + - name: Status + shell: pwsh + env: + OUTCOME: ${{ steps.action-test.outcome }} + CONCLUSION: ${{ steps.action-test.conclusion }} + run: tests/Show-Status.ps1 + + ActionTestPesterGuidMismatch: + name: Action-Test - [Pester GUID Mismatch] + runs-on: ubuntu-latest + + steps: + # Need to check out as part of the test, as it's a local action + - name: Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + # A wrong GUID must make the action fail fast during init, before any tests run. + - name: Action-Test [Pester GUID Mismatch] + uses: ./ + id: action-test + continue-on-error: true + with: + WorkingDirectory: tests/2-Standard + Version: '6.0.0' + Guid: '00000000-0000-0000-0000-000000000000' + + - name: Assert the action failed on the GUID mismatch + shell: pwsh + env: + OUTCOME: ${{ steps.action-test.outcome }} + run: | + if ($env:OUTCOME -ne 'failure') { + Write-Error "Expected the action to fail on a GUID mismatch, but the outcome was '$env:OUTCOME'." + exit 1 + } + Write-Output 'Action failed as expected on the GUID mismatch.' + CatchJob: name: Catch Job - Aggregate Status needs: @@ -378,6 +475,9 @@ jobs: - ActionTest3Advanced - ActionTestPester5RangeConstraint - ActionTestPester6ExactConstraint + - ActionTestPesterGuidPin + - ActionTestPesterGuidMatch + - ActionTestPesterGuidMismatch if: always() runs-on: ubuntu-latest steps: @@ -433,5 +533,13 @@ jobs: ACTIONTESTPESTER6EXACTCONSTRAINT_CONCLUSION: ${{ needs.ActionTestPester6ExactConstraint.outputs.Conclusion }} ACTIONTESTPESTER6EXACTCONSTRAINT_EXECUTED: ${{ needs.ActionTestPester6ExactConstraint.outputs.Executed }} ACTIONTESTPESTER6EXACTCONSTRAINT_RESULT: ${{ needs.ActionTestPester6ExactConstraint.outputs.Result }} + ACTIONTESTPESTERGUIDPIN_OUTCOME: ${{ needs.ActionTestPesterGuidPin.outputs.Outcome }} + ACTIONTESTPESTERGUIDPIN_CONCLUSION: ${{ needs.ActionTestPesterGuidPin.outputs.Conclusion }} + ACTIONTESTPESTERGUIDPIN_EXECUTED: ${{ needs.ActionTestPesterGuidPin.outputs.Executed }} + ACTIONTESTPESTERGUIDPIN_RESULT: ${{ needs.ActionTestPesterGuidPin.outputs.Result }} + ACTIONTESTPESTERGUIDMATCH_OUTCOME: ${{ needs.ActionTestPesterGuidMatch.outputs.Outcome }} + ACTIONTESTPESTERGUIDMATCH_CONCLUSION: ${{ needs.ActionTestPesterGuidMatch.outputs.Conclusion }} + ACTIONTESTPESTERGUIDMATCH_EXECUTED: ${{ needs.ActionTestPesterGuidMatch.outputs.Executed }} + ACTIONTESTPESTERGUIDMATCH_RESULT: ${{ needs.ActionTestPesterGuidMatch.outputs.Result }} with: Script: tests/Test-ActionResults.ps1 diff --git a/README.md b/README.md index 5df868bb..1c2123da 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,7 @@ jobs: | `Path` | Path to where tests are located or a configuration file. | *(none)* | | `Version` | Version of Pester to install (NuGet range, e.g. `[6.0.0,7.0.0)` for any 6.x). Empty installs the latest version. | *(none)* | | `Prerelease` | Allow installing prerelease versions of Pester. | `false` | +| `Guid` | Optional module identity (GUID) the installed Pester must match; fails fast if a different module named Pester is loaded. Mirrors the GUID key of a `#Requires -Modules` pin. | *(none)* | | `ReportAsJson` | Output generated reports in JSON format in addition to the configured format through Pester. | `true` | | `Prescript` | Script to be executed before the test run. This script is executed in the same context as the test run. | *(none)* | | `Notice_Mode` | Controls when to show notices for test completion.
| `Failed` | diff --git a/action.yml b/action.yml index f0e7f499..99301eb6 100644 --- a/action.yml +++ b/action.yml @@ -20,6 +20,12 @@ inputs: Allow installing prerelease versions of Pester. required: false default: 'false' + Guid: + description: | + Optional module identity (GUID) that the installed Pester must match, mirroring the GUID key of a + '#Requires -Modules @{ ... }' declaration. When set, the run fails fast if the loaded Pester's GUID does + not match, guarding against a different module named Pester on the runner's module path. + required: false ReportAsJson: description: | Output generated reports in JSON format in addition to the configured format through Pester. @@ -302,6 +308,7 @@ runs: PSMODULE_INVOKE_PESTER_INPUT_Path: ${{ inputs.Path }} PSMODULE_INVOKE_PESTER_INPUT_Version: ${{ inputs.Version }} PSMODULE_INVOKE_PESTER_INPUT_Prerelease: ${{ inputs.Prerelease }} + PSMODULE_INVOKE_PESTER_INPUT_Guid: ${{ inputs.Guid }} PSMODULE_INVOKE_PESTER_INPUT_Run_Path: ${{ inputs.Run_Path }} PSMODULE_INVOKE_PESTER_INPUT_Run_ExcludePath: ${{ inputs.Run_ExcludePath }} PSMODULE_INVOKE_PESTER_INPUT_Run_ScriptBlock: ${{ inputs.Run_ScriptBlock }} @@ -365,6 +372,7 @@ runs: PSMODULE_INVOKE_PESTER_INPUT_ReportAsJson: ${{ inputs.ReportAsJson }} PSMODULE_INVOKE_PESTER_INPUT_Version: ${{ inputs.Version }} PSMODULE_INVOKE_PESTER_INPUT_Prerelease: ${{ inputs.Prerelease }} + PSMODULE_INVOKE_PESTER_INPUT_Guid: ${{ inputs.Guid }} PSMODULE_INVOKE_PESTER_INPUT_Notice_Mode: ${{ inputs.Notice_Mode }} PSMODULE_INVOKE_PESTER_INPUT_StepSummary_Mode: ${{ inputs.StepSummary_Mode }} PSMODULE_INVOKE_PESTER_INPUT_StepSummary_ShowTestOverview: ${{ inputs.StepSummary_ShowTestOverview }} diff --git a/src/Invoke-Pester.Helpers.psm1 b/src/Invoke-Pester.Helpers.psm1 index e6646a66..300f0c3e 100644 --- a/src/Invoke-Pester.Helpers.psm1 +++ b/src/Invoke-Pester.Helpers.psm1 @@ -1311,6 +1311,11 @@ function Install-PSResourceWithRetry { [Parameter()] [switch] $Prerelease, + # Optional module identity (GUID) the loaded module must match. When set, the imported module's GUID is + # validated and a mismatch throws, guarding against a different module with the same name. + [Parameter()] + [string] $Guid, + # Number of times to retry installation, default is 5 [Parameter()] [int] $RetryCount = 5, @@ -1365,7 +1370,7 @@ function Install-PSResourceWithRetry { if (-not [string]::IsNullOrWhiteSpace($Version)) { $getParams['Version'] = $Version } - $resolved = Get-PSResource @getParams | Sort-Object Version -Descending | Select-Object -First 1 + $resolved = Get-InstalledPSResource @getParams | Sort-Object Version -Descending | Select-Object -First 1 } # Import into the global session state so the resolved version is the one every subsequent @@ -1373,7 +1378,7 @@ function Install-PSResourceWithRetry { # highest version available on PSModulePath. if ($resolved) { Write-Output "Importing module: $Name $($resolved.Version)" - Import-Module -Name $Name -RequiredVersion $resolved.Version -Force -Global -ErrorAction Stop + $imported = Import-Module -Name $Name -RequiredVersion $resolved.Version -Force -Global -PassThru -ErrorAction Stop } elseif (-not [string]::IsNullOrWhiteSpace($Version)) { # A version constraint was requested but no satisfying installed version could be resolved. Importing the # module unconstrained here would silently load an arbitrary copy from PSModulePath (for example the @@ -1381,7 +1386,23 @@ function Install-PSResourceWithRetry { # exists to guarantee. Fail fast instead. throw "No installed '$Name' version satisfies constraint '$Version'; refusing to import an unconstrained version." } else { - Import-Module -Name $Name -Force -Global -ErrorAction Stop + $imported = Import-Module -Name $Name -Force -Global -PassThru -ErrorAction Stop + } + + # Validate module identity when a GUID pin is supplied, so a different module with the same name cannot + # satisfy the request. This fails at install time (shifted left) in addition to any '#Requires' GUID pin. + if (-not [string]::IsNullOrWhiteSpace($Guid)) { + $expectedGuid = [guid]::Empty + if (-not [guid]::TryParse($Guid, [ref]$expectedGuid)) { + throw "The provided Guid '$Guid' is not a valid GUID." + } + # Validate the module this call just imported (via -PassThru), not the highest version that happens + # to be loaded, so a side-by-side loaded version cannot mask a mismatch. + $importedModule = $imported | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 + if (-not $importedModule -or $importedModule.Guid -ne $expectedGuid) { + throw "Loaded '$Name' does not match the required GUID '$expectedGuid' (loaded GUID: $($importedModule.Guid))." + } + Write-Output "Verified module identity for ${Name}: GUID $expectedGuid" } } } diff --git a/src/exec.ps1 b/src/exec.ps1 index 66b19e1c..9810aa46 100644 --- a/src/exec.ps1 +++ b/src/exec.ps1 @@ -10,7 +10,8 @@ Import-Module "$PSScriptRoot/Invoke-Pester.Helpers.psm1" # Install Pester honoring the optional version constraint from the action input. An empty value installs the latest version. $pesterVersion = $env:PSMODULE_INVOKE_PESTER_INPUT_Version $pesterPrerelease = $env:PSMODULE_INVOKE_PESTER_INPUT_Prerelease -eq 'true' -Install-PSResourceWithRetry -Name 'Pester' -Version $pesterVersion -Prerelease:$pesterPrerelease +$pesterGuid = $env:PSMODULE_INVOKE_PESTER_INPUT_Guid +Install-PSResourceWithRetry -Name 'Pester' -Version $pesterVersion -Prerelease:$pesterPrerelease -Guid $pesterGuid '::endgroup::' '::group::Exec - Get test kit versions' @@ -63,7 +64,7 @@ $PSStyle.OutputRendering = 'Ansi' '::group::Eval - Setup prerequisites' # Reuse the Pester version constraint resolved during Exec setup. An empty value installs the latest version. -Install-PSResourceWithRetry -Name 'Pester' -Version $pesterVersion -Prerelease:$pesterPrerelease +Install-PSResourceWithRetry -Name 'Pester' -Version $pesterVersion -Prerelease:$pesterPrerelease -Guid $pesterGuid 'Hashtable', 'TimeSpan', 'Markdown' | Install-PSResourceWithRetry '::endgroup::' diff --git a/src/init.ps1 b/src/init.ps1 index d1a7f25c..23186d25 100644 --- a/src/init.ps1 +++ b/src/init.ps1 @@ -6,7 +6,8 @@ LogGroup 'Init - Setup prerequisites' { # Install Pester honoring the optional version constraint from the action input. An empty value installs the latest version. $pesterVersion = $env:PSMODULE_INVOKE_PESTER_INPUT_Version $pesterPrerelease = $env:PSMODULE_INVOKE_PESTER_INPUT_Prerelease -eq 'true' - Install-PSResourceWithRetry -Name 'Pester' -Version $pesterVersion -Prerelease:$pesterPrerelease + $pesterGuid = $env:PSMODULE_INVOKE_PESTER_INPUT_Guid + Install-PSResourceWithRetry -Name 'Pester' -Version $pesterVersion -Prerelease:$pesterPrerelease -Guid $pesterGuid 'Hashtable', 'TimeSpan', 'Markdown' | Install-PSResourceWithRetry } diff --git a/tests/4-PesterVersionConstraints/PesterGuidMatch/PesterVersion.Tests.ps1 b/tests/4-PesterVersionConstraints/PesterGuidMatch/PesterVersion.Tests.ps1 new file mode 100644 index 00000000..8c7f90c5 --- /dev/null +++ b/tests/4-PesterVersionConstraints/PesterGuidMatch/PesterVersion.Tests.ps1 @@ -0,0 +1,13 @@ +#Requires -Modules @{ ModuleName = 'Pester'; RequiredVersion = '6.0.0' } + +Describe 'Pester GUID input (match)' { + It 'runs when the Guid input matches the installed Pester identity' { + # The GUID is pinned through the action's Guid input (validated at install), not via #Requires here, + # so reaching and passing this test proves the matching Guid input let the run proceed. + # Select the pinned version explicitly so a side-by-side loaded version cannot make the assertion flaky. + $module = Get-Module -Name Pester | Where-Object { $_.Version -eq [version]'6.0.0' } | Select-Object -First 1 + + $module | Should -Not -BeNullOrEmpty + $module.Guid | Should -Be ([guid]'a699dea5-2c73-4616-a270-1f7abb777e71') + } +} diff --git a/tests/4-PesterVersionConstraints/PesterGuidPin/PesterVersion.Tests.ps1 b/tests/4-PesterVersionConstraints/PesterGuidPin/PesterVersion.Tests.ps1 new file mode 100644 index 00000000..db087943 --- /dev/null +++ b/tests/4-PesterVersionConstraints/PesterGuidPin/PesterVersion.Tests.ps1 @@ -0,0 +1,13 @@ +#Requires -Modules @{ ModuleName = 'Pester'; RequiredVersion = '6.0.0'; GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' } + +Describe 'Pester GUID pin' { + It 'loads the Pester module identified by its GUID' { + # The #Requires above pins Pester by module identity (GUID) in addition to version. PowerShell refuses to + # run this file unless the loaded Pester matches both, so reaching this assertion already proves the pin held. + # Select the pinned version explicitly so a side-by-side loaded version cannot make the assertion flaky. + $module = Get-Module -Name Pester | Where-Object { $_.Version -eq [version]'6.0.0' } | Select-Object -First 1 + + $module | Should -Not -BeNullOrEmpty + $module.Guid | Should -Be ([guid]'a699dea5-2c73-4616-a270-1f7abb777e71') + } +} diff --git a/tests/Test-ActionResults.ps1 b/tests/Test-ActionResults.ps1 index 5167d3ce..0fd34adb 100644 --- a/tests/Test-ActionResults.ps1 +++ b/tests/Test-ActionResults.ps1 @@ -1,7 +1,8 @@ <# .DESCRIPTION - Aggregates and validates test results from all Action-Test workflow jobs. - Compares actual outcomes against expected values and generates a summary report. + Aggregates and validates results from the result-producing Action-Test workflow jobs. + Compares actual outcomes against expected values and generates a summary report. The negative + 'Action-Test - [Pester GUID Mismatch]' job self-validates in its own step and is not tabled here. #> [CmdletBinding()] @@ -108,6 +109,20 @@ $jobs = @( Executed = @{ Actual = $env:ACTIONTESTPESTER6EXACTCONSTRAINT_EXECUTED; Expected = 'True' } Result = @{ Actual = $env:ACTIONTESTPESTER6EXACTCONSTRAINT_RESULT; Expected = 'Passed' } } + @{ + Name = 'Action-Test - [Pester GUID Pin]' + Outcome = @{ Actual = $env:ACTIONTESTPESTERGUIDPIN_OUTCOME; Expected = 'success' } + Conclusion = @{ Actual = $env:ACTIONTESTPESTERGUIDPIN_CONCLUSION; Expected = 'success' } + Executed = @{ Actual = $env:ACTIONTESTPESTERGUIDPIN_EXECUTED; Expected = 'True' } + Result = @{ Actual = $env:ACTIONTESTPESTERGUIDPIN_RESULT; Expected = 'Passed' } + } + @{ + Name = 'Action-Test - [Pester GUID Match]' + Outcome = @{ Actual = $env:ACTIONTESTPESTERGUIDMATCH_OUTCOME; Expected = 'success' } + Conclusion = @{ Actual = $env:ACTIONTESTPESTERGUIDMATCH_CONCLUSION; Expected = 'success' } + Executed = @{ Actual = $env:ACTIONTESTPESTERGUIDMATCH_EXECUTED; Expected = 'True' } + Result = @{ Actual = $env:ACTIONTESTPESTERGUIDMATCH_RESULT; Expected = 'Passed' } + } ) # Add Pass property to each check and convert to PSCustomObject for table output