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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<target-framework>`, `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:
Expand Down
11 changes: 3 additions & 8 deletions build.ps1
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
$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 |
ForEach-Object -Process { . $_.FullName }
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
$builtManifest
2 changes: 1 addition & 1 deletion src/PSModuleUtils.psd1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
RootModule = 'PSModuleUtils.psm1'
ModuleVersion = '2.0.1'
ModuleVersion = '2.1.0'
GUID = '3c63c38f-c32c-4837-a6fa-0b456f4099ce'
Author = ''
CompanyName = ''
Expand Down
61 changes: 47 additions & 14 deletions src/Public/Build-PSModule.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<Name>.psd1" and the
function folders it references. ModuleBuilder derives FunctionsToExport from the public filter and
Expand All @@ -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 "<SourceDirectory>/<Name>.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
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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",
Expand All @@ -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')
Expand Down Expand Up @@ -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) {
Expand Down
54 changes: 44 additions & 10 deletions tests/Build-PSModule.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,27 +35,63 @@ 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 '<Types />'
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 `
-Raw
[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'
Expand Down