Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ jobs:
# Windows PowerShell 5.1 and PowerShell 7+ (Desktop and Core, Windows/Linux/macOS)
# Edit freely - nothing regenerates it. Keep it in sync with the target profiles in
# Tools/PSScriptAnalyzer.psd1 and CompatiblePSEditions in the module manifest.
#
# modulePath is the host's CurrentUser module folder, cached below so the slow
# first Install-Module (minutes under WinPS 5.1's inbox PowerShellGet) is paid
# only when Tools/install_dev_requirements.ps1 or module.psd1 changes.
include:
- { os: windows-latest, host: powershell, name: 'win / WinPS 5.1',
modulePath: '~\Documents\WindowsPowerShell\Modules' }
Expand All @@ -36,16 +32,12 @@ jobs:
steps:
- uses: actions/checkout@v7

# On a cache hit the install script finds the modules via Get-Module -ListAvailable
# and skips Install-Module entirely.
- name: Cache PowerShell modules
uses: actions/cache@v6
with:
path: ${{ matrix.modulePath }}
key: psmodules-${{ matrix.os }}-${{ matrix.host }}-${{ hashFiles('Tools/install_dev_requirements.ps1', 'module.psd1') }}

# matrix.host selects the runtime: 'powershell' = Windows PowerShell 5.1, 'pwsh' = PowerShell 7+.
# The shell key cannot use the matrix context, so run under pwsh and invoke the target host.
- name: Install dev requirements
shell: pwsh
run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task install_dev_requirements
Expand All @@ -58,6 +50,10 @@ jobs:
shell: pwsh
run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task build

- name: Test
- name: Test (source tree)
shell: pwsh
run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task test
run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task test -Target Source

- name: Test (built module, artifact checks)
shell: pwsh
run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task test -Target Dist -Path Tests/Module.Tests.ps1
2 changes: 0 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ jobs:
exit 1
fi

# This repository is a template until './tasks.ps1 prepare' has run. Publishing the
# unrenamed template to the Gallery would be a mistake, so refuse it outright.
- name: Refuse to release an unprepared template
if: startsWith(github.ref, 'refs/tags/')
shell: pwsh
Expand Down
33 changes: 33 additions & 0 deletions Docs/CLASSES_AND_ENUMS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Classes and enums

`Source/Enum/` and `Source/Classes/` hold one `.ps1` file per type. PowerShell classes follow
rules that plain functions do not, and each one costs an afternoon when you hit it blind.
This file documents the template, so `./tasks.ps1 prepare` deletes it; a short version
survives in the generated README.

## Rules

**Files must be `.ps1`.** ModuleBuilder and the development loader both ignore `.psm1` files
in the source directories. `using module` pointed at a `.ps1` file always fails, with an error
that misleadingly names a type inside the target file. Together that means one class file can
never `using module` another; load order is what makes cross-file references work.

**Load order is alphabetical by full path, on both trees.** Nothing sorts by dependency. A base
class must sort before its derived class or the import dies with `Unable to find type [Base]`,
even inside the concatenated `.psm1`: forward references to a base class do not resolve. Use
numeric prefixes (`01-Message.ps1`) or numeric subdirectories (`Classes/00_Base/` sorts before
`Classes/Alert.ps1`). One edge case: `Message.Alert.ps1` sorts before `Message.ps1`, because
`.` sorts before letters. Enums already load before classes via the `SourceDirectories` order
in `build.psd1`.

**Callers do not see your types.** `Import-Module` never exposes classes or enums; that is a
PowerShell rule. `using module` on the built manifest does, but never on the source manifest,
because dot-sourced classes stay invisible to it. So behaviour tests that go through public
functions run against either tree, while a test that writes `[Alert]::new(...)` belongs in
`Tests/Module.Tests.ps1`, the artifact suite, with `using module` on `Get-BuiltManifestPath`.
If callers should get your types from plain `Import-Module`, see
[Export classes with type accelerators](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_classes#export-classes-with-type-accelerators); it works from both trees but adds a session-global registration you
maintain by hand, so the template leaves it out.

**`using namespace` in source files is fine.** ModuleBuilder hoists `using` statements to the
top of the built `.psm1`, and a dot-sourced file applies its own, so it works in both trees.
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ lint with PSScriptAnalyzer, and ship to the PowerShell Gallery from a git tag.
| | |
|---|---|
| 🏗️ **ModuleBuilder** | Builds your module from source into a clean, versioned `Dist` output |
| 🧪 **Pester 5** | Test runner wired to `Tests/`, running against the **built** module |
| 🧪 **Pester 5** | Test runner wired to `Tests/`: behaviour tests run against the **source** tree (failures name a source file and line), artifact checks against the **built** module |
| 🔍 **PSScriptAnalyzer** | Style and correctness pass, plus a compatibility pass against your target hosts |
| 📊 **Code coverage** | Per-command coverage report with an optional minimum-percentage gate |
| 📊 **Code coverage** | Per-file coverage report over the source tree with an optional minimum-percentage gate |
| 🎯 **Task runner** | One entry point (`tasks.ps1`) for every tool |
| 🤖 **GitHub Actions** | CI matrix across your target hosts, plus a tag-driven Gallery release |
| 🧩 **Platform presets** | `PowerShell5.1`, `PowerShell7`, or both. One key sets the manifest, the lint targets, and the CI matrix |
| 🪄 **`prepare` task** | Renames and stamps the whole template from a single `module.psd1` |
| 🔐 **Hardening guide** | The GitHub rulesets and settings that make publishing to the Gallery safe |
| 📁 **Structured source** | `Source/` layout with `Enum`, `Classes`, `Private`, and `Public` |
| 📁 **Structured source** | `Source/` layout with `Enum`, `Classes`, `Private`, and `Public`. Classes and enums have rules of their own: see [Docs/CLASSES_AND_ENUMS.md](Docs/CLASSES_AND_ENUMS.md) |

---

Expand Down Expand Up @@ -149,13 +149,15 @@ assembled into the built module.
│ ├── Private/
│ └── Public/
├── 🧪 Tests/
│ ├── _TestHelpers.ps1 # Imports the BUILT module; no module name hardcoded
│ └── Module.Tests.ps1 # Example suite, green on a fresh clone
│ ├── _TestHelpers.ps1 # Target selection and import helpers; no module name hardcoded
│ ├── Harness.Tests.ps1 # Tests for the test harness itself
│ └── Module.Tests.ps1 # Artifact checks against the BUILT module, green on a fresh clone
├── 🔧 Tools/
│ ├── platforms/ # Target-platform presets (removed by prepare)
│ ├── templates/ # Skeletons rendered by prepare (removed by prepare)
│ └── ... # See Tools/README.md
├── 🔐 Docs/HARDENING.md # GitHub settings to set before publishing (survives prepare)
├── 📚 Docs/CLASSES_AND_ENUMS.md # Class/enum rules and limits (removed by prepare)
├── 🤖 .github/workflows/ # ci.yml (matrix from the platform) and release.yml (tag -> PSGallery)
└── 📦 Dist/ # Build output (gitignored, created by build)
```
Expand All @@ -174,9 +176,9 @@ assembled into the built module.
| 🧹 | **cleanup** | One-time teardown. Deletes `prepare.ps1` and itself and strips both tasks out of `tasks.ps1`. Run once the repo is prepared and hardened. |
| 📥 | **install_dev_requirements** | Installs ModuleBuilder, Configuration, Pester 5+, PSScriptAnalyzer, plus your extras. Once per host **per PowerShell edition**. |
| 🏗️ | **build** | Clears `Dist/`, builds with ModuleBuilder into `Dist/<ModuleName>/<ModuleVersion>`. |
| 🧪 | **test** | Runs the Pester suite against the built module. Fails on an empty run. |
| 🧪 | **test** | Builds, then runs the Pester suite. `-Target Source` (default) or `Dist` picks the tree the behaviour tests import; `-Path <file-or-dir>` picks which tests execute. Fails on an empty run. |
| 🔍 | **lint** | PSScriptAnalyzer over `Source/`: style and correctness, then compatibility against your target platform. |
| 📊 | **coverage** | Coverage report over the built module. `-MinimumPercent 90` to gate. |
| 📊 | **coverage** | Per-file coverage report over the source tree. `-MinimumPercent 90` to gate. |
| 🚢 | **prepare_release** | `./tasks.ps1 prepare_release 1.1.0`. Gates, promotes the changelog, stamps the version, rebuilds, verifies. |

📖 Full tool reference: **[Tools/README.md](Tools/README.md)**
Expand Down
4 changes: 3 additions & 1 deletion Source/ModuleTemplate.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
foreach ($dir in 'Enum', 'Classes', 'Private', 'Public') {
$path = Join-Path $PSScriptRoot $dir
if (Test-Path -LiteralPath $path) {
foreach ($file in Get-ChildItem -Path $path -Filter '*.ps1') {
# -Recurse and Sort-Object FullName mirror how ModuleBuilder walks and concatenates the
# tree, so source and built module load files (and thus classes) in the same order.
foreach ($file in Get-ChildItem -Path $path -Filter '*.ps1' -Recurse | Sort-Object FullName) {
. $file.FullName
}
}
Expand Down
14 changes: 14 additions & 0 deletions Tests/Harness.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Tests for the test harness itself, not for the module.

Describe 'Test runner' {
# Regression test: tests.ps1 must lower $ErrorActionPreference to 'Continue' around
# Invoke-Pester, or Write-Error below throws instead of writing to the stream.
It 'lets a command write a non-terminating error to the stream' {
function Invoke-ErrorWriter { [CmdletBinding()] param() Write-Error 'expected'; 'result' }
$out = Invoke-ErrorWriter 2>&1
$errors = @($out | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] })
$errors.Count | Should -Be 1
$errors[0].ToString() | Should -Match 'expected'
$out | Where-Object { $_ -eq 'result' } | Should -Not -BeNullOrEmpty
}
}
80 changes: 77 additions & 3 deletions Tests/Module.Tests.ps1
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
# Example suite. It only asserts things that hold for an empty module, so it is green on a
# fresh clone and 'tests.ps1' has something to run. Keep it, extend it, or replace it as you
# add functions under Source/Public - one <Area>.Tests.ps1 per area is the usual shape.
# The artifact tests, the only file that reads the build output. Green on a fresh clone;
# behaviour tests go in their own <Area>.Tests.ps1 and import via Import-ModuleUnderTest.

# Top level too, not just BeforeAll: -Skip: expressions run at discovery time.
. $PSScriptRoot/_TestHelpers.ps1

# A fresh clone defines no functions; the function-level checks skip then.
$SourceFunctionFiles = @(Get-ChildItem -Path (Get-ModuleInfo).SourceRoot -Recurse -Filter '*.ps1' -File |
Where-Object { $_.Name -ne "$((Get-ModuleInfo).ModuleName).psm1" })
$SourceDefinesFunctions = [bool]($SourceFunctionFiles | Where-Object {
$ast = [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref] $null, [ref] $null)
$ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)
})

BeforeAll {
. $PSScriptRoot/_TestHelpers.ps1
Import-BuiltModule
}

Describe 'Built module' {
It 'was built from the source tree as it stands now' {
$stale = Get-StaleBuildReason
$stale | Should -BeNullOrEmpty -Because "$stale"
}

It 'produced a manifest under Dist' {
Get-BuiltManifestPath | Should -Exist
}
Expand All @@ -19,3 +35,61 @@ Describe 'Built module' {
Get-Module -Name (Get-ModuleInfo).ModuleName | Should -Not -BeNullOrEmpty
}
}

# A function the build misses only fails on a real install; catch it here instead.
Describe 'Every function in the source tree reaches the built module' -Skip:(-not $SourceDefinesFunctions) {
BeforeAll {
$script:Info = Get-ModuleInfo

function script:Get-DefinedFunction {
[OutputType([string[]])]
param([string[]] $Path)

$names = [System.Collections.Generic.HashSet[string]]::new()
foreach ($file in $Path) {
$ast = [System.Management.Automation.Language.Parser]::ParseFile($file, [ref] $null, [ref] $null)
foreach ($function in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
[void] $names.Add($function.Name)
}
}
[string[]] $names
}

$script:InSource = @(Get-DefinedFunction -Path @(Get-ChildItem -Path $script:Info.SourceRoot -Recurse -Filter '*.ps1' -File |
ForEach-Object { $_.FullName }))
$script:InBuild = @(Get-DefinedFunction -Path @(Join-Path (Split-Path -Parent (Get-BuiltManifestPath)) "$($script:Info.ModuleName).psm1"))
}

It 'finds functions on both sides at all' {
$script:InSource.Count | Should -BeGreaterThan 0
$script:InBuild.Count | Should -BeGreaterThan 0
}

It 'defines every function the source tree defines' {
$missing = @($script:InSource | Where-Object { $_ -notin $script:InBuild } | Sort-Object)
$missing | Should -BeNullOrEmpty -Because "the source tree defines these and the built module does not: $($missing -join ', ')"
}
}

# Nothing else keeps build.psd1's directory list and the dev loader's loop in step.
Describe 'The build configuration and the development loader' {
BeforeAll {
$script:Info = Get-ModuleInfo
$script:Configured = @((Import-PowerShellDataFile -LiteralPath (Join-Path $script:Info.RepoRoot 'build.psd1')).SourceDirectories)

$loader = Join-Path $script:Info.SourceRoot "$($script:Info.ModuleName).psm1"
$ast = [System.Management.Automation.Language.Parser]::ParseFile($loader, [ref] $null, [ref] $null)
$loop = $ast.Find({
param($n)
$n -is [System.Management.Automation.Language.ForEachStatementAst] -and $n.Variable.VariablePath.UserPath -eq 'dir'
}, $true)
$script:Loaded = @($loop.Condition.FindAll({ param($n) $n -is [System.Management.Automation.Language.StringConstantExpressionAst] }, $true) |
ForEach-Object { $_.Value })
}

It 'walks the same source directories on both sides' {
$script:Configured.Count | Should -BeGreaterThan 0
$both = "build.psd1 builds from '$($script:Configured -join ', ')' and the development loader dot-sources '$($script:Loaded -join ', ')'"
@($script:Loaded | Sort-Object) | Should -Be @($script:Configured | Sort-Object) -Because $both
}
}
67 changes: 56 additions & 11 deletions Tests/_TestHelpers.ps1
Original file line number Diff line number Diff line change
@@ -1,19 +1,64 @@
# Shared by every *.Tests.ps1: import the BUILT module from Dist (NOT the source).
# ModuleBuilder only exports the public functions in the built module, so tests must run
# against the build output, the same artifact a user installs.
#
# Get-ModuleInfo / Get-BuiltManifestPath come from Tools/module_info.ps1, which resolves the
# module's name from build.psd1 - no name is hardcoded here, so this keeps working after
# './tasks.ps1 prepare' renames everything.
# Shared by every *.Tests.ps1: target selection and import helpers. Dot-sourced in BeforeAll,
# and at top level where a -Skip: expression needs it. Names and paths come from
# Tools/module_info.ps1, so nothing here breaks when prepare renames the module.
. (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'Tools') 'module_info.ps1')

# 'Source' or 'Dist'. An environment variable, not a parameter: Pester evaluates -Skip:
# expressions at discovery time. Set via -Target on ./tasks.ps1 test.
function Get-TestTarget {
[OutputType([string])]
param()
$value = [Environment]::GetEnvironmentVariable((Get-TestTargetVariableName))
if ($value -eq 'Dist') { 'Dist' } else { 'Source' }
}

function Get-ModuleUnderTestPath {
[OutputType([string])]
param()
if ((Get-TestTarget) -eq 'Dist') { Get-BuiltManifestPath } else { (Get-ModuleInfo).SourceManifest }
}

function Import-ModuleUnderTest {
Import-OneModule -Manifest (Get-ModuleUnderTestPath)
}

function Import-BuiltModule {
Import-Module (Get-BuiltManifestPath) -Force
Import-OneModule -Manifest (Get-BuiltManifestPath)
}

# Unload by name first: Import-Module -Force loads a second module beside one imported from
# another path, leaving two modules of one name with doubled exports.
function Import-OneModule {
param(
[Parameter(Mandatory)]
[string] $Manifest
)
Remove-Module -Name (Get-ModuleInfo).ModuleName -Force -ErrorAction SilentlyContinue
Import-Module $Manifest -Force
}

# $null when the build is fresh, otherwise the sentence the artifact test fails with. Catches
# Pester run by hand against yesterday's build; ./tasks.ps1 test builds first anyway.
function Get-StaleBuildReason {
[OutputType([string])]
param()
$info = Get-ModuleInfo
$built = Get-ChildItem -Path $info.DistRoot -Recurse -Filter "$($info.ModuleName).psm1" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $built) {
return "No built module under '$($info.DistRoot)'. Run './tasks.ps1 build'."
}
$newest = Get-ChildItem -Path $info.SourceRoot -Recurse -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($newest -and $newest.LastWriteTime -gt $built.LastWriteTime) {
return ("The built module is older than the source. '$($built.Name)' was built {0}, " -f $built.LastWriteTime.ToString('o')) +
("'$($newest.Name)' was changed {0}. Run './tasks.ps1 build'." -f $newest.LastWriteTime.ToString('o'))
}
$null
}

# Single definition of the host check used by -Skip: expressions, which Pester evaluates at
# DISCOVERY time - so this must work before any BeforeAll runs, and on Windows PowerShell 5.1
# where $IsWindows does not exist.
# Host check for -Skip: expressions; works on Windows PowerShell 5.1 where $IsWindows
# does not exist.
function Test-OnWindowsHost {
[OutputType([bool])]
param()
Expand Down
Loading