diff --git a/.github/actions/install-test-modules/action.yml b/.github/actions/install-test-modules/action.yml new file mode 100644 index 0000000..f5b953b --- /dev/null +++ b/.github/actions/install-test-modules/action.yml @@ -0,0 +1,133 @@ +name: Install test modules +description: >- + Makes the pinned test-only PowerShell modules (Pester, Posh-SSH) available to + later steps in the job. They are restored from the GitHub Actions cache when + possible and only downloaded from the PowerShell Gallery on a cache miss, so a + transient gallery outage can no longer fail an otherwise-green run. + +# Why this exists as an action instead of four copy-pasted `run:` blocks: +# +# * The gallery is a hard dependency today. Run 30149566997 failed at "Install test +# dependencies" with "No match was found ... module name 'Pester'" while the run on +# the identical SHA passed 47s later -- a pure PSGallery blip, but zero tests ran. +# * Versions were unpinned (`MinimumVersion = '5.0'`), which is how CI silently moved +# from Pester 5 to Pester 6 with nobody deciding to. They are now pinned here, in +# exactly one place, as action inputs. +# +# Modules are saved into a workspace-relative `.psmodules/` via `Save-Module` rather +# than installed with `-Scope CurrentUser`: `CurrentUser` resolves to a different +# directory on each OS *and* differs again between PowerShell 7 +# (Documents\PowerShell\Modules) and Windows PowerShell 5.1 +# (Documents\WindowsPowerShell\Modules), whereas a fixed workspace path yields one +# identical, cacheable location for every job. A single pinned Pester version serves +# both editions -- Pester 6.0.1's manifest declares PowerShellVersion = '5.1'. + +inputs: + pester-version: + description: 'Exact Pester version to save. Appears in the cache key, so bumping it invalidates the cache automatically.' + required: false + default: '6.0.1' + posh-ssh-version: + description: 'Exact Posh-SSH version to save. Appears in the cache key, so bumping it invalidates the cache automatically.' + required: false + default: '3.2.7' + shell: + description: >- + Which PowerShell host to run in: 'pwsh' (PowerShell 7+, the default and the only + option on Linux/macOS) or 'powershell' (Windows PowerShell 5.1). A composite + action cannot read the `matrix` context, and `shell:` will not accept it even in a + workflow, so the calling job passes the literal in as an input instead. + required: false + default: 'pwsh' + +runs: + using: composite + steps: + # Two Windows jobs (pwsh and Windows PowerShell 5.1) intentionally share one cache + # key -- the saved files are identical, and this was confirmed in CI: the pwsh job + # restored a cache written by the 5.1 job and imported from it cleanly. + # + # Consequence, verified on run 30169601354: when both jobs miss and both try to save, + # the loser logs + # Failed to save: Unable to reserve cache with key + # psmodules-Windows-..., another job may be creating this cache. + # That reads like a fault but is expected and non-fatal -- the job still succeeds, and + # the winner's cache is what later runs restore. Do NOT "fix" it by adding the shell or + # edition to the key; that would double the cache footprint to store identical files. + # + # v6 (not v4) because v5+ runs on the node24 runtime -- v4 emits a Node 20 deprecation + # warning on every step. There is no actions/cache@v7; v6 is the latest major, even + # though actions/checkout is on v7. + - name: Cache test modules + id: psmodules-cache + uses: actions/cache@v6 + with: + path: .psmodules + key: psmodules-${{ runner.os }}-pester-${{ inputs.pester-version }}-poshssh-${{ inputs.posh-ssh-version }} + + - name: Download test modules from the PowerShell Gallery + if: steps.psmodules-cache.outputs.cache-hit != 'true' + shell: ${{ inputs.shell }} + run: | + # Only reached on a cache miss, so everything gallery-specific lives here. + # Some Windows PowerShell 5.1 hosts default to TLS 1.0/1.1, which the + # PowerShell Gallery rejects -- force TLS 1.2 before hitting it. (Save-Module + # on 5.1 also needs the NuGet provider; the runner images ship with it.) + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + $destination = Join-Path $env:GITHUB_WORKSPACE '.psmodules' + New-Item -ItemType Directory -Path $destination -Force | Out-Null + + function Save-PfbModuleWithRetry { + # 5 attempts with exponential backoff. The previous 3 x 15s (~50s) window was + # routinely outlasted by gallery blips. Caching cannot remove this path + # entirely: GitHub evicts cache entries untouched for 7 days, so cold runs + # are guaranteed to recur. + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Destination, + [int[]]$BackoffSeconds = @(15, 30, 60, 120) + ) + $maxAttempts = $BackoffSeconds.Count + 1 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + try { + Save-Module -Name $Name -RequiredVersion $Version -Path $Destination -Force -ErrorAction Stop + Write-Host "Saved $Name $Version to $Destination" + return + } catch { + if ($attempt -eq $maxAttempts) { throw } + $delay = $BackoffSeconds[$attempt - 1] + Write-Warning "Save-Module '$Name' attempt $attempt/$maxAttempts failed: $($_.Exception.Message). Retrying in $delay s..." + Start-Sleep -Seconds $delay + } + } + } + + Save-PfbModuleWithRetry -Name 'Pester' -Version '${{ inputs.pester-version }}' -Destination $destination + + # Posh-SSH is an optional runtime dependency (see Private/Get-PfbApiTokenViaSsh.ps1), + # but Get-PfbApiTokenViaSsh.Tests.ps1 mocks its cmdlets (New-SSHSession, etc.) -- + # Pester can only mock a command that's resolvable, so it must be installed here + # even though the module under test never actually opens an SSH connection. + Save-PfbModuleWithRetry -Name 'Posh-SSH' -Version '${{ inputs.posh-ssh-version }}' -Destination $destination + + - name: Add test modules to PSModulePath + shell: ${{ inputs.shell }} + run: | + $destination = Join-Path $env:GITHUB_WORKSPACE '.psmodules' + + # PSModulePath has to cross the step boundary: setting $env:PSModulePath here + # would die with this step's process, so it goes through $GITHUB_ENV. Build it + # with the platform's real separator (';' on Windows, ':' elsewhere) rather than + # hardcoding one, and append via .NET so both editions write UTF-8 with no BOM. + $separator = [IO.Path]::PathSeparator + $line = "PSModulePath=$destination$separator$env:PSModulePath" + [IO.File]::AppendAllText($env:GITHUB_ENV, $line + [Environment]::NewLine) + + Write-Host "Prepended '$destination' to PSModulePath. Modules present:" + Get-ChildItem -Path $destination -Directory -ErrorAction SilentlyContinue | + ForEach-Object { + $versions = (Get-ChildItem -Path $_.FullName -Directory -ErrorAction SilentlyContinue).Name -join ', ' + Write-Host " $($_.Name): $versions" + } diff --git a/.github/workflows/cross-platform-tests.yml b/.github/workflows/cross-platform-tests.yml index 8725ed4..065e469 100644 --- a/.github/workflows/cross-platform-tests.yml +++ b/.github/workflows/cross-platform-tests.yml @@ -9,7 +9,10 @@ name: Tests # Two separate jobs rather than one shell-matrixed job: the `shell:` key on a step does # NOT have access to the `matrix` context (unlike `run:`, `env:`, or a job's own `name:`/ # `runs-on:`) -- confirmed via `gh workflow run`: "Unrecognized named-value: 'matrix'. -# Located at position 1 within expression: matrix.shell". `shell:` must be a literal. +# Located at position 1 within expression: matrix.shell". In a workflow, `shell:` must be +# a literal. (Inside a *composite action* `shell:` does accept `${{ inputs.* }}` -- but +# still not `matrix`/`env` -- which is how .github/actions/install-test-modules serves +# both editions from one implementation.) on: push: @@ -28,29 +31,12 @@ jobs: - name: Checkout uses: actions/checkout@v7 + # Pinned + cached; see .github/actions/install-test-modules/action.yml for why + # (including why Posh-SSH is needed at all). - name: Install test dependencies - shell: pwsh - run: | - function Install-PfbModuleWithRetry { - param([hashtable]$Params, [int]$MaxAttempts = 3, [int]$DelaySeconds = 15) - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - try { - Install-Module @Params -ErrorAction Stop - return - } catch { - if ($attempt -eq $MaxAttempts) { throw } - Write-Warning "Install-Module '$($Params.Name)' attempt $attempt/$MaxAttempts failed: $($_.Exception.Message). Retrying in $DelaySeconds s..." - Start-Sleep -Seconds $DelaySeconds - } - } - } - - # Posh-SSH is an optional runtime dependency (see Private/Get-PfbApiTokenViaSsh.ps1), - # but Get-PfbApiTokenViaSsh.Tests.ps1 mocks its cmdlets (New-SSHSession, etc.) -- - # Pester can only mock a command that's resolvable, so it must be installed here - # even though the module under test never actually opens an SSH connection. - Install-PfbModuleWithRetry -Params @{ Name = 'Pester'; MinimumVersion = '5.0'; Force = $true; SkipPublisherCheck = $true; Scope = 'CurrentUser' } - Install-PfbModuleWithRetry -Params @{ Name = 'Posh-SSH'; Force = $true; SkipPublisherCheck = $true; Scope = 'CurrentUser' } + uses: ./.github/actions/install-test-modules + with: + shell: pwsh - name: Run Pester tests shell: pwsh @@ -70,33 +56,14 @@ jobs: - name: Checkout uses: actions/checkout@v7 + # Same pinned modules and same cache entry as the pwsh job on this OS -- the saved + # files are edition-independent (Pester 6.0.1 declares PowerShellVersion = '5.1'). + # `shell:` is passed explicitly because a composite action has no default shell and + # cannot read `matrix`. - name: Install test dependencies - shell: powershell - run: | - # Some older Windows PowerShell 5.1 hosts default to TLS 1.0/1.1, which - # PowerShell Gallery rejects -- force TLS 1.2 before hitting it. - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - - function Install-PfbModuleWithRetry { - param([hashtable]$Params, [int]$MaxAttempts = 3, [int]$DelaySeconds = 15) - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - try { - Install-Module @Params -ErrorAction Stop - return - } catch { - if ($attempt -eq $MaxAttempts) { throw } - Write-Warning "Install-Module '$($Params.Name)' attempt $attempt/$MaxAttempts failed: $($_.Exception.Message). Retrying in $DelaySeconds s..." - Start-Sleep -Seconds $DelaySeconds - } - } - } - - # Posh-SSH is an optional runtime dependency (see Private/Get-PfbApiTokenViaSsh.ps1), - # but Get-PfbApiTokenViaSsh.Tests.ps1 mocks its cmdlets (New-SSHSession, etc.) -- - # Pester can only mock a command that's resolvable, so it must be installed here - # even though the module under test never actually opens an SSH connection. - Install-PfbModuleWithRetry -Params @{ Name = 'Pester'; MinimumVersion = '5.0'; Force = $true; SkipPublisherCheck = $true; Scope = 'CurrentUser' } - Install-PfbModuleWithRetry -Params @{ Name = 'Posh-SSH'; Force = $true; SkipPublisherCheck = $true; Scope = 'CurrentUser' } + uses: ./.github/actions/install-test-modules + with: + shell: powershell - name: Run Pester tests shell: powershell diff --git a/.github/workflows/update-api-capability-map.yml b/.github/workflows/update-api-capability-map.yml index 23be3bf..81398fd 100644 --- a/.github/workflows/update-api-capability-map.yml +++ b/.github/workflows/update-api-capability-map.yml @@ -43,7 +43,7 @@ jobs: "versions=$($versions -join ',')" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - name: Restore cached spec files - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v6 with: path: tools/specs key: pfb-specs-${{ github.run_id }} @@ -55,7 +55,7 @@ jobs: run: ./tools/Update-PfbApiSpecs.ps1 - name: Save spec cache - uses: actions/cache/save@v4 + uses: actions/cache/save@v6 if: always() with: path: tools/specs @@ -85,16 +85,18 @@ jobs: shell: pwsh run: ./tools/Build-PfbApiDriftReport.ps1 + # Pinned + cached, with retry on the gallery path; see + # .github/actions/install-test-modules/action.yml (including why Posh-SSH is + # needed at all -- Pester can only mock a resolvable command). + - name: Install test dependencies + uses: ./.github/actions/install-test-modules + with: + shell: pwsh + - name: Run tests shell: pwsh run: | - Install-Module -Name Pester -MinimumVersion 5.0 -Force -SkipPublisherCheck -Scope CurrentUser Import-Module Pester -MinimumVersion 5.0 -Force - # Posh-SSH is an optional runtime dependency (see Private/Get-PfbApiTokenViaSsh.ps1), - # but Get-PfbApiTokenViaSsh.Tests.ps1 mocks its cmdlets (New-SSHSession, etc.) -- - # Pester can only mock a command that's resolvable, so it must be installed here - # even though the module under test never actually opens an SSH connection. - Install-Module -Name Posh-SSH -Force -SkipPublisherCheck -Scope CurrentUser Import-Module Posh-SSH -Force $cfg = New-PesterConfiguration $cfg.Run.Path = 'Tests' @@ -127,7 +129,11 @@ jobs: - name: Open pull request if: steps.diff.outputs.changed == 'true' && github.repository == 'dmann000/fb-powershell' - uses: peter-evans/create-pull-request@v6 + # v8 for the node24 runtime (v6 and v7 both run on node20 and emit a deprecation + # warning). Safe jump despite skipping a major: v7's only breaking changes were + # renaming the `git-token` input to `branch-token` and removing the deprecated + # PULL_REQUEST_NUMBER output env var, neither of which this step uses. + uses: peter-evans/create-pull-request@v8 with: commit-message: 'Update API capability map' title: "Update API capability map${{ steps.summary.outputs.new_versions && format(' (new REST versions: {0})', steps.summary.outputs.new_versions) || '' }}" diff --git a/.gitignore b/.gitignore index 89e50f2..992084a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,10 @@ tools/specs/ # PowerShell *.pssproj.user +# Pinned test-only modules saved by .github/actions/install-test-modules (Save-Module +# into a workspace-relative path so every OS/edition shares one cache location). +.psmodules/ + # Not yet decided whether this should be tracked -- excluded for now tools/ diff --git a/Data/PfbVersionMap.json b/Data/PfbVersionMap.json index 9544e99..68d0840 100644 --- a/Data/PfbVersionMap.json +++ b/Data/PfbVersionMap.json @@ -82,5 +82,8 @@ }, "2.27": { "purity": "4.8.3" + }, + "2.28": { + "purity": "4.8.4" } } diff --git a/Tests/PfbVersionMap.Coverage.Tests.ps1 b/Tests/PfbVersionMap.Coverage.Tests.ps1 index 599c469..77dc964 100644 --- a/Tests/PfbVersionMap.Coverage.Tests.ps1 +++ b/Tests/PfbVersionMap.Coverage.Tests.ps1 @@ -28,6 +28,56 @@ BeforeAll { $InputObject | ConvertFrom-Json } } + + # REST versions sort numerically, not lexically -- '2.9' is OLDER than '2.27', but a + # string sort puts it after. Collapse to a single integer so comparisons are unambiguous + # on both PowerShell editions. + function script:ConvertTo-PfbComparableVersion { + param([string]$Version) + $parts = "$Version" -split '\.' + $major = 0 + $minor = 0 + [void][int]::TryParse($parts[0], [ref]$major) + if ($parts.Count -gt 1) { [void][int]::TryParse($parts[1], [ref]$minor) } + ($major * 10000) + $minor + } + + # Splits "in the capability map but with no version-map pairing" into LAG AT THE HEAD + # versus a HOLE IN THE MIDDLE. + # + # Data/PfbVersionMap.json is refreshed by a *manual* run of + # tools/Update-PfbVersionMap.ps1 against an Everpure-internal SSOT endpoint that + # GitHub-hosted CI cannot reach. Worse, the SSOT reference table only publishes a + # REST<->Purity//FB pairing some time *after* a REST version ships -- verified + # 2026-07-25, when REST 2.28 was live and already fetched into the capability map while + # the SSOT table still had no 2.28 row at all. So a newly-released version legitimately + # appears in the capability map before any pairing exists anywhere to record. + # + # Failing on that would break the weekly refresh on exactly the runs that have something + # new to report -- and because the job dies before `Open pull request`, the failure would + # swallow the "new REST version detected" signal the workflow exists to emit. So + # head-lag warns and passes; a gap at or below the newest mapped version is a real data + # defect and still fails. + # + # Factored out of the It block so these semantics are unit-testable without needing real + # Data/ files on disk -- see the classification Describe at the bottom of this file. + function script:Split-PfbVersionMapGap { + param([string[]]$ExpectedVersions, [string[]]$MappedVersions) + + $mapped = [System.Collections.Generic.HashSet[string]]::new([string[]]$MappedVersions) + $missing = @($ExpectedVersions | Where-Object { -not $mapped.Contains($_) }) + + $highWaterName = $MappedVersions | + Sort-Object -Property { ConvertTo-PfbComparableVersion $_ } | + Select-Object -Last 1 + $highWater = ConvertTo-PfbComparableVersion $highWaterName + + [pscustomobject]@{ + HighWaterName = $highWaterName + Lagging = @($missing | Where-Object { (ConvertTo-PfbComparableVersion $_) -gt $highWater }) + Holes = @($missing | Where-Object { (ConvertTo-PfbComparableVersion $_) -le $highWater }) + } + } } Describe 'Data/PfbVersionMap.json coverage (skips gracefully if the generated files are not present)' { @@ -43,10 +93,26 @@ Describe 'Data/PfbVersionMap.json coverage (skips gracefully if the generated fi $expectedVersions = $capabilityMap.generatedFrom $expectedVersions | Should -Not -BeNullOrEmpty -Because 'the capability map should record which REST versions it was generated from' - $mappedVersions = [System.Collections.Generic.HashSet[string]]::new([string[]]$versionMap.PSObject.Properties.Name) - $missing = $expectedVersions | Where-Object { -not $mappedVersions.Contains($_) } + # Head-lag warns, a gap below the high-water mark fails -- see + # Split-PfbVersionMapGap's comment for why the two are treated differently. + $gap = Split-PfbVersionMapGap -ExpectedVersions $expectedVersions -MappedVersions ([string[]]$versionMap.PSObject.Properties.Name) + $highWaterName = $gap.HighWaterName + $lagging = $gap.Lagging + $holes = $gap.Holes + + if ($lagging) { + $message = "Data/PfbVersionMap.json has no Purity//FB pairing for newer REST version(s): $($lagging -join ', '). " + + "Newest mapped version is $highWaterName. This is expected lag, not a defect: run " + + 'tools/Update-PfbVersionMap.ps1 manually with SSOT credentials once the SSOT reference table publishes the pairing.' + Write-Warning $message + # Surface it in the Actions run summary too, so the weekly refresh flags the + # outstanding manual step instead of it being buried in step logs. + if ($env:GITHUB_ACTIONS -eq 'true') { + Write-Host "::warning title=Version map behind capability map::$message" + } + } - $missing | Should -BeNullOrEmpty -Because "these REST versions are in the capability map but have no Purity//FB pairing in Data/PfbVersionMap.json: $($missing -join ', ')" + $holes | Should -BeNullOrEmpty -Because "these REST versions are at or below the newest mapped version ($highWaterName) but have no Purity//FB pairing in Data/PfbVersionMap.json: $($holes -join ', ') -- a gap below the high-water mark is a data defect, not upstream lag" } It 'every entry has a non-empty purity property' { @@ -62,3 +128,51 @@ Describe 'Data/PfbVersionMap.json coverage (skips gracefully if the generated fi $emptyEntries | Should -BeNullOrEmpty -Because "these REST versions have an entry but no purity value: $($emptyEntries -join ', ')" } } + +Describe 'Version-map gap classification (head lag vs hole)' { + BeforeAll { + # 2.9 is deliberately present: REST versions sort lexically WRONG ('2.9' > '2.27' as + # strings), so a naive sort would pick 2.9 as the newest mapped version and then + # misclassify every genuinely-newer version as a hole. + $script:mapped = 0..27 | ForEach-Object { "2.$_" } + } + + It 'picks the numerically-newest mapped version as the high-water mark, not the lexically-largest' { + $gap = Split-PfbVersionMapGap -ExpectedVersions $mapped -MappedVersions $mapped + $gap.HighWaterName | Should -Be '2.27' + } + + It 'reports nothing when the two sets agree' { + $gap = Split-PfbVersionMapGap -ExpectedVersions $mapped -MappedVersions $mapped + $gap.Lagging | Should -BeNullOrEmpty + $gap.Holes | Should -BeNullOrEmpty + } + + It 'classifies a newly-released version above the high-water mark as expected lag, not a hole' { + $gap = Split-PfbVersionMapGap -ExpectedVersions ($mapped + '2.28') -MappedVersions $mapped + $gap.Lagging | Should -Be @('2.28') + $gap.Holes | Should -BeNullOrEmpty + } + + It 'classifies a missing version below the high-water mark as a hole, not lag' { + $withHole = $mapped | Where-Object { $_ -ne '2.20' } + $gap = Split-PfbVersionMapGap -ExpectedVersions $mapped -MappedVersions $withHole + $gap.Holes | Should -Be @('2.20') + $gap.Lagging | Should -BeNullOrEmpty + } + + It 'separates the two when a hole and a lagging version occur together' { + $withHole = $mapped | Where-Object { $_ -ne '2.20' } + $gap = Split-PfbVersionMapGap -ExpectedVersions ($mapped + '2.28') -MappedVersions $withHole + $gap.Holes | Should -Be @('2.20') + $gap.Lagging | Should -Be @('2.28') + } + + It 'treats a single-digit-minor gap correctly rather than by string order' { + # 2.9 missing while 2.27 is mapped: numerically below the high-water mark, so a hole. + $withHole = $mapped | Where-Object { $_ -ne '2.9' } + $gap = Split-PfbVersionMapGap -ExpectedVersions $mapped -MappedVersions $withHole + $gap.Holes | Should -Be @('2.9') + $gap.Lagging | Should -BeNullOrEmpty + } +}