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
3 changes: 3 additions & 0 deletions .github/workflows/powershell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ permissions:
jobs:
analyze:
uses: thisjustin816/reusable-workflows/.github/workflows/ps-scriptAnalyzer.yml@main
with:
path: src
use_local_psmoduleutils: true
secrets: inherit
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
out/*
tests/*
tests/coverage.xml
tests/testResults.xml
36 changes: 23 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ Follow [The PowerShell Best Practices and Style Guide](https://poshcode.gitbooks

Use the following additional guidelines:

- The module file itself (`.psm1`) should not contain any functions or logic, in most cases, other than a `foreach` loop to dot source all the `.ps1` files and `New-Alias` statements for specific functions.
- 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.
- 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.
- The module file (`.psm1`) and each function should have a corresponding `.Tests.ps1` file containing Pester unit/integration tests.
- Don't change any documentation or manifest files; they are automatically populated by the pipeline.
- **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`.
- 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 All @@ -34,17 +36,25 @@ The folder structure should be maintained like the example below:
├───.gitignore
├───LICENSE
├───README.md
├───build.ps1
│
└───src
├───src
│ ├───ModuleName.psd1
│ │
│ ├───Public
│ │ └───functionalArea
│ │ └───Verb-Noun.ps1
│ │
│ ├───Private
│ │ └───Verb-Noun.ps1
│ │
│ ├───Assemblies
│ │ └───Dependency.dll
│ │
│ └───Resources
│ └───Template.json
│
└───tests
├───ModuleName.Module.Tests.ps1
├───ModuleName.psm1
│
public
├───functionalArea
│ ├───Verb-Noun.ps1
│ └───Verb-Noun.Tests.ps1
│
private
├───Verb-Noun.ps1
└───Verb-Noun.Tests.ps1
```
22 changes: 16 additions & 6 deletions build.ps1
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
$BuildPSModule = @{
Name = 'PSModuleUtils'
Version = '1.8.0'
Guid = '3c63c38f-c32c-4837-a6fa-0b456f4099ce'
Description = 'A module with helper functions to build and publish PowerShell modules to the PSGallery.'
Tags = ('PSEdition_Desktop', 'PSEdition_Core', 'Windows')
Name = 'PSModuleUtils'
Version = '2.0.0'
CopyPaths = 'Settings'
}

Push-Location -Path $PSScriptRoot
Import-Module -Name "$PSScriptRoot/src/$($BuildPSModule['Name']).psm1" -Force
Import-Module -FullyQualifiedName @{
ModuleName = 'ModuleBuilder'
ModuleVersion = '3.0.0'
MaximumVersion = '3.*'
},
@{
ModuleName = 'Metadata'
ModuleVersion = '1.5.0'
MaximumVersion = '1.*'
} -ErrorAction Stop
Import-Module -Name 'Pester' -MinimumVersion '5.0' -MaximumVersion '5.*' -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
}
Expand Down
30 changes: 0 additions & 30 deletions src/PSModuleUtils.Module.Tests.ps1

This file was deleted.

32 changes: 32 additions & 0 deletions src/PSModuleUtils.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@{
RootModule = 'PSModuleUtils.psm1'
ModuleVersion = '2.0.0'
GUID = '3c63c38f-c32c-4837-a6fa-0b456f4099ce'
Author = ''
CompanyName = ''
Copyright = ''
Description = 'A module with helper functions to build and publish PowerShell modules to the PSGallery.'
PowerShellVersion = '7.4'
CompatiblePSEditions = @('Core')
FunctionsToExport = @()
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
RequiredModules = @(
@{ ModuleName = 'ModuleBuilder'; ModuleVersion = '3.0.0'; MaximumVersion = '3.*' }
@{ ModuleName = 'Metadata'; ModuleVersion = '1.5.0'; MaximumVersion = '1.*' }
@{ ModuleName = 'JBUtils'; ModuleVersion = '1.1.0'; MaximumVersion = '1.*' }
@{ ModuleName = 'Pester'; ModuleVersion = '5.0'; MaximumVersion = '5.*' }
@{ ModuleName = 'PSScriptAnalyzer'; ModuleVersion = '1.20.0'; MaximumVersion = '1.*' }
@{ ModuleName = 'Microsoft.PowerShell.PSResourceGet'; ModuleVersion = '1.0.0' }
)
PrivateData = @{
PSData = @{
Tags = @('PSEdition_Core', 'Windows', 'Linux', 'macOS')
ProjectUri = ''
LicenseUri = 'https://opensource.org/licenses/MIT'
ReleaseNotes = ''
Prerelease = ''
}
}
}
9 changes: 0 additions & 9 deletions src/PSModuleUtils.psm1

This file was deleted.

32 changes: 32 additions & 0 deletions src/Private/Get-PSModuleAnalyzerSettingsPath.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<#
.SYNOPSIS
Internal: resolves the bundled analyzer settings across source and built module layouts.

.DESCRIPTION
ModuleBuilder merges every source function into a single flat .psm1 while copying the Settings directory
intact. The settings directory is one level above a source function and a direct sibling of the built
.psm1. This probes both locations relative to the caller's own $PSScriptRoot.

.PARAMETER CallerScriptRoot
The $PSScriptRoot of the calling function.

.OUTPUTS
System.String path to the settings file, or nothing if neither candidate exists.

.EXAMPLE
Get-PSModuleAnalyzerSettingsPath -CallerScriptRoot $PSScriptRoot
#>
function Get-PSModuleAnalyzerSettingsPath {
[CmdletBinding()]
[OutputType([String])]
param (
[Parameter(Mandatory)]
[String]$CallerScriptRoot
)

$candidates = @(
(Join-Path -Path $CallerScriptRoot -ChildPath 'Settings/PSScriptAnalyzerSettings.psd1')
(Join-Path -Path $CallerScriptRoot -ChildPath '../Settings/PSScriptAnalyzerSettings.psd1')
)
$candidates | Where-Object -FilterScript { Test-Path -Path $_ } | Select-Object -First 1
}
74 changes: 74 additions & 0 deletions src/Private/Get-PSModuleGitMetadata.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<#
.SYNOPSIS
Internal: derives module manifest metadata from a git working tree.

.DESCRIPTION
Reads the git remote and history for the given path and returns the manifest metadata that should be
stamped onto a built module: author list, company name, copyright, project URI, and release notes.
Returns nothing (with a warning) when the path is not inside a git working tree, so callers can treat
git-derived metadata as best effort.

.PARAMETER Path
A path inside the git working tree to read metadata from. Defaults to the current location.

.OUTPUTS
PSCustomObject with Author, CompanyName, Copyright, ProjectUri, and ReleaseNotes properties.

.EXAMPLE
$metadata = Get-PSModuleGitMetadata -Path $SourceDirectory
#>
function Get-PSModuleGitMetadata {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
[CmdletBinding()]
[OutputType([PSCustomObject])]
param (
[String]$Path = "$PWD"
)

$ErrorActionPreference = 'Stop'

Push-Location -Path $Path
try {
$insideWorkTree = ( & git rev-parse --is-inside-work-tree 2>$null )
if ($LASTEXITCODE -ne 0 -or $insideWorkTree -ne 'true') {
Write-Warning -Message "'$Path' is not inside a git working tree; skipping git-derived metadata."
return
}

$repoUrl = ( & git config --get remote.origin.url )
if ($LASTEXITCODE -ne 0 -or [String]::IsNullOrWhiteSpace($repoUrl)) {
Write-Warning -Message "'$Path' has no origin remote; skipping git-derived metadata."
return
}
try {
$remoteMetadata = Resolve-PSModuleGitRemote -RepositoryUrl $repoUrl
}
catch {
Write-Warning -Message (
'A company name was not provided and the Git remote URL could not be resolved; ' +
'leaving CompanyName and ProjectUri blank.'
)
$remoteMetadata = [PSCustomObject]@{
Organization = ''
ProjectUri = ''
}
}
$companyName = $remoteMetadata.Organization
$copyright = if ($companyName) {
"(c) $( Get-Date -Format yyyy ) $companyName. All rights reserved."
}
else {
''
}

[PSCustomObject]@{
Author = (( & git log --format='%aN' -- . | Sort-Object -Unique ) -join ', ')
CompanyName = $companyName
Copyright = $copyright
ProjectUri = $remoteMetadata.ProjectUri
ReleaseNotes = ( & git log -1 --pretty=%B )[0]
}
}
finally {
Pop-Location
}
}
56 changes: 56 additions & 0 deletions src/Private/Get-PSModulePublishedManifest.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<#
.SYNOPSIS
Internal: retrieves a previously published module's manifest from a repository.

.DESCRIPTION
Saves a module from the given repository into a temporary directory and returns its manifest as a
hashtable, so callers can reuse identity fields (most importantly GUID) when generating or migrating
a source manifest template for a module that has already been published. Returns nothing (with a
verbose message, not a warning) when no published version is found, since this is an expected
outcome for a module that has never been published.

.PARAMETER Name
The name of the module to look up.

.PARAMETER Repository
The repository to search. Defaults to PSGallery.

.OUTPUTS
System.Collections.Hashtable of the published manifest, or nothing if none was found.

.EXAMPLE
Get-PSModulePublishedManifest -Name 'MyModule' -Repository 'PSGallery'
#>
function Get-PSModulePublishedManifest {
[CmdletBinding()]
[OutputType([Hashtable])]
param (
[Parameter(Mandatory)]
[String]$Name,

[String]$Repository = 'PSGallery'
)

$lookupPath = Join-Path `
-Path ([IO.Path]::GetTempPath()) `
-ChildPath "psmodule-manifest-lookup-$( (New-Guid).Guid )"
try {
$null = New-Item -ItemType Directory -Path $lookupPath -Force
Save-PSResource `
-Name $Name `
-Repository $Repository `
-Path $lookupPath `
-TrustRepository `
-ErrorAction SilentlyContinue
$publishedManifest = Get-ChildItem -Path $lookupPath -Filter "$Name.psd1" -Recurse | Select-Object -First 1
if ($publishedManifest) {
Import-PowerShellDataFile -Path $publishedManifest.FullName
}
else {
Write-Verbose -Message "No published version of '$Name' was found in repository '$Repository'."
}
}
finally {
Remove-Item -Path $lookupPath -Recurse -Force -ErrorAction SilentlyContinue
}
}
Loading
Loading