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
124 changes: 106 additions & 18 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,63 @@ name: Build & Test

on:
push:
branches: [ main ]
branches: [ main, develop ]
pull_request:
branches: [ main ]
branches: [ main, develop ]

jobs:
test-and-build:
name: Test & Build
test:
name: Test (${{ matrix.os }} / ${{ matrix.shell }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
include:
- os: ubuntu-latest
shell: pwsh
- os: windows-latest
shell: pwsh
- os: windows-latest
shell: powershell

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Cache PowerShell modules
uses: actions/cache@v4
id: ps-cache
with:
path: |
~/.local/share/powershell/Modules
~/Documents/PowerShell/Modules
~/Documents/WindowsPowerShell/Modules
key: ps-modules-${{ matrix.os }}-${{ matrix.shell }}-pester5-pssa

- name: Install Pester
shell: pwsh
if: steps.ps-cache.outputs.cache-hit != 'true'
shell: ${{ matrix.shell }}
run: |
Install-Module -Name Pester -MinimumVersion 5.0 -Force -Scope CurrentUser -SkipPublisherCheck

- name: Install PSScriptAnalyzer
if: steps.ps-cache.outputs.cache-hit != 'true'
shell: ${{ matrix.shell }}
run: |
Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -SkipPublisherCheck

- name: Run PSScriptAnalyzer
shell: ${{ matrix.shell }}
run: |
$Results = Invoke-ScriptAnalyzer -Path './YFridelance.PS.ModuleFactory' -Recurse -Settings './PSScriptAnalyzerSettings.psd1' -ReportSummary
if ($Results) {
$Results | Format-Table -AutoSize
throw "PSScriptAnalyzer found $($Results.Count) issue(s)."
}
Write-Host "PSScriptAnalyzer: No issues found." -ForegroundColor Green

- name: Run Pester tests
shell: pwsh
shell: ${{ matrix.shell }}
run: |
$PesterConfig = New-PesterConfiguration
$PesterConfig.Run.Path = './Tests'
Expand All @@ -40,10 +69,64 @@ jobs:
$PesterConfig.TestResult.OutputFormat = 'NUnitXml'
Invoke-Pester -Configuration $PesterConfig

- name: Build module (dogfooding)
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}-${{ matrix.shell }}
path: ./TestResults.xml

build-and-publish:
name: Build & Publish
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Cache PowerShell modules
uses: actions/cache@v4
id: ps-cache
with:
path: |
~/.local/share/powershell/Modules
key: ps-modules-ubuntu-latest-pwsh-pester5

- name: Install Pester
if: steps.ps-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
Install-Module -Name Pester -MinimumVersion 5.0 -Force -Scope CurrentUser -SkipPublisherCheck

- name: Build module
shell: pwsh
run: ./build.ps1 -Clean

- name: Set prerelease tag
if: github.ref == 'refs/heads/develop'
shell: pwsh
run: |
. ./YFridelance.PS.ModuleFactory/Private/Update-ManifestField.ps1
$Script:DefaultEncoding = [System.Text.UTF8Encoding]::new($true)
$ManifestPath = './dist/YFridelance.PS.ModuleFactory/YFridelance.PS.ModuleFactory.psd1'
$PrereleaseTag = "preview$($env:GITHUB_RUN_NUMBER)"
Update-ManifestField -ManifestPath $ManifestPath -FieldName 'Prerelease' -Value $PrereleaseTag
Write-Host "Prerelease tag set: $PrereleaseTag" -ForegroundColor Green

- name: Ensure no prerelease tag
if: github.ref == 'refs/heads/main'
shell: pwsh
run: |
. ./YFridelance.PS.ModuleFactory/Private/Update-ManifestField.ps1
$Script:DefaultEncoding = [System.Text.UTF8Encoding]::new($true)
$ManifestPath = './dist/YFridelance.PS.ModuleFactory/YFridelance.PS.ModuleFactory.psd1'
Update-ManifestField -ManifestPath $ManifestPath -FieldName 'Prerelease' -Value ''
Write-Host "Prerelease tag cleared for stable release." -ForegroundColor Green

- name: Verify build output
shell: pwsh
run: |
Expand All @@ -52,32 +135,37 @@ jobs:
throw "Build output not found: $ManifestPath"
}
$Manifest = Test-ModuleManifest -Path $ManifestPath
$Prerelease = $Manifest.PrivateData.PSData.Prerelease
Write-Host "Module: $($Manifest.Name)"
Write-Host "Version: $($Manifest.Version)"
if ($Prerelease) { Write-Host "Prerelease: $Prerelease" }
Write-Host "Functions: $($Manifest.ExportedFunctions.Keys -join ', ')"

- name: Upload build artifact
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: YFridelance.PS.ModuleFactory
path: ./dist/YFridelance.PS.ModuleFactory/

- name: Publish to PowerShell Gallery
if: matrix.os == 'ubuntu-latest' && github.event_name == 'push'
shell: pwsh
env:
PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
DOTNET_CLI_UI_LANGUAGE: en
run: |
$ModulePath = './dist/YFridelance.PS.ModuleFactory'
if (-not (Test-Path $ModulePath)) {
throw "Build output not found: $ModulePath"
}
Publish-Module -Path $ModulePath -NuGetApiKey $env:PSGALLERY_API_KEY -Repository PSGallery

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}
path: ./TestResults.xml
try {
Publish-Module -Path $ModulePath -NuGetApiKey $env:PSGALLERY_API_KEY -Repository PSGallery -ErrorAction Stop
Write-Host "Published successfully to PSGallery." -ForegroundColor Green
}
catch {
if ($_.Exception.Message -match '409|already exists|duplicate') {
Write-Warning "Module version already exists on PSGallery. Skipping publish."
}
else {
throw
}
}
6 changes: 6 additions & 0 deletions PSScriptAnalyzerSettings.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@{
ExcludeRules = @(
'PSUseShouldProcessForStateChangingFunctions'
'PSUseSingularNouns'
)
}
2 changes: 1 addition & 1 deletion Tests/Fixtures/MonolithicModule/MonolithicModule.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ PrivateData = @{
# ReleaseNotes = ''

# Prerelease string of this module
# Prerelease = ''
Prerelease = ''

# Flag to indicate whether the module requires explicit user acceptance for install/update/save
# RequireLicenseAcceptance = $false
Expand Down
2 changes: 1 addition & 1 deletion Tests/Fixtures/SampleModule/SampleModule.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ PrivateData = @{
# ReleaseNotes = ''

# Prerelease string of this module
# Prerelease = ''
Prerelease = ''

# Flag to indicate whether the module requires explicit user acceptance for install/update/save
# RequireLicenseAcceptance = $false
Expand Down
52 changes: 52 additions & 0 deletions Tests/Unit/Private/Update-ManifestField.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,56 @@ Describe 'Update-ManifestField' {
$Data.AliasesToExport | Should -Contain 'al2'
}
}

Context 'Scalar field - Prerelease (nested in PSData)' {
BeforeAll {
$Script:PrereleaseManifestPath = Join-Path -Path $Script:TempDir -ChildPath 'PrereleaseTest.psd1'
}

BeforeEach {
# Create a fresh copy for each test
Copy-Item -Path $Script:RefManifestPath -Destination $Script:PrereleaseManifestPath -Force
# Uncomment the Prerelease field so Update-ManifestField can locate and replace it
$Content = [System.IO.File]::ReadAllText($Script:PrereleaseManifestPath, [System.Text.Encoding]::UTF8)
$Content = $Content -replace '# Prerelease = ''''', 'Prerelease = '''''
[System.IO.File]::WriteAllText($Script:PrereleaseManifestPath, $Content, [System.Text.UTF8Encoding]::new($true))
}

It 'should set Prerelease to a preview string' {
Update-ManifestField -ManifestPath $Script:PrereleaseManifestPath -FieldName 'Prerelease' -Value 'preview1'
$Data = Import-PowerShellDataFile -Path $Script:PrereleaseManifestPath
$Data.PrivateData.PSData.Prerelease | Should -Be 'preview1'
}

It 'should clear Prerelease by setting to empty string' {
Update-ManifestField -ManifestPath $Script:PrereleaseManifestPath -FieldName 'Prerelease' -Value 'preview1'
Update-ManifestField -ManifestPath $Script:PrereleaseManifestPath -FieldName 'Prerelease' -Value ''
$Data = Import-PowerShellDataFile -Path $Script:PrereleaseManifestPath
$Data.PrivateData.PSData.Prerelease | Should -Be ''
}

It 'should handle hyphenated prerelease strings' {
Update-ManifestField -ManifestPath $Script:PrereleaseManifestPath -FieldName 'Prerelease' -Value 'beta-1'
$Data = Import-PowerShellDataFile -Path $Script:PrereleaseManifestPath
$Data.PrivateData.PSData.Prerelease | Should -Be 'beta-1'
}

It 'should preserve other manifest fields when updating Prerelease' {
Update-ManifestField -ManifestPath $Script:PrereleaseManifestPath -FieldName 'Prerelease' -Value 'preview1'
$Data = Import-PowerShellDataFile -Path $Script:PrereleaseManifestPath
# Verify Prerelease was updated
$Data.PrivateData.PSData.Prerelease | Should -Be 'preview1'
# Verify top-level fields are preserved
$Data.ModuleVersion | Should -Be '1.0.0'
$Data.Author | Should -Be 'TestAuthor'
$Data.RootModule | Should -Be 'RefModule.psm1'
}

It 'should update Prerelease when it already has a value' {
Update-ManifestField -ManifestPath $Script:PrereleaseManifestPath -FieldName 'Prerelease' -Value 'alpha1'
Update-ManifestField -ManifestPath $Script:PrereleaseManifestPath -FieldName 'Prerelease' -Value 'beta2'
$Data = Import-PowerShellDataFile -Path $Script:PrereleaseManifestPath
$Data.PrivateData.PSData.Prerelease | Should -Be 'beta2'
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function ConvertTo-SortedClassFileName {
function ConvertTo-SortedClassFileName {
<#
.SYNOPSIS
Converts a class name and sort index into a standardised, numerically-prefixed class file name.
Expand Down
6 changes: 3 additions & 3 deletions YFridelance.PS.ModuleFactory/Private/Get-AliasesFromFile.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,16 @@ function Get-AliasesFromFile {

# Pattern: optional leading whitespace, # Alias: <values>
$AliasPattern = '(?m)^\s*#\s*Alias\s*:\s*(.+)\s*$'
$Matches = [System.Text.RegularExpressions.Regex]::Matches($FileContent, $AliasPattern)
$AliasMatches = [System.Text.RegularExpressions.Regex]::Matches($FileContent, $AliasPattern)

if ($Matches.Count -eq 0) {
if ($AliasMatches.Count -eq 0) {
Write-Verbose "Get-AliasesFromFile: No alias annotations found in '$FilePath'."
return [string[]]@()
}

$AliasNames = [System.Collections.Generic.List[string]]::new()

foreach ($Match in $Matches) {
foreach ($Match in $AliasMatches) {
$RawValue = $Match.Groups[1].Value.Trim()
Write-Verbose " Found alias annotation value: '$RawValue'"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function Get-FunctionNamesFromFile {
function Get-FunctionNamesFromFile {
<#
.SYNOPSIS
Extracts the names of all top-level function definitions from a PowerShell script file.
Expand Down
2 changes: 1 addition & 1 deletion YFridelance.PS.ModuleFactory/Private/Merge-SourceFiles.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function Merge-SourceFiles {
function Merge-SourceFiles {
<#
.SYNOPSIS
Merges ordered PowerShell source files into a single monolithic script string.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function New-DevPsm1Content {
function New-DevPsm1Content {
<#
.SYNOPSIS
Generates the content for a development .psm1 file that dot-sources all source files dynamically.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function Split-PsFileContent {
function Split-PsFileContent {
<#
.SYNOPSIS
Parses a PowerShell file and splits its content into typed, named segments.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function Test-ModuleProjectStructure {
function Test-ModuleProjectStructure {
<#
.SYNOPSIS
Validates the structure of a PowerShell module project directory.
Expand Down
20 changes: 17 additions & 3 deletions YFridelance.PS.ModuleFactory/Private/Update-ManifestField.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function Update-ManifestField {
function Update-ManifestField {
<#
.SYNOPSIS
Updates a single field in a PowerShell module manifest (.psd1) file in place.
Expand All @@ -20,14 +20,17 @@ function Update-ManifestField {
Scalar fields (ModuleVersion, Description, Author):
- Written as 'value' (single-quoted string).

Prerelease is handled as a scalar field within the PrivateData.PSData section.
Only alphanumeric characters and hyphens are valid for PSGallery prerelease strings.

The file is written back with UTF-8 BOM encoding and CRLF line endings.

.PARAMETER ManifestPath
Full path to the .psd1 module manifest file. The file must exist.

.PARAMETER FieldName
The manifest field to update. Must be one of:
FunctionsToExport, AliasesToExport, ModuleVersion, Description, Author.
FunctionsToExport, AliasesToExport, ModuleVersion, Description, Author, Prerelease.

.PARAMETER Value
The new value for the field. For array fields, pass a [string[]] or [object[]].
Expand All @@ -43,6 +46,11 @@ function Update-ManifestField {
Update-ManifestField -ManifestPath 'C:\MyModule\MyModule.psd1' `
-FieldName 'FunctionsToExport' `
-Value $Functions

.EXAMPLE
Update-ManifestField -ManifestPath 'C:\MyModule\MyModule.psd1' `
-FieldName 'Prerelease' `
-Value 'beta1'
#>
[CmdletBinding()]
[OutputType([void])]
Expand All @@ -52,7 +60,7 @@ function Update-ManifestField {
[string]$ManifestPath,

[Parameter(Mandatory = $true)]
[ValidateSet('FunctionsToExport', 'AliasesToExport', 'ModuleVersion', 'Description', 'Author')]
[ValidateSet('FunctionsToExport', 'AliasesToExport', 'ModuleVersion', 'Description', 'Author', 'Prerelease')]
[string]$FieldName,

[Parameter(Mandatory = $true)]
Expand Down Expand Up @@ -115,6 +123,12 @@ function Update-ManifestField {

Write-Verbose " New value string: $ValueString"

# Validate Prerelease value for PSGallery compatibility
if ($FieldName -eq 'Prerelease' -and $Value -ne '' -and $Value -match '[^a-zA-Z0-9-]') {
Write-Warning ("Update-ManifestField: Prerelease value '$Value' contains characters not supported by PSGallery. " +
"Only alphanumeric characters and hyphens are allowed.")
}

# Regex pattern to match the field and its current value in the .psd1
# Handles:
# FieldName = 'value'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ PowerShellVersion = '5.1'
# NestedModules = @()

# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
FunctionsToExport = 'Build-PSModule', 'Initialize-PSModule', 'Split-PSModule',
FunctionsToExport = 'Build-PSModule', 'Initialize-PSModule', 'Split-PSModule',
'Update-PSModuleVersion'

# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
Expand Down Expand Up @@ -111,7 +111,7 @@ PrivateData = @{
# ReleaseNotes = ''

# Prerelease string of this module
# Prerelease = ''
Prerelease = ''

# Flag to indicate whether the module requires explicit user acceptance for install/update/save
# RequireLicenseAcceptance = $false
Expand Down
Loading