From 6309a4657605078db942cbb99b6d8610c325f09d Mon Sep 17 00:00:00 2001 From: Justin Beeson Date: Wed, 22 Jul 2026 13:04:53 -0700 Subject: [PATCH 1/2] Infer module build inputs from source Derive build inputs from the source manifest and layout so module build scripts no longer repeat module metadata. - infer the module name when the caller omits it - package source-root assets automatically while preserving explicit copy paths - document and test the convention, and release it as 2.1.0 --- README.md | 6 ++-- build.ps1 | 10 ++---- src/PSModuleUtils.psd1 | 2 +- src/Public/Build-PSModule.ps1 | 61 ++++++++++++++++++++++++++-------- tests/Build-PSModule.Tests.ps1 | 54 ++++++++++++++++++++++++------ 5 files changed, 97 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 22b433b..052a2fd 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@ Follow [The PowerShell Best Practices and Style Guide](https://poshcode.gitbooks Use the following additional guidelines: -- Modules are built with [ModuleBuilder](https://github.com/PoshCode/ModuleBuilder) via `Build-PSModule`. There is no source `.psm1`: ModuleBuilder concatenates every `.ps1` under `Public`/`Private` into a single built `.psm1`, so a module's source directory holds only a manifest template (`ModuleName.psd1`) and its function folders. -- The manifest template is hand-authored and treated as a source file, not a generated one. `Build-PSModule` copies it forward and only overwrites `FunctionsToExport`, `AliasesToExport`, the version/prerelease, and git-derived fields (`Author`, `CompanyName`, `Copyright`, `ProjectUri`, `ReleaseNotes`) — everything else you author (`GUID`, `Description`, `RequiredModules`, `PrivateData.PSData.Tags`, etc.) is preserved as-is. To allow the version/prerelease/release notes to be stamped, pre-declare `PrivateData.PSData.Prerelease` and `PrivateData.PSData.ReleaseNotes` in the template (empty strings are fine). Run `New-PSModuleManifest` to scaffold or migrate a compatible template. +- Modules are built with [ModuleBuilder](https://github.com/PoshCode/ModuleBuilder) via `Build-PSModule`. There is no source `.psm1`: ModuleBuilder concatenates every `.ps1` under the configured source directories into a single built `.psm1`. +- The manifest template is hand-authored and treated as a source file, not a generated one. By default, `Build-PSModule` derives the module name from the manifest filename and uses its `ModuleVersion` and `Prerelease` values. Use `-Version` only for an explicit build override. ModuleBuilder updates `FunctionsToExport` and `AliasesToExport`, while PSModuleUtils stamps git-derived fields (`Author`, `CompanyName`, `Copyright`, `ProjectUri`, `ReleaseNotes`). Everything else you author (`GUID`, `Description`, `RequiredModules`, `PrivateData.PSData.Tags`, etc.) is preserved as-is. Pre-declare `PrivateData.PSData.Prerelease` and `PrivateData.PSData.ReleaseNotes` in the template (empty strings are fine). Run `New-PSModuleManifest` to scaffold or migrate a compatible template. - Ideally, each module and each of its functions should have a set of [Pester](https://github.com/pester/Pester) unit/integration tests. At the least, any new functions or functionality should have an associated test. - Create all functions as single `.ps1` files with the same name and without `Export-ModuleMember` statements. - The files should be in an appropriate nested `Public` folder that corresponds to its API category. - Functions that are used by other functions should be put in either `Utils` or `Private`, depending on their usage. - **Tests must live in a top-level `tests/` folder, never inside `Public`/`Private`.** ModuleBuilder inlines every `.ps1` it finds in those folders with no exclusion, so a co-located `*.Tests.ps1` leaks `Describe`/`It` blocks into the built module and its exports. -- Keep packaged assets outside `Public` and `Private`, then pass their paths to `Build-PSModule -CopyPaths`. Use names that describe their role, such as `Assemblies`, `bin/`, `Settings`, `Schemas`, `Templates`, `Resources`, or culture names such as `en-US`. ModuleBuilder copies each path intact while compiling only the configured source directories into the generated `.psm1`. +- Keep packaged assets outside the configured source directories. `Build-PSModule` copies every other source-root item automatically, preserving directories such as `Assemblies`, `bin`, `Settings`, `Schemas`, `Templates`, `Resources`, and culture names such as `en-US`. Use `-CopyPaths` only for additional files or directories outside that automatic set. - Declare files that PowerShell loads as part of module import in the manifest. Use `RequiredAssemblies` for prerequisite DLLs, `FormatsToProcess` for formatting files, and `TypesToProcess` for type extensions. Use `FileList` only as package inventory. Other runtime assets can be resolved relative to `$PSScriptRoot`; see `Get-PSModuleAnalyzerSettingsPath` for handling source and built layouts. The folder structure should be maintained like the example below: diff --git a/build.ps1 b/build.ps1 index 22fc1f2..18c595c 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,9 +1,3 @@ -$BuildPSModule = @{ - Name = 'PSModuleUtils' - Version = '2.0.1' - CopyPaths = 'Settings' -} - Push-Location -Path $PSScriptRoot Import-Module -Name 'PSModuleUtils' -MinimumVersion '2.0.0' -Force -ErrorAction Stop Get-ChildItem -Path "$PSScriptRoot/src/Private", "$PSScriptRoot/src/Public" -Filter '*.ps1' -Recurse | @@ -11,6 +5,6 @@ Get-ChildItem -Path "$PSScriptRoot/src/Private", "$PSScriptRoot/src/Public" -Fil if (-not $env:GITHUB_ACTIONS) { Invoke-PSModuleAnalyzer -Fix } -Build-PSModule @BuildPSModule -Test-PSModule -Name $BuildPSModule['Name'] +$builtManifest = Build-PSModule +Test-PSModule -Name $builtManifest.BaseName Pop-Location diff --git a/src/PSModuleUtils.psd1 b/src/PSModuleUtils.psd1 index 96ca707..7c080a0 100644 --- a/src/PSModuleUtils.psd1 +++ b/src/PSModuleUtils.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PSModuleUtils.psm1' - ModuleVersion = '2.0.1' + ModuleVersion = '2.1.0' GUID = '3c63c38f-c32c-4837-a6fa-0b456f4099ce' Author = '' CompanyName = '' diff --git a/src/Public/Build-PSModule.ps1 b/src/Public/Build-PSModule.ps1 index 504a664..620b201 100644 --- a/src/Public/Build-PSModule.ps1 +++ b/src/Public/Build-PSModule.ps1 @@ -7,9 +7,9 @@ Compiles a module's public and private function files into a single versioned mo directory with ModuleBuilder's Build-Module, then stamps git-derived metadata (author, company, copyright, project URI, release notes) onto the built manifest with Update-Metadata. -Files and directories that must remain separate in the published module, such as assemblies, schemas, -templates, settings, and localized content, can be copied with CopyPaths. Copy paths retain their source -names beneath the module output directory and are never concatenated into the generated .psm1. +Files and directories outside the compiled source folders are copied into the published module automatically. +Additional paths can be copied with CopyPaths. Copy paths retain their source names beneath the module output +directory and are never concatenated into the generated .psm1. The source directory must contain a hand-authored manifest template named ".psd1" and the function folders it references. ModuleBuilder derives FunctionsToExport from the public filter and @@ -21,7 +21,7 @@ pre-declare PrivateData.PSData.Prerelease and PrivateData.PSData.ReleaseNotes (e Run New-PSModuleManifest to generate a compatible manifest template for a module that does not have one. .PARAMETER Name -The name of the module. The source manifest is expected at "/.psd1". +The name of the module. When omitted, the name is derived from the single module manifest in SourceDirectory. .PARAMETER Version The module version, optionally with a SemVer prerelease label (e.g. "2.0.0-alpha"). When omitted, the @@ -40,9 +40,9 @@ The source subfolders, in load order, that ModuleBuilder concatenates into the b The filter identifying public (exported) function files, relative to the source directory. .PARAMETER CopyPaths -Files or directories to copy recursively into the built module without compilation. Paths are relative -to the source directory unless absolute. Use purpose-specific directories such as Assemblies, bin, -Settings, Schemas, Templates, Resources, or culture names such as en-US. +Additional files or directories to copy recursively into the built module without compilation. Paths are +relative to the source directory unless absolute. Source-root items outside SourceDirectories are copied +automatically. .PARAMETER SkipGitMetadata Skips stamping git-derived metadata onto the built manifest. Useful when building outside a git tree. @@ -51,7 +51,10 @@ Skips stamping git-derived metadata onto the built manifest. Useful when buildin System.IO.FileInfo for the built module manifest. .EXAMPLE -Build-PSModule -Name 'MyModule' -Version '2.0.0' -SourceDirectory "$PWD/src" +Build-PSModule -SourceDirectory "$PWD/src" + +.EXAMPLE +Build-PSModule -Name 'MyModule' -Version '2.0.0-preview1' -SourceDirectory "$PWD/src" .NOTES Requires the ModuleBuilder and Metadata modules. @@ -60,7 +63,7 @@ function Build-PSModule { [CmdletBinding()] [OutputType([System.IO.FileInfo])] param ( - [String]$Name = 'PSModule', + [String]$Name, [String]$Version, [String]$SourceDirectory = "$PWD/src", [String]$OutputDirectory = "$PWD/out", @@ -72,9 +75,19 @@ function Build-PSModule { $ErrorActionPreference = 'Stop' - $sourceManifest = Join-Path -Path $SourceDirectory -ChildPath "$Name.psd1" - if (-not (Test-Path -Path $sourceManifest)) { - throw "Source manifest not found at '$sourceManifest'. Run New-PSModuleManifest to create one." + if ($Name) { + $sourceManifest = Join-Path -Path $SourceDirectory -ChildPath "$Name.psd1" + if (-not (Test-Path -Path $sourceManifest)) { + throw "Source manifest not found at '$sourceManifest'. Run New-PSModuleManifest to create one." + } + } + else { + $sourceManifests = @(Get-ChildItem -LiteralPath $SourceDirectory -Filter '*.psd1' -File) + if ($sourceManifests.Count -ne 1) { + throw "Expected one module manifest in '$SourceDirectory', but found $($sourceManifests.Count)." + } + $sourceManifest = $sourceManifests[0].FullName + $Name = $sourceManifests[0].BaseName } $canonicalSourceDirectories = @('Enum', 'Classes', 'Private', 'Public') @@ -108,8 +121,28 @@ function Build-PSModule { Passthru = $true } - if ($CopyPaths.Count -gt 0) { - $buildModule['CopyPaths'] = $CopyPaths + $compiledSourceRoots = foreach ($sourceDirectoryName in $SourceDirectories) { + $normalizedSourceDirectory = $sourceDirectoryName -replace '\\', '/' + ($normalizedSourceDirectory -split '/', 2)[0] + } + $excludedSourceItems = @( + (Split-Path -Path $sourceManifest -Leaf) + "$Name.psm1" + ) + $automaticCopyPaths = foreach ($sourceItem in Get-ChildItem -LiteralPath $SourceDirectory -Force) { + $isCompiledSourceDirectory = $sourceItem.PSIsContainer -and @( + $compiledSourceRoots | Where-Object { $sourceItem.Name -like $_ } + ).Count -gt 0 + if (-not $isCompiledSourceDirectory -and $sourceItem.Name -notin $excludedSourceItems) { + $sourceItem.Name + } + } + $resolvedCopyPaths = @( + $automaticCopyPaths + $CopyPaths + ) | Select-Object -Unique + if ($resolvedCopyPaths.Count -gt 0) { + $buildModule['CopyPaths'] = $resolvedCopyPaths } if ($Version) { diff --git a/tests/Build-PSModule.Tests.ps1 b/tests/Build-PSModule.Tests.ps1 index 91dd860..3d2138e 100644 --- a/tests/Build-PSModule.Tests.ps1 +++ b/tests/Build-PSModule.Tests.ps1 @@ -10,13 +10,11 @@ Describe 'Integration Tests' -Tag 'Integration' { } It 'should build a versioned module' { - $BuildPSModule = @{ - Name = 'PSModuleUtils' - Version = '1.0.0-pester' - CopyPaths = 'Settings' - } - - Build-PSModule @BuildPSModule -SourceDirectory "$PSScriptRoot/../src" -OutputDirectory "$TestDrive/out" -SkipGitMetadata + Build-PSModule ` + -Version '1.0.0-pester' ` + -SourceDirectory "$PSScriptRoot/../src" ` + -OutputDirectory "$TestDrive/out" ` + -SkipGitMetadata # ModuleBuilder strips the prerelease label from the output folder name. "$TestDrive/out/PSModuleUtils/1.0.0/PSModuleUtils.psd1" | Should -Exist @@ -37,20 +35,21 @@ Describe 'Integration Tests' -Tag 'Integration' { $binaryFixture = [Byte[]](0, 1, 2, 13, 10, 255) Set-Content -Path "$assembliesDirectory/Example.dll" -Value $binaryFixture -AsByteStream Set-Content -Path "$schemasDirectory/Example.schema.json" -Value '{}' + Set-Content -Path "$sourceDirectory/AssetModule.types.ps1xml" -Value '' Set-Content -Path "$publicDirectory/Get-Example.ps1" -Value 'function Get-Example { $true }' New-ModuleManifest -Path "$sourceDirectory/AssetModule.psd1" ` -RootModule 'AssetModule.psm1' ` -ModuleVersion '1.0.0' ` -FunctionsToExport @() - Build-PSModule -Name 'AssetModule' ` - -SourceDirectory $sourceDirectory ` + Build-PSModule -SourceDirectory $sourceDirectory ` -OutputDirectory "$TestDrive/assets-out" ` - -CopyPaths 'Assemblies', 'Schemas' ` -SkipGitMetadata "$TestDrive/assets-out/AssetModule/1.0.0/Assemblies/Example.dll" | Should -Exist "$TestDrive/assets-out/AssetModule/1.0.0/Schemas/Example.schema.json" | Should -Exist + "$TestDrive/assets-out/AssetModule/1.0.0/AssetModule.types.ps1xml" | Should -Exist + "$TestDrive/assets-out/AssetModule/1.0.0/Public" | Should -Not -Exist $copiedBinary = Get-Content ` -Path "$TestDrive/assets-out/AssetModule/1.0.0/Assemblies/Example.dll" ` -AsByteStream ` @@ -58,6 +57,41 @@ Describe 'Integration Tests' -Tag 'Integration' { [Convert]::ToBase64String($copiedBinary) | Should -Be ([Convert]::ToBase64String($binaryFixture)) } + It 'should reject ambiguous source manifest discovery' { + $sourceDirectory = Join-Path -Path $TestDrive -ChildPath 'AmbiguousModule/src' + $null = New-Item -ItemType Directory -Path $sourceDirectory -Force + New-ModuleManifest -Path "$sourceDirectory/First.psd1" -RootModule 'First.psm1' -ModuleVersion '1.0.0' + New-ModuleManifest -Path "$sourceDirectory/Second.psd1" -RootModule 'Second.psm1' -ModuleVersion '1.0.0' + + { + Build-PSModule ` + -SourceDirectory $sourceDirectory ` + -OutputDirectory "$TestDrive/ambiguous-out" ` + -SkipGitMetadata + } | Should -Throw "*Expected one module manifest in '$sourceDirectory', but found 2.*" + } + + It 'should preserve additional explicit copy paths' { + $sourceDirectory = Join-Path -Path $TestDrive -ChildPath 'ExplicitAssetModule/src' + $publicDirectory = Join-Path -Path $sourceDirectory -ChildPath 'Public' + $externalDirectory = Join-Path -Path $TestDrive -ChildPath 'ExternalAssets' + $null = New-Item -ItemType Directory -Path $publicDirectory, $externalDirectory -Force + Set-Content -Path "$publicDirectory/Get-Example.ps1" -Value 'function Get-Example { $true }' + Set-Content -Path "$externalDirectory/NOTICE.txt" -Value 'Additional asset' + New-ModuleManifest -Path "$sourceDirectory/ExplicitAssetModule.psd1" ` + -RootModule 'ExplicitAssetModule.psm1' ` + -ModuleVersion '1.0.0' ` + -FunctionsToExport @() + + Build-PSModule ` + -SourceDirectory $sourceDirectory ` + -OutputDirectory "$TestDrive/explicit-assets-out" ` + -CopyPaths $externalDirectory ` + -SkipGitMetadata + + "$TestDrive/explicit-assets-out/ExplicitAssetModule/1.0.0/ExternalAssets/NOTICE.txt" | Should -Exist + } + It 'should reject noncanonical source directory casing' { $sourceDirectory = Join-Path -Path $TestDrive -ChildPath 'CaseModule/src' $publicDirectory = Join-Path -Path $sourceDirectory -ChildPath 'public' From 5b5f8e4ab44b53902474085b4eaf5d190f36d26f Mon Sep 17 00:00:00 2001 From: Justin Beeson Date: Wed, 22 Jul 2026 13:24:27 -0700 Subject: [PATCH 2/2] Return built manifest from build script --- build.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/build.ps1 b/build.ps1 index 18c595c..2e60300 100644 --- a/build.ps1 +++ b/build.ps1 @@ -8,3 +8,4 @@ if (-not $env:GITHUB_ACTIONS) { $builtManifest = Build-PSModule Test-PSModule -Name $builtManifest.BaseName Pop-Location +$builtManifest