docs/examples/pspihole/PSPiHole.buildfull.ps1

<#
    .SYNOPSIS
    Full validation build for the PSServiceDesk demo module.

    .DESCRIPTION
    Provides an Invoke-Build pipeline that groups validation into the categories used by the
    Stop Breaking Production presentation.
#>

[CmdletBinding()]
param ()

$script:moduleRoot        = $PSScriptRoot
$script:moduleName        = 'PSPiHole'
$script:manifestPath      = Join-Path -Path $script:moduleRoot -ChildPath "$script:moduleName.psd1"
$script:testRoot          = Join-Path -Path $script:moduleRoot -ChildPath 'Tests'
$script:coverageThreshold = 80

function Get-Manifest {
    <#
        .SYNOPSIS
        Gets the module manifest as a hashtable.
    #>


    [CmdletBinding()]
    param ()

    Import-PowerShellDataFile -Path $script:manifestPath
}

function Get-SourceFile {
    <#
        .SYNOPSIS
        Gets PowerShell source files that should be included in code coverage.
    #>


    [CmdletBinding()]
    param ()

    $sourceDirectories = @(
        (Join-Path -Path $script:moduleRoot -ChildPath 'Public'),
        (Join-Path -Path $script:moduleRoot -ChildPath 'Private')
    )

    foreach ($directory in $sourceDirectories) {
        Get-ChildItem -Path $directory -Filter '*.ps1' -Recurse -File
    }

    Get-Item -Path (Join-Path -Path $script:moduleRoot -ChildPath "$script:moduleName.psm1")
}

function Get-CoveragePercent {
    <#
        .SYNOPSIS
        Gets a coverage percentage from a Pester result object.

        .OUTPUTS
        System.Double
    #>


    [CmdletBinding()]
    [OutputType([double])]
    param (
        # Pester result object returned by Invoke-Pester -PassThru
        [Parameter(Mandatory)]
        [psobject]
        $PesterResult
    )

    if ($null -eq $PesterResult.CodeCoverage) {
        throw 'Pester did not return code coverage information.'
    }

    if ($null -ne $PesterResult.CodeCoverage.CoveragePercent) {
        [double]$PesterResult.CodeCoverage.CoveragePercent
        return
    }

    if ($null -ne $PesterResult.CodeCoverage.CommandsAnalyzed) {
        $commandsAnalyzed = [double]$PesterResult.CodeCoverage.CommandsAnalyzed.Count
        $commandsMissed = [double]$PesterResult.CodeCoverage.MissedCommands.Count

        if ($commandsAnalyzed -eq 0) {
            throw 'Pester analyzed zero commands for coverage.'
        }

        (($commandsAnalyzed - $commandsMissed) / $commandsAnalyzed) * 100
        return
    }

    throw 'Unable to read code coverage percentage from the Pester result.'
}

function Test-PSServiceDeskYamlFile {
    <#
        .SYNOPSIS
        Performs lightweight YAML syntax validation for demo config files.
    #>


    [CmdletBinding()]
    param (
        # YAML file to validate
        [Parameter(Mandatory)]
        [string]
        $Path
    )

    if ($null -ne (Get-Command -Name ConvertFrom-Yaml -ErrorAction SilentlyContinue)) {
        Get-Content -Path $Path -Raw | ConvertFrom-Yaml > $null
        return
    }

    $lineNumber = 0
    $blockScalarIndent = $null
    foreach ($line in Get-Content -Path $Path) {
        $lineNumber++

        if ($line -match '\t') {
            throw "YAML file '$Path' contains a tab on line $lineNumber."
        }

        if ($line -match '^\s*$' -or $line -match '^\s*#') {
            continue
        }

        $leadingSpaceCount = ($line.Length - $line.TrimStart(' ').Length)
        if ($null -ne $blockScalarIndent) {
            if ($leadingSpaceCount -gt $blockScalarIndent) {
                continue
            }

            $blockScalarIndent = $null
        }

        if ($leadingSpaceCount % 2 -ne 0) {
            throw "YAML file '$Path' has odd indentation on line $lineNumber."
        }

        if ($line -notmatch '^\s*(-\s+)?[A-Za-z][A-Za-z0-9_-]*:\s*.*$' -and
            $line -notmatch '^\s*-\s+[A-Za-z0-9_-]+\s*$') {
            throw "YAML file '$Path' has unsupported syntax on line $lineNumber."
        }

        if ($line -match ':\s*[|>]\s*$') {
            $blockScalarIndent = $leadingSpaceCount
        }
    }
}

Add-BuildTask . Validate

Add-BuildTask Validate CodeQuality, ReleaseHygiene, Content, ModuleConventions

Add-BuildTask CodeQuality Analyze, Test, Coverage

Add-BuildTask ReleaseHygiene ValidateModuleVersion, ValidateChangelog

Add-BuildTask Content ValidateJson, ValidateYaml

Add-BuildTask ModuleConventions ValidateLayout, ValidateExports, ValidateNaming

Add-BuildTask Analyze {
    $results = Invoke-ScriptAnalyzer -Path $script:moduleRoot -IncludeDefaultRules -Recurse

    if ($results) {
        $results | Format-Table -AutoSize
        throw 'PSScriptAnalyzer reported issues.'
    }
}

Add-BuildTask Test {
    if ($null -eq (Get-Command -Name New-PesterConfiguration -ErrorAction SilentlyContinue)) {
        throw 'Pester 5 is required because these tests use Pester 5 syntax.'
    }

    $configuration = New-PesterConfiguration
    $configuration.Run.Path = $script:testRoot
    $configuration.Run.PassThru = $true
    $configuration.Output.Verbosity = 'Normal'
    $configuration.CodeCoverage.Enabled = $true
    $configuration.CodeCoverage.Path = @(Get-SourceFile | Select-Object -ExpandProperty FullName)

    $result = Invoke-Pester -Configuration $configuration

    if ($result.FailedCount -gt 0) {
        throw "Pester reported $($result.FailedCount) failing test(s)."
    }

    $script:pesterResult = $result
}

Add-BuildTask Coverage -Jobs 'Test', {
    $result = $script:pesterResult
    $coveragePercent = Get-CoveragePercent -PesterResult $result
    Write-Information -MessageData ('Code coverage: {0:N2}%' -f $coveragePercent) -InformationAction Continue

    if ($coveragePercent -lt $script:coverageThreshold) {
        throw "Code coverage is below $script:coverageThreshold%."
    }
}

Add-BuildTask ValidateModuleVersion {
    $publishedModule = Find-Module -Name $script:moduleName -ErrorAction SilentlyContinue
    if ($null -eq $publishedModule) {
        Write-Warning 'Module not published - not validating version.'
        return
    }

    $manifest = Get-Manifest
    $currentVersion = [version]$manifest.ModuleVersion
    $publishedVersion = [version]$publishedModule.Version

    if ($currentVersion -le $publishedVersion) {
        throw "Module version $currentVersion must be greater than published version $publishedVersion."
    }
}

Add-BuildTask ValidateChangelog {
    $manifest = Get-Manifest
    $currentVersion = [version]$manifest.ModuleVersion
    $changelogPath = Join-Path -Path $script:moduleRoot -ChildPath 'CHANGELOG.md'

    if (-not (Test-Path -Path $changelogPath)) {
        throw 'CHANGELOG.md is missing.'
    }

    $versionHeading = '^## ' + [regex]::Escape($currentVersion.ToString()) + '$'
    if (-not (Select-String -Path $changelogPath -Pattern $versionHeading -Quiet)) {
        throw "CHANGELOG.md must contain a heading in the format ## $currentVersion."
    }
}

Add-BuildTask ValidateJson {
    $jsonFiles = Get-ChildItem -Path $script:moduleRoot -Filter '*.json' -Recurse -File

    foreach ($jsonFile in $jsonFiles) {
        Get-Content -Path $jsonFile.FullName -Raw | ConvertFrom-Json > $null
    }
}

Add-BuildTask ValidateYaml {
    $yamlFiles = Get-ChildItem -Path $script:moduleRoot -Include '*.yaml', '*.yml' -Recurse -File

    foreach ($yamlFile in $yamlFiles) {
        Test-PSServiceDeskYamlFile -Path $yamlFile.FullName
    }
}

Add-BuildTask ValidateLayout {
    foreach ($requiredDirectory in @('Public', 'Private', 'Tests')) {
        $path = Join-Path -Path $script:moduleRoot -ChildPath $requiredDirectory
        if (-not (Test-Path -Path $path -PathType Container)) {
            throw "Required directory '$requiredDirectory' is missing."
        }
    }

    $psm1Path = (Join-Path -Path $script:moduleRoot -ChildPath "$script:moduleName.psm1")
    if (Select-String -Path $psm1Path -Pattern '^function ') {
        throw "$script:moduleName.psm1 should only import split function files."
    }
}

Add-BuildTask ValidateExports {
    $manifest = Get-Manifest
    $publicFolder = (Join-Path -Path $script:moduleRoot -ChildPath 'Public')
    $publicFunctionNames = Get-ChildItem -Path $publicFolder -Filter '*.ps1' |
        Select-Object -ExpandProperty BaseName
    $exportedFunctionNames = @($manifest.FunctionsToExport)

    $missingExports = $publicFunctionNames | Where-Object {$PSItem -notin $exportedFunctionNames}
    $extraExports = $exportedFunctionNames | Where-Object {$PSItem -notin $publicFunctionNames}

    if ($missingExports) {
        throw ('Manifest is missing public exports: ' + ($missingExports -join ', '))
    }

    if ($extraExports) {
        throw ('Manifest exports functions without matching Public files: ' + ($extraExports -join ', '))
    }
}

Add-BuildTask ValidateNaming {
    $functionDirectories = @('Public', 'Private') | ForEach-Object {
        Join-Path -Path $script:moduleRoot -ChildPath $PSItem
    }
    $functionFiles = Get-ChildItem -Path $functionDirectories -Filter '*.ps1' -Recurse -File

    foreach ($functionFile in $functionFiles) {
        $functionName = $functionFile.BaseName
        $functionDefinitionPattern = '^\s*function\s+' + [regex]::Escape($functionName) + '\b'
        $selectStringParams = @{
            Path    = $functionFile.FullName
            Pattern = $functionDefinitionPattern
            Quiet   = $true
        }
        $functionDefinition = Select-String @selectStringParams

        if (-not $functionDefinition) {
            throw "File '$($functionFile.Name)' must define function '$functionName'."
        }
    }
}