diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json index 23970e8..5713916 100644 --- a/.github/linters/.jscpd.json +++ b/.github/linters/.jscpd.json @@ -4,7 +4,8 @@ "consoleFull" ], "ignore": [ - "**/tests/**" + "**/tests/**", + "**/linters/**" ], "absolute": true } diff --git a/.github/workflows/Action-Test.yml b/.github/workflows/Action-Test.yml index 405a723..43a4295 100644 --- a/.github/workflows/Action-Test.yml +++ b/.github/workflows/Action-Test.yml @@ -27,6 +27,12 @@ jobs: - name: Action-Test uses: ./ - with: - working-directory: ./tests - subject: PSModule + + - name: Verify Helpers Module Installation + shell: pwsh + run: | + if (Get-Module -Name Helpers -ListAvailable) { + Write-Host "Helpers module successfully installed." + } else { + throw "Helpers module not found!" + } diff --git a/README.md b/README.md index d560186..20e1641 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,63 @@ -# Template-Action +# Install-PSModuleHelpers -A template repository for GitHub Actions +A GitHub Action to install and configure the PSModule helper modules for use in continuous integration and delivery (CI/CD) workflows. This action is +a critical component for setting up a standardized PowerShell environment across repositories using the PSModule framework. + +This GitHub Action is a part of the [PSModule framework](https://github.com/PSModule). It is recommended to use the +[Process-PSModule workflow](https://github.com/PSModule/Process-PSModule) to automate the whole process of managing the PowerShell module. + +## What this action does + +- Removes any existing instances of the `Helpers` module from the PowerShell session. +- Copies the latest version of the `Helpers` module into the PowerShell module directory. +- Imports the `Helpers` module, ensuring it is available for subsequent steps. + +This action helps maintain consistency and reliability across workflows that depend on the PSModule framework. ## Usage -### Inputs +```yaml +- name: Install PSModule Helpers + uses: PSModule/Install-PSModuleHelpers@v1 +``` + +## Inputs + +_No inputs required._ -### Secrets +## Secrets -### Outputs +_No secrets required._ -### Example +## Outputs + +This action does not provide any outputs. + +## Example + +Here's a complete workflow example demonstrating how to use the Install-PSModuleHelpers action: ```yaml -Example here +name: CI + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install PSModule Helpers + uses: PSModule/Install-PSModuleHelpers@v1 + + - name: Run additional steps + shell: pwsh + run: | + # Example usage of imported Helpers module + Get-Command -Module Helpers ``` diff --git a/action.yml b/action.yml index 1fca22c..d8ec7e4 100644 --- a/action.yml +++ b/action.yml @@ -1,48 +1,15 @@ -name: {{ NAME }} -description: {{ DESCRIPTION }} +name: Install-PSModuleHelpers +description: Installs the PSModule helpers. author: PSModule branding: icon: upload-cloud color: white -inputs: - subject: - description: The subject to greet - required: false - default: World - Debug: - description: Enable debug output. - required: false - default: 'false' - Verbose: - description: Enable verbose output. - required: false - default: 'false' - Version: - description: Specifies the version of the GitHub module to be installed. The value must be an exact version. - required: false - Prerelease: - description: Allow prerelease versions if available. - required: false - default: 'false' - WorkingDirectory: - description: The working directory where the script will run from. - required: false - default: ${{ github.workspace }} - runs: using: composite steps: - - name: {{ NAME }} - uses: PSModule/GitHub-Script@v1 - env: - {{ ORG }}_{{ NAME }}_INPUT_subject: ${{ inputs.subject }} - with: - Debug: ${{ inputs.Debug }} - Prerelease: ${{ inputs.Prerelease }} - Verbose: ${{ inputs.Verbose }} - Version: ${{ inputs.Version }} - WorkingDirectory: ${{ inputs.WorkingDirectory }} - Script: | - # {{ NAME }} - ${{ github.action_path }}/scripts/main.ps1 + - name: Install-PSModuleHelpers + shell: pwsh + run: | + # Install-PSModuleHelpers + ${{ github.action_path }}/scripts/main.ps1 diff --git a/scripts/Helpers/Helpers.psd1 b/scripts/Helpers/Helpers.psd1 new file mode 100644 index 0000000..c3dc67a --- /dev/null +++ b/scripts/Helpers/Helpers.psd1 @@ -0,0 +1,4 @@ +@{ + RootModule = 'Helpers.psm1' + ModuleVersion = '999.0.0' +} diff --git a/scripts/Helpers/Helpers.psm1 b/scripts/Helpers/Helpers.psm1 new file mode 100644 index 0000000..ae6cf25 --- /dev/null +++ b/scripts/Helpers/Helpers.psm1 @@ -0,0 +1,325 @@ +[CmdletBinding()] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', + Justification = 'Want to just write to the console, not the pipeline.' +)] +param() + +function Convert-VersionSpec { + <# + .SYNOPSIS + Converts legacy version parameters into a NuGet version range string. + + .DESCRIPTION + This function takes minimum, maximum, or required version parameters + and constructs a NuGet-compatible version range string. + + - If `RequiredVersion` is specified, the output is an exact match range. + - If both `MinimumVersion` and `MaximumVersion` are provided, + an inclusive range is returned. + - If only `MinimumVersion` is provided, it returns a minimum-inclusive range. + - If only `MaximumVersion` is provided, it returns an upper-bound range. + - If no parameters are provided, `$null` is returned. + + .EXAMPLE + Convert-VersionSpec -MinimumVersion "1.0.0" -MaximumVersion "2.0.0" + + Output: + ```powershell + [1.0.0,2.0.0] + ``` + + Returns an inclusive version range from 1.0.0 to 2.0.0. + + .EXAMPLE + Convert-VersionSpec -RequiredVersion "1.5.0" + + Output: + ```powershell + [1.5.0] + ``` + + Returns an exact match for version 1.5.0. + + .EXAMPLE + Convert-VersionSpec -MinimumVersion "1.0.0" + + Output: + ```powershell + [1.0.0, ] + ``` + + Returns a minimum-inclusive version range starting at 1.0.0. + + .EXAMPLE + Convert-VersionSpec -MaximumVersion "2.0.0" + + Output: + ```powershell + (, 2.0.0] + ``` + + Returns an upper-bound range up to version 2.0.0. + + .OUTPUTS + string + + .NOTES + The NuGet version range string based on the provided parameters. + The returned string follows NuGet versioning syntax. + + .LINK + https://psmodule.io/Convert/Functions/Convert-VersionSpec + #> + [OutputType([string])] + [CmdletBinding()] + param( + # The minimum version for the range. If specified alone, the range is open-ended upwards. + [Parameter()] + [string] $MinimumVersion, + + # The maximum version for the range. If specified alone, the range is open-ended downwards. + [Parameter()] + [string] $MaximumVersion, + + # Specifies an exact required version. If set, an exact version range is returned. + [Parameter()] + [string] $RequiredVersion + ) + + if ($RequiredVersion) { + # Use exact match in bracket notation. + return "[$RequiredVersion]" + } elseif ($MinimumVersion -and $MaximumVersion) { + # Both bounds provided; both are inclusive. + return "[$MinimumVersion,$MaximumVersion]" + } elseif ($MinimumVersion) { + # Only a minimum is provided. Use a minimum-inclusive range. + return "[$MinimumVersion, ]" + } elseif ($MaximumVersion) { + # Only a maximum is provided; lower bound open. + return "(, $MaximumVersion]" + } else { + return $null + } +} + +function Import-PSModule { + <# + .SYNOPSIS + Imports a build PS module. + + .DESCRIPTION + Imports a build PS module. + + .EXAMPLE + Import-PSModule -SourceFolderPath $ModuleFolderPath -ModuleName $moduleName + + Imports a module located at $ModuleFolderPath with the name $moduleName. + #> + [CmdletBinding()] + param( + # Path to the folder where the module source code is located. + [Parameter(Mandatory)] + [string] $Path + ) + + $moduleName = Split-Path -Path $Path -Leaf + $manifestFilePath = Join-Path -Path $Path "$moduleName.psd1" + + Write-Host " - Manifest file path: [$manifestFilePath]" + Resolve-PSModuleDependency -ManifestFilePath $manifestFilePath + + Write-Host ' - List installed modules' + Get-InstalledPSResource | Format-Table -AutoSize | Out-String + + Write-Host " - Importing module [$moduleName] v999" + Import-Module $Path + + Write-Host ' - List loaded modules' + $availableModules = Get-Module -ListAvailable -Refresh -Verbose:$false + $availableModules | Select-Object Name, Version, Path | Sort-Object Name | Format-Table -AutoSize | Out-String + Write-Host ' - List commands' + $commands = Get-Command -Module $moduleName -ListImported + Get-Command -Module $moduleName -ListImported | Format-Table -AutoSize | Out-String + + if ($moduleName -notin $commands.Source) { + throw 'Module not found' + } +} + +function Resolve-PSModuleDependency { + <# + .SYNOPSIS + Resolves module dependencies from a manifest file using Install-PSResource. + + .DESCRIPTION + Reads a module manifest (PSD1) and for each required module converts the old + Install-Module parameters (MinimumVersion, MaximumVersion, RequiredVersion) + into a single NuGet version range string for Install-PSResource's –Version parameter. + (Note: If RequiredVersion is set, that value takes precedence.) + + .EXAMPLE + Resolve-PSModuleDependency -ManifestFilePath 'C:\MyModule\MyModule.psd1' + Installs all modules defined in the manifest file, following PSModuleInfo structure. + + .NOTES + Should later be adapted to support both pre-reqs, and dependencies. + Should later be adapted to take 4 parameters sets: specific version ("requiredVersion" | "GUID"), latest version ModuleVersion, + and latest version within a range MinimumVersion - MaximumVersion. + #> + [CmdletBinding()] + param( + # The path to the manifest file. + [Parameter(Mandatory)] + [string] $ManifestFilePath + ) + + Write-Host 'Resolving dependencies' + $manifest = Import-PowerShellDataFile -Path $ManifestFilePath + Write-Host " - Reading [$ManifestFilePath]" + Write-Host " - Found [$($manifest.RequiredModules.Count)] module(s) to install" + + foreach ($requiredModule in $manifest.RequiredModules) { + # Build parameters for Install-PSResource (new version spec). + $psResourceParams = @{ + TrustRepository = $true + } + # Build parameters for Import-Module (legacy version spec). + $importParams = @{ + Force = $true + Verbose = $false + } + + if ($requiredModule -is [string]) { + $psResourceParams.Name = $requiredModule + $importParams.Name = $requiredModule + } else { + $psResourceParams.Name = $requiredModule.ModuleName + $importParams.Name = $requiredModule.ModuleName + + # Convert legacy version info for Install-PSResource. + $versionSpec = Convert-VersionSpec ` + -MinimumVersion $requiredModule.ModuleVersion ` + -MaximumVersion $requiredModule.MaximumVersion ` + -RequiredVersion $requiredModule.RequiredVersion + + if ($versionSpec) { + $psResourceParams.Version = $versionSpec + } + + # For Import-Module, keep the original version parameters. + if ($requiredModule.ModuleVersion) { + $importParams.MinimumVersion = $requiredModule.ModuleVersion + } + if ($requiredModule.RequiredVersion) { + $importParams.RequiredVersion = $requiredModule.RequiredVersion + } + if ($requiredModule.MaximumVersion) { + $importParams.MaximumVersion = $requiredModule.MaximumVersion + } + } + + Write-Host " - [$($psResourceParams.Name)] - Installing module with Install-PSResource using version spec: $($psResourceParams.Version)" + $VerbosePreferenceOriginal = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + $retryCount = 5 + $retryDelay = 10 + for ($i = 0; $i -lt $retryCount; $i++) { + try { + Install-PSResource @psResourceParams + break + } catch { + Write-Warning "Installation of $($psResourceParams.Name) failed with error: $_" + if ($i -eq $retryCount - 1) { + throw + } + Write-Warning "Retrying in $retryDelay seconds..." + Start-Sleep -Seconds $retryDelay + } + } + $VerbosePreference = $VerbosePreferenceOriginal + + Write-Host " - [$($importParams.Name)] - Importing module with legacy version spec" + $VerbosePreferenceOriginal = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + Import-Module @importParams + $VerbosePreference = $VerbosePreferenceOriginal + Write-Host " - [$($importParams.Name)] - Done" + } + Write-Host ' - Resolving dependencies - Done' +} + +function Show-FileContent { + <# + .SYNOPSIS + Prints the content of a file with line numbers in front of each line. + + .DESCRIPTION + Prints the content of a file with line numbers in front of each line. + + .EXAMPLE + $Path = 'C:\Utilities\Show-FileContent.ps1' + Show-FileContent -Path $Path + + Shows the content of the file with line numbers in front of each line. + #> + [CmdletBinding()] + param ( + # The path to the file to show the content of. + [Parameter(Mandatory)] + [string] $Path + ) + + $content = Get-Content -Path $Path + $lineNumber = 1 + $columnSize = $content.Count.ToString().Length + # Foreach line print the line number in front of the line with [ ] around it. + # The linenumber should dynamically adjust to the number of digits with the length of the file. + foreach ($line in $content) { + $lineNumberFormatted = $lineNumber.ToString().PadLeft($columnSize) + Write-Host "[$lineNumberFormatted] $line" + $lineNumber++ + } +} + +function Install-PSModule { + <# + .SYNOPSIS + Installs a build PS module. + + .DESCRIPTION + Installs a build PS module. + + .EXAMPLE + Install-PSModule -SourceFolderPath $ModuleFolderPath -ModuleName $moduleName + + Installs a module located at $ModuleFolderPath with the name $moduleName. + #> + [CmdletBinding()] + param( + # Path to the folder where the module source code is located. + [Parameter(Mandatory)] + [string] $Path, + + # Return the path of the installed module + [Parameter()] + [switch] $PassThru + ) + + $moduleName = Split-Path -Path $Path -Leaf + $manifestFilePath = Join-Path -Path $Path "$moduleName.psd1" + Write-Verbose " - Manifest file path: [$manifestFilePath]" -Verbose + Write-Host '::group::Resolving dependencies' + Resolve-PSModuleDependency -ManifestFilePath $manifestFilePath + Write-Host '::endgroup::' + $PSModulePath = $env:PSModulePath -split [System.IO.Path]::PathSeparator | Select-Object -First 1 + $codePath = New-Item -Path "$PSModulePath/$moduleName/999.0.0" -ItemType Directory -Force | Select-Object -ExpandProperty FullName + Copy-Item -Path "$Path/*" -Destination $codePath -Recurse -Force + Write-Host '::group::Importing module' + Import-Module -Name $moduleName -Verbose + Write-Host '::endgroup::' + if ($PassThru) { + return $codePath + } +} diff --git a/scripts/Helpers/PSScriptAnalyzer.Tests.psd1 b/scripts/Helpers/PSScriptAnalyzer.Tests.psd1 new file mode 100644 index 0000000..9f7bc65 --- /dev/null +++ b/scripts/Helpers/PSScriptAnalyzer.Tests.psd1 @@ -0,0 +1,55 @@ +@{ + Rules = @{ + PSAlignAssignmentStatement = @{ + Enable = $true + CheckHashtable = $true + } + PSAvoidLongLines = @{ + Enable = $true + MaximumLineLength = 150 + } + PSAvoidSemicolonsAsLineTerminators = @{ + Enable = $true + } + PSPlaceCloseBrace = @{ + Enable = $true + NewLineAfter = $false + IgnoreOneLineBlock = $true + NoEmptyLineBefore = $false + } + PSPlaceOpenBrace = @{ + Enable = $true + OnSameLine = $true + NewLineAfter = $true + IgnoreOneLineBlock = $true + } + PSProvideCommentHelp = @{ + Enable = $true + ExportedOnly = $false + BlockComment = $true + VSCodeSnippetCorrection = $false + Placement = 'begin' + } + PSUseConsistentIndentation = @{ + Enable = $true + IndentationSize = 4 + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + Kind = 'space' + } + PSUseConsistentWhitespace = @{ + Enable = $true + CheckInnerBrace = $true + CheckOpenBrace = $true + CheckOpenParen = $true + CheckOperator = $true + CheckPipe = $true + CheckPipeForRedundantWhitespace = $true + CheckSeparator = $true + CheckParameter = $true + IgnoreAssignmentOperatorInsideHashTable = $true + } + } + ExcludeRules = @( + 'PSUseToExportFieldsInManifest' + ) +} diff --git a/scripts/main.ps1 b/scripts/main.ps1 index cfdee7c..6e2b308 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -1,24 +1,16 @@ -#Requires -Modules GitHub - +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', + Justification = 'Wriite to the GitHub Actions log, not the pipeline.' +)] [CmdletBinding()] -param( - [Parameter()] - [string] $Subject = $env:__INPUT_subject -) - -begin { - $scriptName = $MyInvocation.MyCommand.Name - Write-Debug "[$scriptName] - Start" -} - -process { - try { - Write-Output "Hello, $Subject!" - } catch { - throw $_ - } -} +param() -end { - Write-Debug "[$scriptName] - End" -} +$PSModulePath = $env:PSModulePath -split [System.IO.Path]::PathSeparator | Select-Object -First 1 +Remove-Module -Name Helpers -Force -ErrorAction SilentlyContinue +Get-Command -Module Helpers | ForEach-Object { Remove-Item -Path function:$_ -Force } +Get-Item -Path "$PSModulePath/Helpers/999.0.0" -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force +$modulePath = New-Item -Path "$PSModulePath/Helpers/999.0.0" -ItemType Directory -Force | Select-Object -ExpandProperty FullName +Copy-Item -Path "$PSScriptRoot/Helpers/*" -Destination $modulePath -Recurse -Force +Write-Host '::group::Importing helpers' +Import-Module -Name Helpers -Verbose +Write-Host '::endgroup::'