Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions .github/actions/install-test-modules/action.yml
Original file line number Diff line number Diff line change
@@ -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"
}
65 changes: 16 additions & 49 deletions .github/workflows/cross-platform-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
24 changes: 15 additions & 9 deletions .github/workflows/update-api-capability-map.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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) || '' }}"
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
3 changes: 3 additions & 0 deletions Data/PfbVersionMap.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,8 @@
},
"2.27": {
"purity": "4.8.3"
},
"2.28": {
"purity": "4.8.4"
}
}
Loading