A production-ready PowerShell module template. Clone, rename, and ship.
| Gate | Tool |
|---|---|
| Lint & style | PSScriptAnalyzer (all severities) |
| Tests | Pester 5 — JaCoCo coverage + JUnit XML |
| Security scan | PSScriptAnalyzer security rules |
| Build | Invoke-Build — monolithic .psm1 for distribution |
| Docs | platyPS — markdown from comment-based help |
| CI | GitHub Actions (Linux / Windows / macOS) |
| Publish | PSGallery · GitHub Packages · ADO Artifacts |
Open in VS Code and select "Reopen in Container". All tools install automatically.
Invoke-Build # lint -> test -> build -> docsSet-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force
Invoke-Build# 1. Rename source directory and files
Rename-Item src/MyModule src/YourModule
Rename-Item src/YourModule/MyModule.psd1 YourModule.psd1
Rename-Item src/YourModule/MyModule.psm1 YourModule.psm1
# 2. Generate a new GUID and paste it into YourModule.psd1
(New-Guid).Guid
# 3. Update .build.ps1 param: $ModuleName = 'YourModule'
# 4. Update tests: fix the Import-Module path in BeforeAll blocks.
├── src/MyModule/
│ ├── MyModule.psd1 manifest (GUID, version, author, description)
│ ├── MyModule.psm1 dev loader (dot-sources Public/ + Private/)
│ ├── Public/ exported cmdlets — one .ps1 per function
│ └── Private/ internal helpers — not exported
├── tests/
│ ├── Unit/Public/ import full module, test exported behaviour
│ ├── Unit/Private/ dot-source the file directly, test internals
│ └── Integration/ end-to-end tests
├── docs/
│ ├── about_MyModule.md module overview (hand-maintained)
│ └── Get-Example.md per-cmdlet help (auto-generated by platyPS)
├── .build/ build output (git-ignored)
│ └── MyModule/ monolithic module ready to publish
├── .devcontainer/ VS Code dev container (PowerShell 7.4)
├── .github/workflows/
│ ├── ci.yml lint + test + security + build on every PR
│ └── publish.yml publish on v*.*.* tag push
├── .vscode/
├── .claude/commands/
├── .build.ps1
└── PSScriptAnalyzerSettings.psd1
Invoke-Build # default: Lint -> Test -> Build -> Docs
Invoke-Build Lint # PSScriptAnalyzer
Invoke-Build Test # Pester 5 + coverage -> .build/coverage.xml
Invoke-Build Manifest # auto-update FunctionsToExport from Public/
Invoke-Build Build # monolithic .psm1 -> .build/MyModule/
Invoke-Build Docs # platyPS markdown -> docs/
Invoke-Build Clean # remove .build/The source uses a dev loader (MyModule.psm1) that dot-sources all files in
Public/ and Private/ at runtime — convenient for development.
Invoke-Build Build produces a monolithic .psm1 in .build/MyModule/:
- Concatenates all
Private/*.ps1files - Concatenates all
Public/*.ps1files - Appends
Export-ModuleMemberwith an explicit function list - Copies the manifest and writes the exact
FunctionsToExportlist
The built module is what gets published — never the src/ directory.
FunctionsToExport in src/MyModule/MyModule.psd1 is intentionally '*'
(exports everything during development). The Manifest task and the Build
task both auto-discover Public/ and write the precise list into the manifest,
so you never maintain it by hand.
docs/
├── about_MyModule.md ← hand-maintained: module overview, keywords, examples
└── Get-Example.md ← auto-generated by platyPS from comment-based help
Edit comment-based help in the source .ps1 files, then re-run
Invoke-Build Docs to update the markdown. Do not hand-edit the
auto-generated cmdlet files — changes will be overwritten.
All three publish targets are wired in .build.ps1 and triggered by
publish.yml when you push a version tag.
git tag v1.0.0
git push origin v1.0.0Feed URL: (default, no registration needed)
$env:PSGALLERY_API_KEY = 'your-key' # from powershellgallery.com/account/apikeys
Invoke-Build Build, PublishRequired GitHub secret: PSGALLERY_API_KEY
Feed URL: https://nuget.pkg.github.com/OWNER/index.json
$env:GITHUB_TOKEN = 'ghp_...'
$env:GITHUB_OWNER = 'samhodgkinson'
Invoke-Build Build, PublishGitHubInstall from GitHub Packages:
$cred = [pscredential]::new(
'YOUR_USERNAME',
(ConvertTo-SecureString 'ghp_...' -AsPlainText -Force)
)
Register-PSResourceRepository -Name GitHubPackages `
-Uri 'https://nuget.pkg.github.com/OWNER/index.json' `
-Trusted -Credential $cred
Install-PSResource -Name MyModule -Repository GitHubPackages -Credential $credRequired GitHub secret: GITHUB_TOKEN — auto-provided in Actions, no manual secret needed.
Organisation-scoped feed URL:
https://pkgs.dev.azure.com/ORG/_packaging/FEED/nuget/v3/index.json
Project-scoped feed URL:
https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/nuget/v3/index.json
$env:ADO_PAT = 'your-pat'
$env:ADO_ORG = 'your-org'
$env:ADO_FEED = 'your-feed'
Invoke-Build Build, PublishADOInstall from ADO Artifacts:
$cred = [pscredential]::new(
'PAT',
(ConvertTo-SecureString 'your-pat' -AsPlainText -Force)
)
Register-PSResourceRepository -Name ADOFeed `
-Uri 'https://pkgs.dev.azure.com/ORG/_packaging/FEED/nuget/v3/index.json' `
-Trusted -Credential $cred
Install-PSResource -Name MyModule -Repository ADOFeed -Credential $credRequired GitHub secrets: ADO_PAT
Required GitHub variables: ADO_ORG, ADO_FEED
The publish-ado job is skipped when ADO_ORG is not configured — ADO is opt-in.
- One file per function in
Public/andPrivate/. Keeps diffs focused and code review easy. FunctionsToExport = '*'in src — works for development. Build writes the explicit list.- Pester 5
New-PesterConfigurationonly — no legacy hashtable syntax.UseBreakpoints = $falseuses the profiler-based coverage runner (faster). - PSScriptAnalyzer runs all rules —
PSScriptAnalyzerSettings.psd1configures style. CI fails on any finding. - No CodeQL — CodeQL does not support PowerShell. PSScriptAnalyzer security rules
run as a dedicated
securityCI job. docs/split:about_MyModule.mdis hand-maintained; cmdlet.mdfiles are auto-generated by platyPS and should not be edited directly.
tests/Unit/Public/FunctionName.Tests.ps1— imports the full moduletests/Unit/Private/HelperName.Tests.ps1— dot-sources the file directlyBeforeAll/AfterAllfor module import (notBeforeEach)Contextblocks: "When given valid input", "When given invalid input"- 100% branch coverage target on all public functions
- Add function to
Public/orPrivate/ - Write tests in
tests/Unit/ Invoke-Build Lint— fix all findingsInvoke-Build Test— all tests must passInvoke-Build Build, Docs— verify build and regenerate docs- Bump
ModuleVersioninMyModule.psd1 - Update
CHANGELOG.md - Tag:
git tag v1.x.x && git push origin v1.x.x