From 44cb166d6493d7761c86cec25b37e2410479225b Mon Sep 17 00:00:00 2001 From: SamHodgkinson <9842003+samhodgkinson@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:29:27 +0100 Subject: [PATCH 1/3] feat: initial PowerShell module base scaffold Full project template with: - src/MyModule/ layout (Public/ Private/ one-file-per-function) - Pester 5 unit tests with JaCoCo coverage - PSScriptAnalyzer configuration (all severities, formatting rules) - Invoke-Build task script (lint, test, build, docs, publish) - platyPS documentation generation - GitHub Actions CI (lint, test, security, build) - GitHub Actions publish pipeline (PSGallery, GitHub Packages, ADO Artifacts) - Dev container (PowerShell 7.4) - VS Code settings, extensions, launch configs - Claude Code helper commands - README with publishing paths for PSGallery / GitHub Packages / ADO Artifacts - CLAUDE.md AI context --- .claude/commands/build.md | 15 ++ .claude/commands/docs.md | 19 ++ .claude/commands/lint.md | 11 + .claude/commands/test.md | 23 ++ .devcontainer/devcontainer.json | 30 +++ .editorconfig | 18 ++ .github/workflows/ci.yml | 127 ++++++++++ .github/workflows/publish.yml | 84 +++++++ .gitignore | 16 ++ .vscode/extensions.json | 9 + .vscode/launch.json | 27 +++ .vscode/settings.json | 15 ++ Build.ps1 | 113 +++++++++ CHANGELOG.md | 21 ++ CLAUDE.md | 104 +++++++++ LICENSE | 21 ++ PSScriptAnalyzerSettings.psd1 | 29 +++ README.md | 216 ++++++++++++++++++ docs/.gitkeep | 0 src/MyModule/MyModule.psd1 | 24 ++ src/MyModule/MyModule.psm1 | 13 ++ src/MyModule/Private/Invoke-ExampleHelper.ps1 | 10 + src/MyModule/Public/Get-Example.ps1 | 34 +++ tests/Integration/.gitkeep | 0 .../Private/Invoke-ExampleHelper.Tests.ps1 | 13 ++ tests/Unit/Public/Get-Example.Tests.ps1 | 37 +++ 26 files changed, 1029 insertions(+) create mode 100644 .claude/commands/build.md create mode 100644 .claude/commands/docs.md create mode 100644 .claude/commands/lint.md create mode 100644 .claude/commands/test.md create mode 100644 .devcontainer/devcontainer.json create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 Build.ps1 create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 PSScriptAnalyzerSettings.psd1 create mode 100644 docs/.gitkeep create mode 100644 src/MyModule/MyModule.psd1 create mode 100644 src/MyModule/MyModule.psm1 create mode 100644 src/MyModule/Private/Invoke-ExampleHelper.ps1 create mode 100644 src/MyModule/Public/Get-Example.ps1 create mode 100644 tests/Integration/.gitkeep create mode 100644 tests/Unit/Private/Invoke-ExampleHelper.Tests.ps1 create mode 100644 tests/Unit/Public/Get-Example.Tests.ps1 diff --git a/.claude/commands/build.md b/.claude/commands/build.md new file mode 100644 index 0000000..1666111 --- /dev/null +++ b/.claude/commands/build.md @@ -0,0 +1,15 @@ +Run the full build pipeline (lint -> test -> build -> docs): + +```powershell +Invoke-Build +``` + +Or run individual tasks: + +```powershell +Invoke-Build Lint # PSScriptAnalyzer only +Invoke-Build Test # Pester tests with JaCoCo coverage +Invoke-Build Build # Stage module to .build/ +Invoke-Build Docs # Generate markdown help via platyPS +Invoke-Build Clean # Remove .build/ +``` diff --git a/.claude/commands/docs.md b/.claude/commands/docs.md new file mode 100644 index 0000000..2ddb35c --- /dev/null +++ b/.claude/commands/docs.md @@ -0,0 +1,19 @@ +Generate markdown documentation from comment-based help: + +```powershell +# Build the module first, then generate docs +Invoke-Build Build, Docs +``` + +Or after an initial build, regenerate docs only: + +```powershell +Invoke-Build Docs +``` + +Docs are written to `docs/` as platyPS-formatted markdown. + +When adding a new public function: +1. Add the function to `src/MyModule/Public/` +2. Add its name to `FunctionsToExport` in `src/MyModule/MyModule.psd1` +3. Run `Invoke-Build Docs` to generate its help page diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md new file mode 100644 index 0000000..595f400 --- /dev/null +++ b/.claude/commands/lint.md @@ -0,0 +1,11 @@ +Run PSScriptAnalyzer against the source: + +```powershell +Invoke-Build Lint +``` + +Or directly: + +```powershell +Invoke-ScriptAnalyzer -Path ./src -Settings ./PSScriptAnalyzerSettings.psd1 -Recurse -ReportSummary +``` diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 0000000..f3d1842 --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,23 @@ +Run Pester 5 tests with code coverage: + +```powershell +Invoke-Build Test +``` + +Or directly: + +```powershell +$config = New-PesterConfiguration +$config.Run.Path = './tests' +$config.Run.Exit = $true +$config.Output.Verbosity = 'Detailed' +$config.CodeCoverage.Enabled = $true +$config.CodeCoverage.Path = './src' +$config.CodeCoverage.UseBreakpoints = $false +$config.CodeCoverage.OutputFormat = 'JaCoCo' +$config.CodeCoverage.OutputPath = '.build/coverage.xml' +Invoke-Pester -Configuration $config +``` + +Coverage report is written to `.build/coverage.xml` (JaCoCo format, compatible with Codecov). +Test results are written to `.build/testresults.xml` (JUnit XML). diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..00d5c51 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,30 @@ +{ + "name": "PowerShell Module", + "image": "mcr.microsoft.com/devcontainers/powershell:7.4", + "postCreateCommand": "pwsh -Command 'Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force'", + "customizations": { + "vscode": { + "extensions": [ + "ms-vscode.powershell", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "GitHub.copilot", + "GitHub.copilot-chat" + ], + "settings": { + "powershell.scriptAnalysis.enable": true, + "powershell.scriptAnalysis.settingsPath": "${workspaceFolder}/PSScriptAnalyzerSettings.psd1", + "powershell.pester.useLegacyCodeLens": false, + "powershell.pester.outputVerbosity": "Diagnostic", + "[powershell]": { + "editor.defaultFormatter": "ms-vscode.powershell", + "editor.formatOnSave": true, + "editor.rulers": [120] + }, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true + } + } + }, + "remoteUser": "vscode" +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9ba3f5d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{yml,yaml}] +indent_size = 2 + +[*.json] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f4f766 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,127 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install PSScriptAnalyzer + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -Scope CurrentUser -Force + + - name: Run PSScriptAnalyzer + shell: pwsh + run: | + $results = Invoke-ScriptAnalyzer -Path ./src -Settings ./PSScriptAnalyzerSettings.psd1 -Recurse -ReportSummary + if ($results) { + $results | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize + exit 1 + } + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install Pester + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Pester -Scope CurrentUser -Force -MinimumVersion 5.0 + + - name: Run tests with coverage + shell: pwsh + run: | + $null = New-Item .build -ItemType Directory -Force + $config = New-PesterConfiguration + $config.Run.Path = './tests' + $config.Run.Exit = $true + $config.Output.Verbosity = 'Detailed' + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.Path = './src' + $config.CodeCoverage.UseBreakpoints = $false + $config.CodeCoverage.OutputFormat = 'JaCoCo' + $config.CodeCoverage.OutputPath = '.build/coverage.xml' + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = 'JUnitXml' + $config.TestResult.OutputPath = '.build/testresults.xml' + Invoke-Pester -Configuration $config + + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v5 + with: + files: .build/coverage.xml + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }} + path: .build/testresults.xml + + security: + name: Security + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run PSScriptAnalyzer security rules + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -Scope CurrentUser -Force + $securityRules = @( + 'PSAvoidUsingInvokeExpression', + 'PSAvoidUsingConvertToSecureStringWithPlainText', + 'PSAvoidUsingPlainTextForPassword', + 'PSAvoidUsingUsernameAndPasswordParams', + 'PSAvoidUsingComputerNameHardcoded', + 'PSAvoidUsingWMICmdlet' + ) + $results = Invoke-ScriptAnalyzer -Path ./src -Recurse -IncludeRule $securityRules + if ($results) { + $results | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize + exit 1 + } + + build: + name: Build & Package + runs-on: ubuntu-latest + needs: [lint, test, security] + steps: + - uses: actions/checkout@v4 + + - name: Install build tools + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module InvokeBuild, platyPS -Scope CurrentUser -Force + + - name: Build and generate docs + shell: pwsh + run: Invoke-Build Build, Docs + + - name: Upload module artifact + uses: actions/upload-artifact@v4 + with: + name: module-package + path: .build/MyModule/ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..3ad8b1b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,84 @@ +name: Publish + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: read + packages: write + +jobs: + publish-gallery: + name: Publish to PowerShell Gallery + runs-on: ubuntu-latest + environment: release + steps: + - uses: actions/checkout@v4 + + - name: Install build tools + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module InvokeBuild, platyPS -Scope CurrentUser -Force + + - name: Build module + shell: pwsh + run: Invoke-Build Build + + - name: Publish to PSGallery + shell: pwsh + env: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + run: Invoke-Build Publish + + publish-github: + name: Publish to GitHub Packages + runs-on: ubuntu-latest + environment: release + steps: + - uses: actions/checkout@v4 + + - name: Install build tools + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module InvokeBuild, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force + + - name: Build module + shell: pwsh + run: Invoke-Build Build + + - name: Publish to GitHub Packages + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_OWNER: ${{ github.repository_owner }} + run: Invoke-Build PublishGitHub + + publish-ado: + name: Publish to Azure DevOps Artifacts + runs-on: ubuntu-latest + environment: release + if: vars.ADO_ORG != '' + steps: + - uses: actions/checkout@v4 + + - name: Install build tools + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module InvokeBuild, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force + + - name: Build module + shell: pwsh + run: Invoke-Build Build + + - name: Publish to ADO Artifacts + shell: pwsh + env: + ADO_PAT: ${{ secrets.ADO_PAT }} + ADO_ORG: ${{ vars.ADO_ORG }} + ADO_FEED: ${{ vars.ADO_FEED }} + run: Invoke-Build PublishADO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7eabe2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Build output +.build/ + +# Compiled module artifacts +*.nupkg + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/*.code-workspace + +# Secrets +.env +*.env.local diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..bbe2555 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "ms-vscode.powershell", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "GitHub.copilot", + "GitHub.copilot-chat" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cf14fd5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "PowerShell: Launch Current File", + "type": "PowerShell", + "request": "launch", + "script": "${file}", + "cwd": "${workspaceFolder}" + }, + { + "name": "PowerShell: Run Pester Tests", + "type": "PowerShell", + "request": "launch", + "script": "${workspaceFolder}/Build.ps1", + "args": ["-Task", "Test"], + "cwd": "${workspaceFolder}" + }, + { + "name": "PowerShell: Interactive Session", + "type": "PowerShell", + "request": "launch", + "script": "", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3eb8477 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "powershell.scriptAnalysis.enable": true, + "powershell.scriptAnalysis.settingsPath": "${workspaceFolder}/PSScriptAnalyzerSettings.psd1", + "powershell.pester.useLegacyCodeLens": false, + "powershell.pester.outputVerbosity": "Diagnostic", + "[powershell]": { + "editor.defaultFormatter": "ms-vscode.powershell", + "editor.formatOnSave": true, + "editor.rulers": [120] + }, + "editor.tabSize": 4, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.encoding": "utf8" +} diff --git a/Build.ps1 b/Build.ps1 new file mode 100644 index 0000000..bdca84c --- /dev/null +++ b/Build.ps1 @@ -0,0 +1,113 @@ +<# +.SYNOPSIS + Invoke-Build task script for MyModule. +.DESCRIPTION + Run tasks with: Invoke-Build + Default task runs: Lint -> Test -> Build -> Docs + + Tasks: + Clean Remove .build/ output directory + Lint Run PSScriptAnalyzer against src/ + Test Run Pester 5 tests with JaCoCo coverage output + Build Stage module files to .build/MyModule/ + Docs Generate markdown help from comment-based help via platyPS + Publish Publish to PowerShell Gallery (requires PSGALLERY_API_KEY) + PublishGitHub Publish to GitHub Packages (requires GITHUB_TOKEN, GITHUB_OWNER) + PublishADO Publish to ADO Artifacts (requires ADO_PAT, ADO_ORG, ADO_FEED) +#> + +param( + [string] $ModuleName = 'MyModule', + [string] $SrcPath = "$PSScriptRoot/src/$ModuleName", + [string] $BuildPath = "$PSScriptRoot/.build", + [string] $OutputPath = "$PSScriptRoot/.build/$ModuleName", + [string] $TestsPath = "$PSScriptRoot/tests", + [string] $DocsPath = "$PSScriptRoot/docs" +) + +task Clean { + if (Test-Path $BuildPath) { + Remove-Item $BuildPath -Recurse -Force + } + $null = New-Item $OutputPath -ItemType Directory -Force + Write-Build Green 'Clean complete.' +} + +task Lint { + $settings = "$PSScriptRoot/PSScriptAnalyzerSettings.psd1" + $results = Invoke-ScriptAnalyzer -Path $SrcPath -Settings $settings -Recurse -ReportSummary + if ($results) { + $results | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize + throw "PSScriptAnalyzer found $($results.Count) issue(s). Fix them before proceeding." + } + Write-Build Green 'Lint passed.' +} + +task Test { + $null = New-Item $BuildPath -ItemType Directory -Force + $config = New-PesterConfiguration + $config.Run.Path = $TestsPath + $config.Run.Exit = $true + $config.Output.Verbosity = 'Detailed' + + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.Path = $SrcPath + $config.CodeCoverage.UseBreakpoints = $false + $config.CodeCoverage.OutputFormat = 'JaCoCo' + $config.CodeCoverage.OutputPath = "$BuildPath/coverage.xml" + + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = 'JUnitXml' + $config.TestResult.OutputPath = "$BuildPath/testresults.xml" + + Invoke-Pester -Configuration $config +} + +task Build Clean, { + Copy-Item -Path $SrcPath -Destination $OutputPath -Recurse -Force + Write-Build Green "Build output staged at $OutputPath" +} + +task Docs { + assert (Test-Path "$OutputPath/$ModuleName.psd1") "Run 'Invoke-Build Build' before generating docs." + $null = New-Item $DocsPath -ItemType Directory -Force + Import-Module -Name "$OutputPath/$ModuleName.psd1" -Force -ErrorAction Stop + $commands = Get-Command -Module $ModuleName + foreach ($cmd in $commands) { + New-MarkdownHelp -Command $cmd.Name -OutputFolder $DocsPath -Force | Out-Null + } + Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue + Write-Build Green "Docs generated in $DocsPath" +} + +task Publish { + assert $env:PSGALLERY_API_KEY 'Set PSGALLERY_API_KEY before publishing to PSGallery.' + Publish-PSResource -Path $OutputPath -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY + Write-Build Green 'Published to PowerShell Gallery.' +} + +task PublishGitHub { + assert $env:GITHUB_TOKEN 'Set GITHUB_TOKEN before publishing to GitHub Packages.' + assert $env:GITHUB_OWNER 'Set GITHUB_OWNER (username or org) before publishing.' + $uri = "https://nuget.pkg.github.com/$env:GITHUB_OWNER/index.json" + $cred = [pscredential]::new( + $env:GITHUB_OWNER, + (ConvertTo-SecureString $env:GITHUB_TOKEN -AsPlainText -Force) + ) + Register-PSResourceRepository -Name 'GitHubPackages' -Uri $uri -Trusted -Credential $cred -ErrorAction SilentlyContinue + Publish-PSResource -Path $OutputPath -Repository 'GitHubPackages' -ApiKey $env:GITHUB_TOKEN + Write-Build Green 'Published to GitHub Packages.' +} + +task PublishADO { + assert $env:ADO_PAT 'Set ADO_PAT (Personal Access Token) before publishing to ADO.' + assert $env:ADO_ORG 'Set ADO_ORG (Azure DevOps organisation) before publishing.' + assert $env:ADO_FEED 'Set ADO_FEED (Artifacts feed name) before publishing.' + $uri = "https://pkgs.dev.azure.com/$env:ADO_ORG/_packaging/$env:ADO_FEED/nuget/v3/index.json" + $cred = [pscredential]::new('PAT', (ConvertTo-SecureString $env:ADO_PAT -AsPlainText -Force)) + Register-PSResourceRepository -Name 'ADOFeed' -Uri $uri -Trusted -Credential $cred -ErrorAction SilentlyContinue + Publish-PSResource -Path $OutputPath -Repository 'ADOFeed' -Credential $cred + Write-Build Green 'Published to Azure DevOps Artifacts.' +} + +task . Lint, Test, Build, Docs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..20f8081 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-06-07 + +### Added +- Initial module scaffold with `Get-Example` public function +- Pester 5 unit tests with JaCoCo coverage output +- PSScriptAnalyzer configuration with formatting and security rules +- Invoke-Build task script (Lint, Test, Build, Docs, Publish, PublishGitHub, PublishADO) +- platyPS documentation generation +- GitHub Actions CI pipeline (lint, test, security, build) across Ubuntu / Windows / macOS +- GitHub Actions publish pipeline (PSGallery, GitHub Packages, ADO Artifacts) on version tag +- Dev container configuration (PowerShell 7.4) +- VS Code settings, extensions, and launch configurations diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..937f693 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md — AI Context for PowerShell-Project-Base + +This file gives Claude Code (and other AI tools) the context needed to work +effectively in this repository. + +## Project Overview + +A base template for PowerShell modules. Clone this repo and replace `src/MyModule/` +with your own module code. + +The template ships with: + +- **PowerShell 7.2+** minimum requirement; dev container uses PowerShell 7.4 +- **PSScriptAnalyzer** — linting and style enforcement (replaces ad-hoc review); runs on save +- **Pester 5** — test runner with JaCoCo coverage and JUnit XML output +- **platyPS** — markdown documentation from comment-based help +- **Invoke-Build** — task orchestration (lint, test, build, docs, publish) +- **GitHub Actions** — CI (lint, test, security, build) + publish pipeline on version tag +- **Dev Container** — VS Code dev container for zero-install development + +## Repository Layout + +``` +. +├── src/MyModule/ +│ ├── MyModule.psd1 # Module manifest (version, GUID, exports) +│ ├── MyModule.psm1 # Root loader: dot-sources Public/ and Private/ +│ ├── Public/ # Exported functions — one file per cmdlet +│ └── Private/ # Internal helpers — not exported +├── tests/ +│ ├── Unit/Public/ # Public function tests (imports full module) +│ ├── Unit/Private/ # Private function tests (dot-source directly) +│ └── Integration/ +├── docs/ # platyPS markdown (auto-generated — do not hand-edit) +├── .devcontainer/devcontainer.json +├── .github/workflows/ +│ ├── ci.yml # Lint, test, security, build on every PR +│ └── publish.yml # Publish to registries on v*.*.* tag +├── .vscode/ # Editor settings, extensions, launch configs +├── .claude/commands/ # Claude Code helper commands +├── Build.ps1 # Invoke-Build task file +└── PSScriptAnalyzerSettings.psd1 +``` + +## Common Commands + +All commands run inside the dev container terminal or locally with tools installed. + +```powershell +# Install all tools (once per environment) +Set-PSRepository PSGallery -InstallationPolicy Trusted +Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force + +# Run all quality gates (default task: Lint -> Test -> Build -> Docs) +Invoke-Build + +# Individual tasks +Invoke-Build Lint # PSScriptAnalyzer against src/ +Invoke-Build Test # Pester 5 + JaCoCo coverage -> .build/coverage.xml +Invoke-Build Build # Stage module files to .build/MyModule/ +Invoke-Build Docs # platyPS markdown -> docs/ +Invoke-Build Clean # Remove .build/ + +# Publish (require env vars — see README.md) +Invoke-Build Publish # -> PowerShell Gallery (PSGALLERY_API_KEY) +Invoke-Build PublishGitHub # -> GitHub Packages (GITHUB_TOKEN, GITHUB_OWNER) +Invoke-Build PublishADO # -> ADO Artifacts (ADO_PAT, ADO_ORG, ADO_FEED) +``` + +## Architecture Notes + +- **One file per function** in `Public/` and `Private/`. Root `.psm1` dot-sources all of them. +- **`FunctionsToExport`** in `MyModule.psd1` must match the names of files in `Public/`. +- **Pester 5 `New-PesterConfiguration` style only** — no legacy hashtable syntax. + `UseBreakpoints = $false` enables profiler-based coverage (significantly faster on large suites). +- **PSScriptAnalyzer runs all severity levels** configured in `PSScriptAnalyzerSettings.psd1`. + CI lint job fails on any finding. +- **No CodeQL** — GitHub CodeQL does not support PowerShell. PSScriptAnalyzer security rules + cover SAST in the `security` CI job. +- **`Publish-PSResource`** (Microsoft.PowerShell.PSResourceGet) is used for all publish targets. + This is the modern replacement for `Publish-Module` (PowerShellGet v2). + GitHub Packages and ADO Artifacts both use NuGet v3 feeds. +- **`docs/` is auto-generated** by platyPS — do not hand-edit the markdown there. + Edit comment-based help in the `.ps1` source files and re-run `Invoke-Build Docs`. + +## Testing Conventions + +- Test files mirror `src/` structure: `tests/Unit/Public/Get-Example.Tests.ps1` +- Import the module in `BeforeAll`; remove it in `AfterAll` +- Test private functions by dot-sourcing the `.ps1` file directly +- Group with `Context` blocks: "When given valid input", "When given invalid input" +- Aim for 100% branch coverage on all new public functions + +## When Making Changes + +1. Add the function to `Public/` (exported) or `Private/` (internal) +2. Update `FunctionsToExport` in `MyModule.psd1` for public functions +3. Write tests in `tests/Unit/` +4. Run `Invoke-Build Lint` — fix all PSScriptAnalyzer findings +5. Run `Invoke-Build Test` — all tests must pass +6. Run `Invoke-Build Docs` — regenerate markdown help +7. Bump `ModuleVersion` in `MyModule.psd1` before tagging a release +8. Update `CHANGELOG.md` +9. Tag and push: `git tag v1.0.0 && git push origin v1.0.0` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81e8097 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sam Hodgkinson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..8914e1f --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,29 @@ +@{ + Severity = @('Error', 'Warning', 'Information') + ExcludeRules = @() + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = $true + Kind = 'space' + IndentationSize = 4 + } + PSUseConsistentWhitespace = @{ + Enable = $true + CheckInnerBrace = $true + CheckOpenBrace = $true + CheckOpenParen = $true + CheckOperator = $true + CheckPipe = $true + CheckPipeForRedundantWhitespace = $true + CheckSeparator = $true + CheckParameter = $false + } + PSAlignAssignmentStatement = @{ + Enable = $true + CheckHashtable = $true + } + PSAvoidUsingCmdletAliases = @{ + AllowList = @() + } + } +} diff --git a/README.md b/README.md index e69de29..96fa450 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,216 @@ +# PowerShell Module Base + +A production-ready PowerShell module template. Clone this repo and replace `src/MyModule/` +with your own code. + +The template ships with: + +- **PowerShell 7.2+** minimum requirement +- **PSScriptAnalyzer** — linting and style enforcement on save and in CI +- **Pester 5** — test runner with JaCoCo coverage and JUnit XML output +- **platyPS** — markdown documentation generated from comment-based help +- **Invoke-Build** — task orchestration (lint, test, build, docs, publish) +- **GitHub Actions** — CI on every PR + publish pipeline on version tag +- **Dev Container** — VS Code dev container for zero-install development + +## Quick Start + +### Dev Container (Recommended) + +Open in VS Code and select **"Reopen in Container"**. All tools install automatically via +`postCreateCommand`. + +### Local + +```powershell +# Install build tools once +Set-PSRepository PSGallery -InstallationPolicy Trusted +Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force + +# Run all quality gates (lint -> test -> build -> docs) +Invoke-Build +``` + +## Repository Layout + +``` +. +├── src/MyModule/ # Module source (replace MyModule with your name) +│ ├── MyModule.psd1 # Module manifest (version, GUID, exports) +│ ├── MyModule.psm1 # Root loader: dot-sources Public/ and Private/ +│ ├── Public/ # Exported functions — one file per cmdlet +│ │ └── Get-Example.ps1 +│ └── Private/ # Internal helpers — not exported +│ └── Invoke-ExampleHelper.ps1 +├── tests/ +│ ├── Unit/ +│ │ ├── Public/ # Tests mirroring Public/ (import module) +│ │ └── Private/ # Tests for Private/ (dot-source directly) +│ └── Integration/ +├── docs/ # platyPS-generated markdown help (auto-generated) +├── .devcontainer/devcontainer.json +├── .github/workflows/ +│ ├── ci.yml # Lint, test, security, build on every PR +│ └── publish.yml # Publish to registries on version tag +├── .vscode/ # Editor settings, extensions, launch configs +├── .claude/commands/ # Claude Code helper commands +├── Build.ps1 # Invoke-Build task file +├── PSScriptAnalyzerSettings.psd1 +├── .editorconfig +└── CHANGELOG.md +``` + +## Common Commands + +```powershell +# Run all quality gates (default task) +Invoke-Build + +# Individual tasks +Invoke-Build Lint # PSScriptAnalyzer against src/ +Invoke-Build Test # Pester 5 with JaCoCo coverage -> .build/coverage.xml +Invoke-Build Build # Stage module to .build/MyModule/ +Invoke-Build Docs # platyPS markdown -> docs/ +Invoke-Build Clean # Remove .build/ +``` + +## Renaming the Module + +```powershell +# 1. Rename the source directory and module files +Rename-Item src/MyModule src/YourModule +Rename-Item src/YourModule/MyModule.psd1 YourModule.psd1 +Rename-Item src/YourModule/MyModule.psm1 YourModule.psm1 + +# 2. Edit the manifest: update RootModule, GUID (New-Guid), Author, Description +# 3. Edit Build.ps1: change $ModuleName default value to 'YourModule' +# 4. Edit tests: fix the Import-Module path in BeforeAll blocks +``` + +## Publishing + +All publish tasks require the module to be built first (`Invoke-Build Build`). +The `publish.yml` workflow triggers automatically on any `v*.*.*` tag push. + +--- + +### PowerShell Gallery (Public) + +Get your API key from [powershellgallery.com/account/apikeys](https://www.powershellgallery.com/account/apikeys). + +```powershell +$env:PSGALLERY_API_KEY = 'your-api-key' +Invoke-Build Build +Invoke-Build Publish +``` + +Required GitHub secret: `PSGALLERY_API_KEY` + +--- + +### GitHub Packages (Public or Private) + +NuGet v3 feed — works for public and private repositories. + +**Feed URL:** `https://nuget.pkg.github.com/OWNER/index.json` + +```powershell +$env:GITHUB_TOKEN = 'ghp_...' +$env:GITHUB_OWNER = 'samhodgkinson' +Invoke-Build Build +Invoke-Build PublishGitHub +``` + +**Installing from GitHub Packages:** + +```powershell +$cred = [pscredential]::new( + 'YOUR_GITHUB_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 $cred +``` + +Required GitHub secret: `GITHUB_TOKEN` is automatically provided in Actions — no manual secret needed for same-owner repositories. + +--- + +### Azure DevOps Artifacts (Private) + +Create a feed in your ADO organisation first, then use the NuGet v3 endpoint. + +**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 +``` + +```powershell +$env:ADO_PAT = 'your-pat-token' +$env:ADO_ORG = 'your-ado-org' +$env:ADO_FEED = 'your-feed-name' +Invoke-Build Build +Invoke-Build PublishADO +``` + +**Installing from ADO Artifacts:** + +```powershell +$cred = [pscredential]::new( + 'PAT', + (ConvertTo-SecureString 'your-pat-token' -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 $cred +``` + +Required GitHub secrets: `ADO_PAT` +Required GitHub variables: `ADO_ORG`, `ADO_FEED` + +The `publish-ado` job in `publish.yml` is skipped when `ADO_ORG` is not set, so ADO publishing +is opt-in. + +--- + +## Architecture Notes + +- **One file per function** in `Public/` and `Private/`. The root `.psm1` dot-sources all of them. + Keeps diffs clean and code review focused. +- **`FunctionsToExport`** in the manifest must be kept in sync with filenames in `Public/`. +- **Pester 5 `New-PesterConfiguration`** only — no legacy hashtable syntax. + `UseBreakpoints = $false` enables the profiler-based coverage runner (much faster on large suites). +- **PSScriptAnalyzer runs all severity levels** via `PSScriptAnalyzerSettings.psd1`; CI fails on + any finding. Security-specific rules run as a separate CI job. +- **No CodeQL for PowerShell** — GitHub CodeQL does not support PowerShell. PSScriptAnalyzer + security rules cover SAST in CI. +- **`Publish-PSResource`** (PSResourceGet) is used for all publish targets, replacing the legacy + `Publish-Module`. GitHub Packages and ADO Artifacts both accept NuGet v3 feeds. + +## Testing Conventions + +- Test files mirror source structure: `tests/Unit/Public/Get-Example.Tests.ps1` +- Import the module in `BeforeAll`; remove it in `AfterAll` +- Test private functions by dot-sourcing the file directly, not through the module +- Group assertions with `Context` blocks: "When given valid input", "When given invalid input" +- Aim for 100% branch coverage on all public functions + +## When Making Changes + +1. Add the function to `Public/` or `Private/` +2. Add exported function names to `FunctionsToExport` in the manifest +3. Write tests in `tests/Unit/` +4. Run `Invoke-Build Lint` — fix all PSScriptAnalyzer findings +5. Run `Invoke-Build Test` — all tests must pass +6. Run `Invoke-Build Docs` — regenerate markdown help +7. Bump `ModuleVersion` in the manifest before tagging a release +8. Update `CHANGELOG.md` +9. Tag the release: `git tag v1.0.0 && git push origin v1.0.0` diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/MyModule/MyModule.psd1 b/src/MyModule/MyModule.psd1 new file mode 100644 index 0000000..b195054 --- /dev/null +++ b/src/MyModule/MyModule.psd1 @@ -0,0 +1,24 @@ +@{ + RootModule = 'MyModule.psm1' + ModuleVersion = '0.1.0' + GUID = '49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a' + Author = 'Your Name' + CompanyName = 'Your Company' + Copyright = '(c) 2026. All rights reserved.' + Description = 'A PowerShell module — replace this description.' + PowerShellVersion = '7.2' + + FunctionsToExport = @('Get-Example') + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('Template') + LicenseUri = 'https://github.com/samhodgkinson/powershell-project-base/blob/main/LICENSE' + ProjectUri = 'https://github.com/samhodgkinson/powershell-project-base' + ReleaseNotes = 'Initial release' + } + } +} diff --git a/src/MyModule/MyModule.psm1 b/src/MyModule/MyModule.psm1 new file mode 100644 index 0000000..1c783e4 --- /dev/null +++ b/src/MyModule/MyModule.psm1 @@ -0,0 +1,13 @@ +$Public = @(Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1" -ErrorAction SilentlyContinue) +$Private = @(Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -ErrorAction SilentlyContinue) + +foreach ($import in @($Public + $Private)) { + try { + . $import.FullName + } catch { + Write-Error "Failed to import function $($import.FullName): $_" + throw + } +} + +Export-ModuleMember -Function $Public.BaseName diff --git a/src/MyModule/Private/Invoke-ExampleHelper.ps1 b/src/MyModule/Private/Invoke-ExampleHelper.ps1 new file mode 100644 index 0000000..e537df8 --- /dev/null +++ b/src/MyModule/Private/Invoke-ExampleHelper.ps1 @@ -0,0 +1,10 @@ +function Invoke-ExampleHelper { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string] $Name + ) + + "Hello, $Name!" +} diff --git a/src/MyModule/Public/Get-Example.ps1 b/src/MyModule/Public/Get-Example.ps1 new file mode 100644 index 0000000..cbb2365 --- /dev/null +++ b/src/MyModule/Public/Get-Example.ps1 @@ -0,0 +1,34 @@ +function Get-Example { + <# + .SYNOPSIS + Returns a greeting message. + .DESCRIPTION + Returns a greeting string for the given name. This function demonstrates + the standard layout used in this module template: CmdletBinding, OutputType, + Mandatory parameter, pipeline support, and comment-based help. + .PARAMETER Name + The name to greet. Accepts pipeline input. + .EXAMPLE + Get-Example -Name 'World' + Hello, World! + .EXAMPLE + 'Alice', 'Bob' | Get-Example + Hello, Alice! + Hello, Bob! + .OUTPUTS + System.String + .LINK + https://github.com/samhodgkinson/powershell-project-base + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [string] $Name + ) + + process { + Invoke-ExampleHelper -Name $Name + } +} diff --git a/tests/Integration/.gitkeep b/tests/Integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/Private/Invoke-ExampleHelper.Tests.ps1 b/tests/Unit/Private/Invoke-ExampleHelper.Tests.ps1 new file mode 100644 index 0000000..df10d77 --- /dev/null +++ b/tests/Unit/Private/Invoke-ExampleHelper.Tests.ps1 @@ -0,0 +1,13 @@ +BeforeAll { + . "$PSScriptRoot/../../../src/MyModule/Private/Invoke-ExampleHelper.ps1" +} + +Describe 'Invoke-ExampleHelper' { + It 'Returns a formatted greeting string' { + Invoke-ExampleHelper -Name 'Bob' | Should -Be 'Hello, Bob!' + } + + It 'Handles names with spaces' { + Invoke-ExampleHelper -Name 'Jane Doe' | Should -Be 'Hello, Jane Doe!' + } +} diff --git a/tests/Unit/Public/Get-Example.Tests.ps1 b/tests/Unit/Public/Get-Example.Tests.ps1 new file mode 100644 index 0000000..1e5b741 --- /dev/null +++ b/tests/Unit/Public/Get-Example.Tests.ps1 @@ -0,0 +1,37 @@ +BeforeAll { + $modulePath = "$PSScriptRoot/../../../src/MyModule/MyModule.psd1" + Import-Module -Name $modulePath -Force +} + +AfterAll { + Remove-Module -Name MyModule -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-Example' { + Context 'When given a valid name' { + It 'Returns a greeting string' { + Get-Example -Name 'World' | Should -Be 'Hello, World!' + } + + It 'Accepts pipeline input by value' { + 'Alice' | Get-Example | Should -Be 'Hello, Alice!' + } + + It 'Processes multiple pipeline values' { + $result = 'Alice', 'Bob' | Get-Example + $result | Should -HaveCount 2 + $result[0] | Should -Be 'Hello, Alice!' + $result[1] | Should -Be 'Hello, Bob!' + } + } + + Context 'When given invalid input' { + It 'Throws on empty string' { + { Get-Example -Name '' } | Should -Throw + } + + It 'Throws when Name is not supplied' { + { Get-Example } | Should -Throw + } + } +} From d055c26c2e6c8069781ea339a56207a88f996d62 Mon Sep 17 00:00:00 2001 From: SamHodgkinson <9842003+samhodgkinson@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:58:16 +0100 Subject: [PATCH 2/3] fix: auto-manifest, monolithic build, CI tool install, better docs - Build.ps1: Manifest task auto-discovers Public/ functions + validates GUID; Build produces a monolithic PSM1 for distribution with explicit exports - PSD1: FunctionsToExport = '*' in src (dev/test); build writes explicit list - CI: install all tools once up-front, then delegate to Invoke-Build tasks; cross-platform matrix (Linux/Windows/macOS) with Pester wired correctly - Get-Example.ps1: richer comment-based help (all sections, multiple examples) - docs/about_MyModule.md: proper module-level about_ help file - README + CLAUDE.md: full rewrite with clearer structure --- .github/workflows/ci.yml | 65 ++++---- .github/workflows/publish.yml | 87 +++++++--- Build.ps1 | 120 ++++++++++---- CLAUDE.md | 140 ++++++++-------- README.md | 240 +++++++++++++++------------- docs/about_MyModule.md | 55 +++++++ src/MyModule/MyModule.psd1 | 8 +- src/MyModule/Public/Get-Example.ps1 | 48 +++++- 8 files changed, 492 insertions(+), 271 deletions(-) create mode 100644 docs/about_MyModule.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f4f766..95cd82b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,20 +16,15 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install PSScriptAnalyzer + - name: Install tools shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module PSScriptAnalyzer -Scope CurrentUser -Force + Install-Module InvokeBuild, PSScriptAnalyzer -Scope CurrentUser -Force - - name: Run PSScriptAnalyzer + - name: Lint shell: pwsh - run: | - $results = Invoke-ScriptAnalyzer -Path ./src -Settings ./PSScriptAnalyzerSettings.psd1 -Recurse -ReportSummary - if ($results) { - $results | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize - exit 1 - } + run: Invoke-Build Lint test: name: Test (${{ matrix.os }}) @@ -41,29 +36,15 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Pester + - name: Install tools shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module Pester -Scope CurrentUser -Force -MinimumVersion 5.0 + Install-Module InvokeBuild, Pester -Scope CurrentUser -Force -MinimumVersion @{Pester='5.0'} - - name: Run tests with coverage + - name: Test shell: pwsh - run: | - $null = New-Item .build -ItemType Directory -Force - $config = New-PesterConfiguration - $config.Run.Path = './tests' - $config.Run.Exit = $true - $config.Output.Verbosity = 'Detailed' - $config.CodeCoverage.Enabled = $true - $config.CodeCoverage.Path = './src' - $config.CodeCoverage.UseBreakpoints = $false - $config.CodeCoverage.OutputFormat = 'JaCoCo' - $config.CodeCoverage.OutputPath = '.build/coverage.xml' - $config.TestResult.Enabled = $true - $config.TestResult.OutputFormat = 'JUnitXml' - $config.TestResult.OutputPath = '.build/testresults.xml' - Invoke-Pester -Configuration $config + run: Invoke-Build Test - name: Upload coverage if: matrix.os == 'ubuntu-latest' @@ -84,12 +65,16 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Run PSScriptAnalyzer security rules + - name: Install tools shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted Install-Module PSScriptAnalyzer -Scope CurrentUser -Force - $securityRules = @( + + - name: Security scan + shell: pwsh + run: | + $rules = @( 'PSAvoidUsingInvokeExpression', 'PSAvoidUsingConvertToSecureStringWithPlainText', 'PSAvoidUsingPlainTextForPassword', @@ -97,31 +82,41 @@ jobs: 'PSAvoidUsingComputerNameHardcoded', 'PSAvoidUsingWMICmdlet' ) - $results = Invoke-ScriptAnalyzer -Path ./src -Recurse -IncludeRule $securityRules + $results = Invoke-ScriptAnalyzer -Path ./src -Recurse -IncludeRule $rules if ($results) { $results | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize exit 1 } build: - name: Build & Package + name: Build & Docs runs-on: ubuntu-latest needs: [lint, test, security] steps: - uses: actions/checkout@v4 - - name: Install build tools + - name: Install tools shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module InvokeBuild, platyPS -Scope CurrentUser -Force + Install-Module InvokeBuild, PSScriptAnalyzer, platyPS -Scope CurrentUser -Force + + - name: Build + shell: pwsh + run: Invoke-Build Build - - name: Build and generate docs + - name: Docs shell: pwsh - run: Invoke-Build Build, Docs + run: Invoke-Build Docs - name: Upload module artifact uses: actions/upload-artifact@v4 with: name: module-package path: .build/MyModule/ + + - name: Upload docs artifact + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs/ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3ad8b1b..d99d33d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,75 +10,110 @@ permissions: packages: write jobs: - publish-gallery: - name: Publish to PowerShell Gallery + build: + name: Build runs-on: ubuntu-latest - environment: release steps: - uses: actions/checkout@v4 - - name: Install build tools + - name: Install tools shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module InvokeBuild, platyPS -Scope CurrentUser -Force + Install-Module InvokeBuild, PSScriptAnalyzer, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force - - name: Build module + - name: Build shell: pwsh run: Invoke-Build Build - - name: Publish to PSGallery + - name: Upload module artifact + uses: actions/upload-artifact@v4 + with: + name: module-package + path: .build/MyModule/ + retention-days: 1 + + publish-gallery: + name: Publish to PowerShell Gallery + runs-on: ubuntu-latest + needs: build + environment: release + steps: + - uses: actions/download-artifact@v4 + with: + name: module-package + path: .build/MyModule/ + + - name: Install PSResourceGet + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force + + - name: Publish shell: pwsh env: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - run: Invoke-Build Publish + run: | + Publish-PSResource -Path .build/MyModule -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY publish-github: name: Publish to GitHub Packages runs-on: ubuntu-latest + needs: build environment: release steps: - - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: module-package + path: .build/MyModule/ - - name: Install build tools + - name: Install PSResourceGet shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module InvokeBuild, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force - - - name: Build module - shell: pwsh - run: Invoke-Build Build + Install-Module Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force - - name: Publish to GitHub Packages + - name: Publish shell: pwsh env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_OWNER: ${{ github.repository_owner }} - run: Invoke-Build PublishGitHub + run: | + $uri = "https://nuget.pkg.github.com/$env:GITHUB_OWNER/index.json" + $cred = [pscredential]::new( + $env:GITHUB_OWNER, + (ConvertTo-SecureString $env:GITHUB_TOKEN -AsPlainText -Force) + ) + Register-PSResourceRepository -Name 'GitHubPackages' -Uri $uri -Trusted -Credential $cred -ErrorAction SilentlyContinue + Publish-PSResource -Path .build/MyModule -Repository 'GitHubPackages' -ApiKey $env:GITHUB_TOKEN publish-ado: name: Publish to Azure DevOps Artifacts runs-on: ubuntu-latest + needs: build environment: release if: vars.ADO_ORG != '' steps: - - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: module-package + path: .build/MyModule/ - - name: Install build tools + - name: Install PSResourceGet shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module InvokeBuild, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force - - - name: Build module - shell: pwsh - run: Invoke-Build Build + Install-Module Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force - - name: Publish to ADO Artifacts + - name: Publish shell: pwsh env: ADO_PAT: ${{ secrets.ADO_PAT }} ADO_ORG: ${{ vars.ADO_ORG }} ADO_FEED: ${{ vars.ADO_FEED }} - run: Invoke-Build PublishADO + run: | + $uri = "https://pkgs.dev.azure.com/$env:ADO_ORG/_packaging/$env:ADO_FEED/nuget/v3/index.json" + $cred = [pscredential]::new('PAT', (ConvertTo-SecureString $env:ADO_PAT -AsPlainText -Force)) + Register-PSResourceRepository -Name 'ADOFeed' -Uri $uri -Trusted -Credential $cred -ErrorAction SilentlyContinue + Publish-PSResource -Path .build/MyModule -Repository 'ADOFeed' -Credential $cred diff --git a/Build.ps1 b/Build.ps1 index bdca84c..8e60e63 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -2,57 +2,61 @@ .SYNOPSIS Invoke-Build task script for MyModule. .DESCRIPTION - Run tasks with: Invoke-Build - Default task runs: Lint -> Test -> Build -> Docs + Run tasks with: Invoke-Build [TaskName] + Omit TaskName to run the default task: Lint -> Test -> Build -> Docs Tasks: Clean Remove .build/ output directory Lint Run PSScriptAnalyzer against src/ - Test Run Pester 5 tests with JaCoCo coverage output - Build Stage module files to .build/MyModule/ - Docs Generate markdown help from comment-based help via platyPS - Publish Publish to PowerShell Gallery (requires PSGALLERY_API_KEY) - PublishGitHub Publish to GitHub Packages (requires GITHUB_TOKEN, GITHUB_OWNER) - PublishADO Publish to ADO Artifacts (requires ADO_PAT, ADO_ORG, ADO_FEED) + Test Run Pester 5 tests with JaCoCo + JUnit output + Manifest Auto-discover Public/ functions, update FunctionsToExport, + validate GUID is not the template placeholder + Build Create a monolithic .psm1 and stage to .build/MyModule/ + Docs Generate per-cmdlet markdown via platyPS; create about_ doc + Publish Publish to PowerShell Gallery (PSGALLERY_API_KEY) + PublishGitHub Publish to GitHub Packages (GITHUB_TOKEN, GITHUB_OWNER) + PublishADO Publish to ADO Artifacts (ADO_PAT, ADO_ORG, ADO_FEED) #> param( - [string] $ModuleName = 'MyModule', - [string] $SrcPath = "$PSScriptRoot/src/$ModuleName", - [string] $BuildPath = "$PSScriptRoot/.build", - [string] $OutputPath = "$PSScriptRoot/.build/$ModuleName", - [string] $TestsPath = "$PSScriptRoot/tests", - [string] $DocsPath = "$PSScriptRoot/docs" + [string] $ModuleName = 'MyModule', + [string] $SrcPath = "$PSScriptRoot/src/$ModuleName", + [string] $BuildPath = "$PSScriptRoot/.build", + [string] $OutputPath = "$PSScriptRoot/.build/$ModuleName", + [string] $TestsPath = "$PSScriptRoot/tests", + [string] $DocsPath = "$PSScriptRoot/docs", + [string] $TemplateGuid = '49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a' ) +# --------------------------------------------------------------------------- task Clean { - if (Test-Path $BuildPath) { - Remove-Item $BuildPath -Recurse -Force - } + if (Test-Path $BuildPath) { Remove-Item $BuildPath -Recurse -Force } $null = New-Item $OutputPath -ItemType Directory -Force Write-Build Green 'Clean complete.' } +# --------------------------------------------------------------------------- task Lint { $settings = "$PSScriptRoot/PSScriptAnalyzerSettings.psd1" $results = Invoke-ScriptAnalyzer -Path $SrcPath -Settings $settings -Recurse -ReportSummary if ($results) { $results | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize - throw "PSScriptAnalyzer found $($results.Count) issue(s). Fix them before proceeding." + throw "PSScriptAnalyzer found $($results.Count) issue(s)." } Write-Build Green 'Lint passed.' } +# --------------------------------------------------------------------------- task Test { $null = New-Item $BuildPath -ItemType Directory -Force $config = New-PesterConfiguration - $config.Run.Path = $TestsPath - $config.Run.Exit = $true - $config.Output.Verbosity = 'Detailed' + $config.Run.Path = $TestsPath + $config.Run.Exit = $true + $config.Output.Verbosity = 'Detailed' $config.CodeCoverage.Enabled = $true $config.CodeCoverage.Path = $SrcPath - $config.CodeCoverage.UseBreakpoints = $false + $config.CodeCoverage.UseBreakpoints = $false # Profiler mode: faster on large suites $config.CodeCoverage.OutputFormat = 'JaCoCo' $config.CodeCoverage.OutputPath = "$BuildPath/coverage.xml" @@ -63,23 +67,80 @@ task Test { Invoke-Pester -Configuration $config } -task Build Clean, { - Copy-Item -Path $SrcPath -Destination $OutputPath -Recurse -Force - Write-Build Green "Build output staged at $OutputPath" +# --------------------------------------------------------------------------- +task Manifest { + $manifestPath = "$SrcPath/$ModuleName.psd1" + $data = Import-PowerShellDataFile -Path $manifestPath + + # Warn if still using the template GUID so the author knows to replace it + if ($data.GUID -eq $TemplateGuid) { + Write-Build Yellow @" +WARNING: MyModule.psd1 still uses the template GUID. +Run the following and paste the result into the manifest: + + (New-Guid).Guid + +"@ + } + + # Auto-discover exported functions from Public/ + $functions = @( + Get-ChildItem -Path "$SrcPath/Public/*.ps1" -ErrorAction SilentlyContinue + ).BaseName | Sort-Object + + Update-ModuleManifest -Path $manifestPath -FunctionsToExport $functions + Write-Build Green "Manifest updated: $($functions.Count) function(s) exported ($($functions -join ', '))." } +# --------------------------------------------------------------------------- +task Build Clean, Manifest, { + $functions = @(Get-ChildItem "$SrcPath/Public/*.ps1" -ErrorAction SilentlyContinue).BaseName | Sort-Object + $sb = [System.Text.StringBuilder]::new() + + # Concatenate private helpers first, then public functions + foreach ($file in Get-ChildItem "$SrcPath/Private/*.ps1" -ErrorAction SilentlyContinue) { + $null = $sb.AppendLine((Get-Content -Path $file.FullName -Raw)) + } + foreach ($file in Get-ChildItem "$SrcPath/Public/*.ps1" -ErrorAction SilentlyContinue) { + $null = $sb.AppendLine((Get-Content -Path $file.FullName -Raw)) + } + + # Explicit export list in monolithic module — safer than Export-ModuleMember -Function * + $exportList = ($functions | ForEach-Object { "'$_'" }) -join ', ' + $null = $sb.AppendLine("Export-ModuleMember -Function @($exportList)") + + Set-Content -Path "$OutputPath/$ModuleName.psm1" -Value $sb.ToString() -Encoding utf8NoBOM + + # Copy manifest and write explicit FunctionsToExport into the build copy + Copy-Item -Path "$SrcPath/$ModuleName.psd1" -Destination $OutputPath + Update-ModuleManifest -Path "$OutputPath/$ModuleName.psd1" -FunctionsToExport $functions + + Write-Build Green "Build staged at $OutputPath ($($functions.Count) public function(s))." +} + +# --------------------------------------------------------------------------- task Docs { - assert (Test-Path "$OutputPath/$ModuleName.psd1") "Run 'Invoke-Build Build' before generating docs." + assert (Test-Path "$OutputPath/$ModuleName.psd1") "Run 'Invoke-Build Build' before Docs." $null = New-Item $DocsPath -ItemType Directory -Force + Import-Module -Name "$OutputPath/$ModuleName.psd1" -Force -ErrorAction Stop + + # Generate / update one markdown file per exported cmdlet $commands = Get-Command -Module $ModuleName foreach ($cmd in $commands) { - New-MarkdownHelp -Command $cmd.Name -OutputFolder $DocsPath -Force | Out-Null + $docFile = "$DocsPath/$($cmd.Name).md" + if (Test-Path $docFile) { + Update-MarkdownHelp -Path $docFile -Force | Out-Null + } else { + New-MarkdownHelp -Command $cmd.Name -OutputFolder $DocsPath -Force | Out-Null + } } + Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue - Write-Build Green "Docs generated in $DocsPath" + Write-Build Green "Docs generated in $DocsPath ($($commands.Count) cmdlet(s))." } +# --------------------------------------------------------------------------- task Publish { assert $env:PSGALLERY_API_KEY 'Set PSGALLERY_API_KEY before publishing to PSGallery.' Publish-PSResource -Path $OutputPath -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY @@ -100,7 +161,7 @@ task PublishGitHub { } task PublishADO { - assert $env:ADO_PAT 'Set ADO_PAT (Personal Access Token) before publishing to ADO.' + assert $env:ADO_PAT 'Set ADO_PAT before publishing to ADO Artifacts.' assert $env:ADO_ORG 'Set ADO_ORG (Azure DevOps organisation) before publishing.' assert $env:ADO_FEED 'Set ADO_FEED (Artifacts feed name) before publishing.' $uri = "https://pkgs.dev.azure.com/$env:ADO_ORG/_packaging/$env:ADO_FEED/nuget/v3/index.json" @@ -110,4 +171,5 @@ task PublishADO { Write-Build Green 'Published to Azure DevOps Artifacts.' } +# Default task task . Lint, Test, Build, Docs diff --git a/CLAUDE.md b/CLAUDE.md index 937f693..0aaa245 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,104 +1,114 @@ # CLAUDE.md — AI Context for PowerShell-Project-Base -This file gives Claude Code (and other AI tools) the context needed to work -effectively in this repository. +This file gives Claude Code the context needed to work effectively in this repository. ## Project Overview -A base template for PowerShell modules. Clone this repo and replace `src/MyModule/` -with your own module code. +A production-ready base template for PowerShell modules. Clone and replace +`src/MyModule/` with your own code. The dev container provides a fully +working environment with no local PowerShell setup required. -The template ships with: +Toolchain: -- **PowerShell 7.2+** minimum requirement; dev container uses PowerShell 7.4 -- **PSScriptAnalyzer** — linting and style enforcement (replaces ad-hoc review); runs on save -- **Pester 5** — test runner with JaCoCo coverage and JUnit XML output -- **platyPS** — markdown documentation from comment-based help -- **Invoke-Build** — task orchestration (lint, test, build, docs, publish) -- **GitHub Actions** — CI (lint, test, security, build) + publish pipeline on version tag -- **Dev Container** — VS Code dev container for zero-install development +| Concern | Tool | +|---------|------| +| Lint & style | PSScriptAnalyzer (all severities) | +| Tests | Pester 5 | +| Coverage | JaCoCo XML (Pester built-in) | +| Security SAST | PSScriptAnalyzer security rules | +| Build / publish | Invoke-Build | +| Docs | platyPS (from comment-based help) | +| Container | mcr.microsoft.com/devcontainers/powershell:7.4 | ## Repository Layout ``` . ├── src/MyModule/ -│ ├── MyModule.psd1 # Module manifest (version, GUID, exports) -│ ├── MyModule.psm1 # Root loader: dot-sources Public/ and Private/ -│ ├── Public/ # Exported functions — one file per cmdlet -│ └── Private/ # Internal helpers — not exported +│ ├── MyModule.psd1 manifest (GUID, version, FunctionsToExport='*' in src) +│ ├── MyModule.psm1 dev loader: dot-sources Public/ + Private/ +│ ├── Public/ exported cmdlets, one file per function +│ └── Private/ internal helpers, not exported ├── tests/ -│ ├── Unit/Public/ # Public function tests (imports full module) -│ ├── Unit/Private/ # Private function tests (dot-source directly) +│ ├── Unit/Public/ tests that import the full module +│ ├── Unit/Private/ tests that dot-source the helper directly │ └── Integration/ -├── docs/ # platyPS markdown (auto-generated — do not hand-edit) +├── docs/ +│ ├── about_MyModule.md hand-maintained module overview +│ └── *.md auto-generated per-cmdlet pages (platyPS) +├── .build/ git-ignored build output ├── .devcontainer/devcontainer.json ├── .github/workflows/ -│ ├── ci.yml # Lint, test, security, build on every PR -│ └── publish.yml # Publish to registries on v*.*.* tag -├── .vscode/ # Editor settings, extensions, launch configs -├── .claude/commands/ # Claude Code helper commands -├── Build.ps1 # Invoke-Build task file +│ ├── ci.yml lint + test (3 OS) + security + build +│ └── publish.yml triggered by v*.*.* tag +├── Build.ps1 Invoke-Build task file └── PSScriptAnalyzerSettings.psd1 ``` ## Common Commands -All commands run inside the dev container terminal or locally with tools installed. - ```powershell -# Install all tools (once per environment) +# Install tools (once per environment) Set-PSRepository PSGallery -InstallationPolicy Trusted Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force -# Run all quality gates (default task: Lint -> Test -> Build -> Docs) +# Default task: Lint -> Test -> Build -> Docs Invoke-Build # Individual tasks -Invoke-Build Lint # PSScriptAnalyzer against src/ -Invoke-Build Test # Pester 5 + JaCoCo coverage -> .build/coverage.xml -Invoke-Build Build # Stage module files to .build/MyModule/ -Invoke-Build Docs # platyPS markdown -> docs/ -Invoke-Build Clean # Remove .build/ - -# Publish (require env vars — see README.md) -Invoke-Build Publish # -> PowerShell Gallery (PSGALLERY_API_KEY) -Invoke-Build PublishGitHub # -> GitHub Packages (GITHUB_TOKEN, GITHUB_OWNER) -Invoke-Build PublishADO # -> ADO Artifacts (ADO_PAT, ADO_ORG, ADO_FEED) +Invoke-Build Lint # PSScriptAnalyzer against src/ +Invoke-Build Test # Pester 5 -> .build/coverage.xml + testresults.xml +Invoke-Build Manifest # auto-update FunctionsToExport in src manifest +Invoke-Build Build # monolithic .psm1 -> .build/MyModule/ +Invoke-Build Docs # platyPS markdown -> docs/ +Invoke-Build Clean # remove .build/ + +# Publish (set env vars first — see README.md) +Invoke-Build Publish # -> PowerShell Gallery +Invoke-Build PublishGitHub # -> GitHub Packages (NuGet v3) +Invoke-Build PublishADO # -> ADO Artifacts (NuGet v3) ``` +## How the Build Works + +- **Source (`src/`)**: `MyModule.psm1` is a dev loader that dot-sources all + `.ps1` files at runtime. `FunctionsToExport = '*'` in the manifest exports + everything — fine for dev/test. +- **Build output (`.build/MyModule/`)**: a monolithic `.psm1` produced by + concatenating Private/ then Public/ files, with an explicit + `Export-ModuleMember` call. The manifest gets an exact `FunctionsToExport` + list auto-discovered from `Public/`. This is what gets published. +- **`Manifest` task** validates the GUID is not the template placeholder and + updates `FunctionsToExport` in the source manifest. + ## Architecture Notes -- **One file per function** in `Public/` and `Private/`. Root `.psm1` dot-sources all of them. -- **`FunctionsToExport`** in `MyModule.psd1` must match the names of files in `Public/`. -- **Pester 5 `New-PesterConfiguration` style only** — no legacy hashtable syntax. - `UseBreakpoints = $false` enables profiler-based coverage (significantly faster on large suites). -- **PSScriptAnalyzer runs all severity levels** configured in `PSScriptAnalyzerSettings.psd1`. - CI lint job fails on any finding. -- **No CodeQL** — GitHub CodeQL does not support PowerShell. PSScriptAnalyzer security rules - cover SAST in the `security` CI job. -- **`Publish-PSResource`** (Microsoft.PowerShell.PSResourceGet) is used for all publish targets. - This is the modern replacement for `Publish-Module` (PowerShellGet v2). - GitHub Packages and ADO Artifacts both use NuGet v3 feeds. -- **`docs/` is auto-generated** by platyPS — do not hand-edit the markdown there. - Edit comment-based help in the `.ps1` source files and re-run `Invoke-Build Docs`. +- **One file per function**. Do not group multiple functions in one file. +- **`FunctionsToExport` is auto-managed**. Never edit it by hand. +- **Pester 5 `New-PesterConfiguration`** only. `UseBreakpoints = $false` for + faster profiler-based coverage. +- **PSScriptAnalyzer all rules**. `PSScriptAnalyzerSettings.psd1` configures + formatting rules. CI fails on any finding. +- **No CodeQL** — not supported for PowerShell. PSScriptAnalyzer security + rules run in the dedicated `security` CI job. +- **`docs/`**: `about_MyModule.md` is hand-maintained. Per-cmdlet `.md` files + are platyPS-generated — do not edit them directly. ## Testing Conventions -- Test files mirror `src/` structure: `tests/Unit/Public/Get-Example.Tests.ps1` -- Import the module in `BeforeAll`; remove it in `AfterAll` -- Test private functions by dot-sourcing the `.ps1` file directly -- Group with `Context` blocks: "When given valid input", "When given invalid input" -- Aim for 100% branch coverage on all new public functions +- `tests/Unit/Public/` — import the full module; test exported behaviour only +- `tests/Unit/Private/` — dot-source the `.ps1` file; test internal logic +- `BeforeAll` / `AfterAll` for module lifecycle (not per-test) +- `Context` blocks: "When given valid input", "When given invalid input" +- 100% branch coverage on all new public functions ## When Making Changes -1. Add the function to `Public/` (exported) or `Private/` (internal) -2. Update `FunctionsToExport` in `MyModule.psd1` for public functions -3. Write tests in `tests/Unit/` -4. Run `Invoke-Build Lint` — fix all PSScriptAnalyzer findings -5. Run `Invoke-Build Test` — all tests must pass -6. Run `Invoke-Build Docs` — regenerate markdown help -7. Bump `ModuleVersion` in `MyModule.psd1` before tagging a release -8. Update `CHANGELOG.md` -9. Tag and push: `git tag v1.0.0 && git push origin v1.0.0` +1. Add function to `Public/` or `Private/` +2. Write tests in `tests/Unit/` +3. `Invoke-Build Lint` — fix all PSScriptAnalyzer findings +4. `Invoke-Build Test` — all tests must pass +5. `Invoke-Build Build, Docs` — verify build; regenerate docs +6. Bump `ModuleVersion` in `MyModule.psd1` +7. Update `CHANGELOG.md` +8. Tag the release: `git tag v1.x.x && git push origin v1.x.x` diff --git a/README.md b/README.md index 96fa450..db64048 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,138 @@ # PowerShell Module Base -A production-ready PowerShell module template. Clone this repo and replace `src/MyModule/` -with your own code. - -The template ships with: - -- **PowerShell 7.2+** minimum requirement -- **PSScriptAnalyzer** — linting and style enforcement on save and in CI -- **Pester 5** — test runner with JaCoCo coverage and JUnit XML output -- **platyPS** — markdown documentation generated from comment-based help -- **Invoke-Build** — task orchestration (lint, test, build, docs, publish) -- **GitHub Actions** — CI on every PR + publish pipeline on version tag -- **Dev Container** — VS Code dev container for zero-install development +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 | ## Quick Start ### Dev Container (Recommended) -Open in VS Code and select **"Reopen in Container"**. All tools install automatically via -`postCreateCommand`. +Open in VS Code and select **"Reopen in Container"**. All tools install automatically. + +```powershell +Invoke-Build # lint -> test -> build -> docs +``` ### Local ```powershell -# Install build tools once Set-PSRepository PSGallery -InstallationPolicy Trusted Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force - -# Run all quality gates (lint -> test -> build -> docs) Invoke-Build ``` +## Rename the Template + +```powershell +# 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 +``` + ## Repository Layout ``` . -├── src/MyModule/ # Module source (replace MyModule with your name) -│ ├── MyModule.psd1 # Module manifest (version, GUID, exports) -│ ├── MyModule.psm1 # Root loader: dot-sources Public/ and Private/ -│ ├── Public/ # Exported functions — one file per cmdlet -│ │ └── Get-Example.ps1 -│ └── Private/ # Internal helpers — not exported -│ └── Invoke-ExampleHelper.ps1 +├── 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/ # Tests mirroring Public/ (import module) -│ │ └── Private/ # Tests for Private/ (dot-source directly) -│ └── Integration/ -├── docs/ # platyPS-generated markdown help (auto-generated) -├── .devcontainer/devcontainer.json +│ ├── 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 to registries on version tag -├── .vscode/ # Editor settings, extensions, launch configs -├── .claude/commands/ # Claude Code helper commands -├── Build.ps1 # Invoke-Build task file -├── PSScriptAnalyzerSettings.psd1 -├── .editorconfig -└── CHANGELOG.md +│ ├── ci.yml lint + test + security + build on every PR +│ └── publish.yml publish on v*.*.* tag push +├── .vscode/ +├── .claude/commands/ +├── Build.ps1 +└── PSScriptAnalyzerSettings.psd1 ``` ## Common Commands ```powershell -# Run all quality gates (default task) -Invoke-Build - -# Individual tasks -Invoke-Build Lint # PSScriptAnalyzer against src/ -Invoke-Build Test # Pester 5 with JaCoCo coverage -> .build/coverage.xml -Invoke-Build Build # Stage module to .build/MyModule/ -Invoke-Build Docs # platyPS markdown -> docs/ -Invoke-Build Clean # Remove .build/ +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/ ``` -## Renaming the Module +## How the Build Works -```powershell -# 1. Rename the source directory and module files -Rename-Item src/MyModule src/YourModule -Rename-Item src/YourModule/MyModule.psd1 YourModule.psd1 -Rename-Item src/YourModule/MyModule.psm1 YourModule.psm1 +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/`: + +1. Concatenates all `Private/*.ps1` files +2. Concatenates all `Public/*.ps1` files +3. Appends `Export-ModuleMember` with an explicit function list +4. Copies the manifest and writes the exact `FunctionsToExport` list + +The built module is what gets published — never the `src/` directory. -# 2. Edit the manifest: update RootModule, GUID (New-Guid), Author, Description -# 3. Edit Build.ps1: change $ModuleName default value to 'YourModule' -# 4. Edit tests: fix the Import-Module path in BeforeAll blocks +`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 + +``` +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. + ## Publishing -All publish tasks require the module to be built first (`Invoke-Build Build`). -The `publish.yml` workflow triggers automatically on any `v*.*.*` tag push. +All three publish targets are wired in `Build.ps1` and triggered by +`publish.yml` when you push a version tag. ---- +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` ### PowerShell Gallery (Public) -Get your API key from [powershellgallery.com/account/apikeys](https://www.powershellgallery.com/account/apikeys). +**Feed URL:** (default, no registration needed) ```powershell -$env:PSGALLERY_API_KEY = 'your-api-key' -Invoke-Build Build -Invoke-Build Publish +$env:PSGALLERY_API_KEY = 'your-key' # from powershellgallery.com/account/apikeys +Invoke-Build Build, Publish ``` Required GitHub secret: `PSGALLERY_API_KEY` @@ -110,38 +141,33 @@ Required GitHub secret: `PSGALLERY_API_KEY` ### GitHub Packages (Public or Private) -NuGet v3 feed — works for public and private repositories. - **Feed URL:** `https://nuget.pkg.github.com/OWNER/index.json` ```powershell $env:GITHUB_TOKEN = 'ghp_...' $env:GITHUB_OWNER = 'samhodgkinson' -Invoke-Build Build -Invoke-Build PublishGitHub +Invoke-Build Build, PublishGitHub ``` -**Installing from GitHub Packages:** +**Install from GitHub Packages:** ```powershell $cred = [pscredential]::new( - 'YOUR_GITHUB_USERNAME', + 'YOUR_USERNAME', (ConvertTo-SecureString 'ghp_...' -AsPlainText -Force) ) -Register-PSResourceRepository -Name 'GitHubPackages' ` +Register-PSResourceRepository -Name GitHubPackages ` -Uri 'https://nuget.pkg.github.com/OWNER/index.json' ` -Trusted -Credential $cred Install-PSResource -Name MyModule -Repository GitHubPackages -Credential $cred ``` -Required GitHub secret: `GITHUB_TOKEN` is automatically provided in Actions — no manual secret needed for same-owner repositories. +Required GitHub secret: `GITHUB_TOKEN` — auto-provided in Actions, no manual secret needed. --- ### Azure DevOps Artifacts (Private) -Create a feed in your ADO organisation first, then use the NuGet v3 endpoint. - **Organisation-scoped feed URL:** ``` https://pkgs.dev.azure.com/ORG/_packaging/FEED/nuget/v3/index.json @@ -153,21 +179,20 @@ https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/nuget/v3/index.json ``` ```powershell -$env:ADO_PAT = 'your-pat-token' -$env:ADO_ORG = 'your-ado-org' -$env:ADO_FEED = 'your-feed-name' -Invoke-Build Build -Invoke-Build PublishADO +$env:ADO_PAT = 'your-pat' +$env:ADO_ORG = 'your-org' +$env:ADO_FEED = 'your-feed' +Invoke-Build Build, PublishADO ``` -**Installing from ADO Artifacts:** +**Install from ADO Artifacts:** ```powershell $cred = [pscredential]::new( 'PAT', - (ConvertTo-SecureString 'your-pat-token' -AsPlainText -Force) + (ConvertTo-SecureString 'your-pat' -AsPlainText -Force) ) -Register-PSResourceRepository -Name 'ADOFeed' ` +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 $cred @@ -176,41 +201,36 @@ Install-PSResource -Name MyModule -Repository ADOFeed -Credential $cred Required GitHub secrets: `ADO_PAT` Required GitHub variables: `ADO_ORG`, `ADO_FEED` -The `publish-ado` job in `publish.yml` is skipped when `ADO_ORG` is not set, so ADO publishing -is opt-in. - ---- +The `publish-ado` job is skipped when `ADO_ORG` is not configured — ADO is opt-in. ## Architecture Notes -- **One file per function** in `Public/` and `Private/`. The root `.psm1` dot-sources all of them. - Keeps diffs clean and code review focused. -- **`FunctionsToExport`** in the manifest must be kept in sync with filenames in `Public/`. -- **Pester 5 `New-PesterConfiguration`** only — no legacy hashtable syntax. - `UseBreakpoints = $false` enables the profiler-based coverage runner (much faster on large suites). -- **PSScriptAnalyzer runs all severity levels** via `PSScriptAnalyzerSettings.psd1`; CI fails on - any finding. Security-specific rules run as a separate CI job. -- **No CodeQL for PowerShell** — GitHub CodeQL does not support PowerShell. PSScriptAnalyzer - security rules cover SAST in CI. -- **`Publish-PSResource`** (PSResourceGet) is used for all publish targets, replacing the legacy - `Publish-Module`. GitHub Packages and ADO Artifacts both accept NuGet v3 feeds. +- **One file per function** in `Public/` and `Private/`. Keeps diffs focused and code review easy. +- **`FunctionsToExport = '*'` in src** — works for development. Build writes the explicit list. +- **Pester 5 `New-PesterConfiguration` only** — no legacy hashtable syntax. + `UseBreakpoints = $false` uses the profiler-based coverage runner (faster). +- **PSScriptAnalyzer runs all rules** — `PSScriptAnalyzerSettings.psd1` configures style. + CI fails on any finding. +- **No CodeQL** — CodeQL does not support PowerShell. PSScriptAnalyzer security rules + run as a dedicated `security` CI job. +- **`docs/` split**: `about_MyModule.md` is hand-maintained; cmdlet `.md` files are + auto-generated by platyPS and should not be edited directly. ## Testing Conventions -- Test files mirror source structure: `tests/Unit/Public/Get-Example.Tests.ps1` -- Import the module in `BeforeAll`; remove it in `AfterAll` -- Test private functions by dot-sourcing the file directly, not through the module -- Group assertions with `Context` blocks: "When given valid input", "When given invalid input" -- Aim for 100% branch coverage on all public functions +- `tests/Unit/Public/FunctionName.Tests.ps1` — imports the full module +- `tests/Unit/Private/HelperName.Tests.ps1` — dot-sources the file directly +- `BeforeAll` / `AfterAll` for module import (not `BeforeEach`) +- `Context` blocks: "When given valid input", "When given invalid input" +- 100% branch coverage target on all public functions ## When Making Changes -1. Add the function to `Public/` or `Private/` -2. Add exported function names to `FunctionsToExport` in the manifest -3. Write tests in `tests/Unit/` -4. Run `Invoke-Build Lint` — fix all PSScriptAnalyzer findings -5. Run `Invoke-Build Test` — all tests must pass -6. Run `Invoke-Build Docs` — regenerate markdown help -7. Bump `ModuleVersion` in the manifest before tagging a release -8. Update `CHANGELOG.md` -9. Tag the release: `git tag v1.0.0 && git push origin v1.0.0` +1. Add function to `Public/` or `Private/` +2. Write tests in `tests/Unit/` +3. `Invoke-Build Lint` — fix all findings +4. `Invoke-Build Test` — all tests must pass +5. `Invoke-Build Build, Docs` — verify build and regenerate docs +6. Bump `ModuleVersion` in `MyModule.psd1` +7. Update `CHANGELOG.md` +8. Tag: `git tag v1.x.x && git push origin v1.x.x` diff --git a/docs/about_MyModule.md b/docs/about_MyModule.md new file mode 100644 index 0000000..204e744 --- /dev/null +++ b/docs/about_MyModule.md @@ -0,0 +1,55 @@ +--- +Module Name: MyModule +Module Guid: 49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a +Help Version: 0.1.0 +Locale: en-US +--- + +# about_MyModule + +## SHORT DESCRIPTION + +A PowerShell module template — replace this description with your own. + +## LONG DESCRIPTION + +This module is scaffolded from **PowerShell-Project-Base**, a production-ready +module template. Replace the content under `src/MyModule/` with your own +cmdlets. + +The template enforces a consistent layout: + +- **Public/** — one `.ps1` file per exported cmdlet. The build auto-discovers + these and writes an explicit `FunctionsToExport` list into the distributed + manifest. +- **Private/** — internal helpers that are not exported. Test them by + dot-sourcing the file directly. +- **Monolithic build** — `Invoke-Build Build` concatenates all source files + into a single `.psm1` in `.build/MyModule/` for distribution. + +## GETTING STARTED + +Install the module from the source: + +```powershell +Import-Module ./src/MyModule/MyModule.psd1 -Force +Get-Example -Name 'World' +``` + +Or install from the PowerShell Gallery once published: + +```powershell +Install-PSResource -Name MyModule +``` + +## CMDLETS + +| Cmdlet | Synopsis | +|--------|----------| +| [Get-Example](Get-Example.md) | Returns a greeting message for the specified name. | + +## KEYWORDS + +- Template +- Module +- Scaffold diff --git a/src/MyModule/MyModule.psd1 b/src/MyModule/MyModule.psd1 index b195054..0bab4c4 100644 --- a/src/MyModule/MyModule.psd1 +++ b/src/MyModule/MyModule.psd1 @@ -1,14 +1,20 @@ @{ RootModule = 'MyModule.psm1' ModuleVersion = '0.1.0' + + # TODO: Replace with your own GUID — run (New-Guid).Guid in PowerShell GUID = '49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a' + Author = 'Your Name' CompanyName = 'Your Company' Copyright = '(c) 2026. All rights reserved.' Description = 'A PowerShell module — replace this description.' PowerShellVersion = '7.2' - FunctionsToExport = @('Get-Example') + # '*' exports everything in development; Invoke-Build Manifest/Build + # auto-discovers Public/ functions and writes an explicit list to the + # built manifest under .build/MyModule/. + FunctionsToExport = @('*') CmdletsToExport = @() VariablesToExport = @() AliasesToExport = @() diff --git a/src/MyModule/Public/Get-Example.ps1 b/src/MyModule/Public/Get-Example.ps1 index cbb2365..4a2b1e5 100644 --- a/src/MyModule/Public/Get-Example.ps1 +++ b/src/MyModule/Public/Get-Example.ps1 @@ -1,22 +1,60 @@ function Get-Example { <# .SYNOPSIS - Returns a greeting message. + Returns a greeting message for the specified name. + .DESCRIPTION - Returns a greeting string for the given name. This function demonstrates - the standard layout used in this module template: CmdletBinding, OutputType, - Mandatory parameter, pipeline support, and comment-based help. + Get-Example is the template public function for this module. + It demonstrates the standard function layout used throughout: + + - [CmdletBinding()] for common parameters (-Verbose, -WhatIf, etc.) + - [OutputType()] so callers and platyPS know what to expect + - A Mandatory, pipeline-accepting parameter with validation + - process {} block for pipeline support + - Comment-based help covering all sections + + Replace this function with your own cmdlets. Keep one function per + file in Public/; the build will auto-discover and export them. + .PARAMETER Name - The name to greet. Accepts pipeline input. + The name to include in the greeting. Must not be null or empty. + Accepts value from the pipeline and from a pipeline object's Name + property. + .EXAMPLE Get-Example -Name 'World' + Hello, World! + + Passes a single name directly. + .EXAMPLE 'Alice', 'Bob' | Get-Example + Hello, Alice! Hello, Bob! + + Pipes multiple names; each produces one output string. + + .EXAMPLE + [pscustomobject]@{ Name = 'Carol' } | Get-Example + + Hello, Carol! + + Pipes an object whose Name property matches the parameter name. + + .INPUTS + System.String + You can pipe a string to the Name parameter. + .OUTPUTS System.String + A greeting string of the form "Hello, !". + + .NOTES + Author : Your Name + Version: 0.1.0 + .LINK https://github.com/samhodgkinson/powershell-project-base #> From 2830d80a929a9059a21a153e420668576457aaea Mon Sep 17 00:00:00 2001 From: SamHodgkinson <9842003+samhodgkinson@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:51:44 +0100 Subject: [PATCH 3/3] fix: address all expert review findings Bugs fixed: - B1: ci.yml test job had -MinimumVersion @{Pester='5.0'} (invalid syntax); split into separate Install-Module calls - B2: Manifest task no longer called from Build; Build never touches source files. Manifest task uses [regex]::Replace to preserve comments (no Update-ModuleManifest on source). Build uses inline regex to update only the .build/ manifest copy - B3: PowerShellGet no longer needed in CI; manifest updates use regex throughout - B4: Added .TrimEnd() to monolithic PSM1 concatenation to prevent double-newlines from CRLF source files Design improvements: - D1: publish.yml now extracts version from tag (v1.2.3 -> 1.2.3) and patches ModuleVersion in the built manifest before uploading artifact - D2: Pester coverage threshold added (80% CoveragePercentTarget) - D3: Test-ModuleManifest called after Build to validate manifest before publish - D4: Docs task now calls New-ExternalHelp to compile MAML into en-US/; Get-Help about_MyModule now works for installed module - D5: Get-Example gains begin{} and end{} blocks with usage guidance - D6: Added test for ValueFromPipelineByPropertyName - D7: PSAlignAssignmentStatement moved to ExcludeRules (too noisy in practice) - D8: Publish artifact name includes tag version - D9: CompatiblePSEditions = @('Core') added to manifest (PS 7 = Core only) Minor: - devcontainer postCreateCommand uses array form (avoids shell quoting issues) - Added SECURITY.md placeholder --- .devcontainer/devcontainer.json | 5 +- .github/workflows/ci.yml | 9 ++- .github/workflows/publish.yml | 24 ++++-- Build.ps1 | 103 ++++++++++++++---------- PSScriptAnalyzerSettings.psd1 | 10 +-- SECURITY.md | 18 +++++ src/MyModule/MyModule.psd1 | 31 +++---- src/MyModule/Public/Get-Example.ps1 | 17 +++- tests/Unit/Public/Get-Example.Tests.ps1 | 4 + 9 files changed, 143 insertions(+), 78 deletions(-) create mode 100644 SECURITY.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 00d5c51..9088e1c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,10 @@ { "name": "PowerShell Module", "image": "mcr.microsoft.com/devcontainers/powershell:7.4", - "postCreateCommand": "pwsh -Command 'Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force'", + "postCreateCommand": [ + "pwsh", "-Command", + "Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force" + ], "customizations": { "vscode": { "extensions": [ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95cd82b..1804eea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,8 @@ jobs: shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module InvokeBuild, PSScriptAnalyzer -Scope CurrentUser -Force + Install-Module InvokeBuild -Scope CurrentUser -Force + Install-Module PSScriptAnalyzer -Scope CurrentUser -Force - name: Lint shell: pwsh @@ -40,7 +41,8 @@ jobs: shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module InvokeBuild, Pester -Scope CurrentUser -Force -MinimumVersion @{Pester='5.0'} + Install-Module InvokeBuild -Scope CurrentUser -Force + Install-Module Pester -Scope CurrentUser -Force -MinimumVersion '5.0' - name: Test shell: pwsh @@ -99,7 +101,8 @@ jobs: shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module InvokeBuild, PSScriptAnalyzer, platyPS -Scope CurrentUser -Force + Install-Module InvokeBuild -Scope CurrentUser -Force + Install-Module platyPS -Scope CurrentUser -Force - name: Build shell: pwsh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d99d33d..46cfa41 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,16 +20,27 @@ jobs: shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module InvokeBuild, PSScriptAnalyzer, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force + Install-Module InvokeBuild -Scope CurrentUser -Force + Install-Module platyPS -Scope CurrentUser -Force - name: Build shell: pwsh run: Invoke-Build Build + - name: Set ModuleVersion from tag + shell: pwsh + run: | + $version = '${{ github.ref_name }}' -replace '^v', '' + $manifestPath = '.build/MyModule/MyModule.psd1' + $content = Get-Content -Path $manifestPath -Raw + $content = [regex]::Replace($content, "ModuleVersion\s*=\s*'[^']*'", "ModuleVersion = '$version'") + Set-Content -Path $manifestPath -Value $content.TrimEnd() -Encoding utf8NoBOM + Write-Host "ModuleVersion set to $version" + - name: Upload module artifact uses: actions/upload-artifact@v4 with: - name: module-package + name: module-package-${{ github.ref_name }} path: .build/MyModule/ retention-days: 1 @@ -41,7 +52,7 @@ jobs: steps: - uses: actions/download-artifact@v4 with: - name: module-package + name: module-package-${{ github.ref_name }} path: .build/MyModule/ - name: Install PSResourceGet @@ -54,8 +65,7 @@ jobs: shell: pwsh env: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - run: | - Publish-PSResource -Path .build/MyModule -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY + run: Publish-PSResource -Path .build/MyModule -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY publish-github: name: Publish to GitHub Packages @@ -65,7 +75,7 @@ jobs: steps: - uses: actions/download-artifact@v4 with: - name: module-package + name: module-package-${{ github.ref_name }} path: .build/MyModule/ - name: Install PSResourceGet @@ -97,7 +107,7 @@ jobs: steps: - uses: actions/download-artifact@v4 with: - name: module-package + name: module-package-${{ github.ref_name }} path: .build/MyModule/ - name: Install PSResourceGet diff --git a/Build.ps1 b/Build.ps1 index 8e60e63..92c8a6f 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -9,23 +9,24 @@ Clean Remove .build/ output directory Lint Run PSScriptAnalyzer against src/ Test Run Pester 5 tests with JaCoCo + JUnit output - Manifest Auto-discover Public/ functions, update FunctionsToExport, - validate GUID is not the template placeholder - Build Create a monolithic .psm1 and stage to .build/MyModule/ - Docs Generate per-cmdlet markdown via platyPS; create about_ doc + Manifest Sync FunctionsToExport in source PSD1 from Public/ (dev tool; + preserves comments; not called automatically by Build) + Build Concatenate source into a monolithic .psm1, update the built + manifest, validate it, and stage to .build/MyModule/ + Docs Generate per-cmdlet markdown via platyPS and compile to MAML Publish Publish to PowerShell Gallery (PSGALLERY_API_KEY) PublishGitHub Publish to GitHub Packages (GITHUB_TOKEN, GITHUB_OWNER) PublishADO Publish to ADO Artifacts (ADO_PAT, ADO_ORG, ADO_FEED) #> param( - [string] $ModuleName = 'MyModule', - [string] $SrcPath = "$PSScriptRoot/src/$ModuleName", - [string] $BuildPath = "$PSScriptRoot/.build", - [string] $OutputPath = "$PSScriptRoot/.build/$ModuleName", - [string] $TestsPath = "$PSScriptRoot/tests", - [string] $DocsPath = "$PSScriptRoot/docs", - [string] $TemplateGuid = '49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a' + [string] $ModuleName = 'MyModule', + [string] $SrcPath = "$PSScriptRoot/src/$ModuleName", + [string] $BuildPath = "$PSScriptRoot/.build", + [string] $OutputPath = "$PSScriptRoot/.build/$ModuleName", + [string] $TestsPath = "$PSScriptRoot/tests", + [string] $DocsPath = "$PSScriptRoot/docs", + [string] $TemplateGuid = '49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a' ) # --------------------------------------------------------------------------- @@ -50,15 +51,16 @@ task Lint { task Test { $null = New-Item $BuildPath -ItemType Directory -Force $config = New-PesterConfiguration - $config.Run.Path = $TestsPath - $config.Run.Exit = $true - $config.Output.Verbosity = 'Detailed' + $config.Run.Path = $TestsPath + $config.Run.Exit = $true + $config.Output.Verbosity = 'Detailed' - $config.CodeCoverage.Enabled = $true - $config.CodeCoverage.Path = $SrcPath - $config.CodeCoverage.UseBreakpoints = $false # Profiler mode: faster on large suites - $config.CodeCoverage.OutputFormat = 'JaCoCo' - $config.CodeCoverage.OutputPath = "$BuildPath/coverage.xml" + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.Path = $SrcPath + $config.CodeCoverage.UseBreakpoints = $false # Profiler mode: faster on large suites + $config.CodeCoverage.OutputFormat = 'JaCoCo' + $config.CodeCoverage.OutputPath = "$BuildPath/coverage.xml" + $config.CodeCoverage.CoveragePercentTarget = 80 # Fail if coverage drops below 80% $config.TestResult.Enabled = $true $config.TestResult.OutputFormat = 'JUnitXml' @@ -68,54 +70,64 @@ task Test { } # --------------------------------------------------------------------------- +# Manifest: developer tool — syncs FunctionsToExport in the SOURCE manifest from +# Public/ contents. Uses regex replacement to preserve comments. Not called by +# Build (Build never touches source files). task Manifest { $manifestPath = "$SrcPath/$ModuleName.psd1" $data = Import-PowerShellDataFile -Path $manifestPath - # Warn if still using the template GUID so the author knows to replace it if ($data.GUID -eq $TemplateGuid) { Write-Build Yellow @" -WARNING: MyModule.psd1 still uses the template GUID. -Run the following and paste the result into the manifest: - - (New-Guid).Guid +WARNING: $ModuleName.psd1 still uses the template GUID. +Run (New-Guid).Guid in PowerShell and paste the result into the GUID field. "@ } - # Auto-discover exported functions from Public/ $functions = @( Get-ChildItem -Path "$SrcPath/Public/*.ps1" -ErrorAction SilentlyContinue ).BaseName | Sort-Object - Update-ModuleManifest -Path $manifestPath -FunctionsToExport $functions - Write-Build Green "Manifest updated: $($functions.Count) function(s) exported ($($functions -join ', '))." + $joined = ($functions | ForEach-Object { "'$_'" }) -join ', ' + $newLine = "FunctionsToExport = @($joined)" + $content = Get-Content -Path $manifestPath -Raw + $content = [regex]::Replace($content, '(?s)FunctionsToExport\s*=\s*@\(.*?\)', $newLine) + Set-Content -Path $manifestPath -Value $content.TrimEnd() -Encoding utf8NoBOM + + Write-Build Green "Source manifest updated: $($functions.Count) function(s) ($($functions -join ', '))." } # --------------------------------------------------------------------------- -task Build Clean, Manifest, { - $functions = @(Get-ChildItem "$SrcPath/Public/*.ps1" -ErrorAction SilentlyContinue).BaseName | Sort-Object - $sb = [System.Text.StringBuilder]::new() +task Build Clean, { + # Auto-discover public functions — never reads or writes the source manifest + $functions = @(Get-ChildItem "$SrcPath/Public/*.ps1" -ErrorAction SilentlyContinue).BaseName | Sort-Object - # Concatenate private helpers first, then public functions + # Concatenate Private/ then Public/ into a single .psm1 for distribution + $sb = [System.Text.StringBuilder]::new() foreach ($file in Get-ChildItem "$SrcPath/Private/*.ps1" -ErrorAction SilentlyContinue) { - $null = $sb.AppendLine((Get-Content -Path $file.FullName -Raw)) + $null = $sb.AppendLine((Get-Content -Path $file.FullName -Raw).TrimEnd()) + $null = $sb.AppendLine() } foreach ($file in Get-ChildItem "$SrcPath/Public/*.ps1" -ErrorAction SilentlyContinue) { - $null = $sb.AppendLine((Get-Content -Path $file.FullName -Raw)) + $null = $sb.AppendLine((Get-Content -Path $file.FullName -Raw).TrimEnd()) + $null = $sb.AppendLine() } - - # Explicit export list in monolithic module — safer than Export-ModuleMember -Function * - $exportList = ($functions | ForEach-Object { "'$_'" }) -join ', ' - $null = $sb.AppendLine("Export-ModuleMember -Function @($exportList)") - + $joined = ($functions | ForEach-Object { "'$_'" }) -join ', ' + $null = $sb.AppendLine("Export-ModuleMember -Function @($joined)") Set-Content -Path "$OutputPath/$ModuleName.psm1" -Value $sb.ToString() -Encoding utf8NoBOM - # Copy manifest and write explicit FunctionsToExport into the build copy + # Copy manifest to build output and patch FunctionsToExport (source untouched) Copy-Item -Path "$SrcPath/$ModuleName.psd1" -Destination $OutputPath - Update-ModuleManifest -Path "$OutputPath/$ModuleName.psd1" -FunctionsToExport $functions - - Write-Build Green "Build staged at $OutputPath ($($functions.Count) public function(s))." + $builtManifest = "$OutputPath/$ModuleName.psd1" + $manifestContent = Get-Content -Path $builtManifest -Raw + $newExports = "FunctionsToExport = @($joined)" + $manifestContent = [regex]::Replace($manifestContent, '(?s)FunctionsToExport\s*=\s*@\(.*?\)', $newExports) + Set-Content -Path $builtManifest -Value $manifestContent.TrimEnd() -Encoding utf8NoBOM + + # Validate the built manifest before anything tries to publish it + $validated = Test-ModuleManifest -Path $builtManifest + Write-Build Green "Build staged at $OutputPath — $($validated.Name) v$($validated.Version), $($functions.Count) function(s)." } # --------------------------------------------------------------------------- @@ -136,8 +148,13 @@ task Docs { } } + # Compile markdown to MAML XML so Get-Help works for the installed module + $helpPath = "$OutputPath/en-US" + $null = New-Item $helpPath -ItemType Directory -Force + New-ExternalHelp -Path $DocsPath -OutputPath $helpPath -Force | Out-Null + Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue - Write-Build Green "Docs generated in $DocsPath ($($commands.Count) cmdlet(s))." + Write-Build Green "Docs: $($commands.Count) cmdlet(s) -> $DocsPath (markdown) + $helpPath (MAML)." } # --------------------------------------------------------------------------- diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index 8914e1f..61fac52 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -1,6 +1,10 @@ @{ Severity = @('Error', 'Warning', 'Information') - ExcludeRules = @() + ExcludeRules = @( + # Enforcing column-alignment on ALL assignment statements produces excessive + # noise in practice. Hashtable alignment is handled by the editor formatter. + 'PSAlignAssignmentStatement' + ) Rules = @{ PSUseConsistentIndentation = @{ Enable = $true @@ -18,10 +22,6 @@ CheckSeparator = $true CheckParameter = $false } - PSAlignAssignmentStatement = @{ - Enable = $true - CheckHashtable = $true - } PSAvoidUsingCmdletAliases = @{ AllowList = @() } diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fb818ad --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 0.x | Yes | + +## Reporting a Vulnerability + +Please **do not** open a public GitHub issue for security vulnerabilities. + +Report vulnerabilities by emailing the maintainer directly or by using +[GitHub's private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) +for this repository. + +You should receive an acknowledgement within 48 hours and a resolution +timeline within 7 days. diff --git a/src/MyModule/MyModule.psd1 b/src/MyModule/MyModule.psd1 index 0bab4c4..911ca7a 100644 --- a/src/MyModule/MyModule.psd1 +++ b/src/MyModule/MyModule.psd1 @@ -1,23 +1,24 @@ @{ - RootModule = 'MyModule.psm1' - ModuleVersion = '0.1.0' + RootModule = 'MyModule.psm1' + ModuleVersion = '0.1.0' # TODO: Replace with your own GUID — run (New-Guid).Guid in PowerShell - GUID = '49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a' + GUID = '49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a' - Author = 'Your Name' - CompanyName = 'Your Company' - Copyright = '(c) 2026. All rights reserved.' - Description = 'A PowerShell module — replace this description.' - PowerShellVersion = '7.2' + Author = 'Your Name' + CompanyName = 'Your Company' + Copyright = '(c) 2026. All rights reserved.' + Description = 'A PowerShell module — replace this description.' + PowerShellVersion = '7.2' + CompatiblePSEditions = @('Core') # PS 7+ is Core edition only - # '*' exports everything in development; Invoke-Build Manifest/Build - # auto-discovers Public/ functions and writes an explicit list to the - # built manifest under .build/MyModule/. - FunctionsToExport = @('*') - CmdletsToExport = @() - VariablesToExport = @() - AliasesToExport = @() + # '*' exports everything in development; Invoke-Build Build auto-discovers + # Public/ functions and writes an explicit list to the built manifest under + # .build/MyModule/ without touching this file. + FunctionsToExport = @('*') + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() PrivateData = @{ PSData = @{ diff --git a/src/MyModule/Public/Get-Example.ps1 b/src/MyModule/Public/Get-Example.ps1 index 4a2b1e5..951d546 100644 --- a/src/MyModule/Public/Get-Example.ps1 +++ b/src/MyModule/Public/Get-Example.ps1 @@ -10,11 +10,11 @@ function Get-Example { - [CmdletBinding()] for common parameters (-Verbose, -WhatIf, etc.) - [OutputType()] so callers and platyPS know what to expect - A Mandatory, pipeline-accepting parameter with validation - - process {} block for pipeline support + - begin{} / process{} / end{} blocks for correct pipeline semantics - Comment-based help covering all sections Replace this function with your own cmdlets. Keep one function per - file in Public/; the build will auto-discover and export them. + file in Public/; the build auto-discovers and exports them. .PARAMETER Name The name to include in the greeting. Must not be null or empty. @@ -34,14 +34,14 @@ function Get-Example { Hello, Alice! Hello, Bob! - Pipes multiple names; each produces one output string. + Pipes multiple strings; each produces one output string. .EXAMPLE [pscustomobject]@{ Name = 'Carol' } | Get-Example Hello, Carol! - Pipes an object whose Name property matches the parameter name. + Pipes an object whose Name property binds via ValueFromPipelineByPropertyName. .INPUTS System.String @@ -66,7 +66,16 @@ function Get-Example { [string] $Name ) + begin { + # One-time setup: open connections, validate cross-parameter constraints, + # initialise accumulators for aggregation in end{}. + } + process { Invoke-ExampleHelper -Name $Name } + + end { + # Post-pipeline cleanup: close connections, emit aggregated results. + } } diff --git a/tests/Unit/Public/Get-Example.Tests.ps1 b/tests/Unit/Public/Get-Example.Tests.ps1 index 1e5b741..c7d7245 100644 --- a/tests/Unit/Public/Get-Example.Tests.ps1 +++ b/tests/Unit/Public/Get-Example.Tests.ps1 @@ -23,6 +23,10 @@ Describe 'Get-Example' { $result[0] | Should -Be 'Hello, Alice!' $result[1] | Should -Be 'Hello, Bob!' } + + It 'Accepts ValueFromPipelineByPropertyName via Name property' { + [pscustomobject]@{ Name = 'Carol' } | Get-Example | Should -Be 'Hello, Carol!' + } } Context 'When given invalid input' {