From a1a6c813c62e5dc39678c206a9415834ec34f90f Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Sat, 25 Jul 2026 11:21:13 -0700 Subject: [PATCH 1/4] ci: cache and pin the test-only PowerShell modules Run 30149566997 failed at "Install test dependencies" with "No match was found ... module name 'Pester'" while the run on the identical SHA passed 47 seconds later -- a transient PSGallery blip that cost a whole job's worth of test coverage. PSGallery is now a fast-path-only dependency, not a hard one. - New composite action .github/actions/install-test-modules wraps actions/cache@v4 around Save-Module into a workspace-relative .psmodules/, so the gallery is only touched on a cache miss. - Pin Pester 6.0.1 and Posh-SSH 3.2.7 (exactly what CI resolves to today, so no behaviour change) as action inputs -- one place to bump. The previous MinimumVersion = '5.0' is how CI silently moved onto Pester 6 with nobody deciding to. - 5 attempts with 15/30/60/120s backoff on the miss path, replacing the old 3 x 15s (~50s) window that gallery blips routinely outlast. - Save-Module into a fixed workspace path instead of -Scope CurrentUser: that scope resolves to a different directory per OS and per edition, whereas one workspace path yields one identical, cacheable location for every job. PSModulePath is exported via $GITHUB_ENV using [IO.Path]::PathSeparator. - Collapses four copy-pasted install blocks into one, and gives update-api-capability-map.yml retry + pinning, which it had neither of. Local dual-edition smoke test: Pester 6.0.1 saved via -RequiredVersion into a non-standard directory imports and runs a Tests/ subset (39 assertions, 0 failures) under both pwsh 7.6.4 and Windows PowerShell 5.1 -- including the 5.1 host reading the directory saved by pwsh, which is what the shared per-OS cache key relies on. Co-Authored-By: Claude Opus 5 (1M context) --- .../actions/install-test-modules/action.yml | 121 ++++++++++++++++++ .github/workflows/cross-platform-tests.yml | 65 +++------- .../workflows/update-api-capability-map.yml | 14 +- .gitignore | 4 + 4 files changed, 149 insertions(+), 55 deletions(-) create mode 100644 .github/actions/install-test-modules/action.yml diff --git a/.github/actions/install-test-modules/action.yml b/.github/actions/install-test-modules/action.yml new file mode 100644 index 0000000..6af26ca --- /dev/null +++ b/.github/actions/install-test-modules/action.yml @@ -0,0 +1,121 @@ +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. If both miss and both try to save, + # actions/cache logs "Cache already exists"; that is a warning, not a failure. + - name: Cache test modules + id: psmodules-cache + uses: actions/cache@v4 + 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..5057498 100644 --- a/.github/workflows/update-api-capability-map.yml +++ b/.github/workflows/update-api-capability-map.yml @@ -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' 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/ From 0f46fdeae441584bbd0f21e249b9f11f624362e3 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Sat, 25 Jul 2026 11:35:18 -0700 Subject: [PATCH 2/4] ci: bump actions/cache to v6 for the node24 runtime actions/cache@v4 runs on Node 20 and emits a deprecation warning on every step. v5 moved to node24; v6 adds the ESM migration and is the latest major. There is no actions/cache@v7 -- only actions/checkout goes that high -- so v6 is the correct target here. Covers the new composite action and the two pre-existing cache/restore and cache/save pins in update-api-capability-map.yml, which had the same warning. restore/save sub-actions and the cache-hit output are unchanged in v6. Also replaces a guessed log string in the action's comment with the one actually observed on run 30169601354 ("Failed to save: Unable to reserve cache with key ...") and records that both Windows jobs sharing one key is deliberate and CI-verified. --- .../actions/install-test-modules/action.yml | 18 +++++++++++++++--- .../workflows/update-api-capability-map.yml | 4 ++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/actions/install-test-modules/action.yml b/.github/actions/install-test-modules/action.yml index 6af26ca..f5b953b 100644 --- a/.github/actions/install-test-modules/action.yml +++ b/.github/actions/install-test-modules/action.yml @@ -44,11 +44,23 @@ runs: using: composite steps: # Two Windows jobs (pwsh and Windows PowerShell 5.1) intentionally share one cache - # key -- the saved files are identical. If both miss and both try to save, - # actions/cache logs "Cache already exists"; that is a warning, not a failure. + # 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@v4 + uses: actions/cache@v6 with: path: .psmodules key: psmodules-${{ runner.os }}-pester-${{ inputs.pester-version }}-poshssh-${{ inputs.posh-ssh-version }} diff --git a/.github/workflows/update-api-capability-map.yml b/.github/workflows/update-api-capability-map.yml index 5057498..11b9fac 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 From 49cd0a8c05b0007aa04ffdd4b80354bf860adf45 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Sat, 25 Jul 2026 12:04:20 -0700 Subject: [PATCH 3/4] ci: bump create-pull-request to v8 and stop the version-map gate self-sabotaging Two changes to the capability-map workflow's failure modes. peter-evans/create-pull-request v6 -> v8: v6 and v7 both run on node20 and emit a deprecation warning; v8 is the node24 release. Safe despite skipping a major -- v7's breaking changes were renaming the `git-token` input to `branch-token` and removing the deprecated PULL_REQUEST_NUMBER output env var, and this step uses neither. The version-map coverage gate now distinguishes lag at the head from a hole in the middle. Data/PfbVersionMap.json is refreshed by a manual run against an internal SSOT endpoint CI cannot reach, and the SSOT table publishes a REST<->Purity//FB pairing only some time AFTER a REST version ships -- verified 2026-07-25, when REST 2.28 was live and already in the capability map while the SSOT document contained no 2.28 row at all. The old assertion therefore failed on exactly the runs that had something new to report, and because the job died before `Open pull request` it swallowed the "new REST version detected" signal the workflow exists to emit. Head-lag now warns (plus a ::warning:: annotation so the outstanding manual step surfaces in the run summary) and passes. A missing pairing at or below the newest mapped version is still a hard failure -- that is a data defect, not upstream lag. The classification is factored into Split-PfbVersionMapGap so the semantics are unit-testable without real Data/ files, with 6 new tests. Note REST versions sort lexically wrong ('2.9' > '2.27' as strings); the comparison collapses to an integer, and reverting it to a naive sort fails 4 of the 6 new tests. --- .../workflows/update-api-capability-map.yml | 6 +- Tests/PfbVersionMap.Coverage.Tests.ps1 | 120 +++++++++++++++++- 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/.github/workflows/update-api-capability-map.yml b/.github/workflows/update-api-capability-map.yml index 11b9fac..81398fd 100644 --- a/.github/workflows/update-api-capability-map.yml +++ b/.github/workflows/update-api-capability-map.yml @@ -129,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/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 + } +} From 016bfbb1991f968587c1908b8d2f10f8bade68aa Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Sat, 25 Jul 2026 12:13:24 -0700 Subject: [PATCH 4/4] data: map REST 2.28 to Purity//FB 4.8.4 (entered by hand, ahead of the SSOT) Force-refreshed all 29 published specs (tools/Update-PfbApiSpecs.ps1 -Force, including the new fb2.28.json) and re-ran tools/Update-PfbVersionMap.ps1. The script discovered 2.28 on its own from tools/specs/ and reached the SSOT successfully, but the SSOT reference document still has no 2.28 row at all -- the string does not appear anywhere in it, while 2.27 does, and the parser is healthy (32 rows, 1.8 through 2.27, newest pairing 2.27 = Purity//FB 4.8.3). So the generator correctly left the file untouched and this pairing is entered manually instead. CAVEAT, deliberately recorded: because Update-PfbVersionMap.ps1 only looks up versions NOT already present in the map, adding 2.28 by hand means the script will never query the SSOT for 2.28 again -- it now short-circuits with "No versions need looking up". If the SSOT eventually publishes a pairing other than 4.8.4, this entry will stay silently wrong. Re-verify (or delete the entry and re-run) once the SSOT document catches up. tools/specs/ itself stays uncommitted per .gitignore. --- Data/PfbVersionMap.json | 3 +++ 1 file changed, 3 insertions(+) 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" } }