Merge pull request #177 from juemerson-at-purestorage/feat/ci-static-… #184
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Tests | |
| # Runs the Pester suite across every OS/PowerShell-edition combination the module | |
| # claims to support (PureStorageFlashBladePowerShell.psd1: PowerShellVersion = '5.1'), | |
| # so platform-specific bugs (e.g. a .NET marshaling difference between Windows and | |
| # Linux/macOS) are caught on every push/PR instead of only surfacing much later on | |
| # whichever CI job happens to run on a non-Windows runner. | |
| # | |
| # 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". 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.) | |
| # | |
| # workflow_call: publish-to-gallery.yml calls this workflow as its test gate, so a | |
| # release only ships after passing on all 4 OS/PowerShell-edition combinations below -- | |
| # not just whichever single platform a standalone Pester step would happen to run on. | |
| # `push` is filtered to main; `pull_request` is not. An unfiltered `push` matched every branch | |
| # commit, and a commit on a branch with an open PR ALSO matches `pull_request` -- so one push | |
| # ran the whole matrix twice, 10 jobs for one commit, with Windows pwsh alone near 17 minutes | |
| # on each side. | |
| # | |
| # Branch commits keep their coverage: `pull_request` fires on every push to a branch with an | |
| # open PR (the `synchronize` event). The only case that loses a run is a branch commit pushed | |
| # BEFORE its PR exists -- and a `pull_request` run is the better of the two anyway, because it | |
| # tests the MERGE commit rather than the branch tip, i.e. the tree that would actually ship. | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| workflow_dispatch: {} | |
| workflow_call: {} | |
| # Read-only: this workflow checks out, restores/saves the spec cache, moves artifacts between | |
| # jobs and runs Pester. It never writes to the repository. Declared rather than inherited | |
| # because without a permissions block the GITHUB_TOKEN gets whatever the repo's | |
| # default_workflow_permissions setting happens to be -- a setting an admin can flip to write | |
| # with no change to any file here. Cache and artifact actions are unaffected either way: they | |
| # authenticate with ACTIONS_RUNTIME_TOKEN, not this token. | |
| permissions: | |
| contents: read | |
| jobs: | |
| # Issue #63: tools/specs/ is a ~50MB cache of raw OpenAPI specs, gitignored because it is a | |
| # build input rather than source, so on a bare runner it does not exist. Every tooling test | |
| # gated on its presence skipped gracefully while the job reported success -- roughly 23% of | |
| # the suite, including the absolute-path regression guards from PR #62, invisible in the run | |
| # summary. This job materialises the cache once per run and hands it to every test leg. | |
| # | |
| # The cache key prefix is deliberately shared with update-api-capability-map.yml, so a warm | |
| # cache written by either workflow serves both. Update-PfbApiSpecs.ps1 skips any version | |
| # already on disk, so a cache hit means only newly-published versions get fetched instead of | |
| # the full ~29-version history. | |
| # | |
| # An artifact rather than a per-leg cache restore: it fetches from the published spec index | |
| # at most once per run instead of up to four times, and it is immune to cache key/branch | |
| # scoping differences across the matrix. 29 JSON files, ~50MB raw, which compress well. | |
| prepare-specs: | |
| name: Prepare API spec cache | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Restore cached spec files | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: tools/specs | |
| key: pfb-specs-${{ github.run_id }} | |
| restore-keys: | | |
| pfb-specs- | |
| - name: Fetch any missing REST API spec versions | |
| shell: pwsh | |
| run: ./tools/Update-PfbApiSpecs.ps1 | |
| # The whole point of this job. A silent no-op here would put us straight back to the | |
| # green-but-empty runs issue #63 is about, so an empty result is a hard failure. | |
| - name: Assert the spec set is non-empty | |
| shell: pwsh | |
| run: ./scripts/Assert-PfbSpecCache.ps1 | |
| - name: Save spec cache | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| if: always() | |
| with: | |
| path: tools/specs | |
| key: pfb-specs-${{ github.run_id }} | |
| - name: Publish specs to the test jobs | |
| uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | |
| with: | |
| name: pfb-specs | |
| path: tools/specs | |
| retention-days: 1 | |
| test-pwsh: | |
| name: Test (${{ matrix.os }}, pwsh) | |
| runs-on: ${{ matrix.os }} | |
| needs: prepare-specs | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, windows-latest, macos-latest] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Issue #63: without this, every tools/specs-gated test skips and the job still passes. | |
| - name: Download API spec cache | |
| uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 | |
| with: | |
| name: pfb-specs | |
| path: tools/specs | |
| # Pinned + cached; see .github/actions/install-test-modules/action.yml for why | |
| # (including why Posh-SSH is needed at all). | |
| - name: Install test dependencies | |
| uses: ./.github/actions/install-test-modules | |
| with: | |
| shell: pwsh | |
| # Suite invocation + the issue-#63 coverage gate live in scripts/Invoke-PfbCiPester.ps1 | |
| # rather than inline here: this same block was copy-pasted in three places across two | |
| # workflows, and all three needed the same change. A script is also lintable, diffable | |
| # and runnable outside Actions, which YAML-embedded PowerShell is not. | |
| - name: Run Pester tests | |
| shell: pwsh | |
| run: ./scripts/Invoke-PfbCiPester.ps1 -Edition pwsh7 | |
| # T8 + T11 -- PSScriptAnalyzer. | |
| # | |
| # WHY A CI JOB AND NOT A HOOK. The PostToolUse parse-check hook is per-edit and only ever | |
| # sees the file just written. This rule set is repo-wide and low-frequency: what it catches | |
| # is "someone added a 5.1-incompatible construct anywhere", which no per-file check can see. | |
| # | |
| # WHY ubuntu-latest AND ONE LEG. The analyzer parses; it does not execute the module, so its | |
| # findings do not vary by host OS or PowerShell edition -- verified by running the identical | |
| # sweep on Windows pwsh 7 and on Ubuntu 26.04 / pwsh 7.6.3 and getting the same total (276 | |
| # at the time of that check, 149 once the dead-variable cleanup landed), the same zeros on | |
| # every guard, and the same Private/ controls at 12 and 2. Running it | |
| # across the existing 4-leg matrix would quadruple the cost for four identical results. | |
| # | |
| # The COMPATIBILITY rules are the apparent exception and are not: they target 5.1 and 7.0 by | |
| # configuration, from static profiles that ship with the analyzer on every platform, so this | |
| # job reports on 5.1 from a Linux runner with no 5.1 in sight. That the two Windows profiles | |
| # resolve on a Linux install was checked, not assumed -- if they did not, the rule would | |
| # evaluate nothing and hand the Public/ gate a permanent free pass. | |
| # | |
| # WHY IT DOES NOT NEED prepare-specs. Nothing here reads tools/specs/, so the job does not | |
| # download the spec artifact and does not depend on that job. It starts immediately and | |
| # finishes while the test matrix is still running. | |
| analyze: | |
| name: PSScriptAnalyzer | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Pinned and cached for the same reason Pester and Posh-SSH are pinned in | |
| # .github/actions/install-test-modules: an unpinned MinimumVersion is how this | |
| # repo silently moved from Pester 5 to Pester 6 with nobody deciding to, and a | |
| # transient PSGallery blip has already failed a run on a SHA that passed 47s | |
| # later. A new analyzer version can add rules or change counts, which would | |
| # present as an unexplained CI failure on an unrelated PR. | |
| # | |
| # Deliberately NOT added as a third module to install-test-modules: that action | |
| # runs in all four test legs, which would download the analyzer four times per | |
| # run for a job that needs it once. | |
| - name: Restore cached PSScriptAnalyzer | |
| id: pssa-cache | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: .psmodules | |
| key: pssa-${{ runner.os }}-1.25.0 | |
| - name: Save PSScriptAnalyzer | |
| if: steps.pssa-cache.outputs.cache-hit != 'true' | |
| shell: pwsh | |
| run: | | |
| New-Item -ItemType Directory -Force -Path .psmodules | Out-Null | |
| Save-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Path .psmodules -Force | |
| - name: Run PSScriptAnalyzer | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $env:PSModulePath = (Resolve-Path .psmodules).Path + [IO.Path]::PathSeparator + $env:PSModulePath | |
| Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 | |
| $settings = './PSScriptAnalyzerSettings.psd1' | |
| # --------------------------------------------------------------- | |
| # CONTROL FIRST. Every assertion below is "this count is zero", and an | |
| # analyzer that returns nothing satisfies all of them. That is not | |
| # hypothetical: Invoke-ScriptAnalyzer honours -WhatIf and returns an empty | |
| # set, and a mis-specified rule name or an unparseable settings file can | |
| # produce the same silence. So prove the tool reports a finding it must | |
| # report before believing any zero it gives us. | |
| # --------------------------------------------------------------- | |
| $control = @(Invoke-ScriptAnalyzer -ScriptDefinition 'function Test-Probe { $x = 1 }' ` | |
| -IncludeRule PSUseDeclaredVarsMoreThanAssignments) | |
| if ($control.Count -lt 1) { | |
| throw 'CONTROL FAILED: the analyzer found nothing in a snippet that definitely has a finding. Every zero in this run is vacuous.' | |
| } | |
| Write-Host "control: analyzer live ($($control.Count) finding on the probe)" | |
| # Paths. The repo ROOT is included as an explicit FILE list, not as a | |
| # directory: the module .psd1/.psm1 live there, and a directory sweep of the | |
| # five source folders never analyses them -- which silently disables four | |
| # manifest/module rules including the PSGallery preset's own | |
| # PSMissingModuleManifestField. As files it also cannot recurse and | |
| # double-count. build/ is gitignored generated output and stays out. | |
| $paths = @('Public', 'Private', 'Tests', 'tools', 'scripts') | | |
| Where-Object { Test-Path $_ } | | |
| ForEach-Object { [pscustomobject]@{ Path = $_; Recurse = $true } } | |
| $paths += Get-ChildItem -File | | |
| Where-Object Extension -in '.ps1', '.psm1', '.psd1' | | |
| ForEach-Object { [pscustomobject]@{ Path = $_.FullName; Recurse = $false } } | |
| # -Path takes a single string. Passing an array fails with a type-conversion | |
| # error and leaves the variable empty, so a naive .Count then reports a | |
| # confident "0 findings". Hence the loop. | |
| $all = foreach ($p in $paths) { | |
| $splat = @{ Path = $p.Path; Settings = $settings } | |
| if ($p.Recurse) { $splat.Recurse = $true } | |
| Invoke-ScriptAnalyzer @splat | |
| } | |
| $all = @($all) | |
| # Severity round-trips as UInt32 in some paths and compares as neither | |
| # reliably; cast to string before comparing, always. | |
| $errors = @($all | Where-Object { [string]$_.Severity -eq 'Error' }) | |
| $warnings = @($all | Where-Object { [string]$_.Severity -eq 'Warning' }) | |
| Write-Host "total $($all.Count): $($errors.Count) Error, $($warnings.Count) Warning" | |
| $failures = [System.Collections.Generic.List[string]]::new() | |
| # --- Guard 1: no Errors, ever. Reached by T9; this holds the line. | |
| if ($errors.Count) { | |
| $failures.Add("$($errors.Count) Error-severity finding(s)") | |
| $errors | ForEach-Object { Write-Host "::error file=$($_.ScriptPath),line=$($_.Line)::$($_.RuleName): $($_.Message)" } | |
| } | |
| # --- Guards 2-5: rules at a genuine repo-wide zero. Each is a REGRESSION | |
| # guard: it is not cleaning anything up, it is refusing to let the first one | |
| # in. PSUseCompatibleSyntax is the load-bearing one -- it enforces the 5.1 | |
| # mandate with the real parser rather than the hook's regexes. | |
| # | |
| # PSUseDeclaredVarsMoreThanAssignments is here only because the 127 dead | |
| # $manifest assignments were deleted first. That is the whole point of having | |
| # deleted them: at 127 the rule reported nothing but known boilerplate, so a | |
| # genuinely dead variable in a new test file was finding 128 of 127 and | |
| # invisible, and no gate could ever be written. It is also the rule the | |
| # control probe above uses, so its liveness is proven on every run. | |
| foreach ($rule in 'PSUseCompatibleSyntax', 'PSAvoidAssignmentToAutomaticVariable', 'PSUseBOMForUnicodeEncodedFile', 'PSUseApprovedVerbs', 'PSUseDeclaredVarsMoreThanAssignments') { | |
| $hits = @($all | Where-Object RuleName -eq $rule) | |
| if ($hits.Count) { | |
| $failures.Add("$rule regressed: $($hits.Count) finding(s), expected 0") | |
| $hits | ForEach-Object { Write-Host "::error file=$($_.ScriptPath),line=$($_.Line)::$($_.RuleName): $($_.Message)" } | |
| } | |
| } | |
| # --- Guards 6-7 (T11): two rules that are clean in Public/ ONLY, so they are | |
| # requested explicitly and scoped there. | |
| # | |
| # THE RuleName FILTER IS MANDATORY, NOT DEFENSIVE. A caller's -IncludeRule is | |
| # UNION'd with the settings file's IncludeRules -- measured; it does not | |
| # replace it. Unfiltered, this scan returns 22 records over Public/ and none | |
| # of them belong to the rule being gated, so the gate would fail on unrelated | |
| # preset findings while reporting the wrong cause. | |
| foreach ($rule in 'PSProvideCommentHelp', 'PSUseCompatibleCommands') { | |
| $scoped = @(Invoke-ScriptAnalyzer -Path 'Public' -Recurse -Settings $settings -IncludeRule $rule | | |
| Where-Object RuleName -eq $rule) | |
| # Per-rule control: the same rule must be NONZERO in Private/. Without it | |
| # a zero cannot be distinguished from an inert rule -- and for | |
| # PSProvideCommentHelp that is the LIKELY failure, because its default | |
| # ExportedOnly = $true silences it completely in this codebase (one | |
| # function per dot-sourced file, exports declared in the manifest). The | |
| # settings file sets $false; if that config is ever dropped, this control | |
| # fails instead of the gate silently passing forever. | |
| $control2 = @(Invoke-ScriptAnalyzer -Path 'Private' -Recurse -Settings $settings -IncludeRule $rule | | |
| Where-Object RuleName -eq $rule) | |
| if ($control2.Count -eq 0) { | |
| $failures.Add("CONTROL FAILED for ${rule}: 0 findings in Private/ too, so the Public/ zero proves nothing") | |
| } | |
| if ($scoped.Count) { | |
| # ${rule}, not $rule -- "$rule:" parses the colon as a SCOPE qualifier | |
| # (as in $script:x) and is a syntax error, not a runtime surprise. | |
| $failures.Add("${rule}: $($scoped.Count) finding(s) in Public/, expected 0") | |
| $scoped | ForEach-Object { Write-Host "::error file=$($_.ScriptPath),line=$($_.Line)::$($_.RuleName): $($_.Message)" } | |
| } else { | |
| Write-Host "gate $rule (Public/): 0, control Private/: $($control2.Count)" | |
| } | |
| } | |
| # Warnings are reported, not gated. 149 stand today and the plan does not | |
| # clear them; a threshold would either be met trivially or block every PR. | |
| # Revisit only with a number someone has committed to driving down. | |
| Write-Host "::notice::PSScriptAnalyzer: $($warnings.Count) Warning-severity findings (not gated)" | |
| if ($failures.Count) { | |
| Write-Host '' | |
| $failures | ForEach-Object { Write-Host "FAIL: $_" } | |
| throw "PSScriptAnalyzer gate failed: $($failures.Count) condition(s)." | |
| } | |
| Write-Host 'PSScriptAnalyzer gate passed.' | |
| # ===================================================================== | |
| # NOT gated, and why -- each of these would fail today: | |
| # | |
| # PSUseCompatibleCommands (repo-wide) 21,889 -- 21,870 of them in Tests/, because | |
| # the rule knows only built-in commands and so | |
| # reports every Pester `Should` parameter. | |
| # Gated on Public/ only, above. | |
| # PSAvoidGlobalVars 61, a deliberate Pester cross-mock-scope | |
| # pattern. In the PSGallery preset, so it is | |
| # kept in the settings file and not gated. | |
| # PSUseSingularNouns 44, deliberately kept in the settings file | |
| # and deliberately not gated. | |
| # PSAvoidUsingEmptyCatchBlock 11, deferred to dmann000/fb-powershell#117, | |
| # which already adjudicated all three shipped | |
| # sites. Reported, never gated -- so this job | |
| # is green with them outstanding, and fixing | |
| # one would need a live FlashBlade run because | |
| # every working fix adds an executable line to | |
| # shipped code. A comment does NOT clear this | |
| # rule. | |
| # | |
| # Warning count today: 149. Measured under the committed settings file, not carried | |
| # forward -- every earlier figure here went stale within days: 302, then 276 once | |
| # T1/T2 and T3 took the BOM 24 and the automatic-variable 1 to zero, then 149 once the | |
| # 127 dead $manifest assignments went. Re-measure rather than trusting this line. | |
| # ===================================================================== | |
| test-windows-powershell-5-1: | |
| name: Test (windows-latest, Windows PowerShell 5.1) | |
| runs-on: windows-latest | |
| needs: prepare-specs | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Parses everything this leg parses (Public/, Private/, the root module, Tests/, | |
| # scripts/) with the 5.1 parser, and checks shipped code for 5.1 runtime and | |
| # edition-divergence traps. A parse error fails here with its file and line; runtime | |
| # and divergence findings are warnings. Run directly, not through a nested | |
| # `powershell.exe` child: this step's shell already IS Windows PowerShell 5.1. | |
| # The script exits 2 when it cannot run, but this shell reports any non-zero exit as | |
| # 1, so the step fails either way and the log says which it was. | |
| - name: Check Windows PowerShell 5.1 compatibility | |
| shell: powershell | |
| run: ./tools/Test-PfbPs51Compat.ps1 -All | |
| # Wired here too, for symmetry with the pwsh job. The tools/specs-gated Describes are | |
| # additionally PS7-gated so they still skip on this leg, but a future spec-dependent | |
| # test that does NOT carry the PS7 guard then works with no workflow change. | |
| - name: Download API spec cache | |
| uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 | |
| with: | |
| name: pfb-specs | |
| path: tools/specs | |
| # 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 | |
| uses: ./.github/actions/install-test-modules | |
| with: | |
| shell: powershell | |
| # `shell:` stays per-leg and must remain a literal (see the header note about the | |
| # matrix context) -- it selects the interpreter. -Edition selects only which | |
| # Tests/coverage-baseline.psd1 block applies, since the two editions legitimately | |
| # differ by ~200 skips. | |
| - name: Run Pester tests | |
| shell: powershell | |
| run: ./scripts/Invoke-PfbCiPester.ps1 -Edition winps51 |