{"id":50933703,"url":"https://github.com/klark-morrigan/common-powershell","last_synced_at":"2026-06-17T07:01:22.448Z","repository":{"id":348940576,"uuid":"1200161149","full_name":"Klark-Morrigan/Common-PowerShell","owner":"Klark-Morrigan","description":null,"archived":false,"fork":false,"pushed_at":"2026-06-17T05:50:17.000Z","size":477,"stargazers_count":0,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-06-17T06:35:10.410Z","etag":null,"topics":["dependencies","powershell","shared"],"latest_commit_sha":null,"homepage":"","language":"PowerShell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Klark-Morrigan.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-03T05:18:52.000Z","updated_at":"2026-06-17T05:41:17.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/Klark-Morrigan/Common-PowerShell","commit_stats":null,"previous_names":["vitaliiandreev/infrastructure-common","vitaliiandreev/powershell-common","vitaliiandreev/common-powershell","klark-morrigan/common-powershell"],"tags_count":40,"template":false,"template_full_name":null,"purl":"pkg:github/Klark-Morrigan/Common-PowerShell","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Klark-Morrigan%2FCommon-PowerShell","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Klark-Morrigan%2FCommon-PowerShell/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Klark-Morrigan%2FCommon-PowerShell/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Klark-Morrigan%2FCommon-PowerShell/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Klark-Morrigan","download_url":"https://codeload.github.com/Klark-Morrigan/Common-PowerShell/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Klark-Morrigan%2FCommon-PowerShell/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34437451,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-17T02:00:05.408Z","response_time":127,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["dependencies","powershell","shared"],"created_at":"2026-06-17T07:00:40.179Z","updated_at":"2026-06-17T07:01:22.440Z","avatar_url":"https://github.com/Klark-Morrigan.png","language":"PowerShell","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Common-PowerShell\n\nShared PowerShell functions and reusable PowerShell centric GitHub composite actions and workflows.\n\n## Index\n\n- [Requirements](#requirements)\n- [Overview](#overview)\n- [Installation](#installation)\n- [Publishing](#publishing)\n  - [Shipping a module release (PSGallery)](#shipping-a-module-release-psgallery)\n  - [Shipping an action / workflow release (for `@vN` consumers)](#shipping-an-action--workflow-release-for-vn-consumers)\n- [API reference](#api-reference)\n  - Top-level utilities\n    - [Assert-RequiredProperties](#assert-requiredproperties)\n    - [ConvertTo-Array](#convertto-array)\n    - [Invoke-ModuleInstall](#invoke-moduleinstall)\n  - Retry (`Public/Retry/`)\n    - Loop\n      - [Invoke-WithExitCodeRetry](#invoke-withexitcoderetry)\n      - [Invoke-WithRetry](#invoke-withretry)\n    - Transient-error strategies (`Public/Retry/TransientErrorStrategies/`)\n      - [New-TransientNetworkRetryStrategy](#new-transientnetworkretrystrategy)\n      - [New-FileLockRetryStrategy](#new-filelockretrystrategy)\n      - [New-TransientPowerShellModuleInstallRetryStrategy](#new-transientpowershellmoduleinstallretrystrategy)\n    - Backoff strategies (`Public/Retry/BackoffStrategies/`)\n      - [New-ExponentialBackoffStrategy](#new-exponentialbackoffstrategy)\n      - [New-LinearBackoffStrategy](#new-linearbackoffstrategy)\n      - [New-ConstantBackoffStrategy](#new-constantbackoffstrategy)\n      - [New-CustomBackoffStrategy](#new-custombackoffstrategy)\n- [Reusable CI](#reusable-ci)\n- [Repo structure](#repo-structure)\n\n---\n\n## Requirements\n\nPowerShell 7+ (`pwsh`).\n\n---\n\n## Overview\n\nProvides cross-cutting utilities used by all infrastructure repos so the logic\ndoes not need to be duplicated and tested in each one independently. Functions\nare grouped on disk by concern; the retry family lives under\n`Public/Retry/`.\n\n**Top-level utilities**\n- **`Assert-RequiredProperties`** - validates that a PSCustomObject has all\n  required properties present and non-empty; collects every violation before\n  throwing so the consumer sees the full picture in one run.\n- **`ConvertTo-Array`** - ensures a value is always an array regardless of\n  whether PowerShell unrolled a single-item collection.\n- **`Invoke-ModuleInstall`** - installs a module from PSGallery if absent or\n  below the required minimum version, then imports it.\n\n**Retry (`Public/Retry/`)** - subdivided by strategy category so each\nfolder stays small as more factories land:\n\n- *Loop (root of `Public/Retry/`)*\n  - **`Invoke-WithExitCodeRetry`** - exit-code counterpart for native\n    commands (`netsh`, `git`, `docker`, `wsl`, ...) that fail via\n    `$LASTEXITCODE` rather than a thrown exception. Reuses the same backoff\n    strategies; retries any non-zero exit by default, or only a\n    caller-supplied `-RetryableExitCode` set. For predicate-based\n    classification, throw and use `Invoke-WithRetry` instead.\n  - **`Invoke-WithRetry`** - generic retry loop. Consumes hashtable-shaped\n    retry strategies (`ShouldRetry` classifiers) and a backoff strategy\n    (`GetDelay` provider). Multiple retry strategies are OR-composed so a\n    single call can cover several legitimately-transient failure classes\n    (e.g. network + file-lock). Defaults to exponential backoff when none\n    is supplied.\n- *Transient-error strategies (`Public/Retry/TransientErrorStrategies/`)* - factories that\n  return `@{ Name; ShouldRetry }` classifiers consumed by\n  `Invoke-WithRetry`. Compose multiple via `-RetryStrategy`\n  when a single call legitimately touches several transient-failure\n  classes (e.g. network + file-lock).\n  - **`New-TransientNetworkRetryStrategy`** - matches DNS/socket/5xx.\n  - **`New-FileLockRetryStrategy`** - matches `System.IO.IOException`\n    (Hyper-V VMMS handle-release case).\n  - **`New-TransientPowerShellModuleInstallRetryStrategy`** - matches\n    PSGallery source-resolution flakes from `Install-Module` /\n    `Install-Package`. OR-compose with the network strategy for the\n    full transient surface.\n- *Backoff strategies (`Public/Retry/BackoffStrategies/`)* - factories\n  that return `@{ Name; GetDelay }` providers consumed by\n  `Invoke-WithRetry` via `-BackoffStrategy`. Pick the curve that\n  matches the underlying failure; reach for `New-CustomBackoffStrategy`\n  when the built-ins do not (HTTP 429 `Retry-After`, jittered\n  exponential, deadline-aware backoff, ...).\n  - **`New-ExponentialBackoffStrategy`** - doubles each attempt up to a\n    cap. Sensible default for most call sites.\n  - **`New-LinearBackoffStrategy`** - grows linearly per attempt up to\n    a cap. Predictable spacing when exponential ramps up too fast.\n  - **`New-ConstantBackoffStrategy`** - same delay every attempt. Use\n    when the failure has a known fixed recovery window.\n  - **`New-CustomBackoffStrategy`** - wraps a caller-supplied\n    `GetDelay` script block in the standard hashtable shape.\n\nThis repo is also the canonical home of the reusable CI workflows and composite\nactions that every infrastructure module shares - see\n[Reusable CI](#reusable-ci).\n\n### Bootstrap note\n\n`Invoke-ModuleInstall` cannot install itself. Each consumer script that needs\nthis module must include a short inline guard to install `Common.PowerShell`\nfirst - this is a one-time cost per script, and all other module installs then\nflow through `Invoke-ModuleInstall`.\n\n```powershell\n# Inline bootstrap - cannot use Invoke-ModuleInstall to install itself.\n$_common = Get-Module -ListAvailable -Name Common.PowerShell |\n    Sort-Object Version -Descending | Select-Object -First 1\nif (-not $_common -or $_common.Version -lt [Version]'4.0.1') {\n    Install-Module Common.PowerShell -Scope CurrentUser -Force\n    # Re-query so the comparison below uses the freshly installed version.\n    $_common = Get-Module -ListAvailable -Name Common.PowerShell |\n        Sort-Object Version -Descending | Select-Object -First 1\n}\n# Reload only when the loaded state differs from the target (multiple\n# versions live, or wrong version live). Mirrors the conditional in\n# Invoke-ModuleInstall - inlined here because the bootstrap installs\n# the very module that defines that function.\n$_loaded = @(Get-Module -Name Common.PowerShell)\nif ($_loaded.Count -ne 1 -or $_loaded[0].Version -ne $_common.Version) {\n    if ($_loaded) { $_loaded | Remove-Module -Force }\n    Import-Module Common.PowerShell -Force -ErrorAction Stop\n}\n```\n\n---\n\n## Installation\n\nConsuming repos install automatically from PSGallery via the bootstrap block\nabove - no manual step needed.\n\nTo install manually:\n\n```powershell\nInstall-Module Common.PowerShell -Scope CurrentUser\n```\n\nTo update an existing installation:\n\n```powershell\nUpdate-Module Common.PowerShell\n```\n\n**For local development of this module:** use `scripts\\Install.ps1` to install\nfrom source instead of PSGallery.\n\n---\n\n## Publishing\n\nThis repo carries **two independent tag streams** that share the git repo\nbut not the version numbering. Each has its own release flow.\n\n| Stream | Tag shape | Driven by | Consumed by |\n|---|---|---|---|\n| Module | `X.Y.Z` (e.g. `6.0.0`) | Automated: `tag-from-manifest` on `.psd1` bump | `Install-Module Common.PowerShell` from PSGallery |\n| 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/.../\u003cname\u003e@vN` |\n\nThe two namespaces never collide (`7.0.0` and `v7.0.0` are different tag\nnames), so each stream can advance on its own cadence. Numbers across\nstreams may drift; they are not required to align.\n\n### Shipping a module release (PSGallery)\n\n1. Bump `ModuleVersion` in [Common.PowerShell/Common.PowerShell.psd1](Common.PowerShell/Common.PowerShell.psd1)\n2. Promote the `## [Unreleased]` section in [CHANGELOG.md](CHANGELOG.md) to\n   `## [X.Y.Z] - \u003cdate\u003e` (matching the new version), open a fresh empty\n   `[Unreleased]` above it, and add the footer compare link\n3. Open a PR, get it reviewed and merged\n\nOn merge, [.github/workflows/release.yml](.github/workflows/release.yml)\nchecks the version is new, **asserts the manifest and changelog agree on\n`X.Y.Z`** (the `assert-changelog-version` action - the release fails here if\nyou forgot to promote the changelog), runs the unit + integration tests, and\ncreates a matching `X.Y.Z` git tag. It then, in parallel, publishes to\nPSGallery and creates a **GitHub Release** whose body is the `X.Y.Z`\nCHANGELOG.md section (Common-Automation's `create-github-release`). No manual\ntagging or release-notes step required.\n\nAdd entries under `[Unreleased]` as you merge feature work, so step 2 at\nrelease time is only the rename. GitHub Releases attach to this module\n(`X.Y.Z`) stream; the `vN` actions stream below stays release-free - those\ntags are ref pins, not published artifacts.\n\n**One-time setup:** add your PSGallery API key as a repository secret named\n`PSGALLERY_API_KEY` under Settings -\u003e Secrets and variables -\u003e Actions.\nGenerate a key at [powershellgallery.com/account/apikeys](https://www.powershellgallery.com/account/apikeys).\n\n### Shipping an action / workflow release (for `@vN` consumers)\n\nWhen you change a reusable workflow under `.github/workflows/` or a\ncomposite action under `.github/actions/`, downstream repos pinning\n`uses: …@vN` will not see the change until you publish new action tags.\nRun from any branch:\n\n```powershell\n.\\scripts\\Publish-VersionTags.ps1 -Version vX.Y.Z\n```\n\nIt resolves `origin/master` to an explicit commit SHA, creates the\nimmutable `vX.Y.Z` tag (refuses to re-point), and force-moves the rolling\n`vX` tag to that SHA. Consumers on `@vX` automatically get the new release\non their next workflow run; consumers can pin to `@vX.Y.Z` for an\nimmutable reference.\n\nTriggering is intentionally manual so PSGallery does not receive a module\nrelease for what is purely a CI / workflow change.\n\n---\n\n## API reference\n\nFunctions are grouped on disk by concern. Top-level utilities sit at the\nroot of `Public/`; the retry family lives under `Public/Retry/` and is\nfurther subdivided into `TransientErrorStrategies/` and (in a later\nstep) `BackoffStrategies/`.\n\n### Top-level utilities\n\n### `Assert-RequiredProperties`\n\nValidates that a PSCustomObject has all required properties present and\nnon-empty. All violations are collected before throwing so the consumer\nsees the full picture in one run rather than fixing one field at a time.\n\n| Parameter      | Type          | Required | Description                                              |\n|----------------|---------------|----------|----------------------------------------------------------|\n| `-Object`      | object        | Yes      | The PSCustomObject to validate (e.g. a config entry)     |\n| `-Properties`  | string[]      | Yes      | Property names that must be present and non-empty        |\n| `-Context`     | string        | Yes      | Identifies the object in error messages, e.g. `\"VM 'ubuntu-01'\"` |\n\n```powershell\nAssert-RequiredProperties -Object $vm `\n    -Properties @('vmName', 'ipAddress') `\n    -Context \"VM '$($vm.vmName)'\"\n```\n\n---\n\n### `ConvertTo-Array`\n\nWraps a value in a single-element array if PowerShell unrolled it down to a\nscalar, and returns an existing array unchanged. Use after any pipeline or\n`ConvertFrom-Json` call where a one-item result must still be enumerable.\n\n| Parameter   | Type   | Required | Description                          |\n|-------------|--------|----------|--------------------------------------|\n| `-Value`    | object | Yes      | The value to normalise to an array   |\n\n```powershell\n$entries = ConvertTo-Array ($json | ConvertFrom-Json)\nforeach ($entry in $entries) { ... }   # safe even when $json had one element\n```\n\n---\n\n### `Invoke-ModuleInstall`\n\nInstalls a module from PSGallery if absent or below the minimum required\nversion, then imports it.\n\n| Parameter        | Type    | Required | Description                                                     |\n|------------------|---------|----------|-----------------------------------------------------------------|\n| `-ModuleName`    | string  | Yes      | The module to install and import                                 |\n| `-MinimumVersion`| Version | No       | Minimum acceptable version; any installed version accepted if omitted |\n\n```powershell\n# Install with a minimum version constraint\nInvoke-ModuleInstall -ModuleName 'Pester' -MinimumVersion '5.0'\n\n# Install if absent, accept any version\nInvoke-ModuleInstall -ModuleName 'Posh-SSH'\n```\n\n---\n\n### Retry (`Public/Retry/`)\n\n#### Loop\n\n### `Invoke-WithExitCodeRetry`\n\nExit-code counterpart to `Invoke-WithRetry`. Native commands (`netsh`,\n`git`, `docker`, `wsl`, ...) signal failure through `$LASTEXITCODE`, not\nexceptions, so `Invoke-WithRetry`'s `ShouldRetry` predicates cannot\nclassify them. This loop reads `$LASTEXITCODE` after each attempt and\nreuses the same backoff strategies.\n\nContract: the script block's final statement must be the native command\nwhose exit code matters - `$LASTEXITCODE` is read immediately after the\nblock returns. Thrown exceptions are not retried here; use\n`Invoke-WithRetry` for exception-classified retry.\n\nRetriability is intentionally limited to a data-driven exit-code set\n(`-RetryableExitCode`), keeping the function true to \"retry on the exit\ncode\". For anything a set cannot express - \"retry all except these\npermanent codes\", ranges, classifying on stderr - throw inside the\nscript block and use `Invoke-WithRetry`, whose `ShouldRetry` strategies\nare the home for predicate-based classification.\n\n| Parameter            | Type        | Required | Description                                                                                                                |\n|----------------------|-------------|----------|----------------------------------------------------------------------------------------------------------------------------|\n| `-ScriptBlock`       | scriptblock | Yes      | The work to attempt. Its pipeline output is the return value on success (exit 0).                                          |\n| `-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`. |\n| `-MaxAttempts`       | int         | No       | Total attempts including the first. Defaults to `3`. Pass `1` to disable retry.                                            |\n| `-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. |\n| `-OperationName`     | string      | No       | Label surfaced in the per-retry warning and failure message. Defaults to `native command`.                                |\n\n```powershell\n# Refresh a netsh portproxy rule, absorbing a transient iphlpsvc hiccup.\nInvoke-WithExitCodeRetry `\n    -OperationName 'netsh portproxy add' `\n    -ScriptBlock {\n        \u0026 netsh interface portproxy add v4tov4 `\n            listenaddress=0.0.0.0 listenport=2222 `\n            connectaddress=192.168.137.10 connectport=22 | Out-Null\n    }\n```\n\n---\n\n### `Invoke-WithRetry`\n\nGeneric retry loop. The classification of \"what counts as retryable\" is\nsupplied by hashtable-shaped retry strategies (`ShouldRetry`\npredicates); the inter-attempt pacing comes from a backoff strategy\n(`GetDelay` provider). Multiple retry strategies are OR-composed: if\nany predicate returns `$true`, the loop retries; if none match, the\nfailure propagates immediately. The matched strategy's `Name` is\nsurfaced in the per-retry warning so operators can tell which policy\nfired when several are composed.\n\n`-BackoffStrategy` defaults to `New-ExponentialBackoffStrategy`\n(2s -\u003e 4s -\u003e 8s, capped at 30s) because that policy fits both currently\nknown call sites (HTTP + file-lock); callers wanting a different curve\npass one explicitly.\n\n| Parameter         | Type          | Required | Description                                                                                  |\n|-------------------|---------------|----------|----------------------------------------------------------------------------------------------|\n| `-ScriptBlock`    | scriptblock   | Yes      | The work to attempt. Its return value is the function's return value on success.             |\n| `-RetryStrategy`  | hashtable[]   | Yes      | One or more `@{ Name; ShouldRetry }` strategies. Mandatory so \"never retries\" cannot happen silently. |\n| `-BackoffStrategy`| hashtable     | No       | A `@{ Name; GetDelay }` strategy. Defaults to `New-ExponentialBackoffStrategy`.              |\n| `-MaxAttempts`    | int           | No       | Total attempts including the first. Defaults to `3`. Pass `1` to disable retry.              |\n| `-OperationName`  | string        | No       | Label surfaced in the per-retry warning. Defaults to `operation`.                            |\n\n```powershell\n# Network call with default exponential backoff.\n$json = Invoke-WithRetry `\n    -OperationName 'Adoptium release lookup' `\n    -RetryStrategy (New-TransientNetworkRetryStrategy) `\n    -ScriptBlock   { Invoke-RestMethod $uri }\n\n# File-lock with a tighter attempt budget.\nInvoke-WithRetry `\n    -OperationName 'delete VHDX' `\n    -RetryStrategy (New-FileLockRetryStrategy) `\n    -MaxAttempts   5 `\n    -ScriptBlock   { Remove-Item $vhdxPath -Force -ErrorAction Stop }\n```\n\n---\n\n#### Transient-error strategies (`Public/Retry/TransientErrorStrategies/`)\n\n### `New-TransientNetworkRetryStrategy`\n\nBuilds a retry-strategy hashtable matching transient network failures\n(DNS hiccups, connection drops, 5xx responses, HttpClient timeouts) for\nuse with `Invoke-WithRetry`. 4xx HttpResponseExceptions and non-network\nerrors are treated as permanent so failures stay fast.\n\nTakes no parameters. Returns:\n\n```powershell\n@{\n    Name        = 'TransientNetwork'\n    ShouldRetry = { param($ErrorRecord) \u003cbool\u003e }\n}\n```\n\n```powershell\nInvoke-WithRetry `\n    -ScriptBlock   { Invoke-RestMethod $uri } `\n    -RetryStrategy (New-TransientNetworkRetryStrategy)\n```\n\n---\n\n### `New-FileLockRetryStrategy`\n\nBuilds a retry-strategy hashtable matching `System.IO.IOException`\nanywhere in the exception chain - the canonical Hyper-V VMMS\nhandle-release case where `Remove-Item` briefly fails after `Remove-VM`.\n`UnauthorizedAccessException` is intentionally **not** matched: ACL\nproblems will not resolve on their own, and retrying just stalls the\ncaller before the real error surfaces.\n\nTakes no parameters. Returns:\n\n```powershell\n@{\n    Name        = 'FileLock'\n    ShouldRetry = { param($ErrorRecord) \u003cbool\u003e }\n}\n```\n\n```powershell\nInvoke-WithRetry `\n    -ScriptBlock   { Remove-Item -Path $vhdxPath -Force -ErrorAction Stop } `\n    -RetryStrategy (New-FileLockRetryStrategy) `\n    -MaxAttempts   5\n```\n\n---\n\n### `New-TransientPowerShellModuleInstallRetryStrategy`\n\nBuilds a retry-strategy hashtable matching transient PSGallery\nsource-resolution failures emitted by `Install-Module` /\n`Install-Package` (e.g. `Unable to resolve package source`). Generic\nnetwork failures (DNS, timeout, 5xx) are intentionally out of scope -\nOR-compose with `New-TransientNetworkRetryStrategy` to cover both.\nPermanent errors (typos, signature mismatches, auth) propagate\nimmediately so misconfiguration fails fast instead of burning the\nfull attempt budget.\n\nTakes no parameters. Returns:\n\n```powershell\n@{\n    Name        = 'TransientPowerShellModuleInstall'\n    ShouldRetry = { param($ErrorRecord) \u003cbool\u003e }\n}\n```\n\n```powershell\nInvoke-WithRetry `\n    -ScriptBlock   { Install-Module Foo -ErrorAction Stop } `\n    -RetryStrategy @(\n        (New-TransientPowerShellModuleInstallRetryStrategy),\n        (New-TransientNetworkRetryStrategy)\n    ) `\n    -MaxAttempts   6\n```\n\n---\n\n#### Backoff strategies (`Public/Retry/BackoffStrategies/`)\n\nAll four factories return the same hashtable shape consumed by\n`Invoke-WithRetry` via `-BackoffStrategy`:\n\n```powershell\n@{\n    Name     = '\u003ccurve name\u003e'\n    GetDelay = { param($Attempt, $LastError) \u003cseconds\u003e }\n}\n```\n\n`GetDelay` receives the current attempt number (1-based) and the most\nrecent `ErrorRecord` so custom providers can adapt the delay to the\nfailure (HTTP 429 `Retry-After`, deadline-aware backoff, ...).\n\n### `New-ExponentialBackoffStrategy`\n\nDoubles the delay each attempt, capped at a configurable ceiling.\nFormula: `delay = min(InitialDelaySeconds * 2^(Attempt - 1), MaxIntervalSeconds)`.\nDefaults (2s initial, 30s cap) suit the currently known call sites\n(HTTP + file-lock).\n\n| Parameter              | Type | Required | Description                                  |\n|------------------------|------|----------|----------------------------------------------|\n| `-InitialDelaySeconds` | int  | No       | Seconds before the first retry. Default `2`. |\n| `-MaxIntervalSeconds`  | int  | No       | Upper bound per attempt. Default `30`.       |\n\n```powershell\n$backoff = New-ExponentialBackoffStrategy -InitialDelaySeconds 1 -MaxIntervalSeconds 10\n```\n\n---\n\n### `New-LinearBackoffStrategy`\n\nGrows the delay linearly per attempt up to a cap.\nFormula: `delay = min(StepSeconds * Attempt, MaxIntervalSeconds)`.\n\n| Parameter             | Type | Required | Description                                  |\n|-----------------------|------|----------|----------------------------------------------|\n| `-StepSeconds`        | int  | No       | Increment per attempt. Default `2`.          |\n| `-MaxIntervalSeconds` | int  | No       | Upper bound per attempt. Default `30`.       |\n\n```powershell\n$backoff = New-LinearBackoffStrategy -StepSeconds 2 -MaxIntervalSeconds 10\n# Delays: 2, 4, 6, 8, 10, 10, ...\n```\n\n---\n\n### `New-ConstantBackoffStrategy`\n\nReturns the same delay on every attempt. Use when the failure has a\nknown fixed recovery window (service restart cycle, fixed lease\nrenewal, ...) and exponential growth would just oversleep.\n\n| Parameter       | Type | Required | Description                          |\n|-----------------|------|----------|--------------------------------------|\n| `-DelaySeconds` | int  | No       | Delay returned every call. Default `2`. |\n\n```powershell\n$backoff = New-ConstantBackoffStrategy -DelaySeconds 5\n```\n\n---\n\n### `New-CustomBackoffStrategy`\n\nWraps a caller-supplied `GetDelay` script block in the standard\nbackoff-strategy hashtable shape. Escape hatch for cases the built-ins\ndo not cover (HTTP 429 `Retry-After`, jittered exponential,\ndeadline-aware backoff).\n\n| Parameter        | Type        | Required | Description                                                                |\n|------------------|-------------|----------|----------------------------------------------------------------------------|\n| `-DelayProvider` | scriptblock | Yes      | Called as `\u0026 $DelayProvider $Attempt $LastError`; must return seconds.     |\n| `-Name`          | string      | No       | Label surfaced by `Invoke-WithRetry` in the per-retry warning. Default `Custom`. |\n\n```powershell\n$jittered = New-CustomBackoffStrategy -Name 'JitteredExponential' `\n    -DelayProvider {\n        param($Attempt, $LastError)\n        $base = [Math]::Min(2 * [Math]::Pow(2, $Attempt - 1), 30)\n        $base + (Get-Random -Minimum 0 -Maximum 2)\n    }\n```\n\n---\n\n## Reusable CI\n\nThe composite actions under `.github/actions/` and the reusable workflows\nunder `.github/workflows/` are consumed by sibling repos\n(`Infrastructure-GitHub`, `Infrastructure-HyperV`, `Infrastructure-Secrets`,\n`Infrastructure-GitHubRunners`, ...). They are the canonical implementation;\nsibling repos call them via `workflow_call` and `uses:` references to\n`@master` rather than duplicating the logic.\n\n| Reusable workflow | Purpose |\n|---|---|\n| `ci-powershell.yml` | Pester unit tests on Windows |\n| `ci-powershell-docker-host.yml` | Pester integration tests inside a Docker container |\n| `ci-powershell-docker-target.yml` | SSH integration tests against a Docker target |\n| `tag.yml` | Creates a git tag from the manifest version |\n| `publish.yml` | Publishes a module directory to PSGallery |\n\nThis repo also *consumes* Common-Automation's reusable lint workflows for its\nown YAML and bash surfaces, the same way sibling repos consume the table above:\n\n| Consumer workflow | Delegates to (in `Common-Automation`) |\n|---|---|\n| `ci-yaml.yml` | `ci-yaml.yml` - actionlint, action-validator, yamllint, ansible-lint (each auto-skips when its surface is absent) |\n| `ci-bash.yml` | `ci-bash.yml` - shellcheck on the `scripts/` shims, check-sh-executable, bats |\n\nRun the same checks locally via three sibling shims (Docker required; each shims to\nCommon-Automation's engine - pointed at this repo through\n`COMMON_AUTOMATION_TARGET_REPO` - so local and CI cannot drift). `Common-Automation`\nmust be a sibling checkout (`..\\Common-Automation`):\n\n- `scripts/run-ci-yaml-and-bash.sh` (+ `.bat`) - PRIMARY local entry: runs the full\n  lint suite AND the bats tests (local equivalent of this repo's `ci-yaml.yml` +\n  `ci-bash.yml`).\n- `scripts/run-lint-yaml-and-bash.sh` (+ `.bat`) - run a single half: the LINT half\n  only (shellcheck, actionlint, action-validator, yamllint, ansible-lint).\n- `scripts/run-tests-bash.sh` (+ `.bat`) - run a single half: the bats TEST half only.\n\n---\n\n## Repo structure\n\n```\nCommon-PowerShell/\n|- Common.PowerShell/\n|  |- Private/                          # Module-internal helpers (not exported); mirrors Public\\ layout\n|  |  `- Retry/\n|  |     `- Assert-RetryStrategyShape.ps1\n|  |- Public/\n|  |  |- Assert-RequiredProperties.ps1\n|  |  |- ConvertTo-Array.ps1\n|  |  |- Invoke-ModuleInstall.ps1\n|  |  `- Retry/                         # Retry family (loop + strategies)\n|  |     |- Invoke-WithRetry.ps1             # generic retry loop\n|  |     |- TransientErrorStrategies/       # ShouldRetry classifiers\n|  |     |  |- New-FileLockRetryStrategy.ps1\n|  |     |  |- New-TransientNetworkRetryStrategy.ps1\n|  |     |  `- New-TransientPowerShellModuleInstallRetryStrategy.ps1\n|  |     `- BackoffStrategies/              # GetDelay providers\n|  |        |- New-ConstantBackoffStrategy.ps1\n|  |        |- New-CustomBackoffStrategy.ps1\n|  |        |- New-ExponentialBackoffStrategy.ps1\n|  |        `- New-LinearBackoffStrategy.ps1\n|  |- Common.PowerShell.psm1        # Dot-sources Public\\ (recursively); exports Public functions\n|  `- Common.PowerShell.psd1        # Module manifest (version, GUID, exports)\n|- Tests/                               # One .Tests.ps1 per Public\\ fn, mirroring its layout (Retry\\, ...)\n|  |- ...                               #   plus these CI-helper tests with no Public\\ counterpart:\n|  |- Find-IntegrationTests.Tests.ps1   # Shared CI helper tests\n|  |- Get-UnitTestFiles.Tests.ps1       # run-unit-tests\\lib helper\n|  |- Invoke-Publish.Tests.ps1\n|  |- Invoke-TagFromManifest.Tests.ps1\n|  |- Limit-TestLogRetention.Tests.ps1  # run-unit-tests\\lib helper (+ .IntegrationTests)\n|  |- Run-IntegrationTests.Tests.ps1\n|  |- Test-NoBareReturnEmptyArray.Tests.ps1\n|  |- Test-PowerShellParses.Tests.ps1   # Syntax-gate lint helper tests\n|  `- Integration.DockerHost/           # Integration tests - run in Docker only\n|- .github/\n|  |- actions/                          # Reusable composite actions (canonical)\n|  |  |- Helpers.ps1                    # Shared PS helpers dot-sourced by action scripts\n|  |  |- check-version-is-new/\n|  |  |- tag-from-manifest/\n|  |  |- run-unit-tests/                 # Unit test runner (canonical)\n|  |  |  |- Run-Tests.ps1                #   Entry point: dispatch + optional self-logging\n|  |  |  |- Module.Tests.ps1             #   Shared module-registration test (injected)\n|  |  |  `- lib/                         #   Helpers, one per file, dot-sourced\n|  |  |     |- Get-UnitTestFiles.ps1     #     Discovers unit test files (excludes Docker dirs)\n|  |  |     |- Invoke-UnitTestRun.ps1    #     Installs Pester, runs discovered tests\n|  |  |     `- Limit-TestLogRetention.ps1 #   Prunes old logs via Limit-RetainedItem\n|  |  |- run-integration-tests/\n|  |  |- run-ssh-integration-tests/\n|  |  |- scan-integration-tests/\n|  |  |- lint-no-bare-return-empty-array/  # Regex lint: bans bare `return @()` (invoked by ci-powershell.yml)\n|  |  |- lint-powershell-parses/            # Syntax gate: parses every .ps1/.psm1/.psd1 via the PS parser\n|  |  `- publish/\n|  `- workflows/                        # Reusable workflows (canonical)\n|     |- ci-powershell.yml\n|     |- ci-powershell-docker-host.yml\n|     |- ci-powershell-docker-target.yml\n|     |- tag.yml\n|     |- publish.yml\n|     |- release.yml\n|     |- ci-bash.yml                     #   Consumer wrapper -\u003e Common-Automation ci-bash (shellchecks scripts\\ shims)\n|     `- ci-yaml.yml                     #   Consumer wrapper -\u003e Common-Automation ci-yaml (actionlint/yamllint/...)\n|- docs/\n|  `- dev/\n|     `- implementation/                # Per-feature problem.md + plan.md\n|- scripts/                  # User-facing entry-point scripts\n|  |- Install.ps1            # Installs from source for local development\n|  |- Publish.ps1            # Publishes to PSGallery (called by publish.yml)\n|  |- Publish-VersionTags.ps1 # Publishes GitHub Actions vX.Y.Z + rolling vX tags\n|  |- Find-GitBashExecutable.ps1     # Helper dot-sourced by Publish-VersionTags\n|  |- Run-Tests.ps1          # Runs Pester unit tests locally (thin wrapper)\n|  |- Run-IntegrationTests-InDocker.ps1  # Integration tests in Docker\n|  |- Run-IntegrationTests-AgainstDockerTarget.ps1\n|  |- run-ci-yaml-and-bash.sh / .bat     # PRIMARY local entry: full lint suite + bats tests -\u003e Common-Automation\n|  |- run-lint-yaml-and-bash.sh / .bat   # Lint half only (shellcheck/actionlint/action-validator/yamllint/ansible-lint)\n|  |- run-tests-bash.sh / .bat           # Bats test half only -\u003e Common-Automation\n|  `- fix-permissions.sh / .bat  # Re-stages +x on tracked *.sh via the Common-Automation engine\n|- .gitattributes           # Pins *.sh -\u003e LF, *.bat -\u003e CRLF (Linux runners reject CRLF shebangs)\n`- README.md\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fklark-morrigan%2Fcommon-powershell","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fklark-morrigan%2Fcommon-powershell","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fklark-morrigan%2Fcommon-powershell/lists"}