https://github.com/klark-morrigan/common-powershell
https://github.com/klark-morrigan/common-powershell
dependencies powershell shared
Last synced: 19 days ago
JSON representation
- Host: GitHub
- URL: https://github.com/klark-morrigan/common-powershell
- Owner: Klark-Morrigan
- License: mit
- Created: 2026-04-03T05:18:52.000Z (3 months ago)
- Default Branch: master
- Last Pushed: 2026-06-17T05:50:17.000Z (19 days ago)
- Last Synced: 2026-06-17T06:35:10.410Z (19 days ago)
- Topics: dependencies, powershell, shared
- Language: PowerShell
- Homepage:
- Size: 466 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 2
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# Common-PowerShell
Shared PowerShell functions and reusable PowerShell centric GitHub composite actions and workflows.
## Index
- [Requirements](#requirements)
- [Overview](#overview)
- [Installation](#installation)
- [Publishing](#publishing)
- [Shipping a module release (PSGallery)](#shipping-a-module-release-psgallery)
- [Shipping an action / workflow release (for `@vN` consumers)](#shipping-an-action--workflow-release-for-vn-consumers)
- [API reference](#api-reference)
- Top-level utilities
- [Assert-RequiredProperties](#assert-requiredproperties)
- [ConvertTo-Array](#convertto-array)
- [Invoke-ModuleInstall](#invoke-moduleinstall)
- Retry (`Public/Retry/`)
- Loop
- [Invoke-WithExitCodeRetry](#invoke-withexitcoderetry)
- [Invoke-WithRetry](#invoke-withretry)
- Transient-error strategies (`Public/Retry/TransientErrorStrategies/`)
- [New-TransientNetworkRetryStrategy](#new-transientnetworkretrystrategy)
- [New-FileLockRetryStrategy](#new-filelockretrystrategy)
- [New-TransientPowerShellModuleInstallRetryStrategy](#new-transientpowershellmoduleinstallretrystrategy)
- Backoff strategies (`Public/Retry/BackoffStrategies/`)
- [New-ExponentialBackoffStrategy](#new-exponentialbackoffstrategy)
- [New-LinearBackoffStrategy](#new-linearbackoffstrategy)
- [New-ConstantBackoffStrategy](#new-constantbackoffstrategy)
- [New-CustomBackoffStrategy](#new-custombackoffstrategy)
- [Reusable CI](#reusable-ci)
- [Repo structure](#repo-structure)
---
## Requirements
PowerShell 7+ (`pwsh`).
---
## Overview
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
`$LASTEXITCODE` rather than a thrown exception. Reuses the same backoff
strategies; retries any non-zero exit by default, or only a
caller-supplied `-RetryableExitCode` set. For predicate-based
classification, throw and use `Invoke-WithRetry` instead.
- **`Invoke-WithRetry`** - generic retry loop. Consumes hashtable-shaped
retry strategies (`ShouldRetry` classifiers) and a backoff strategy
(`GetDelay` provider). 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 by
`Invoke-WithRetry`. Compose multiple via `-RetryStrategy`
when a single call legitimately touches several transient-failure
classes (e.g. network + file-lock).
- **`New-TransientNetworkRetryStrategy`** - matches DNS/socket/5xx.
- **`New-FileLockRetryStrategy`** - matches `System.IO.IOException`
(Hyper-V VMMS handle-release case).
- **`New-TransientPowerShellModuleInstallRetryStrategy`** - matches
PSGallery source-resolution flakes from `Install-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 by
`Invoke-WithRetry` via `-BackoffStrategy`. Pick the curve that
matches the underlying failure; reach for `New-CustomBackoffStrategy`
when the built-ins do not (HTTP 429 `Retry-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-supplied
`GetDelay` script 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](#reusable-ci).
### Bootstrap note
`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`.
```powershell
# 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
}
```
---
## Installation
Consuming repos install automatically from PSGallery via the bootstrap block
above - no manual step needed.
To install manually:
```powershell
Install-Module Common.PowerShell -Scope CurrentUser
```
To update an existing installation:
```powershell
Update-Module Common.PowerShell
```
**For local development of this module:** use `scripts\Install.ps1` to install
from source instead of PSGallery.
---
## Publishing
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](scripts/Publish-VersionTags.ps1) | Other repos pinning `uses: Klark-Morrigan/Common-PowerShell/.github/.../@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.
### Shipping a module release (PSGallery)
1. Bump `ModuleVersion` in [Common.PowerShell/Common.PowerShell.psd1](Common.PowerShell/Common.PowerShell.psd1)
2. Promote the `## [Unreleased]` section in [CHANGELOG.md](CHANGELOG.md) to
`## [X.Y.Z] - ` (matching the new version), open a fresh empty
`[Unreleased]` above it, and add the footer compare link
3. Open a PR, get it reviewed and merged
On merge, [.github/workflows/release.yml](.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](https://www.powershellgallery.com/account/apikeys).
### Shipping an action / workflow release (for `@vN` consumers)
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:
```powershell
.\scripts\Publish-VersionTags.ps1 -Version vX.Y.Z
```
It 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.
---
## API reference
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/`.
### Top-level utilities
### `Assert-RequiredProperties`
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'"` |
```powershell
Assert-RequiredProperties -Object $vm `
-Properties @('vmName', 'ipAddress') `
-Context "VM '$($vm.vmName)'"
```
---
### `ConvertTo-Array`
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 |
```powershell
$entries = ConvertTo-Array ($json | ConvertFrom-Json)
foreach ($entry in $entries) { ... } # safe even when $json had one element
```
---
### `Invoke-ModuleInstall`
Installs 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 |
```powershell
# Install with a minimum version constraint
Invoke-ModuleInstall -ModuleName 'Pester' -MinimumVersion '5.0'
# Install if absent, accept any version
Invoke-ModuleInstall -ModuleName 'Posh-SSH'
```
---
### Retry (`Public/Retry/`)
#### Loop
### `Invoke-WithExitCodeRetry`
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`. |
```powershell
# 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
}
```
---
### `Invoke-WithRetry`
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`. |
```powershell
# 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 }
```
---
#### Transient-error strategies (`Public/Retry/TransientErrorStrategies/`)
### `New-TransientNetworkRetryStrategy`
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:
```powershell
@{
Name = 'TransientNetwork'
ShouldRetry = { param($ErrorRecord) }
}
```
```powershell
Invoke-WithRetry `
-ScriptBlock { Invoke-RestMethod $uri } `
-RetryStrategy (New-TransientNetworkRetryStrategy)
```
---
### `New-FileLockRetryStrategy`
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:
```powershell
@{
Name = 'FileLock'
ShouldRetry = { param($ErrorRecord) }
}
```
```powershell
Invoke-WithRetry `
-ScriptBlock { Remove-Item -Path $vhdxPath -Force -ErrorAction Stop } `
-RetryStrategy (New-FileLockRetryStrategy) `
-MaxAttempts 5
```
---
### `New-TransientPowerShellModuleInstallRetryStrategy`
Builds 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:
```powershell
@{
Name = 'TransientPowerShellModuleInstall'
ShouldRetry = { param($ErrorRecord) }
}
```
```powershell
Invoke-WithRetry `
-ScriptBlock { Install-Module Foo -ErrorAction Stop } `
-RetryStrategy @(
(New-TransientPowerShellModuleInstallRetryStrategy),
(New-TransientNetworkRetryStrategy)
) `
-MaxAttempts 6
```
---
#### Backoff strategies (`Public/Retry/BackoffStrategies/`)
All four factories return the same hashtable shape consumed by
`Invoke-WithRetry` via `-BackoffStrategy`:
```powershell
@{
Name = ''
GetDelay = { param($Attempt, $LastError) }
}
```
`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, ...).
### `New-ExponentialBackoffStrategy`
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`. |
```powershell
$backoff = New-ExponentialBackoffStrategy -InitialDelaySeconds 1 -MaxIntervalSeconds 10
```
---
### `New-LinearBackoffStrategy`
Grows 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`. |
```powershell
$backoff = New-LinearBackoffStrategy -StepSeconds 2 -MaxIntervalSeconds 10
# Delays: 2, 4, 6, 8, 10, 10, ...
```
---
### `New-ConstantBackoffStrategy`
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`. |
```powershell
$backoff = New-ConstantBackoffStrategy -DelaySeconds 5
```
---
### `New-CustomBackoffStrategy`
Wraps 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`. |
```powershell
$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)
}
```
---
## Reusable CI
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 via three sibling shims (Docker required; each shims to
Common-Automation's engine - pointed at this repo through
`COMMON_AUTOMATION_TARGET_REPO` - so local and CI cannot drift). `Common-Automation`
must be a sibling checkout (`..\Common-Automation`):
- `scripts/run-ci-yaml-and-bash.sh` (+ `.bat`) - PRIMARY local entry: runs the full
lint suite AND the bats tests (local equivalent of this repo's `ci-yaml.yml` +
`ci-bash.yml`).
- `scripts/run-lint-yaml-and-bash.sh` (+ `.bat`) - run a single half: the LINT half
only (shellcheck, actionlint, action-validator, yamllint, ansible-lint).
- `scripts/run-tests-bash.sh` (+ `.bat`) - run a single half: the bats TEST half only.
---
## Repo structure
```
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-ci-yaml-and-bash.sh / .bat # PRIMARY local entry: full lint suite + bats tests -> Common-Automation
| |- run-lint-yaml-and-bash.sh / .bat # Lint half only (shellcheck/actionlint/action-validator/yamllint/ansible-lint)
| |- run-tests-bash.sh / .bat # Bats test half only -> Common-Automation
| `- 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
```