From 73810c53e7e69bcedd284057146bd5a004014df0 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 16:03:39 -0400 Subject: [PATCH 01/13] fix(tests): correct $parameterNames variable and preserve original exception - tests/Help.tests.ps1: rename undefined $parameterNames to $commandParameterNames in the help-vs-code parameter existence check. The variable was never assigned in scope, silently making the assertion always evaluate against $null. - {{ModuleName}}/{{ModuleName}}.psm1: re-throw original exception object in the dot-source catch block instead of throwing a new string, preserving stack traces and inner-exception details for debugging. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Help.tests.ps1 | 2 +- {{ModuleName}}/{{ModuleName}}.psm1 | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Help.tests.ps1 b/tests/Help.tests.ps1 index 8ad1d69..4f1a16c 100644 --- a/tests/Help.tests.ps1 +++ b/tests/Help.tests.ps1 @@ -213,7 +213,7 @@ Describe "Test help for <_.Name>" -ForEach $commands { # Shouldn't find extra parameters in help It 'finds help parameter in code: <_>' { - $_ -in $parameterNames | Should -Be $true + $_ -in $commandParameterNames | Should -Be $true } } } diff --git a/{{ModuleName}}/{{ModuleName}}.psm1 b/{{ModuleName}}/{{ModuleName}}.psm1 index 954f7a1..6c1fa95 100644 --- a/{{ModuleName}}/{{ModuleName}}.psm1 +++ b/{{ModuleName}}/{{ModuleName}}.psm1 @@ -5,7 +5,8 @@ foreach ($import in @($public + $private)) { try { . $import.FullName } catch { - throw "Unable to dot source [$($import.FullName)]" + Write-Error "Unable to dot source '$($import.FullName)'" + throw $_ } } From 9b494253bac724b9fbd1a9732cac1129c88cc243 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 16:04:55 -0400 Subject: [PATCH 02/13] feat(tests): SemVer-aware dependency version constraints Replace the single-shape Manifest.tests.ps1 dependency check with constraint- specific assertions and a SemVer comparison helper module. - tests/ManifestHelpers.psm1: new module exporting Test-VersionConstraint, with SemVer 2.0.0 prerelease ordering, .NET Version normalization, and Equal / GreaterOrEqual / LessOrEqual constraint modes. - tests/Manifest.tests.ps1: differentiate ModuleVersion (minimum), RequiredVersion (exact), and MaximumVersion (maximum) checks; accept both string and hashtable shapes for entries in requirements.psd1; detect duplicate RequiredModules entries; skip with a reason for plain-string dependencies. Preserves the existing BHBuildOutput override that points at Output///. Validated end-to-end against an initialized SmokeTest module: 30 passed, 0 failed, 2 -Skip'd. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Manifest.tests.ps1 | 140 +++++++++++++- tests/ManifestHelpers.psm1 | 379 +++++++++++++++++++++++++++++++++++++ 2 files changed, 512 insertions(+), 7 deletions(-) create mode 100644 tests/ManifestHelpers.psm1 diff --git a/tests/Manifest.tests.ps1 b/tests/Manifest.tests.ps1 index 83a1602..e52f6b8 100644 --- a/tests/Manifest.tests.ps1 +++ b/tests/Manifest.tests.ps1 @@ -24,6 +24,31 @@ 'dependencies', Justification = 'false positive' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'dependencyName', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'dependencyRawData', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'manifestRawData', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'requirementsVersionSkipReason', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'requirementsVersion', + Justification = 'false positive' +)] param() BeforeDiscovery { @@ -85,7 +110,19 @@ BeforeAll { ErrorAction = 'Stop' WarningAction = 'SilentlyContinue' } + $importDataFileParameters = @{ + Path = $moduleManifestPath + ErrorAction = 'Stop' + WarningAction = 'SilentlyContinue' + } $manifestData = Test-ModuleManifest @testModuleManifestParameters + $manifestRawData = Import-PowerShellDataFile @importDataFileParameters + + # Import ManifestHelpers.psm1 for SemVer helper functions + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'ManifestHelpers.psm1') -Verbose:$false -Force + + $requirementsPath = Join-Path -Path $env:BHProjectPath -ChildPath 'requirements.psd1' + $requirements = Import-PowerShellDataFile -Path $requirementsPath -ErrorAction Stop # Parse the version from the changelog $changelogPath = Join-Path -Path $Env:BHProjectPath -ChildPath 'CHANGELOG.md' @@ -143,19 +180,108 @@ Describe 'Module manifest' { } Context 'Module Dependency' -ForEach $dependencies { - # This ensures we keep our dependant modules in sync between the manifest file and the requirements + # This ensures we keep our dependent modules in sync between the manifest file and the requirements # script used to bootstrap and test. BeforeAll { - $requirementsPath = Join-Path -Path $Env:BHProjectPath -Child 'requirements.psd1' - $requirements = Import-PowerShellDataFile $requirementsPath + $dependencyName = $_.Name + $dependencyRawData = $manifestRawData.RequiredModules | Where-Object { + $_ -eq $dependencyName -or $_.ModuleName -eq $dependencyName + } + # Ensure exactly one match - duplicates should fail, not silently skip + if (@($dependencyRawData).Count -gt 1) { + throw "Duplicate RequiredModules entry found for '$dependencyName'" + } + # Handle plain-string module references (not hashtables with version info) + if ($dependencyRawData -isnot [hashtable]) { + $dependencyRawData = $null + } + + # Extract version from requirements.psd1 (shared logic for all version constraint tests) + $requirementsVersionSkipReason = $null + $requirementsVersion = $null + + if (-not $requirements.ContainsKey($dependencyName)) { + $requirementsVersionSkipReason = 'dependency not found in requirements.psd1' + } elseif ($requirements.Item($dependencyName) -is [string]) { + # Plain string format: 'ModuleName' = '1.2.3' + $requirementsVersion = $requirements.Item($dependencyName) + } elseif ($requirements.Item($dependencyName) -is [hashtable] -and $requirements.Item($dependencyName).ContainsKey('Version')) { + # Hashtable format: 'ModuleName' = @{ Version = '1.2.3' } + $requirementsVersion = $requirements.Item($dependencyName).Version + } else { + # Invalid format + $requirementsVersionSkipReason = "requirements.psd1 entry for '$dependencyName' must be a string or hashtable with a Version key" + } + } + + It '<_.Name> exists in requirements.psd1' { + $requirements.ContainsKey($dependencyName) | Should -BeTrue + } + + It '<_.Name> uses at least one version key' { + if ($null -eq $dependencyRawData) { + Set-ItResult -Skipped -Because 'Plain-string module reference without version constraints' + } + + # Valid dependency version keys + $validDependencyKeys = @( + 'ModuleVersion' # Specifies a minimum acceptable version of the module + 'RequiredVersion' # Specifies an exact, required version of the module + 'MaximumVersion' # Specifies a maximum acceptable version of the module + ) + $dependencyKeysUsed = $dependencyRawData.Keys | Where-Object { $_ -in $validDependencyKeys } + $dependencyKeysUsed.Count | Should -BeGreaterThan 0 + } + + It '<_.Name> has a matching required version in requirements.psd1' { + if ($null -eq $dependencyRawData -or -not $dependencyRawData.ContainsKey('RequiredVersion')) { + Set-ItResult -Skipped -Because 'No RequiredVersion specified in the manifest' + } + + if ($requirementsVersionSkipReason) { + Set-ItResult -Skipped -Because $requirementsVersionSkipReason + } + + $constraintParameters = @{ + ManifestVersion = $dependencyRawData.RequiredVersion + RequirementsVersion = $requirementsVersion + Constraint = 'Equal' + } + Test-VersionConstraint @constraintParameters | Should -BeTrue } - It '<_.Name> exists in Requirements.psd1' { - $requirements.ContainsKey($_.Name) | Should -BeTrue + It '<_.Name> has a maximum version greater than or equal to requirements.psd1' { + if ($null -eq $dependencyRawData -or -not $dependencyRawData.ContainsKey('MaximumVersion')) { + Set-ItResult -Skipped -Because 'No MaximumVersion specified in the manifest' + } + + if ($requirementsVersionSkipReason) { + Set-ItResult -Skipped -Because $requirementsVersionSkipReason + } + + $constraintParameters = @{ + ManifestVersion = $dependencyRawData.MaximumVersion + RequirementsVersion = $requirementsVersion + Constraint = 'GreaterOrEqual' + } + Test-VersionConstraint @constraintParameters | Should -BeTrue } - It '<_.Name> has matching version in the Requirements.psd1' { - [Version]$requirements.Item($_.Name).Version | Should -Be $_.Version + It '<_.Name> has a minimum version at or below requirements.psd1' { + if ($null -eq $dependencyRawData -or -not $dependencyRawData.ContainsKey('ModuleVersion')) { + Set-ItResult -Skipped -Because 'No ModuleVersion specified in the manifest' + } + + if ($requirementsVersionSkipReason) { + Set-ItResult -Skipped -Because $requirementsVersionSkipReason + } + + $constraintParameters = @{ + ManifestVersion = $dependencyRawData.ModuleVersion + RequirementsVersion = $requirementsVersion + Constraint = 'LessOrEqual' + } + Test-VersionConstraint @constraintParameters | Should -BeTrue } } } diff --git a/tests/ManifestHelpers.psm1 b/tests/ManifestHelpers.psm1 new file mode 100644 index 0000000..21e694f --- /dev/null +++ b/tests/ManifestHelpers.psm1 @@ -0,0 +1,379 @@ +<# + This module provides helper functions for validating module dependency versions + using Semantic Versioning (SemVer) conventions, including prerelease version support. +#> + +function Split-SemVerString { + <# + .SYNOPSIS + Splits a version string into version and prerelease components. + + .DESCRIPTION + Parses a SemVer-formatted version string (e.g., "1.2.3-beta.1") into separate + version and prerelease components. This enables proper comparison of prerelease + versions using SemVer 2.0.0 specification rules. + + .PARAMETER VersionString + The version string to parse. Can be in the format "1.2.3" or "1.2.3-prerelease". + + .OUTPUTS + [hashtable] + Returns a hashtable with two keys: + - Version: The numeric version portion (e.g., "1.2.3") + - Prerelease: The prerelease identifier (e.g., "beta.1") or $null if none + + .EXAMPLE + Split-SemVerString -VersionString "1.2.3-beta.1" + + Returns: @{ Version = "1.2.3"; Prerelease = "beta.1" } + + .EXAMPLE + Split-SemVerString -VersionString "2.0.0" + + Returns: @{ Version = "2.0.0"; Prerelease = $null } + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string]$VersionString + ) + + if ([string]::IsNullOrEmpty($VersionString)) { + throw "VersionString cannot be empty or null" + } + + $parts = $VersionString -split '-', 2 + return @{ + Version = $parts[0] + Prerelease = if ($parts.Length -gt 1) { $parts[1] } else { $null } + } +} + +function Compare-SemVerPrerelease { + <# + .SYNOPSIS + Compares two SemVer prerelease identifiers according to SemVer 2.0.0 specification. + + .DESCRIPTION + Implements SemVer 2.0.0 prerelease comparison rules (section 11.4): + - Compares dot-separated identifiers from left to right + - Numeric identifiers are compared as integers + - Alphanumeric identifiers are compared lexically (ASCII sort) + - Numeric identifiers always have lower precedence than alphanumeric + - More identifiers > fewer identifiers (if all preceding are equal) + + .PARAMETER FirstPrerelease + The first prerelease identifier (e.g., "alpha.1"). Must not be null. + + .PARAMETER SecondPrerelease + The second prerelease identifier (e.g., "beta.2"). Must not be null. + + .OUTPUTS + [int] + Returns -1 if first < second, 0 if equal, 1 if first > second. + + .EXAMPLE + Compare-SemVerPrerelease -FirstPrerelease "alpha.1" -SecondPrerelease "alpha.2" + Returns: -1 (alpha.1 < alpha.2) + + .EXAMPLE + Compare-SemVerPrerelease -FirstPrerelease "beta.11" -SecondPrerelease "beta.2" + Returns: 1 (11 > 2 numerically) + + .EXAMPLE + Compare-SemVerPrerelease -FirstPrerelease "1" -SecondPrerelease "alpha" + Returns: -1 (numeric < alphanumeric) + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory = $true)] + [string]$FirstPrerelease, + + [Parameter(Mandatory = $true)] + [string]$SecondPrerelease + ) + + $firstIdentifiers = $FirstPrerelease -split '\.' + $secondIdentifiers = $SecondPrerelease -split '\.' + + $maxLength = [Math]::Max($firstIdentifiers.Length, $secondIdentifiers.Length) + + for ($i = 0; $i -lt $maxLength; $i++) { + # If first has fewer identifiers, it has lower precedence + if ($i -ge $firstIdentifiers.Length) { + return -1 + } + + # If second has fewer identifiers, it has lower precedence + if ($i -ge $secondIdentifiers.Length) { + return 1 + } + + $firstId = $firstIdentifiers[$i] + $secondId = $secondIdentifiers[$i] + + # Check if identifiers are numeric (consist only of digits) + $firstIsNumeric = $firstId -match '^\d+$' + $secondIsNumeric = $secondId -match '^\d+$' + + if ($firstIsNumeric -and $secondIsNumeric) { + # Both numeric: compare as integers + $firstNum = [long]$firstId + $secondNum = [long]$secondId + + if ($firstNum -lt $secondNum) { + return -1 + } + elseif ($firstNum -gt $secondNum) { + return 1 + } + # Equal, continue to next identifier + } + elseif ($firstIsNumeric) { + # First is numeric, second is alphanumeric: numeric < alphanumeric + return -1 + } + elseif ($secondIsNumeric) { + # First is alphanumeric, second is numeric: alphanumeric > numeric + return 1 + } + else { + # Both alphanumeric: compare lexically + $comparison = [string]::Compare($firstId, $secondId, [System.StringComparison]::Ordinal) + if ($comparison -ne 0) { + return [Math]::Sign($comparison) + } + # Equal, continue to next identifier + } + } + + # All identifiers are equal + return 0 +} + +function Test-VersionComparison { + <# + .SYNOPSIS + Compares two SemVer versions according to SemVer 2.0.0 specification. + + .DESCRIPTION + Compares two semantic versions, including their prerelease components, following + SemVer 2.0.0 rules. Returns $true if the first version is newer than the second version. + + Comparison logic: + 1. Compare base versions (major.minor.patch) numerically + 2. If base versions equal, apply prerelease precedence rules: + - Version without prerelease > version with prerelease + - Compare prerelease identifiers using SemVer rules + + .PARAMETER FirstVersion + The numeric version portion of the first version (e.g., "1.2.3"). + + .PARAMETER FirstPrerelease + The prerelease identifier of the first version (e.g., "beta.1") or $null if none. + + .PARAMETER SecondVersion + The numeric version portion of the second version (e.g., "1.2.2"). + + .PARAMETER SecondPrerelease + The prerelease identifier of the second version (e.g., "alpha.5") or $null if none. + + .OUTPUTS + [bool] + Returns $true if the first version is newer than the second version, $false otherwise. + + .EXAMPLE + Test-VersionComparison -FirstVersion "1.2.3" -FirstPrerelease "beta.1" -SecondVersion "1.2.3" -SecondPrerelease "alpha.1" + + Returns: $true (beta.1 is newer than alpha.1) + + .EXAMPLE + Test-VersionComparison -FirstVersion "1.2.3" -FirstPrerelease $null -SecondVersion "1.2.2" -SecondPrerelease $null + + Returns: $true (1.2.3 is newer than 1.2.2) + + .EXAMPLE + Test-VersionComparison -FirstVersion "1.2.3" -FirstPrerelease $null -SecondVersion "1.2.3" -SecondPrerelease "beta.1" + + Returns: $true (1.2.3 > 1.2.3-beta.1 per SemVer spec) + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string]$FirstVersion, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$FirstPrerelease, + + [Parameter(Mandatory = $true)] + [string]$SecondVersion, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$SecondPrerelease + ) + + # Normalize versions to ensure consistent component count (fixes .NET Version comparison quirks) + # .NET treats "1.0.0" (Revision=-1) and "1.0.0.0" (Revision=0) as different, so normalize both to 4 components + $normalizedFirst = $FirstVersion + $firstComponents = $normalizedFirst.Split('.') + if ($firstComponents.Count -gt 4) { + throw "Version string '$FirstVersion' has too many components. .NET Version supports maximum 4 components (Major.Minor.Build.Revision)." + } + for ($c = $firstComponents.Count; $c -lt 4; $c++) { $normalizedFirst += '.0' } + + $normalizedSecond = $SecondVersion + $secondComponents = $normalizedSecond.Split('.') + if ($secondComponents.Count -gt 4) { + throw "Version string '$SecondVersion' has too many components. .NET Version supports maximum 4 components (Major.Minor.Build.Revision)." + } + for ($c = $secondComponents.Count; $c -lt 4; $c++) { $normalizedSecond += '.0' } + + # Compare base versions using .NET Version type + $firstVer = [version]$normalizedFirst + $secondVer = [version]$normalizedSecond + + $versionComparison = $firstVer.CompareTo($secondVer) + + if ($versionComparison -ne 0) { + # Base versions differ: return based on numeric comparison + return $versionComparison -gt 0 + } + + # Base versions are equal, apply prerelease precedence rules + $firstHasPrerelease = -not [string]::IsNullOrEmpty($FirstPrerelease) + $secondHasPrerelease = -not [string]::IsNullOrEmpty($SecondPrerelease) + + if (-not $firstHasPrerelease -and -not $secondHasPrerelease) { + # Both are release versions: equal + return $false + } + + if (-not $firstHasPrerelease) { + # First is release, second is prerelease: release > prerelease + return $true + } + + if (-not $secondHasPrerelease) { + # First is prerelease, second is release: prerelease < release + return $false + } + + # Both have prerelease: compare using SemVer prerelease rules + $prereleaseComparison = Compare-SemVerPrerelease -FirstPrerelease $FirstPrerelease -SecondPrerelease $SecondPrerelease + return $prereleaseComparison -gt 0 +} + +function Test-VersionConstraint { + <# + .SYNOPSIS + Tests whether a manifest version satisfies a version constraint. + + .DESCRIPTION + Validates that a module manifest version meets a specific constraint relative to + a requirements version. Supports three constraint types: + - Equal: Versions must be exactly the same (for RequiredVersion) + - GreaterOrEqual: Manifest version must be >= requirements (for MaximumVersion) + - LessOrEqual: Manifest version must be <= requirements (for ModuleVersion/minimum) + + .PARAMETER ManifestVersion + The version from the module manifest (e.g., "1.2.3-beta.1"). + + .PARAMETER RequirementsVersion + The version from requirements.psd1 (e.g., "1.2.3"). + + .PARAMETER Constraint + The type of constraint to validate. Valid values are: + - Equal: Versions must match exactly + - GreaterOrEqual: ManifestVersion >= RequirementsVersion + - LessOrEqual: ManifestVersion <= RequirementsVersion + + .OUTPUTS + [bool] + Returns $true if the constraint is satisfied, $false otherwise. + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "1.2.3" -RequirementsVersion "1.2.3" -Constraint "Equal" + + Returns: $true (versions match exactly) + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "2.0.0" -RequirementsVersion "1.5.0" -Constraint "GreaterOrEqual" + + Returns: $true (2.0.0 >= 1.5.0) + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "1.0.0" -RequirementsVersion "1.5.0" -Constraint "LessOrEqual" + + Returns: $true (1.0.0 <= 1.5.0) + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "1.2.3-beta.1" -RequirementsVersion "1.2.3-alpha.5" -Constraint "GreaterOrEqual" + + Returns: $true (beta.1 is newer than alpha.5) + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string]$ManifestVersion, + + [Parameter(Mandatory = $true)] + [string]$RequirementsVersion, + + [Parameter(Mandatory = $true)] + [ValidateSet('Equal', 'GreaterOrEqual', 'LessOrEqual')] + [string]$Constraint + ) + + # Validate input versions are not empty + if ([string]::IsNullOrWhiteSpace($ManifestVersion)) { + throw "ManifestVersion cannot be empty or whitespace" + } + if ([string]::IsNullOrWhiteSpace($RequirementsVersion)) { + throw "RequirementsVersion cannot be empty or whitespace" + } + + $manifestParts = Split-SemVerString $ManifestVersion + $requirementsParts = Split-SemVerString $RequirementsVersion + + $comparisonParameters = @{ + FirstVersion = $requirementsParts.Version + FirstPrerelease = $requirementsParts.Prerelease + SecondVersion = $manifestParts.Version + SecondPrerelease = $manifestParts.Prerelease + } + $requirementsIsNewer = Test-VersionComparison @comparisonParameters + + $reversedComparisonParameters = @{ + FirstVersion = $manifestParts.Version + FirstPrerelease = $manifestParts.Prerelease + SecondVersion = $requirementsParts.Version + SecondPrerelease = $requirementsParts.Prerelease + } + $manifestIsNewer = Test-VersionComparison @reversedComparisonParameters + + switch ($Constraint) { + 'Equal' { + # RequiredVersion must exactly match (neither version is newer than the other) + return (-not $requirementsIsNewer) -and (-not $manifestIsNewer) + } + 'GreaterOrEqual' { + # MaximumVersion must be >= requirements (requirements not newer, or equal) + return (-not $requirementsIsNewer) + } + 'LessOrEqual' { + # ModuleVersion must be <= requirements (manifest not newer than requirements) + return (-not $manifestIsNewer) + } + default { + throw "Unsupported constraint: '$Constraint'" + } + } +} + +Export-ModuleMember -Function 'Test-VersionConstraint' From 91db22c6570412727e2ebff17b620474d9268ce5 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 16:05:05 -0400 Subject: [PATCH 03/13] chore: add .gitattributes, markdownlint config, and about_ help stub - .gitattributes: mark docs/en-US/* as linguist-generated so platyPS-generated help files don't skew GitHub language stats. - .markdownlint-cli2.jsonc: relax MD013 in tables and code blocks, allow MD024 duplicate headings under different parents, and ignore AGENTS.md, the generated docs/en-US/**, and the instructions/** AI-agent guides. - docs/en-US/about_{{ModuleName}}.help.md: stub for Get-Help about_. Initialize-Template.ps1 already renames {{ModuleName}} files in docs/en-US. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitattributes | 2 ++ .markdownlint-cli2.jsonc | 20 ++++++++++++++++++++ docs/en-US/about_{{ModuleName}}.help.md | 19 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 .gitattributes create mode 100644 .markdownlint-cli2.jsonc create mode 100644 docs/en-US/about_{{ModuleName}}.help.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..df492b0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# The docs are generated by the build script and should be considered artifacts +docs/en-US/* linguist-generated diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..116311a --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/v0.19.1/schema/markdownlint-cli2-config-schema.json", + "config": { + "MD013": { + "tables": false, + "code_blocks": false + }, + "MD024": { + "siblings_only": true + } + }, + "ignores": [ + "AGENTS.md", + // Intentionally narrow: only ignores docs/en-US (platyPS-generated help) so that + // other markdown files in docs/ are still linted. No other language directories + // are expected. + "docs/en-US/**", + "instructions/**" + ] +} diff --git a/docs/en-US/about_{{ModuleName}}.help.md b/docs/en-US/about_{{ModuleName}}.help.md new file mode 100644 index 0000000..f6b5d27 --- /dev/null +++ b/docs/en-US/about_{{ModuleName}}.help.md @@ -0,0 +1,19 @@ +# {{ModuleName}} + +## about_{{ModuleName}} + +## SHORT DESCRIPTION + +## LONG DESCRIPTION + +## EXAMPLES + +## NOTE + +## TROUBLESHOOTING NOTE + +## SEE ALSO + +## KEYWORDS + +- From 5bdf33b2e65f31c0b3ded387de8eb7c4036fa9c5 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 16:05:15 -0400 Subject: [PATCH 04/13] chore(analyzer): structured PSScriptAnalyzer settings, bump to 1.25.0 - PSScriptAnalyzerSettings.psd1: replace the single-line @{ IncludeRules = @('*') } with the standard structured form (IncludeDefaultRules + Include/Exclude/Rules blocks). Includes a commented-out PSUseCompatibleSyntax/PSUseCompatibleCmdlets scaffold for projects that want cross-version compatibility checks. - build.depend.psd1: bump PSScriptAnalyzer from 1.24.0 to 1.25.0 to pick up newer rule fixes. Co-Authored-By: Claude Opus 4.7 (1M context) --- PSScriptAnalyzerSettings.psd1 | 27 ++++++++++++++++++++++++++- build.depend.psd1 | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index e3487c2..a521c0e 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -1,3 +1,28 @@ +# https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/using-scriptanalyzer @{ - IncludeRules = @('*') + IncludeDefaultRules = $true + + IncludeRules = @( + # Default rules + 'PS*' + ) + + # If IncludeRules and ExcludeRules are empty, all rules will be applied + ExcludeRules = @() + + Rules = @{ + # PSUseCompatibleSyntax = @{ + # # This turns the rule on (setting it to false will turn it off) + # Enable = $true + + # # List the targeted versions of PowerShell here + # TargetVersions = @( + # '5.1', + # '7.2' + # ) + # } + # PSUseCompatibleCmdlets = @{ + # compatibility = @('core-7.2.0-windows') + # } + } } diff --git a/build.depend.psd1 b/build.depend.psd1 index e8900de..22be15d 100644 --- a/build.depend.psd1 +++ b/build.depend.psd1 @@ -23,6 +23,6 @@ Version = '0.7.3' } 'PSScriptAnalyzer' = @{ - Version = '1.24.0' + Version = '1.25.0' } } From 13bee8d4f4e2caef9969b8858bf1ddb83beffcf8 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 16:05:28 -0400 Subject: [PATCH 05/13] feat(init): split README and rename docs/en-US files during init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo's README.md was a placeholder-laden module README, which made it look like docs for a hypothetical {{ModuleName}} when viewed on GitHub. Split into two files so the GitHub template page sells the template itself. - README.md: rewritten as template documentation — what's included (build, CI, devcontainer, instructions, test infra), quick-start with Initialize- Template.ps1, placeholder reference table, and post-init project structure. - README.template.md: holds the original placeholder-based module README. The file-processing loop in Initialize-Template.ps1 still substitutes its placeholders, then the script moves it over README.md as the final step. - Initialize-Template.ps1: - Rename files in docs/en-US/ that contain {{ModuleName}} (e.g., about_{{ModuleName}}.help.md), parallel to the Public/Private/test Prefix-rename loop. - After renames, replace template-facing README.md with the now-substituted README.template.md. - Adjust the "Next steps" message to match the new flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- Initialize-Template.ps1 | 27 ++++++- README.md | 167 +++++++++++++++++++++------------------- README.template.md | 113 +++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 80 deletions(-) create mode 100644 README.template.md diff --git a/Initialize-Template.ps1 b/Initialize-Template.ps1 index 3eeb9c8..343bf29 100644 --- a/Initialize-Template.ps1 +++ b/Initialize-Template.ps1 @@ -262,6 +262,31 @@ if (Test-Path -Path $templateModuleFolder) { Write-Host ' Renamed example function files' -ForegroundColor Green } +# Rename files in docs/en-US/ that contain {{ModuleName}} placeholder (e.g., about_{{ModuleName}}.help.md) +$docsFolder = Join-Path -Path $PSScriptRoot -ChildPath 'docs\en-US' +if (Test-Path -Path $docsFolder) { + $docsFiles = Get-ChildItem -Path $docsFolder -File | Where-Object { + $_.Name -match '\{\{ModuleName\}\}' + } + foreach ($file in $docsFiles) { + $newName = $file.Name -replace '\{\{ModuleName\}\}', $ModuleName + Rename-Item -Path $file.FullName -NewName $newName + Write-Verbose "Renamed: $($file.Name) -> $newName" + } + if ($docsFiles) { + Write-Host " Renamed docs/en-US files" -ForegroundColor Green + } +} + +# Replace template-facing README.md with the module-facing README.template.md +# (placeholders inside README.template.md were already substituted by the file-processing loop above) +$readmeTemplate = Join-Path -Path $PSScriptRoot -ChildPath 'README.template.md' +$readmePath = Join-Path -Path $PSScriptRoot -ChildPath 'README.md' +if (Test-Path -Path $readmeTemplate) { + Move-Item -Path $readmeTemplate -Destination $readmePath -Force + Write-Host ' Generated module README.md from template' -ForegroundColor Green +} + # Initialize Git repository if requested if (-not $NoGitInit) { $gitFolder = Join-Path -Path $PSScriptRoot -ChildPath '.git' @@ -303,7 +328,7 @@ Write-Host '========================================' -ForegroundColor Green Write-Host '' Write-Host 'Next steps:' -ForegroundColor Cyan Write-Host " 1. Review the generated files in the $ModuleName folder" -Write-Host ' 2. Update the README.md with your project details' +Write-Host ' 2. Review README.md and adjust to taste' Write-Host ' 3. Add your functions to the Public/ and Private/ folders' Write-Host ' 4. Run ./build.ps1 -Task Test to verify everything works' Write-Host ' 5. Push to your GitHub repository' diff --git a/README.md b/README.md index bf154ca..abcdb7f 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,122 @@ -# {{ModuleName}} +# PowerShell Module Template -{{Description}} +A GitHub repository template for building, testing, and publishing PowerShell modules. Click **Use this template** at the top of the repo, run a one-shot init script, and you have a working module project with CI, tests, and documentation scaffolding ready to go. -## Installation +## What's included -### From PowerShell Gallery +### Build & test -```powershell -Install-Module -Name {{ModuleName}} -Scope CurrentUser -``` +- **psake + PowerShellBuild** task pipeline (`build.ps1`, `build.psake.ps1`) +- **Pester 5.x** test layout with `tests/Unit/{Public,Private}` scaffolding +- **PSScriptAnalyzer** lint configuration +- **Code coverage** via JaCoCo + Codecov (`codecov.yml`) +- **Manifest validation tests** with SemVer-aware version-constraint checks (`tests/Manifest.tests.ps1`, `tests/ManifestHelpers.psm1`) +- **Help documentation tests** that verify every public function has comment-based help with synopsis, description, and examples (`tests/Help.tests.ps1`) +- **Meta tests** that catch UTF-16 files and tab indentation (`tests/Meta.tests.ps1`) +- **Integration test loader** — `tests/local.settings.example.ps1` documents how to wire local secrets without committing them -### From Source +### CI/CD (GitHub Actions) -```powershell -git clone {{ProjectUri}}.git -cd {{ModuleName}} -./build.ps1 -Task Build -Bootstrap -Import-Module ./Output/{{ModuleName}}/*/{{ModuleName}}.psd1 -``` +- `CI.yaml` — lint + test on push and PR +- `PublishModuleToPowerShellGallery.yaml` — publish on release +- `auto-merge-bots.yml` — auto-merge dependabot/pre-commit PRs +- `ggshield.yaml` — secret scanning +- Dependabot config and FUNDING file -## Requirements +### Developer experience -- PowerShell 5.1 or later (Desktop or Core) -- Windows, Linux, or macOS +- **`.devcontainer/`** with Docker Compose + host setup script +- **`.pre-commit-config.yaml`** with ggshield secret scanning +- **`instructions/`** — 12 markdown guides for AI agents (PowerShell style, testing, releases, git workflow, etc.) +- **`AGENTS.md`** — top-level AI agent guidance +- Markdown linting via `.markdownlint-cli2.jsonc` -## Quick Start +### Module scaffolding -```powershell -# Import the module -Import-Module {{ModuleName}} - -# Get help for available commands -Get-Command -Module {{ModuleName}} +- Full `.psd1` manifest with PSEdition tags, license/project URIs, and PSData metadata +- `.psm1` with public/private dot-source pattern +- Example public function (`Get-{{Prefix}}Example`) and private helper (`Invoke-{{Prefix}}Helper`) +- `docs/en-US/about_{{ModuleName}}.help.md` stub for `Get-Help about_` -# Example usage -Get-{{Prefix}}Example -Name 'World' -``` +## Quick start -## Available Commands +1. Click **Use this template → Create a new repository** at the top of this repo. +2. Clone your new repository locally. +3. Run the initialization script: -| Command | Description | -|---------|-------------| -| `Get-{{Prefix}}Example` | Example public function | + ```powershell + ./Initialize-Template.ps1 + ``` -## Development + You'll be prompted for module name, function prefix, author, description, and project URL. Pass them as parameters for non-interactive use: -### Prerequisites + ```powershell + ./Initialize-Template.ps1 ` + -ModuleName 'MyAwesomeModule' ` + -Prefix 'Mam' ` + -Author 'Jane Doe' ` + -Description 'Does awesome things' ` + -ProjectUri 'https://github.com/janedoe/MyAwesomeModule' + ``` -- PowerShell 5.1+ or PowerShell 7+ -- Git +4. The script substitutes placeholders, renames files, optionally runs `git init`, and bootstraps build dependencies. Delete `Initialize-Template.ps1` when done. -### Building +## Placeholders -```powershell -# Clone the repository -git clone {{ProjectUri}}.git -cd {{ModuleName}} +`Initialize-Template.ps1` replaces these tokens across all `.ps1`, `.psm1`, `.psd1`, `.md`, `.json`, `.yml`, `.yaml`, `.xml`, and `.txt` files: -# Bootstrap dependencies and build -./build.ps1 -Task Build -Bootstrap +| Placeholder | Replaced with | Example | +|---|---|---| +| `{{ModuleName}}` | Module name | `MyAwesomeModule` | +| `{{Prefix}}` | Function noun prefix | `Mam` | +| `{{Author}}` | Author name | `Jane Doe` | +| `{{Description}}` | Module description | `Does awesome things` | +| `{{ProjectUri}}` | Repository URL | `https://github.com/...` | +| `{{GUID}}` | Generated GUID | (new GUID per run) | +| `{{Date}}` | ISO date at init | `2026-04-29` | +| `{{Year}}` | Year at init | `2026` | -# Run tests -./build.ps1 -Task Test -``` +The script also renames the `{{ModuleName}}` folder, files containing `{{ModuleName}}` or `{{Prefix}}` in their names (in `Public/`, `Private/`, `tests/Unit/`, `docs/en-US/`), and replaces `README.md` with the post-init module README sourced from `README.template.md`. -### Project Structure +## Project structure (post-init) ``` -{{ModuleName}}/ -├── {{ModuleName}}/ # Module source -│ ├── Public/ # Exported functions -│ └── Private/ # Internal helpers -├── tests/ # Pester tests -│ ├── Unit/ # Unit tests -│ ├── Meta.tests.ps1 # Code style tests -│ ├── Manifest.tests.ps1 # Manifest validation -│ └── Help.tests.ps1 # Help documentation tests -├── docs/ # Documentation -├── .github/workflows/ # CI/CD pipelines -└── build.ps1 # Build entry point +/ +├── / # Module source +│ ├── Public/ # Exported functions +│ └── Private/ # Internal helpers +├── tests/ +│ ├── Unit/{Public,Private}/ # Per-function tests +│ ├── Help.tests.ps1 # Comment-based-help validation +│ ├── Manifest.tests.ps1 # Manifest + dependency-version validation +│ ├── Meta.tests.ps1 # Encoding + indentation checks +│ └── ManifestHelpers.psm1 # SemVer comparison helpers +├── docs/en-US/ # platyPS help (generated) +├── instructions/ # AI agent guidance (12 files) +├── .github/workflows/ # CI, publish, auto-merge, secret scan +├── .devcontainer/ # VS Code dev container +├── build.ps1 # Build entry point +├── build.psake.ps1 # psake task definitions +├── build.depend.psd1 # Build/test module dependencies +└── requirements.psd1 # Runtime module dependencies ``` -### Available Build Tasks +## Working on the template itself + +If you want to contribute to the template (this repo) rather than use it: ```powershell -./build.ps1 -Help +./build.ps1 -Task Test -Bootstrap ``` -| Task | Description | -|------|-------------| -| `Build` | Build the module to Output/ | -| `Test` | Run all tests with code coverage | -| `Analyze` | Run PSScriptAnalyzer | -| `Pester` | Run Pester tests only | -| `Clean` | Remove build artifacts | -| `Publish` | Publish to PowerShell Gallery | +The test suite runs against the `{{ModuleName}}` placeholder module to verify the scaffolding is sound. See [AGENTS.md](AGENTS.md) and [`instructions/`](instructions/) for contribution conventions. -## Contributing +## Requirements -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Run tests: `./build.ps1 -Task Test` -5. Submit a pull request +- PowerShell 5.1+ or PowerShell 7+ +- Git +- (Optional) Docker for the devcontainer ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## Changelog - -See [CHANGELOG.md](CHANGELOG.md) for version history. +[MIT](LICENSE) diff --git a/README.template.md b/README.template.md new file mode 100644 index 0000000..bf154ca --- /dev/null +++ b/README.template.md @@ -0,0 +1,113 @@ +# {{ModuleName}} + +{{Description}} + +## Installation + +### From PowerShell Gallery + +```powershell +Install-Module -Name {{ModuleName}} -Scope CurrentUser +``` + +### From Source + +```powershell +git clone {{ProjectUri}}.git +cd {{ModuleName}} +./build.ps1 -Task Build -Bootstrap +Import-Module ./Output/{{ModuleName}}/*/{{ModuleName}}.psd1 +``` + +## Requirements + +- PowerShell 5.1 or later (Desktop or Core) +- Windows, Linux, or macOS + +## Quick Start + +```powershell +# Import the module +Import-Module {{ModuleName}} + +# Get help for available commands +Get-Command -Module {{ModuleName}} + +# Example usage +Get-{{Prefix}}Example -Name 'World' +``` + +## Available Commands + +| Command | Description | +|---------|-------------| +| `Get-{{Prefix}}Example` | Example public function | + +## Development + +### Prerequisites + +- PowerShell 5.1+ or PowerShell 7+ +- Git + +### Building + +```powershell +# Clone the repository +git clone {{ProjectUri}}.git +cd {{ModuleName}} + +# Bootstrap dependencies and build +./build.ps1 -Task Build -Bootstrap + +# Run tests +./build.ps1 -Task Test +``` + +### Project Structure + +``` +{{ModuleName}}/ +├── {{ModuleName}}/ # Module source +│ ├── Public/ # Exported functions +│ └── Private/ # Internal helpers +├── tests/ # Pester tests +│ ├── Unit/ # Unit tests +│ ├── Meta.tests.ps1 # Code style tests +│ ├── Manifest.tests.ps1 # Manifest validation +│ └── Help.tests.ps1 # Help documentation tests +├── docs/ # Documentation +├── .github/workflows/ # CI/CD pipelines +└── build.ps1 # Build entry point +``` + +### Available Build Tasks + +```powershell +./build.ps1 -Help +``` + +| Task | Description | +|------|-------------| +| `Build` | Build the module to Output/ | +| `Test` | Run all tests with code coverage | +| `Analyze` | Run PSScriptAnalyzer | +| `Pester` | Run Pester tests only | +| `Clean` | Remove build artifacts | +| `Publish` | Publish to PowerShell Gallery | + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: `./build.ps1 -Task Test` +5. Submit a pull request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for version history. From dd64c22e81671bf1e5d362b3b53176ebbd2b2b2d Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 18:01:12 -0400 Subject: [PATCH 06/13] fix(review): address PR #14 inline findings - ManifestHelpers: strip SemVer build metadata in Split-SemVerString; use [bigint] (not [long]) for numeric prerelease comparison so large identifiers don't overflow. - Initialize-Template: replace 'docs\en-US' and 'tests\Unit\{Public,Private}' with forward-slash child paths so docs/test renaming works on PS7+ Linux/macOS. - Manifest.tests: skip with a clear reason when requirements.psd1 has an empty/whitespace Version, instead of throwing inside Test-VersionConstraint. - {{ModuleName}}.psm1: bare 'throw' in the dot-source catch to preserve the original ErrorRecord (was 'throw $_'). - about_{{ModuleName}}.help.md: drop trailing lone '-' that rendered as an empty bullet under KEYWORDS. - README.md / README.template.md: label project-tree fence as 'text' (markdownlint MD040). Co-Authored-By: Claude Opus 4.7 (1M context) --- Initialize-Template.ps1 | 6 +++--- README.md | 2 +- README.template.md | 2 +- docs/en-US/about_{{ModuleName}}.help.md | 2 -- tests/Manifest.tests.ps1 | 19 +++++++++++++++++-- tests/ManifestHelpers.psm1 | 12 ++++++++---- {{ModuleName}}/{{ModuleName}}.psm1 | 2 +- 7 files changed, 31 insertions(+), 14 deletions(-) diff --git a/Initialize-Template.ps1 b/Initialize-Template.ps1 index 343bf29..d3b2a1c 100644 --- a/Initialize-Template.ps1 +++ b/Initialize-Template.ps1 @@ -241,8 +241,8 @@ if (Test-Path -Path $templateModuleFolder) { # Rename example function files $publicFolder = Join-Path -Path $moduleFolder -ChildPath 'Public' $privateFolder = Join-Path -Path $moduleFolder -ChildPath 'Private' - $testPublicFolder = Join-Path -Path $PSScriptRoot -ChildPath 'tests\Unit\Public' - $testPrivateFolder = Join-Path -Path $PSScriptRoot -ChildPath 'tests\Unit\Private' + $testPublicFolder = Join-Path -Path $PSScriptRoot -ChildPath 'tests/Unit/Public' + $testPrivateFolder = Join-Path -Path $PSScriptRoot -ChildPath 'tests/Unit/Private' $foldersToCheck = @($publicFolder, $privateFolder, $testPublicFolder, $testPrivateFolder) @@ -263,7 +263,7 @@ if (Test-Path -Path $templateModuleFolder) { } # Rename files in docs/en-US/ that contain {{ModuleName}} placeholder (e.g., about_{{ModuleName}}.help.md) -$docsFolder = Join-Path -Path $PSScriptRoot -ChildPath 'docs\en-US' +$docsFolder = Join-Path -Path $PSScriptRoot -ChildPath 'docs/en-US' if (Test-Path -Path $docsFolder) { $docsFiles = Get-ChildItem -Path $docsFolder -File | Where-Object { $_.Name -match '\{\{ModuleName\}\}' diff --git a/README.md b/README.md index abcdb7f..6d1df4a 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The script also renames the `{{ModuleName}}` folder, files containing `{{ModuleN ## Project structure (post-init) -``` +```text / ├── / # Module source │ ├── Public/ # Exported functions diff --git a/README.template.md b/README.template.md index bf154ca..49a4628 100644 --- a/README.template.md +++ b/README.template.md @@ -66,7 +66,7 @@ cd {{ModuleName}} ### Project Structure -``` +```text {{ModuleName}}/ ├── {{ModuleName}}/ # Module source │ ├── Public/ # Exported functions diff --git a/docs/en-US/about_{{ModuleName}}.help.md b/docs/en-US/about_{{ModuleName}}.help.md index f6b5d27..55c7a03 100644 --- a/docs/en-US/about_{{ModuleName}}.help.md +++ b/docs/en-US/about_{{ModuleName}}.help.md @@ -15,5 +15,3 @@ ## SEE ALSO ## KEYWORDS - -- diff --git a/tests/Manifest.tests.ps1 b/tests/Manifest.tests.ps1 index e52f6b8..1f33276 100644 --- a/tests/Manifest.tests.ps1 +++ b/tests/Manifest.tests.ps1 @@ -49,6 +49,11 @@ 'requirementsVersion', Justification = 'false positive' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'candidateVersion', + Justification = 'false positive' +)] param() BeforeDiscovery { @@ -204,10 +209,20 @@ Describe 'Module manifest' { $requirementsVersionSkipReason = 'dependency not found in requirements.psd1' } elseif ($requirements.Item($dependencyName) -is [string]) { # Plain string format: 'ModuleName' = '1.2.3' - $requirementsVersion = $requirements.Item($dependencyName) + $candidateVersion = $requirements.Item($dependencyName) + if ([string]::IsNullOrWhiteSpace($candidateVersion)) { + $requirementsVersionSkipReason = "requirements.psd1 entry for '$dependencyName' has an empty Version" + } else { + $requirementsVersion = $candidateVersion + } } elseif ($requirements.Item($dependencyName) -is [hashtable] -and $requirements.Item($dependencyName).ContainsKey('Version')) { # Hashtable format: 'ModuleName' = @{ Version = '1.2.3' } - $requirementsVersion = $requirements.Item($dependencyName).Version + $candidateVersion = $requirements.Item($dependencyName).Version + if ([string]::IsNullOrWhiteSpace($candidateVersion)) { + $requirementsVersionSkipReason = "requirements.psd1 entry for '$dependencyName' has an empty Version" + } else { + $requirementsVersion = $candidateVersion + } } else { # Invalid format $requirementsVersionSkipReason = "requirements.psd1 entry for '$dependencyName' must be a string or hashtable with a Version key" diff --git a/tests/ManifestHelpers.psm1 b/tests/ManifestHelpers.psm1 index 21e694f..5ac5d25 100644 --- a/tests/ManifestHelpers.psm1 +++ b/tests/ManifestHelpers.psm1 @@ -43,7 +43,10 @@ function Split-SemVerString { throw "VersionString cannot be empty or null" } - $parts = $VersionString -split '-', 2 + # Strip build metadata per SemVer 2.0.0 — it does not affect precedence and is + # not valid for [System.Version], so it must be removed before further parsing. + $coreVersion = ($VersionString -split '\+', 2)[0] + $parts = $coreVersion -split '-', 2 return @{ Version = $parts[0] Prerelease = if ($parts.Length -gt 1) { $parts[1] } else { $null } @@ -119,9 +122,10 @@ function Compare-SemVerPrerelease { $secondIsNumeric = $secondId -match '^\d+$' if ($firstIsNumeric -and $secondIsNumeric) { - # Both numeric: compare as integers - $firstNum = [long]$firstId - $secondNum = [long]$secondId + # SemVer 2.0.0 permits arbitrarily large numeric identifiers — use BigInteger + # rather than [long] to avoid overflow. + $firstNum = [bigint]$firstId + $secondNum = [bigint]$secondId if ($firstNum -lt $secondNum) { return -1 diff --git a/{{ModuleName}}/{{ModuleName}}.psm1 b/{{ModuleName}}/{{ModuleName}}.psm1 index 6c1fa95..ec2cf09 100644 --- a/{{ModuleName}}/{{ModuleName}}.psm1 +++ b/{{ModuleName}}/{{ModuleName}}.psm1 @@ -6,7 +6,7 @@ foreach ($import in @($public + $private)) { . $import.FullName } catch { Write-Error "Unable to dot source '$($import.FullName)'" - throw $_ + throw } } From 65fd9e47817ef6d20845622bcb5a3ea390824bbd Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 18:42:01 -0400 Subject: [PATCH 07/13] chore(changelog): rename CHANGELOG.md to CHANGELOG.template.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing CHANGELOG.md is the downstream-module starter (contains {{Date}} and {{Prefix}}Example placeholders). Rename it to follow the README.template.md convention so a separate CHANGELOG.md can hold the template's own version history. Initialize-Template.ps1 will swap CHANGELOG.template.md over CHANGELOG.md during init in a follow-up commit. Pure rename — no content change — so git log --follow stays clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md => CHANGELOG.template.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CHANGELOG.md => CHANGELOG.template.md (100%) diff --git a/CHANGELOG.md b/CHANGELOG.template.md similarity index 100% rename from CHANGELOG.md rename to CHANGELOG.template.md From a0e1579f7808461ef9a60036c0839a9aabb7709e Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 18:43:44 -0400 Subject: [PATCH 08/13] feat(changelog): add template-level CHANGELOG.md (CalVer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track changes to the PowerShell module template itself with its own changelog, separate from the downstream-module starter changelog. CHANGELOG.md (new) — template's own history. Uses Calendar Versioning (YYYY.MM.DD) since the template has no API contract for SemVer to describe; date-based answers the question downstream consumers actually ask ("how stale is my init?"). First entry [2026.04.29] captures the five chunks landed in the chore/template-sync branch. Initialize-Template.ps1: - Add CHANGELOG.md to the substitution-loop exclusion list. Without this, init would rewrite literal {{ModuleName}} mentions in the template's history to the user's chosen module name. - After the README.template.md → README.md swap, add a parallel CHANGELOG.template.md → CHANGELOG.md swap so downstream modules receive the placeholder-laden starter changelog (the existing CHANGELOG.template.md, formerly CHANGELOG.md), not the template's own history. tests/Manifest.tests.ps1: - Skip the "Changelog and manifest versions are the same" assertion when running on the un-initialized template, where CHANGELOG.md holds the template's CalVer version which intentionally diverges from the {{ModuleName}}.psd1 ModuleVersion. Marker: presence of CHANGELOG.template.md (exists only pre-init; survives init's substitution loop because nothing in the path matches a placeholder token). The "valid version in changelog" assertion stays — PowerShell parses [Version]'2026.4.29' fine. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ Initialize-Template.ps1 | 10 ++++++++++ tests/Manifest.tests.ps1 | 10 +++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..761ca3a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this template will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MM.DD`). + +For the changelog of a *module initialized from this template*, see the module's +own `CHANGELOG.md` (generated from `CHANGELOG.template.md` during init). + +## [Unreleased] + +## [2026.04.29] - 2026-04-29 + +### Added + +- SemVer-aware dependency version checks: new `tests/ManifestHelpers.psm1` exporting `Test-VersionConstraint`. `tests/Manifest.tests.ps1` now differentiates `RequiredVersion` / `ModuleVersion` / `MaximumVersion`, accepts both string and hashtable shapes in `requirements.psd1`, and detects duplicate `RequiredModules` entries. +- README split: template-facing `README.md` (what GitHub visitors see) and module-facing `README.template.md` (substituted into `README.md` during init). +- `docs/en-US/about_{{ModuleName}}.help.md` stub for `Get-Help about_`. `Initialize-Template.ps1` now also renames `{{ModuleName}}` files in `docs/en-US/`. +- `.gitattributes` marking `docs/en-US/*` as `linguist-generated`. +- `.markdownlint-cli2.jsonc` config (relax MD013 in tables/code, allow MD024 siblings, ignore generated docs and `instructions/`). + +### Changed + +- `PSScriptAnalyzerSettings.psd1`: replaced one-line `@{ IncludeRules = @('*') }` with the structured form (Include/Exclude/Rules + commented compat scaffold). +- Bumped `PSScriptAnalyzer` 1.24.0 → 1.25.0. + +### Fixed + +- `tests/Help.tests.ps1`: replaced undefined `$parameterNames` with `$commandParameterNames` in the help-vs-code parameter check (was silently asserting against `$null`). +- `{{ModuleName}}/{{ModuleName}}.psm1`: dot-source catch block now preserves the original `ErrorRecord` via bare `throw` (was `throw $_`, which wrapped the error). + +[Unreleased]: https://github.com/tablackburn/PowerShellModuleTemplate/compare/v2026.04.29...HEAD +[2026.04.29]: https://github.com/tablackburn/PowerShellModuleTemplate/releases/tag/v2026.04.29 diff --git a/Initialize-Template.ps1 b/Initialize-Template.ps1 index d3b2a1c..28d23f0 100644 --- a/Initialize-Template.ps1 +++ b/Initialize-Template.ps1 @@ -180,6 +180,7 @@ $filesToProcess = Get-ChildItem -Path $PSScriptRoot -Recurse -File | Where-Objec $_.FullName -notmatch '[\\/]Output[\\/]' -and $_.FullName -notmatch '[\\/]out[\\/]' -and $_.Name -ne 'Initialize-Template.ps1' -and + $_.Name -ne 'CHANGELOG.md' -and $_.Extension -in @('.ps1', '.psm1', '.psd1', '.md', '.json', '.yml', '.yaml', '.xml', '.txt', '') } @@ -287,6 +288,15 @@ if (Test-Path -Path $readmeTemplate) { Write-Host ' Generated module README.md from template' -ForegroundColor Green } +# Replace template-facing CHANGELOG.md with the module-facing CHANGELOG.template.md +# (placeholders inside CHANGELOG.template.md were already substituted by the file-processing loop above) +$changelogTemplate = Join-Path -Path $PSScriptRoot -ChildPath 'CHANGELOG.template.md' +$changelogPath = Join-Path -Path $PSScriptRoot -ChildPath 'CHANGELOG.md' +if (Test-Path -Path $changelogTemplate) { + Move-Item -Path $changelogTemplate -Destination $changelogPath -Force + Write-Host ' Generated module CHANGELOG.md from template' -ForegroundColor Green +} + # Initialize Git repository if requested if (-not $NoGitInit) { $gitFolder = Join-Path -Path $PSScriptRoot -ChildPath '.git' diff --git a/tests/Manifest.tests.ps1 b/tests/Manifest.tests.ps1 index 1f33276..fe6e3f6 100644 --- a/tests/Manifest.tests.ps1 +++ b/tests/Manifest.tests.ps1 @@ -86,6 +86,14 @@ BeforeDiscovery { } $manifestData = Test-ModuleManifest @testModuleManifestParameters $dependencies = $manifestData.RequiredModules + + # When running on the un-initialized template, CHANGELOG.md tracks the template's + # CalVer version (YYYY.MM.DD), which deliberately decouples from the manifest's + # ModuleVersion. Skip the equality assertion in that case; downstream modules (post-init) + # keep the assertion. Marker: CHANGELOG.template.md exists only pre-init — + # Initialize-Template.ps1 moves it onto CHANGELOG.md during init. The marker survives + # the init substitution loop because no token in the path matches a {{Placeholder}}. + $isTemplate = Test-Path -LiteralPath (Join-Path -Path $Env:BHProjectPath -ChildPath 'CHANGELOG.template.md') } BeforeAll { # Check if the BHBuildOutput environment variable exists to determine if this test is running in a psake @@ -180,7 +188,7 @@ Describe 'Module manifest' { $changelogVersion -as [Version] | Should -Not -BeNullOrEmpty } - It 'Changelog and manifest versions are the same' { + It 'Changelog and manifest versions are the same' -Skip:$isTemplate { $changelogVersion -as [Version] | Should -Be ( $manifestData.Version -as [Version] ) } From 90ecacb70c2b0ada951f6ba5d40201aed62d155b Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 18:44:13 -0400 Subject: [PATCH 09/13] docs: link template CHANGELOG from README.md Add a Changelog section to the template-facing README so visitors of the template repo on GitHub can discover the version history. Inserted between Requirements and License, with a one-line note that the template uses CalVer rather than SemVer (so reviewers don't read date-shaped numbers as broken SemVer). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 6d1df4a..86c3eba 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ The test suite runs against the `{{ModuleName}}` placeholder module to verify th - Git - (Optional) Docker for the devcontainer +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for the template's version history. The template uses [Calendar Versioning](https://calver.org/) (`YYYY.MM.DD`) — the version reflects when a snapshot of the template was cut, not API compatibility. + ## License [MIT](LICENSE) From 83ea2b3ee8a7db4a67626fbbd67348842847fdb0 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 19:58:26 -0400 Subject: [PATCH 10/13] ci: skip template-incompatible jobs when run on un-initialized template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI.yaml's unit-tests job runs ./build.ps1 -Task Build,Test, which fails on the template because Build's GENERATEMARKDOWN task imports {{ModuleName}}/{{ModuleName}}.psd1 and {{GUID}} can't be parsed as a valid Guid. PublishModuleToPowerShellGallery.yaml would, if it ever fired against main with placeholder content, try to publish a literal "{{ModuleName}}" module to PSGallery. Add a job-level guard to both: if: hashFiles('CHANGELOG.template.md') == '' CHANGELOG.template.md exists only pre-init (Initialize-Template.ps1 moves it onto CHANGELOG.md during init), so the marker flips on the template / off downstream. The path contains no {{Placeholder}} token, so init's substitution loop leaves the if-clause untouched in the copied workflow files — downstream repos run all jobs normally. The lint job in CI.yaml is left unchanged: Invoke-ScriptAnalyzer runs fine on the template's placeholder folder. ggshield.yaml and auto-merge-bots.yml are universal and need no guard. Companion to PR #15 (template-level CHANGELOG.md / CalVer); reuses the same marker the Manifest.tests.ps1 -Skip:$isTemplate logic uses there. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/CI.yaml | 7 +++++++ .github/workflows/PublishModuleToPowerShellGallery.yaml | 3 +++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 68c2b42..43fd17f 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -56,6 +56,13 @@ jobs: unit-tests: name: Unit Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} + # Skip on the un-initialized template — Build's GENERATEMARKDOWN task + # can't import the manifest while {{GUID}} is still a literal placeholder. + # Marker: CHANGELOG.template.md exists only pre-init; Initialize-Template.ps1 + # moves it onto CHANGELOG.md during init, so downstream repos run the full + # job. Path has no placeholder token, so init's substitution loop leaves it + # intact in this file. + if: hashFiles('CHANGELOG.template.md') == '' strategy: fail-fast: false matrix: diff --git a/.github/workflows/PublishModuleToPowerShellGallery.yaml b/.github/workflows/PublishModuleToPowerShellGallery.yaml index 15a194e..8226dc1 100644 --- a/.github/workflows/PublishModuleToPowerShellGallery.yaml +++ b/.github/workflows/PublishModuleToPowerShellGallery.yaml @@ -15,6 +15,9 @@ jobs: publish: name: Publish Module runs-on: ubuntu-latest + # Never publish the un-initialized template — the .psd1 still has + # {{ModuleName}} and {{GUID}} placeholders. Same marker as CI.yaml. + if: hashFiles('CHANGELOG.template.md') == '' steps: - name: Checkout uses: actions/checkout@v6 From 997c47f99c2454e9400da9a22de318aacb42ab77 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 22:51:58 -0400 Subject: [PATCH 11/13] fix(review): address PR #16 inline findings - ci: hashFiles() is not allowed in jobs..if (actionlint and the GitHub Actions docs both flag it). Replace the job-level guard with a Detect template state step that writes is_template to $GITHUB_OUTPUT, then gate downstream steps on that output. CI.yaml guards Cache, Build/Test, and the upload steps; PublishModuleToPowerShellGallery.yaml guards Get Module Version and Check if Release Exists, with the remaining publish steps cascading via their existing dependencies on steps.check_release.outputs.exists. - tests: [bigint] is a PS 7+ accelerator only; Windows PowerShell 5.1 needs the full type. Spell it [System.Numerics.BigInteger] in Compare-SemVerPrerelease so the SemVer numeric-identifier comparison works on the 5.1 runner. - init: switch the two hardcoded forward-slash test paths to Join-Path for consistency with the rest of Initialize-Template.ps1. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/CI.yaml | 32 +++++++++++++------ .../PublishModuleToPowerShellGallery.yaml | 22 +++++++++++-- Initialize-Template.ps1 | 5 +-- tests/ManifestHelpers.psm1 | 7 ++-- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 43fd17f..2f046c6 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -56,13 +56,6 @@ jobs: unit-tests: name: Unit Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} - # Skip on the un-initialized template — Build's GENERATEMARKDOWN task - # can't import the manifest while {{GUID}} is still a literal placeholder. - # Marker: CHANGELOG.template.md exists only pre-init; Initialize-Template.ps1 - # moves it onto CHANGELOG.md during init, so downstream repos run the full - # job. Path has no placeholder token, so init's substitution loop leaves it - # intact in this file. - if: hashFiles('CHANGELOG.template.md') == '' strategy: fail-fast: false matrix: @@ -70,7 +63,27 @@ jobs: steps: - uses: actions/checkout@v6 + # Skip subsequent steps on the un-initialized template — Build's + # GENERATEMARKDOWN task can't import the manifest while {{GUID}} is still + # a literal placeholder. Marker: CHANGELOG.template.md exists only + # pre-init; Initialize-Template.ps1 moves it onto CHANGELOG.md during + # init, so downstream repos run the full job. The marker path contains + # no placeholder token, so init's substitution loop leaves this guard + # intact when the workflow is copied into a new module. + # hashFiles() is not allowed in jobs..if, so we evaluate it in a + # step and gate downstream steps on the resulting output. + - name: Detect template state + id: template_guard + shell: bash + run: | + if [ -f CHANGELOG.template.md ]; then + echo "is_template=true" >> "$GITHUB_OUTPUT" + else + echo "is_template=false" >> "$GITHUB_OUTPUT" + fi + - name: Cache PowerShell modules + if: steps.template_guard.outputs.is_template == 'false' uses: actions/cache@v5 with: path: | @@ -81,14 +94,15 @@ jobs: ${{ runner.os }}-psmodules- - name: Build and Test + if: steps.template_guard.outputs.is_template == 'false' shell: pwsh run: | New-Item -Path out -ItemType Directory -Force | Out-Null ./build.ps1 -Task Build,Test -Bootstrap - name: Upload Coverage to Codecov + if: success() && steps.template_guard.outputs.is_template == 'false' uses: codecov/codecov-action@v6 - if: success() with: token: ${{ secrets.CODECOV_TOKEN }} files: out/codeCoverage.xml @@ -96,8 +110,8 @@ jobs: fail_ci_if_error: false - name: Upload Test Results + if: always() && steps.template_guard.outputs.is_template == 'false' uses: actions/upload-artifact@v7 - if: always() with: name: test-results-${{ matrix.os }} path: out/ diff --git a/.github/workflows/PublishModuleToPowerShellGallery.yaml b/.github/workflows/PublishModuleToPowerShellGallery.yaml index 8226dc1..ba1a622 100644 --- a/.github/workflows/PublishModuleToPowerShellGallery.yaml +++ b/.github/workflows/PublishModuleToPowerShellGallery.yaml @@ -15,17 +15,32 @@ jobs: publish: name: Publish Module runs-on: ubuntu-latest - # Never publish the un-initialized template — the .psd1 still has - # {{ModuleName}} and {{GUID}} placeholders. Same marker as CI.yaml. - if: hashFiles('CHANGELOG.template.md') == '' steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 + # Never publish the un-initialized template — the .psd1 still has + # {{ModuleName}} and {{GUID}} placeholders. Same marker as CI.yaml: + # CHANGELOG.template.md exists only pre-init. hashFiles() is not allowed + # in jobs..if, so we evaluate it in a step and gate the + # version-detection and release-check steps on the resulting output; + # everything downstream cascades on those steps' outputs and skips + # naturally when they don't run. + - name: Detect template state + id: template_guard + shell: bash + run: | + if [ -f CHANGELOG.template.md ]; then + echo "is_template=true" >> "$GITHUB_OUTPUT" + else + echo "is_template=false" >> "$GITHUB_OUTPUT" + fi + - name: Get Module Version id: version + if: steps.template_guard.outputs.is_template == 'false' shell: pwsh run: | $manifest = Import-PowerShellDataFile -Path ./{{ModuleName}}/{{ModuleName}}.psd1 @@ -35,6 +50,7 @@ jobs: - name: Check if Release Exists id: check_release + if: steps.template_guard.outputs.is_template == 'false' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Initialize-Template.ps1 b/Initialize-Template.ps1 index 28d23f0..9c04263 100644 --- a/Initialize-Template.ps1 +++ b/Initialize-Template.ps1 @@ -242,8 +242,9 @@ if (Test-Path -Path $templateModuleFolder) { # Rename example function files $publicFolder = Join-Path -Path $moduleFolder -ChildPath 'Public' $privateFolder = Join-Path -Path $moduleFolder -ChildPath 'Private' - $testPublicFolder = Join-Path -Path $PSScriptRoot -ChildPath 'tests/Unit/Public' - $testPrivateFolder = Join-Path -Path $PSScriptRoot -ChildPath 'tests/Unit/Private' + $testUnitFolder = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath 'tests') -ChildPath 'Unit' + $testPublicFolder = Join-Path -Path $testUnitFolder -ChildPath 'Public' + $testPrivateFolder = Join-Path -Path $testUnitFolder -ChildPath 'Private' $foldersToCheck = @($publicFolder, $privateFolder, $testPublicFolder, $testPrivateFolder) diff --git a/tests/ManifestHelpers.psm1 b/tests/ManifestHelpers.psm1 index 5ac5d25..b797866 100644 --- a/tests/ManifestHelpers.psm1 +++ b/tests/ManifestHelpers.psm1 @@ -123,9 +123,10 @@ function Compare-SemVerPrerelease { if ($firstIsNumeric -and $secondIsNumeric) { # SemVer 2.0.0 permits arbitrarily large numeric identifiers — use BigInteger - # rather than [long] to avoid overflow. - $firstNum = [bigint]$firstId - $secondNum = [bigint]$secondId + # rather than [long] to avoid overflow. [bigint] is a PS 7+ accelerator only, + # so spell out the full type for Windows PowerShell 5.1 compatibility. + $firstNum = [System.Numerics.BigInteger]$firstId + $secondNum = [System.Numerics.BigInteger]$secondId if ($firstNum -lt $secondNum) { return -1 From c6a35cf3225e5bf5f8dfc10d975a6eddc0c819b7 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 23:18:40 -0400 Subject: [PATCH 12/13] fix(init): preserve existing README.md / CHANGELOG.md on re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README and CHANGELOG generation steps used Move-Item -Force, which silently overwrites the destination. The top-of-script "already initialized" check is just a warning the user can dismiss, so a re-run after customization (e.g., user re-runs init to redo something, or template files reappear via git checkout) could clobber their work. Guard each move on the destination not existing. If both source and destination are present, warn and leave both in place — manual resolution is safer than picking a side automatically. Addresses CodeRabbit feedback on PR #16. Co-Authored-By: Claude Opus 4.7 (1M context) --- Initialize-Template.ps1 | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/Initialize-Template.ps1 b/Initialize-Template.ps1 index 9c04263..c11d04e 100644 --- a/Initialize-Template.ps1 +++ b/Initialize-Template.ps1 @@ -281,21 +281,34 @@ if (Test-Path -Path $docsFolder) { } # Replace template-facing README.md with the module-facing README.template.md -# (placeholders inside README.template.md were already substituted by the file-processing loop above) +# (placeholders inside README.template.md were already substituted by the file-processing loop above). +# If the destination already exists (e.g., the user is re-running init after customizing it), +# leave both files in place and warn — manually resolving is safer than overwriting customizations. $readmeTemplate = Join-Path -Path $PSScriptRoot -ChildPath 'README.template.md' $readmePath = Join-Path -Path $PSScriptRoot -ChildPath 'README.md' if (Test-Path -Path $readmeTemplate) { - Move-Item -Path $readmeTemplate -Destination $readmePath -Force - Write-Host ' Generated module README.md from template' -ForegroundColor Green + if (Test-Path -Path $readmePath) { + Write-Warning ' README.md already exists; leaving it in place. Resolve README.template.md manually.' + } + else { + Move-Item -Path $readmeTemplate -Destination $readmePath + Write-Host ' Generated module README.md from template' -ForegroundColor Green + } } # Replace template-facing CHANGELOG.md with the module-facing CHANGELOG.template.md -# (placeholders inside CHANGELOG.template.md were already substituted by the file-processing loop above) +# (placeholders inside CHANGELOG.template.md were already substituted by the file-processing loop above). +# Same guard as README above — preserve any existing CHANGELOG.md rather than clobber it. $changelogTemplate = Join-Path -Path $PSScriptRoot -ChildPath 'CHANGELOG.template.md' $changelogPath = Join-Path -Path $PSScriptRoot -ChildPath 'CHANGELOG.md' if (Test-Path -Path $changelogTemplate) { - Move-Item -Path $changelogTemplate -Destination $changelogPath -Force - Write-Host ' Generated module CHANGELOG.md from template' -ForegroundColor Green + if (Test-Path -Path $changelogPath) { + Write-Warning ' CHANGELOG.md already exists; leaving it in place. Resolve CHANGELOG.template.md manually.' + } + else { + Move-Item -Path $changelogTemplate -Destination $changelogPath + Write-Host ' Generated module CHANGELOG.md from template' -ForegroundColor Green + } } # Initialize Git repository if requested From d37b827e851d07e0446b4ae80a3bb5d87d47851d Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 23:34:55 -0400 Subject: [PATCH 13/13] ci(lint): skip PSScriptAnalyzer on un-initialized template PowerShell's parser splits the literal './{{ModuleName}}' into mismatched script-block delimiters, so Invoke-ScriptAnalyzer fails before it can read the folder: A positional parameter cannot be found that accepts argument '{ModuleName}'. The original PR claimed lint worked on the template; testing post- Actions-re-enable proves otherwise. Apply the same Detect template state guard the unit-tests job uses, gated on Cache PowerShell modules, Install PSScriptAnalyzer, and Run PSScriptAnalyzer. Downstream init's substitution turns './{{ModuleName}}' into a real path so the guard flips off and lint runs normally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/CI.yaml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 2f046c6..a1b8c43 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -18,7 +18,23 @@ jobs: steps: - uses: actions/checkout@v6 + # Skip lint on the un-initialized template — the literal `./{{ModuleName}}` + # path argument can't be parsed by PowerShell (the double braces split into + # mismatched script-block delimiters), so Invoke-ScriptAnalyzer fails with + # a positional-argument error before it ever touches the folder. Same + # marker as the unit-tests job below. + - name: Detect template state + id: template_guard + shell: bash + run: | + if [ -f CHANGELOG.template.md ]; then + echo "is_template=true" >> "$GITHUB_OUTPUT" + else + echo "is_template=false" >> "$GITHUB_OUTPUT" + fi + - name: Cache PowerShell modules + if: steps.template_guard.outputs.is_template == 'false' id: cache-lint-modules uses: actions/cache@v5 with: @@ -28,13 +44,14 @@ jobs: ${{ runner.os }}-psmodules-lint- - name: Install PSScriptAnalyzer - if: steps.cache-lint-modules.outputs.cache-hit != 'true' + if: steps.template_guard.outputs.is_template == 'false' && steps.cache-lint-modules.outputs.cache-hit != 'true' shell: pwsh run: | Set-PSRepository -Name PSGallery -InstallationPolicy Trusted Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser - name: Run PSScriptAnalyzer + if: steps.template_guard.outputs.is_template == 'false' shell: pwsh run: | $results = Invoke-ScriptAnalyzer -Path ./{{ModuleName}} -Recurse -Settings PSGallery -ReportSummary