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
The table of contents is too big for display.
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"
}
70 changes: 21 additions & 49 deletions .github/workflows/cross-platform-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ 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.)
#
# 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.

on:
push:
pull_request:
workflow_dispatch: {}
workflow_call: {}

jobs:
test-pwsh:
Expand All @@ -28,29 +36,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 +61,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
85 changes: 85 additions & 0 deletions .github/workflows/publish-to-gallery.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Publishes EverpureFBModule (the rebranded package built from PureStorageFlashBladePowerShell)
# to the PowerShell Gallery.
#
# --- Workflow Notes ---
#
# Trigger: a pushed version tag (`v*`, e.g. `v2.0.6`) plus a manual `workflow_dispatch`.
# A tag push is an explicit, intentional "ship this" action distinct from an ordinary merge
# to main, so a release isn't triggered just because a version-bump PR happened to land;
# `workflow_dispatch` is kept as a manual fallback/re-run path.
#
# Build/brand/publish: delegated to ./scripts/Publish-Gallery.ps1, which builds the module,
# produces the rebranded EverpureFBModule package, validates the manifest, and (with
# -Publish -ApiKey) pushes it via Publish-Module -Repository PSGallery. This workflow no
# longer calls Publish-Module directly -- it just runs the test gate, the duplicate-version
# guard, and then invokes the script.
#
# Test gate: the `test` job calls cross-platform-tests.yml as a reusable workflow
# (workflow_call) instead of running its own single-platform Pester step. That workflow
# already covers every OS/PowerShell-edition combination the module claims to support --
# duplicating a subset of that here would both under-test releases (a bug caught only on
# macOS or Windows PowerShell 5.1 would slip through an ubuntu-only gate) and drift out of
# sync with the real test setup over time. `publish` depends on `test` via `needs:`.
#
# Repository guard: this file is expected to exist identically in both the development fork
# and, after merge, the official upstream repo. The `if:` guard on the publish job restricts
# actual publishing to the official repo below, so a stray tag pushed in the fork is a safe
# no-op instead of an accidental (and secret-less) publish attempt. The `test` job itself has
# no such guard -- it's harmless (and useful) to run in the fork too.
#
# Required secret: PSGALLERY_API_KEY -- a PowerShell Gallery API key with push rights to
# EverpureFBModule. Must be created in the *upstream* repo (PureStorage-OpenConnect) under
# Settings -> Secrets and variables -> Actions before this workflow can succeed there. It's
# only added there, not in development branch, since only the upstream repo actually publishes
# (see the repository guard above). This workflow will fail at the publish step until that
# secret exists.

name: Publish to PowerShell Gallery

on:
push:
tags:
- 'v*'
workflow_dispatch: {}

jobs:
test:
uses: ./.github/workflows/cross-platform-tests.yml

publish:
Comment on lines +47 to +49
name: Build and publish
runs-on: ubuntu-latest
needs: test
# Only ever publish from the official upstream repo -- see design notes above.
if: github.repository == 'PureStorage-OpenConnect/flashblade-powershell'
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Check for duplicate version on the Gallery
id: version_check
shell: pwsh
run: |
$manifest = Import-PowerShellDataFile -Path ./PureStorageFlashBladePowerShell.psd1
$localVersion = $manifest.ModuleVersion
Write-Host "Local ModuleVersion: $localVersion"

# Check the published (rebranded) package name, not the source module name --
# scripts/Publish-Gallery.ps1 publishes as EverpureFBModule.
$published = Find-Module -Name EverpureFBModule -ErrorAction SilentlyContinue
if ($published -and $published.Version -eq [version]$localVersion) {
Write-Host "Version $localVersion is already published on the Gallery -- skipping publish."
"skip=true" >> $env:GITHUB_OUTPUT
}
else {
Write-Host "Version $localVersion is not yet published -- proceeding."
"skip=false" >> $env:GITHUB_OUTPUT
}

- name: Publish to PowerShell Gallery
if: steps.version_check.outputs.skip == 'false'
shell: pwsh
run: |
./scripts/Publish-Gallery.ps1 -Publish -ApiKey $env:PSGALLERY_API_KEY
env:
PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
Comment on lines +50 to +85
Loading
Loading