Shared PowerShell functions and reusable PowerShell centric GitHub composite actions and workflows.
- Requirements
- Overview
- Installation
- Publishing
- API reference
- Top-level utilities
- Retry (
Public/Retry/)- Loop
- Transient-error strategies (
Public/Retry/TransientErrorStrategies/) - Backoff strategies (
Public/Retry/BackoffStrategies/)
- Reusable CI
- Repo structure
PowerShell 7+ (pwsh).
Provides cross-cutting utilities used by all infrastructure repos so the logic
does not need to be duplicated and tested in each one independently. Functions
are grouped on disk by concern; the retry family lives under
Public/Retry/.
Top-level utilities
Assert-RequiredProperties- validates that a PSCustomObject has all required properties present and non-empty; collects every violation before throwing so the consumer sees the full picture in one run.ConvertTo-Array- ensures a value is always an array regardless of whether PowerShell unrolled a single-item collection.Invoke-ModuleInstall- installs a module from PSGallery if absent or below the required minimum version, then imports it.
Retry (Public/Retry/) - subdivided by strategy category so each
folder stays small as more factories land:
- Loop (root of
Public/Retry/)Invoke-WithExitCodeRetry- exit-code counterpart for native commands (netsh,git,docker,wsl, ...) that fail via$LASTEXITCODErather than a thrown exception. Reuses the same backoff strategies; retries any non-zero exit by default, or only a caller-supplied-RetryableExitCodeset. For predicate-based classification, throw and useInvoke-WithRetryinstead.Invoke-WithRetry- generic retry loop. Consumes hashtable-shaped retry strategies (ShouldRetryclassifiers) and a backoff strategy (GetDelayprovider). Multiple retry strategies are OR-composed so a single call can cover several legitimately-transient failure classes (e.g. network + file-lock). Defaults to exponential backoff when none is supplied.
- Transient-error strategies (
Public/Retry/TransientErrorStrategies/) - factories that return@{ Name; ShouldRetry }classifiers consumed byInvoke-WithRetry. Compose multiple via-RetryStrategywhen a single call legitimately touches several transient-failure classes (e.g. network + file-lock).New-TransientNetworkRetryStrategy- matches DNS/socket/5xx.New-FileLockRetryStrategy- matchesSystem.IO.IOException(Hyper-V VMMS handle-release case).New-TransientPowerShellModuleInstallRetryStrategy- matches PSGallery source-resolution flakes fromInstall-Module/Install-Package. OR-compose with the network strategy for the full transient surface.
- Backoff strategies (
Public/Retry/BackoffStrategies/) - factories that return@{ Name; GetDelay }providers consumed byInvoke-WithRetryvia-BackoffStrategy. Pick the curve that matches the underlying failure; reach forNew-CustomBackoffStrategywhen the built-ins do not (HTTP 429Retry-After, jittered exponential, deadline-aware backoff, ...).New-ExponentialBackoffStrategy- doubles each attempt up to a cap. Sensible default for most call sites.New-LinearBackoffStrategy- grows linearly per attempt up to a cap. Predictable spacing when exponential ramps up too fast.New-ConstantBackoffStrategy- same delay every attempt. Use when the failure has a known fixed recovery window.New-CustomBackoffStrategy- wraps a caller-suppliedGetDelayscript block in the standard hashtable shape.
This repo is also the canonical home of the reusable CI workflows and composite actions that every infrastructure module shares - see Reusable CI.
Invoke-ModuleInstall cannot install itself. Each consumer script that needs
this module must include a short inline guard to install Common.PowerShell
first - this is a one-time cost per script, and all other module installs then
flow through Invoke-ModuleInstall.
# Inline bootstrap - cannot use Invoke-ModuleInstall to install itself.
$_common = Get-Module -ListAvailable -Name Common.PowerShell |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $_common -or $_common.Version -lt [Version]'4.0.1') {
Install-Module Common.PowerShell -Scope CurrentUser -Force
# Re-query so the comparison below uses the freshly installed version.
$_common = Get-Module -ListAvailable -Name Common.PowerShell |
Sort-Object Version -Descending | Select-Object -First 1
}
# Reload only when the loaded state differs from the target (multiple
# versions live, or wrong version live). Mirrors the conditional in
# Invoke-ModuleInstall - inlined here because the bootstrap installs
# the very module that defines that function.
$_loaded = @(Get-Module -Name Common.PowerShell)
if ($_loaded.Count -ne 1 -or $_loaded[0].Version -ne $_common.Version) {
if ($_loaded) { $_loaded | Remove-Module -Force }
Import-Module Common.PowerShell -Force -ErrorAction Stop
}Consuming repos install automatically from PSGallery via the bootstrap block above - no manual step needed.
To install manually:
Install-Module Common.PowerShell -Scope CurrentUserTo update an existing installation:
Update-Module Common.PowerShellFor local development of this module: use scripts\Install.ps1 to install
from source instead of PSGallery.
This repo carries two independent tag streams that share the git repo but not the version numbering. Each has its own release flow.
| Stream | Tag shape | Driven by | Consumed by |
|---|---|---|---|
| Module | X.Y.Z (e.g. 6.0.0) |
Automated: tag-from-manifest on .psd1 bump |
Install-Module Common.PowerShell from PSGallery |
| Actions | vX.Y.Z + rolling vX (e.g. v7.0.0, v7) |
Manual: scripts/Publish-VersionTags.ps1 | Other repos pinning uses: VitaliiAndreev/Common-PowerShell/.github/.../<name>@vN |
The two namespaces never collide (7.0.0 and v7.0.0 are different tag
names), so each stream can advance on its own cadence. Numbers across
streams may drift; they are not required to align.
- Bump
ModuleVersionin Common.PowerShell/Common.PowerShell.psd1 - Promote the
## [Unreleased]section in CHANGELOG.md to## [X.Y.Z] - <date>(matching the new version), open a fresh empty[Unreleased]above it, and add the footer compare link - Open a PR, get it reviewed and merged
On merge, .github/workflows/release.yml
checks the version is new, asserts the manifest and changelog agree on
X.Y.Z (the assert-changelog-version action - the release fails here if
you forgot to promote the changelog), runs the unit + integration tests, and
creates a matching X.Y.Z git tag. It then, in parallel, publishes to
PSGallery and creates a GitHub Release whose body is the X.Y.Z
CHANGELOG.md section (Common-Automation's create-github-release). No manual
tagging or release-notes step required.
Add entries under [Unreleased] as you merge feature work, so step 2 at
release time is only the rename. GitHub Releases attach to this module
(X.Y.Z) stream; the vN actions stream below stays release-free - those
tags are ref pins, not published artifacts.
One-time setup: add your PSGallery API key as a repository secret named
PSGALLERY_API_KEY under Settings -> Secrets and variables -> Actions.
Generate a key at powershellgallery.com/account/apikeys.
When you change a reusable workflow under .github/workflows/ or a
composite action under .github/actions/, downstream repos pinning
uses: …@vN will not see the change until you publish new action tags.
Run from any branch:
.\scripts\Publish-VersionTags.ps1 -Version vX.Y.ZIt resolves origin/master to an explicit commit SHA, creates the
immutable vX.Y.Z tag (refuses to re-point), and force-moves the rolling
vX tag to that SHA. Consumers on @vX automatically get the new release
on their next workflow run; consumers can pin to @vX.Y.Z for an
immutable reference.
Triggering is intentionally manual so PSGallery does not receive a module release for what is purely a CI / workflow change.
Functions are grouped on disk by concern. Top-level utilities sit at the
root of Public/; the retry family lives under Public/Retry/ and is
further subdivided into TransientErrorStrategies/ and (in a later
step) BackoffStrategies/.
Validates that a PSCustomObject has all required properties present and non-empty. All violations are collected before throwing so the consumer sees the full picture in one run rather than fixing one field at a time.
| Parameter | Type | Required | Description |
|---|---|---|---|
-Object |
object | Yes | The PSCustomObject to validate (e.g. a config entry) |
-Properties |
string[] | Yes | Property names that must be present and non-empty |
-Context |
string | Yes | Identifies the object in error messages, e.g. "VM 'ubuntu-01'" |
Assert-RequiredProperties -Object $vm `
-Properties @('vmName', 'ipAddress') `
-Context "VM '$($vm.vmName)'"Wraps a value in a single-element array if PowerShell unrolled it down to a
scalar, and returns an existing array unchanged. Use after any pipeline or
ConvertFrom-Json call where a one-item result must still be enumerable.
| Parameter | Type | Required | Description |
|---|---|---|---|
-Value |
object | Yes | The value to normalise to an array |
$entries = ConvertTo-Array ($json | ConvertFrom-Json)
foreach ($entry in $entries) { ... } # safe even when $json had one elementInstalls a module from PSGallery if absent or below the minimum required version, then imports it.
| Parameter | Type | Required | Description |
|---|---|---|---|
-ModuleName |
string | Yes | The module to install and import |
-MinimumVersion |
Version | No | Minimum acceptable version; any installed version accepted if omitted |
# Install with a minimum version constraint
Invoke-ModuleInstall -ModuleName 'Pester' -MinimumVersion '5.0'
# Install if absent, accept any version
Invoke-ModuleInstall -ModuleName 'Posh-SSH'Exit-code counterpart to Invoke-WithRetry. Native commands (netsh,
git, docker, wsl, ...) signal failure through $LASTEXITCODE, not
exceptions, so Invoke-WithRetry's ShouldRetry predicates cannot
classify them. This loop reads $LASTEXITCODE after each attempt and
reuses the same backoff strategies.
Contract: the script block's final statement must be the native command
whose exit code matters - $LASTEXITCODE is read immediately after the
block returns. Thrown exceptions are not retried here; use
Invoke-WithRetry for exception-classified retry.
Retriability is intentionally limited to a data-driven exit-code set
(-RetryableExitCode), keeping the function true to "retry on the exit
code". For anything a set cannot express - "retry all except these
permanent codes", ranges, classifying on stderr - throw inside the
script block and use Invoke-WithRetry, whose ShouldRetry strategies
are the home for predicate-based classification.
| Parameter | Type | Required | Description |
|---|---|---|---|
-ScriptBlock |
scriptblock | Yes | The work to attempt. Its pipeline output is the return value on success (exit 0). |
-BackoffStrategy |
hashtable | No | A @{ Name; GetDelay } strategy. GetDelay receives (attempt, $null) - exit-code failures carry no ErrorRecord, so the same strategies that pair with Invoke-WithRetry work unchanged. Defaults to New-ExponentialBackoffStrategy. |
-MaxAttempts |
int | No | Total attempts including the first. Defaults to 3. Pass 1 to disable retry. |
-RetryableExitCode |
int[] | No | Exit codes that count as retryable. Empty (default) means any non-zero. Pass a set to retry only those, fail fast on the rest. |
-OperationName |
string | No | Label surfaced in the per-retry warning and failure message. Defaults to native command. |
# Refresh a netsh portproxy rule, absorbing a transient iphlpsvc hiccup.
Invoke-WithExitCodeRetry `
-OperationName 'netsh portproxy add' `
-ScriptBlock {
& netsh interface portproxy add v4tov4 `
listenaddress=0.0.0.0 listenport=2222 `
connectaddress=192.168.137.10 connectport=22 | Out-Null
}Generic retry loop. The classification of "what counts as retryable" is
supplied by hashtable-shaped retry strategies (ShouldRetry
predicates); the inter-attempt pacing comes from a backoff strategy
(GetDelay provider). Multiple retry strategies are OR-composed: if
any predicate returns $true, the loop retries; if none match, the
failure propagates immediately. The matched strategy's Name is
surfaced in the per-retry warning so operators can tell which policy
fired when several are composed.
-BackoffStrategy defaults to New-ExponentialBackoffStrategy
(2s -> 4s -> 8s, capped at 30s) because that policy fits both currently
known call sites (HTTP + file-lock); callers wanting a different curve
pass one explicitly.
| Parameter | Type | Required | Description |
|---|---|---|---|
-ScriptBlock |
scriptblock | Yes | The work to attempt. Its return value is the function's return value on success. |
-RetryStrategy |
hashtable[] | Yes | One or more @{ Name; ShouldRetry } strategies. Mandatory so "never retries" cannot happen silently. |
-BackoffStrategy |
hashtable | No | A @{ Name; GetDelay } strategy. Defaults to New-ExponentialBackoffStrategy. |
-MaxAttempts |
int | No | Total attempts including the first. Defaults to 3. Pass 1 to disable retry. |
-OperationName |
string | No | Label surfaced in the per-retry warning. Defaults to operation. |
# Network call with default exponential backoff.
$json = Invoke-WithRetry `
-OperationName 'Adoptium release lookup' `
-RetryStrategy (New-TransientNetworkRetryStrategy) `
-ScriptBlock { Invoke-RestMethod $uri }
# File-lock with a tighter attempt budget.
Invoke-WithRetry `
-OperationName 'delete VHDX' `
-RetryStrategy (New-FileLockRetryStrategy) `
-MaxAttempts 5 `
-ScriptBlock { Remove-Item $vhdxPath -Force -ErrorAction Stop }Builds a retry-strategy hashtable matching transient network failures
(DNS hiccups, connection drops, 5xx responses, HttpClient timeouts) for
use with Invoke-WithRetry. 4xx HttpResponseExceptions and non-network
errors are treated as permanent so failures stay fast.
Takes no parameters. Returns:
@{
Name = 'TransientNetwork'
ShouldRetry = { param($ErrorRecord) <bool> }
}Invoke-WithRetry `
-ScriptBlock { Invoke-RestMethod $uri } `
-RetryStrategy (New-TransientNetworkRetryStrategy)Builds a retry-strategy hashtable matching System.IO.IOException
anywhere in the exception chain - the canonical Hyper-V VMMS
handle-release case where Remove-Item briefly fails after Remove-VM.
UnauthorizedAccessException is intentionally not matched: ACL
problems will not resolve on their own, and retrying just stalls the
caller before the real error surfaces.
Takes no parameters. Returns:
@{
Name = 'FileLock'
ShouldRetry = { param($ErrorRecord) <bool> }
}Invoke-WithRetry `
-ScriptBlock { Remove-Item -Path $vhdxPath -Force -ErrorAction Stop } `
-RetryStrategy (New-FileLockRetryStrategy) `
-MaxAttempts 5Builds a retry-strategy hashtable matching transient PSGallery
source-resolution failures emitted by Install-Module /
Install-Package (e.g. Unable to resolve package source). Generic
network failures (DNS, timeout, 5xx) are intentionally out of scope -
OR-compose with New-TransientNetworkRetryStrategy to cover both.
Permanent errors (typos, signature mismatches, auth) propagate
immediately so misconfiguration fails fast instead of burning the
full attempt budget.
Takes no parameters. Returns:
@{
Name = 'TransientPowerShellModuleInstall'
ShouldRetry = { param($ErrorRecord) <bool> }
}Invoke-WithRetry `
-ScriptBlock { Install-Module Foo -ErrorAction Stop } `
-RetryStrategy @(
(New-TransientPowerShellModuleInstallRetryStrategy),
(New-TransientNetworkRetryStrategy)
) `
-MaxAttempts 6All four factories return the same hashtable shape consumed by
Invoke-WithRetry via -BackoffStrategy:
@{
Name = '<curve name>'
GetDelay = { param($Attempt, $LastError) <seconds> }
}GetDelay receives the current attempt number (1-based) and the most
recent ErrorRecord so custom providers can adapt the delay to the
failure (HTTP 429 Retry-After, deadline-aware backoff, ...).
Doubles the delay each attempt, capped at a configurable ceiling.
Formula: delay = min(InitialDelaySeconds * 2^(Attempt - 1), MaxIntervalSeconds).
Defaults (2s initial, 30s cap) suit the currently known call sites
(HTTP + file-lock).
| Parameter | Type | Required | Description |
|---|---|---|---|
-InitialDelaySeconds |
int | No | Seconds before the first retry. Default 2. |
-MaxIntervalSeconds |
int | No | Upper bound per attempt. Default 30. |
$backoff = New-ExponentialBackoffStrategy -InitialDelaySeconds 1 -MaxIntervalSeconds 10Grows the delay linearly per attempt up to a cap.
Formula: delay = min(StepSeconds * Attempt, MaxIntervalSeconds).
| Parameter | Type | Required | Description |
|---|---|---|---|
-StepSeconds |
int | No | Increment per attempt. Default 2. |
-MaxIntervalSeconds |
int | No | Upper bound per attempt. Default 30. |
$backoff = New-LinearBackoffStrategy -StepSeconds 2 -MaxIntervalSeconds 10
# Delays: 2, 4, 6, 8, 10, 10, ...Returns the same delay on every attempt. Use when the failure has a known fixed recovery window (service restart cycle, fixed lease renewal, ...) and exponential growth would just oversleep.
| Parameter | Type | Required | Description |
|---|---|---|---|
-DelaySeconds |
int | No | Delay returned every call. Default 2. |
$backoff = New-ConstantBackoffStrategy -DelaySeconds 5Wraps a caller-supplied GetDelay script block in the standard
backoff-strategy hashtable shape. Escape hatch for cases the built-ins
do not cover (HTTP 429 Retry-After, jittered exponential,
deadline-aware backoff).
| Parameter | Type | Required | Description |
|---|---|---|---|
-DelayProvider |
scriptblock | Yes | Called as & $DelayProvider $Attempt $LastError; must return seconds. |
-Name |
string | No | Label surfaced by Invoke-WithRetry in the per-retry warning. Default Custom. |
$jittered = New-CustomBackoffStrategy -Name 'JitteredExponential' `
-DelayProvider {
param($Attempt, $LastError)
$base = [Math]::Min(2 * [Math]::Pow(2, $Attempt - 1), 30)
$base + (Get-Random -Minimum 0 -Maximum 2)
}The composite actions under .github/actions/ and the reusable workflows
under .github/workflows/ are consumed by sibling repos
(Infrastructure-GitHub, Infrastructure-HyperV, Infrastructure-Secrets,
Infrastructure-GitHubRunners, ...). They are the canonical implementation;
sibling repos call them via workflow_call and uses: references to
@master rather than duplicating the logic.
| Reusable workflow | Purpose |
|---|---|
ci-powershell.yml |
Pester unit tests on Windows |
ci-powershell-docker-host.yml |
Pester integration tests inside a Docker container |
ci-powershell-docker-target.yml |
SSH integration tests against a Docker target |
tag.yml |
Creates a git tag from the manifest version |
publish.yml |
Publishes a module directory to PSGallery |
This repo also consumes Common-Automation's reusable lint workflows for its own YAML and bash surfaces, the same way sibling repos consume the table above:
| Consumer workflow | Delegates to (in Common-Automation) |
|---|---|
ci-yaml.yml |
ci-yaml.yml - actionlint, action-validator, yamllint, ansible-lint (each auto-skips when its surface is absent) |
ci-bash.yml |
ci-bash.yml - shellcheck on the scripts/ shims, check-sh-executable, bats |
Run the same checks locally with scripts/run-lint.sh (Docker required; it
shims to Common-Automation's engine so local and CI cannot drift).
Common-PowerShell/
|- Common.PowerShell/
| |- Private/ # Module-internal helpers (not exported); mirrors Public\ layout
| | `- Retry/
| | `- Assert-RetryStrategyShape.ps1
| |- Public/
| | |- Assert-RequiredProperties.ps1
| | |- ConvertTo-Array.ps1
| | |- Invoke-ModuleInstall.ps1
| | `- Retry/ # Retry family (loop + strategies)
| | |- Invoke-WithRetry.ps1 # generic retry loop
| | |- TransientErrorStrategies/ # ShouldRetry classifiers
| | | |- New-FileLockRetryStrategy.ps1
| | | |- New-TransientNetworkRetryStrategy.ps1
| | | `- New-TransientPowerShellModuleInstallRetryStrategy.ps1
| | `- BackoffStrategies/ # GetDelay providers
| | |- New-ConstantBackoffStrategy.ps1
| | |- New-CustomBackoffStrategy.ps1
| | |- New-ExponentialBackoffStrategy.ps1
| | `- New-LinearBackoffStrategy.ps1
| |- Common.PowerShell.psm1 # Dot-sources Public\ (recursively); exports Public functions
| `- Common.PowerShell.psd1 # Module manifest (version, GUID, exports)
|- Tests/ # One .Tests.ps1 per Public\ fn, mirroring its layout (Retry\, ...)
| |- ... # plus these CI-helper tests with no Public\ counterpart:
| |- Find-IntegrationTests.Tests.ps1 # Shared CI helper tests
| |- Get-UnitTestFiles.Tests.ps1 # run-unit-tests\lib helper
| |- Invoke-Publish.Tests.ps1
| |- Invoke-TagFromManifest.Tests.ps1
| |- Limit-TestLogRetention.Tests.ps1 # run-unit-tests\lib helper (+ .IntegrationTests)
| |- Run-IntegrationTests.Tests.ps1
| |- Test-NoBareReturnEmptyArray.Tests.ps1
| |- Test-PowerShellParses.Tests.ps1 # Syntax-gate lint helper tests
| `- Integration.DockerHost/ # Integration tests - run in Docker only
|- .github/
| |- actions/ # Reusable composite actions (canonical)
| | |- Helpers.ps1 # Shared PS helpers dot-sourced by action scripts
| | |- check-version-is-new/
| | |- tag-from-manifest/
| | |- run-unit-tests/ # Unit test runner (canonical)
| | | |- Run-Tests.ps1 # Entry point: dispatch + optional self-logging
| | | |- Module.Tests.ps1 # Shared module-registration test (injected)
| | | `- lib/ # Helpers, one per file, dot-sourced
| | | |- Get-UnitTestFiles.ps1 # Discovers unit test files (excludes Docker dirs)
| | | |- Invoke-UnitTestRun.ps1 # Installs Pester, runs discovered tests
| | | `- Limit-TestLogRetention.ps1 # Prunes old logs via Limit-RetainedItem
| | |- run-integration-tests/
| | |- run-ssh-integration-tests/
| | |- scan-integration-tests/
| | |- lint-no-bare-return-empty-array/ # Regex lint: bans bare `return @()` (invoked by ci-powershell.yml)
| | |- lint-powershell-parses/ # Syntax gate: parses every .ps1/.psm1/.psd1 via the PS parser
| | `- publish/
| `- workflows/ # Reusable workflows (canonical)
| |- ci-powershell.yml
| |- ci-powershell-docker-host.yml
| |- ci-powershell-docker-target.yml
| |- tag.yml
| |- publish.yml
| |- release.yml
| |- ci-bash.yml # Consumer wrapper -> Common-Automation ci-bash (shellchecks scripts\ shims)
| `- ci-yaml.yml # Consumer wrapper -> Common-Automation ci-yaml (actionlint/yamllint/...)
|- docs/
| `- dev/
| `- implementation/ # Per-feature problem.md + plan.md
|- scripts/ # User-facing entry-point scripts
| |- Install.ps1 # Installs from source for local development
| |- Publish.ps1 # Publishes to PSGallery (called by publish.yml)
| |- Publish-VersionTags.ps1 # Publishes GitHub Actions vX.Y.Z + rolling vX tags
| |- Find-GitBashExecutable.ps1 # Helper dot-sourced by Publish-VersionTags
| |- Run-Tests.ps1 # Runs Pester unit tests locally (thin wrapper)
| |- Run-IntegrationTests-InDocker.ps1 # Integration tests in Docker
| |- Run-IntegrationTests-AgainstDockerTarget.ps1
| |- run-lint.sh / .bat # Local lint suite -> Common-Automation (yamllint/actionlint/action-validator/shellcheck)
| `- fix-permissions.sh / .bat # Re-stages +x on tracked *.sh via the Common-Automation engine
|- .gitattributes # Pins *.sh -> LF, *.bat -> CRLF (Linux runners reject CRLF shebangs)
`- README.md