From 9ad2ea88fd973225472afb52c87b3ef8ee206b13 Mon Sep 17 00:00:00 2001 From: Bill Berry Date: Fri, 20 Mar 2026 20:45:36 -0700 Subject: [PATCH 1/2] feat: adopt hve-core PowerShell CI infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add CIHelpers and LintingHelpers shared modules for CI abstraction - add Invoke-PSScriptAnalyzer with CI annotations and changed-files mode - upgrade Invoke-Pester with CI integration and structured output - add Get-ChangedTestFiles and pester.config for incremental testing - update workflow files to use script-based analyzer and Pester runner 🚀 - Generated by Copilot --- .github/workflows/powershell-lint.yml | 51 +- .github/workflows/pr-validation.yml | 32 + .../resource-provider-pwsh-tests.yml | 17 +- scripts/Invoke-Pester.ps1 | 132 ++-- scripts/ci/Modules/CIHelpers.psm1 | 565 ++++++++++++++++++ scripts/linting/Invoke-PSScriptAnalyzer.ps1 | 79 +++ scripts/linting/Modules/LintingHelpers.psm1 | 37 ++ scripts/tests/Get-ChangedTestFiles.ps1 | 22 + scripts/tests/pester.config.ps1 | 29 + 9 files changed, 866 insertions(+), 98 deletions(-) create mode 100644 scripts/ci/Modules/CIHelpers.psm1 create mode 100644 scripts/linting/Invoke-PSScriptAnalyzer.ps1 create mode 100644 scripts/linting/Modules/LintingHelpers.psm1 create mode 100644 scripts/tests/Get-ChangedTestFiles.ps1 create mode 100644 scripts/tests/pester.config.ps1 diff --git a/.github/workflows/powershell-lint.yml b/.github/workflows/powershell-lint.yml index ef5ff056..04e58084 100644 --- a/.github/workflows/powershell-lint.yml +++ b/.github/workflows/powershell-lint.yml @@ -43,31 +43,11 @@ jobs: with: persist-credentials: false - - name: Install PSScriptAnalyzer - shell: pwsh - run: | - Install-Module -Name PSScriptAnalyzer -RequiredVersion '1.22.0' -Force -Scope CurrentUser - - name: Run PSScriptAnalyzer + id: lint shell: pwsh run: | - $settings = './PSScriptAnalyzerSettings.psd1' - $files = Get-ChildItem -Recurse -Include '*.ps1','*.psm1','*.psd1' | - Where-Object { $_.FullName -notmatch 'node_modules|\.copilot-tracking' } - - $results = $files | ForEach-Object { Invoke-ScriptAnalyzer -Path $_.FullName -Settings $settings } - - New-Item -ItemType Directory -Force -Path lint-results | Out-Null - - if ($results) { - $output = $results | Format-Table -AutoSize | Out-String - $output | Set-Content -Path lint-results/psscriptanalyzer-results.txt - Write-Host $output - "PSSCRIPTANALYZER_FAILED=true" | Out-File -FilePath $env:GITHUB_ENV -Append - } else { - "No PSScriptAnalyzer violations found." | Set-Content -Path lint-results/psscriptanalyzer-results.txt - Write-Host "No PSScriptAnalyzer violations found." - } + ./scripts/linting/Invoke-PSScriptAnalyzer.ps1 -SoftFail:$${{ inputs.soft-fail == 'true' }} - name: Upload Lint Results if: always() @@ -76,30 +56,3 @@ jobs: name: powershell-lint-results path: lint-results/ retention-days: 30 - - - name: Job Summary - if: always() - shell: bash - run: | - { - echo "## PowerShell Lint Results" - echo "" - if [ "${PSSCRIPTANALYZER_FAILED:-}" = "true" ]; then - echo "- :x: PSScriptAnalyzer: violations found" - else - echo "- :white_check_mark: PSScriptAnalyzer: passed" - fi - } >> "$GITHUB_STEP_SUMMARY" - - - name: Fail on Lint Violations - if: always() && env.PSSCRIPTANALYZER_FAILED == 'true' - shell: bash - env: - SOFT_FAIL: ${{ inputs.soft-fail }} - run: | - if [ "$SOFT_FAIL" = "true" ]; then - echo "::warning::PowerShell lint violations found (soft-fail enabled)" - else - echo "::error::PowerShell lint violations found" - exit 1 - fi diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index d2b65735..92ab9b43 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -43,6 +43,10 @@ on: # yamllint disable-line rule:truthy default: false type: boolean +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: # CodeQL Analysis job for PR code security scanning codeql-analysis: @@ -235,6 +239,7 @@ jobs: with: working-directory: 'src/azure-resource-providers' test-results-output: 'PWSH-TEST-RESULTS.xml' + secrets: inherit # Terraform variable compliance check for PRs terraform-var-compliance: @@ -291,3 +296,30 @@ jobs: "securityThreshold": "high" } secrets: inherit + + # Pester tests for PowerShell scripts + pester-tests: + name: Pester Tests + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + steps: + - name: Checkout + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v4.2.2 + with: + fetch-depth: 0 + + - name: Run Pester Tests + id: pester + shell: pwsh + run: | + ./scripts/Invoke-Pester.ps1 -CI -ChangedOnly + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4 + with: + name: pester-test-results + path: test-results/ + retention-days: 30 diff --git a/.github/workflows/resource-provider-pwsh-tests.yml b/.github/workflows/resource-provider-pwsh-tests.yml index be283f74..46f80226 100644 --- a/.github/workflows/resource-provider-pwsh-tests.yml +++ b/.github/workflows/resource-provider-pwsh-tests.yml @@ -52,10 +52,10 @@ on: # yamllint disable-line rule:truthy type: string default: 'src/azure-resource-providers' test-results-output: - description: 'Path to output PowerShell test results' + description: 'Directory path for PowerShell test result output files' required: false type: string - default: 'PWSH-TEST-RESULTS.xml' + default: 'test-results' permissions: contents: read @@ -71,22 +71,19 @@ jobs: # Run Pester tests for the resource provider scripts - name: Run Pester Tests + id: pester shell: pwsh + working-directory: ${{ inputs.working-directory }} run: | - ./scripts/Invoke-Pester.ps1 -Path ./${{ inputs.working-directory }} -OutputFile ${{ inputs.test-results-output }} - if ($LASTEXITCODE -ne 0) { - Write-Error "Pester tests failed with exit code $LASTEXITCODE" - exit $LASTEXITCODE - } - working-directory: ${{ github.workspace }} + ../../scripts/Invoke-Pester.ps1 -CI -OutputPath '${{ inputs.test-results-output }}' # Publish the Pester test results - name: Publish Test Results if: ${{ !cancelled() }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: pester-test-results - path: ${{ inputs.test-results-output }} + name: rp-pester-test-results + path: ${{ inputs.test-results-output }}/ - name: Publish test summary to GitHub if: ${{ !cancelled() }} diff --git a/scripts/Invoke-Pester.ps1 b/scripts/Invoke-Pester.ps1 index 0a45dbc8..d01edab0 100644 --- a/scripts/Invoke-Pester.ps1 +++ b/scripts/Invoke-Pester.ps1 @@ -1,55 +1,109 @@ -# Used for Azure DevOps unit test results +[CmdletBinding()] +param( + [switch]$CI, + [switch]$ChangedOnly, + [switch]$CodeCoverage, + [string]$ConfigPath = (Join-Path $PSScriptRoot 'tests/pester.config.ps1'), + [string]$OutputPath = './test-results', + [string[]]$Path +) -# To run locally, simply just run Invoke-Pester, no need to run this script +$ErrorActionPreference = 'Stop' -param ( - [Parameter(Mandatory = $true)] - [string]$Path, +Import-Module (Join-Path $PSScriptRoot 'ci/Modules/CIHelpers.psm1') -Force - [Parameter(Mandatory = $true)] - [string]$OutputFile -) +$pesterModule = Get-Module -ListAvailable -Name Pester | + Sort-Object Version -Descending | + Select-Object -First 1 -function Find-PesterModule { - param ( - [string]$ModuleName = "Pester", - [string]$MinimumVersion = "5.0.0" - ) +if (-not $pesterModule -or $pesterModule.Version -lt [version]'5.0.0') { + Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck -MinimumVersion '5.4.0' +} - # Check if the module is installed - $module = Get-Module -ListAvailable -Name $ModuleName | Sort-Object -Property Version -Descending | Select-Object -First 1 +Import-Module Pester -MinimumVersion '5.4.0' -Force - if ($null -eq $module -or $module.Version -lt [version]$MinimumVersion) { - Write-Host "Installing or updating $ModuleName to version $MinimumVersion or higher..." - Install-Module -Name $ModuleName -MinimumVersion $MinimumVersion -Force -AllowClobber - } - else { - Write-Host "$ModuleName version $($module.Version) is already installed." +$configParams = @{} +if ($CI) { $configParams['CI'] = $true } +if ($CodeCoverage) { $configParams['CodeCoverage'] = $true } +if ($Path) { $configParams['Path'] = $Path } +$configParams['OutputPath'] = $OutputPath + +$config = & $ConfigPath @configParams + +if ($ChangedOnly) { + $changedTests = & (Join-Path $PSScriptRoot 'tests/Get-ChangedTestFiles.ps1') + if ($changedTests.Count -eq 0) { + Write-Host 'No changed test files found.' + Write-CIStepSummary "## Pester Test Results`n`nNo changed test files to run." + Set-CIOutput -Name 'test-result' -Value 'passed' + Set-CIOutput -Name 'test-count' -Value '0' + Set-CIOutput -Name 'fail-count' -Value '0' + exit 0 } + $config.Run.Path = $changedTests } -# Ensure Pester module is installed and at least version 5 -Find-PesterModule -ModuleName "Pester" -MinimumVersion "5.0.0" - +if (-not (Test-Path $OutputPath)) { + New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +} -Import-Module Pester +$result = Invoke-Pester -Configuration $config -$configuration = [PesterConfiguration]@{ - Run = @{ - Path = $Path - } - Output = @{ - Verbosity = 'Detailed' +function Get-FailedTest { + param([object]$Container) + $failures = @() + foreach ($block in $Container.Blocks) { + foreach ($test in $block.Tests) { + if ($test.Result -eq 'Failed') { + $failures += @{ + Name = $test.ExpandedName + Error = $test.ErrorRecord.Exception.Message + File = $test.ScriptBlock.File + Line = $test.ScriptBlock.StartPosition.StartLine + } + } + } + if ($block.Blocks.Count -gt 0) { + $failures += Get-FailedTest -Container $block + } } - TestResult = @{ - Enabled = $true - OutputFormat = "NUnitXml" - OutputPath = $OutputFile + return $failures +} + +$allFailures = @() +foreach ($container in $result.Containers) { + $allFailures += Get-FailedTest -Container $container +} + +$result | Select-Object TotalCount, PassedCount, FailedCount, SkippedCount, Duration | + ConvertTo-Json | Set-Content (Join-Path $OutputPath 'test-summary.json') + +if ($allFailures.Count -gt 0) { + $allFailures | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $OutputPath 'test-failures.json') + foreach ($failure in $allFailures) { + Write-CIAnnotation -Level 'Error' -Message "Test failed: $($failure.Name) — $($failure.Error)" ` + -File $failure.File -Line $failure.Line } } -# Print out the Path and OutputFile variables -Write-Host "Test file path: $Path" -Write-Host "Output test file path: $OutputFile" +$summary = @" +## Pester Test Results -Invoke-Pester -Configuration $configuration +| Metric | Value | +|--------|-------| +| Total | $($result.TotalCount) | +| Passed | $($result.PassedCount) | +| Failed | $($result.FailedCount) | +| Skipped | $($result.SkippedCount) | +| Duration | $($result.Duration) | +"@ + +Write-CIStepSummary $summary + +Set-CIOutput -Name 'test-result' -Value $(if ($result.FailedCount -eq 0) { 'passed' } else { 'failed' }) +Set-CIOutput -Name 'test-count' -Value $result.TotalCount.ToString() +Set-CIOutput -Name 'fail-count' -Value $result.FailedCount.ToString() + +if ($CI -and $result.FailedCount -gt 0) { + exit 1 +} diff --git a/scripts/ci/Modules/CIHelpers.psm1 b/scripts/ci/Modules/CIHelpers.psm1 new file mode 100644 index 00000000..ed55de1e --- /dev/null +++ b/scripts/ci/Modules/CIHelpers.psm1 @@ -0,0 +1,565 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +# CIHelpers.psm1 +# +# Purpose: Shared CI platform detection and output utilities for hve-core scripts. +# Author: HVE Core Team + +#Requires -Version 7.0 + +function ConvertTo-GitHubActionsEscaped { + <# + .SYNOPSIS + Escapes a string for safe use in GitHub Actions workflow commands. + + .DESCRIPTION + Percent-encodes characters that have special meaning in GitHub Actions + logging commands to prevent workflow command injection attacks. + + .PARAMETER Value + The string to escape. + + .PARAMETER ForProperty + If set, also escapes colon and comma characters used in property values. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Value, + + [Parameter(Mandatory = $false)] + [switch]$ForProperty + ) + + if ([string]::IsNullOrEmpty($Value)) { + return $Value + } + + # Order matters: escape % first to avoid double-encoding + $escaped = $Value -replace '%', '%25' + $escaped = $escaped -replace "`r", '%0D' + $escaped = $escaped -replace "`n", '%0A' + # Escape :: patterns to neutralize command sequences (defense in depth) + # This prevents ::command:: patterns. When ForProperty is false, single colons like C:\ are preserved. + $escaped = $escaped -replace '::', '%3A%3A' + + if ($ForProperty) { + $escaped = $escaped -replace ':', '%3A' + $escaped = $escaped -replace ',', '%2C' + } + + return $escaped +} + +function ConvertTo-AzureDevOpsEscaped { + <# + .SYNOPSIS + Escapes a string for safe use in Azure DevOps logging commands. + + .DESCRIPTION + Percent-encodes characters that have special meaning in Azure DevOps + logging commands to prevent workflow command injection attacks. + + .PARAMETER Value + The string to escape. + + .PARAMETER ForProperty + If set, also escapes semicolon and bracket characters used in property values. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Value, + + [Parameter(Mandatory = $false)] + [switch]$ForProperty + ) + + if ([string]::IsNullOrEmpty($Value)) { + return $Value + } + + # Order matters: escape % first to avoid double-encoding + $escaped = $Value -replace '%', '%AZP25' + $escaped = $escaped -replace "`r", '%AZP0D' + $escaped = $escaped -replace "`n", '%AZP0A' + # Escape brackets to prevent ##vso[ command patterns (defense in depth) + $escaped = $escaped -replace '\[', '%AZP5B' + $escaped = $escaped -replace '\]', '%AZP5D' + + if ($ForProperty) { + $escaped = $escaped -replace ';', '%AZP3B' + } + + return $escaped +} + +function Get-CIPlatform { + <# + .SYNOPSIS + Detects the current CI platform. + + .DESCRIPTION + Returns the CI platform identifier based on environment variables. + Supports GitHub Actions, Azure DevOps, and local development. + + .OUTPUTS + System.String - 'github', 'azdo', or 'local' + #> + [CmdletBinding()] + [OutputType([string])] + param() + + if ($env:GITHUB_ACTIONS -eq 'true') { + return 'github' + } + if ($env:TF_BUILD -eq 'True' -or $env:AZURE_PIPELINES -eq 'True') { + return 'azdo' + } + return 'local' +} + +function Test-CIEnvironment { + <# + .SYNOPSIS + Tests whether running in a CI environment. + + .DESCRIPTION + Returns true if running in GitHub Actions or Azure DevOps. + + .OUTPUTS + System.Boolean - $true if in CI, $false otherwise + #> + [CmdletBinding()] + [OutputType([bool])] + param() + + return (Get-CIPlatform) -ne 'local' +} + +function Set-CIOutput { + <# + .SYNOPSIS + Sets a CI output variable. + + .DESCRIPTION + Sets an output variable that can be consumed by subsequent workflow steps. + Uses GITHUB_OUTPUT for GitHub Actions and task.setvariable for Azure DevOps. + + .PARAMETER Name + The variable name. + + .PARAMETER Value + The variable value. + + .PARAMETER IsOutput + For Azure DevOps, marks the variable as an output variable. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$Value, + + [Parameter(Mandatory = $false)] + [switch]$IsOutput + ) + + $platform = Get-CIPlatform + + switch ($platform) { + 'github' { + if ($env:GITHUB_OUTPUT) { + # GITHUB_OUTPUT uses file-based output, less vulnerable but still escape newlines + $escapedName = ConvertTo-GitHubActionsEscaped -Value $Name + $escapedValue = ConvertTo-GitHubActionsEscaped -Value $Value + "$escapedName=$escapedValue" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } + else { + Write-Verbose "GITHUB_OUTPUT not set, would set: $Name=$Value" + } + } + 'azdo' { + $outputFlag = if ($IsOutput) { ';isOutput=true' } else { '' } + $escapedName = ConvertTo-AzureDevOpsEscaped -Value $Name -ForProperty + $escapedValue = ConvertTo-AzureDevOpsEscaped -Value $Value + Write-Output "##vso[task.setvariable variable=$escapedName$outputFlag]$escapedValue" + } + 'local' { + Write-Verbose "CI Output: $Name=$Value" + } + } +} + +function Set-CIEnv { + <# + .SYNOPSIS + Sets a CI environment variable. + + .DESCRIPTION + Writes environment variables for GitHub Actions or Azure DevOps. + + .PARAMETER Name + The environment variable name. + + .PARAMETER Value + The environment variable value. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$Value + ) + + $platform = Get-CIPlatform + + switch ($platform) { + 'github' { + if ($env:GITHUB_ENV) { + if ($Name -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { + throw "Invalid GitHub Actions environment variable name: '$Name'. Names must match '^[A-Za-z_][A-Za-z0-9_]*\$'." + } + + $delimiter = "EOF_$([guid]::NewGuid().ToString('N'))" + @( + "$Name<<$delimiter" + $Value + $delimiter + ) | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } + else { + Write-Verbose "GITHUB_ENV not set, would set: $Name=$Value" + } + } + 'azdo' { + $escapedName = ConvertTo-AzureDevOpsEscaped -Value $Name -ForProperty + $escapedValue = ConvertTo-AzureDevOpsEscaped -Value $Value + Write-Output "##vso[task.setvariable variable=$escapedName]$escapedValue" + } + 'local' { + Write-Verbose "CI Env: $Name=$Value" + } + } +} + +function Write-CIStepSummary { + <# + .SYNOPSIS + Writes content to the CI step summary. + + .DESCRIPTION + Appends markdown content to the step summary for GitHub Actions. + For Azure DevOps, outputs as a section header and content. + + .PARAMETER Content + The markdown content to append. + + .PARAMETER Path + Path to a file containing markdown content. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ParameterSetName = 'Content')] + [string]$Content, + + [Parameter(Mandatory = $true, ParameterSetName = 'Path')] + [string]$Path + ) + + $platform = Get-CIPlatform + $markdown = if ($PSCmdlet.ParameterSetName -eq 'Path') { + Get-Content -Path $Path -Raw + } + else { + $Content + } + + switch ($platform) { + 'github' { + if ($env:GITHUB_STEP_SUMMARY) { + $markdown | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + else { + Write-Verbose "GITHUB_STEP_SUMMARY not set" + Write-Verbose $markdown + } + } + 'azdo' { + Write-Output "##[section]Step Summary" + Write-Output $markdown + } + 'local' { + Write-Verbose "Step Summary:" + Write-Verbose $markdown + } + } +} + +function Write-CIAnnotation { + <# + .SYNOPSIS + Writes a CI annotation (warning, error, notice). + + .DESCRIPTION + Creates a workflow annotation that appears in the GitHub Actions or Azure DevOps UI. + + .PARAMETER Message + The annotation message. + + .PARAMETER Level + The severity level: Warning, Error, or Notice. + + .PARAMETER File + Optional file path for file-level annotations. + + .PARAMETER Line + Optional line number for the annotation. + + .PARAMETER Column + Optional column number for the annotation. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet('Warning', 'Error', 'Notice')] + [string]$Level = 'Warning', + + [Parameter(Mandatory = $false)] + [string]$File, + + [Parameter(Mandatory = $false)] + [int]$Line, + + [Parameter(Mandatory = $false)] + [int]$Column + ) + + $platform = Get-CIPlatform + + switch ($platform) { + 'github' { + $levelLower = $Level.ToLower() + $annotation = "::$levelLower" + $params = @() + if ($File) { + $normalizedFile = $File -replace '\\', '/' + $escapedFile = ConvertTo-GitHubActionsEscaped -Value $normalizedFile -ForProperty + $params += "file=$escapedFile" + } + if ($Line -gt 0) { $params += "line=$Line" } + if ($Column -gt 0) { $params += "col=$Column" } + if ($params.Count -gt 0) { + $annotation += " $($params -join ',')" + } + $escapedMessage = ConvertTo-GitHubActionsEscaped -Value $Message + Write-Output "$annotation::$escapedMessage" + } + 'azdo' { + $typeMap = @{ + 'Warning' = 'warning' + 'Error' = 'error' + 'Notice' = 'info' + } + $adoType = $typeMap[$Level] + $annotation = "##vso[task.logissue type=$adoType" + if ($File) { + $escapedFile = ConvertTo-AzureDevOpsEscaped -Value $File -ForProperty + $annotation += ";sourcepath=$escapedFile" + } + if ($Line -gt 0) { $annotation += ";linenumber=$Line" } + if ($Column -gt 0) { $annotation += ";columnnumber=$Column" } + $escapedMessage = ConvertTo-AzureDevOpsEscaped -Value $Message + Write-Output "$annotation]$escapedMessage" + } + 'local' { + $prefix = switch ($Level) { + 'Warning' { 'WARNING' } + 'Error' { 'ERROR' } + 'Notice' { 'NOTICE' } + } + $location = if ($File) { " [$File" + $(if ($Line) { ":$Line" } else { '' }) + ']' } else { '' } + Write-Warning "$prefix$location $Message" + } + } +} + +function Write-CIAnnotations { + <# + .SYNOPSIS + Writes CI annotations for summary results. + + .DESCRIPTION + Emits annotations for each issue in a summary object, mapping errors and warnings + to the platform-specific annotation formats. + + .PARAMETER Summary + Summary object containing Results with Issues and file metadata. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Summary + ) + + if (-not $Summary -or -not $Summary.Results) { + return + } + + foreach ($result in $Summary.Results) { + if (-not $result -or -not $result.Issues) { + continue + } + + foreach ($issue in $result.Issues) { + if (-not $issue) { + continue + } + + # Skip issues with null or empty messages + if ([string]::IsNullOrWhiteSpace($issue.Message)) { + continue + } + + $level = if ($issue.Type -eq 'Error') { 'Error' } else { 'Warning' } + $line = if ($issue.Line -gt 0) { $issue.Line } else { 1 } + $filePath = if ($result.RelativePath) { $result.RelativePath } elseif ($issue.FilePath) { $issue.FilePath } else { $null } + + $annotationParams = @{ + Message = [string]$issue.Message + Level = $level + } + + if ($filePath) { + $annotationParams['File'] = [string]$filePath + $annotationParams['Line'] = $line + } + + if ($issue.Column -gt 0) { + $annotationParams['Column'] = $issue.Column + } + + Write-CIAnnotation @annotationParams + } + } +} + +function Set-CITaskResult { + <# + .SYNOPSIS + Sets the CI task/step result status. + + .DESCRIPTION + Sets the overall result of the current task or step. + + .PARAMETER Result + The result status: Succeeded, SucceededWithIssues, or Failed. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Succeeded', 'SucceededWithIssues', 'Failed')] + [string]$Result + ) + + $platform = Get-CIPlatform + + switch ($platform) { + 'github' { + Write-Verbose "GitHub Actions task result: $Result" + if ($Result -eq 'Failed') { + Write-Output "::error::Task failed" + } + } + 'azdo' { + Write-Output "##vso[task.complete result=$Result]" + } + 'local' { + Write-Verbose "Task result: $Result" + } + } +} + +function Publish-CIArtifact { + <# + .SYNOPSIS + Publishes a CI artifact. + + .DESCRIPTION + Publishes a file or folder as a CI artifact. + For GitHub Actions, outputs the path for use with actions/upload-artifact. + For Azure DevOps, uses the artifact.upload command. + + .PARAMETER Path + The path to the file or folder to publish. + + .PARAMETER Name + The artifact name. + + .PARAMETER ContainerFolder + For Azure DevOps, the container folder path within the artifact. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $false)] + [string]$ContainerFolder + ) + + $platform = Get-CIPlatform + + if (-not (Test-Path $Path)) { + Write-Warning "Artifact path not found: $Path" + return + } + + switch ($platform) { + 'github' { + Set-CIOutput -Name "artifact-path-$Name" -Value $Path + Set-CIOutput -Name "artifact-name-$Name" -Value $Name + Write-Verbose "GitHub artifact ready: $Name at $Path" + } + 'azdo' { + $container = if ($ContainerFolder) { $ContainerFolder } else { $Name } + $escapedContainer = ConvertTo-AzureDevOpsEscaped -Value $container -ForProperty + $escapedName = ConvertTo-AzureDevOpsEscaped -Value $Name -ForProperty + $escapedPath = ConvertTo-AzureDevOpsEscaped -Value $Path + Write-Output "##vso[artifact.upload containerfolder=$escapedContainer;artifactname=$escapedName]$escapedPath" + } + 'local' { + Write-Verbose "Artifact: $Name at $Path" + } + } +} + +Export-ModuleMember -Function @( + 'ConvertTo-GitHubActionsEscaped', + 'ConvertTo-AzureDevOpsEscaped', + 'Get-CIPlatform', + 'Test-CIEnvironment', + 'Set-CIOutput', + 'Set-CIEnv', + 'Write-CIStepSummary', + 'Write-CIAnnotation', + 'Write-CIAnnotations', + 'Set-CITaskResult', + 'Publish-CIArtifact' +) diff --git a/scripts/linting/Invoke-PSScriptAnalyzer.ps1 b/scripts/linting/Invoke-PSScriptAnalyzer.ps1 new file mode 100644 index 00000000..b2df8171 --- /dev/null +++ b/scripts/linting/Invoke-PSScriptAnalyzer.ps1 @@ -0,0 +1,79 @@ +[CmdletBinding()] +param( + [switch]$ChangedOnly, + [switch]$SoftFail, + [string]$SettingsPath = (Join-Path $PSScriptRoot '../../PSScriptAnalyzerSettings.psd1'), + [string]$OutputPath = './lint-results' +) + +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot '../ci/Modules/CIHelpers.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'Modules/LintingHelpers.psm1') -Force + +if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { + Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -AllowClobber +} +Import-Module PSScriptAnalyzer -Force + +if ($ChangedOnly) { + $files = Get-ChangedFilesFromGit -Extension @('.ps1', '.psm1', '.psd1') + if ($files.Count -eq 0) { + Write-Host 'No changed PowerShell files found.' + Write-CIStepSummary '## PSScriptAnalyzer Results\n\nNo changed PowerShell files to scan.' + Set-CIOutput -Name 'has-findings' -Value 'false' + exit 0 + } +} else { + $files = Get-FilesRecursive -Extension @('.ps1', '.psm1', '.psd1') +} + +Write-Host "Scanning $($files.Count) file(s)..." + +$allResults = @() +foreach ($file in $files) { + $results = Invoke-ScriptAnalyzer -Path $file -Settings $SettingsPath -ReportSummary + $allResults += $results +} + +if (-not (Test-Path $OutputPath)) { + New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +} + +$allResults | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $OutputPath 'psscriptanalyzer-results.json') + +foreach ($result in $allResults) { + $type = switch ($result.Severity) { + 'Error' { 'Error' } + 'Warning' { 'Warning' } + 'Information' { 'Notice' } + default { 'Notice' } + } + Write-CIAnnotation -Level $type -Message "$($result.RuleName): $($result.Message)" ` + -File $result.ScriptPath -Line $result.Line -Column $result.Column +} + +$errorCount = ($allResults | Where-Object Severity -eq 'Error').Count +$warningCount = ($allResults | Where-Object Severity -eq 'Warning').Count +$infoCount = ($allResults | Where-Object Severity -eq 'Information').Count + +$summary = @" +## PSScriptAnalyzer Results + +| Severity | Count | +|----------|-------| +| Error | $errorCount | +| Warning | $warningCount | +| Information | $infoCount | +| **Total** | **$($allResults.Count)** | + +Files scanned: $($files.Count) +"@ + +Write-CIStepSummary $summary +Set-CIOutput -Name 'has-findings' -Value ($allResults.Count -gt 0).ToString().ToLower() + +if ($allResults.Count -gt 0 -and -not $SoftFail) { + Write-Host "Found $($allResults.Count) issue(s)." + exit 1 +} diff --git a/scripts/linting/Modules/LintingHelpers.psm1 b/scripts/linting/Modules/LintingHelpers.psm1 new file mode 100644 index 00000000..67f2f69f --- /dev/null +++ b/scripts/linting/Modules/LintingHelpers.psm1 @@ -0,0 +1,37 @@ +function Get-ChangedFilesFromGit { + [CmdletBinding()] + param( + [string[]]$Extension = @('.ps1', '.psm1', '.psd1'), + [string]$BaseBranch = 'origin/main' + ) + + $diffOutput = git diff --name-only --diff-filter=d "$BaseBranch...HEAD" 2>$null + + if (-not $diffOutput) { return @() } + + $changedFiles = $diffOutput | Where-Object { + $ext = [System.IO.Path]::GetExtension($_) + $Extension -contains $ext + } | Where-Object { Test-Path $_ } | ForEach-Object { Resolve-Path $_ } + + return $changedFiles +} + +function Get-FilesRecursive { + [CmdletBinding()] + param( + [string]$Path = '.', + [string[]]$Extension = @('.ps1', '.psm1', '.psd1'), + [string[]]$ExcludePattern = @('node_modules', '.copilot-tracking') + ) + + $includeGlobs = $Extension | ForEach-Object { "*$_" } + + Get-ChildItem -Path $Path -Recurse -File -Include $includeGlobs | + Where-Object { + $fullPath = $_.FullName + -not ($ExcludePattern | Where-Object { $fullPath -match [regex]::Escape($_) }) + } +} + +Export-ModuleMember -Function 'Get-ChangedFilesFromGit', 'Get-FilesRecursive' diff --git a/scripts/tests/Get-ChangedTestFiles.ps1 b/scripts/tests/Get-ChangedTestFiles.ps1 new file mode 100644 index 00000000..e8ea90d8 --- /dev/null +++ b/scripts/tests/Get-ChangedTestFiles.ps1 @@ -0,0 +1,22 @@ +[CmdletBinding()] +param( + [string]$BaseBranch = 'origin/main', + [string[]]$TestPattern = @('*.Tests.ps1', '*.tests.ps1') +) + +$ErrorActionPreference = 'Stop' + +$diffOutput = git diff --name-only --diff-filter=d "$BaseBranch...HEAD" 2>$null + +if (-not $diffOutput) { + Write-Verbose 'No changes detected from base branch.' + return @() +} + +$testFiles = $diffOutput | Where-Object { + $fileName = Split-Path $_ -Leaf + $TestPattern | Where-Object { $fileName -like $_ } +} | Where-Object { Test-Path $_ } | ForEach-Object { Resolve-Path $_ } + +Write-Host "Found $($testFiles.Count) changed test file(s)." +return $testFiles diff --git a/scripts/tests/pester.config.ps1 b/scripts/tests/pester.config.ps1 new file mode 100644 index 00000000..a9ee58c0 --- /dev/null +++ b/scripts/tests/pester.config.ps1 @@ -0,0 +1,29 @@ +param( + [switch]$CI, + [switch]$CodeCoverage, + [string]$OutputPath = './test-results', + [string[]]$Path = @('./scripts') +) + +$config = New-PesterConfiguration + +$config.Run.Path = $Path +$config.Run.Exit = $CI.IsPresent +$config.Run.PassThru = $true + +$config.Output.Verbosity = if ($CI) { 'Detailed' } else { 'Normal' } + +$config.TestResult.Enabled = $true +$config.TestResult.OutputFormat = 'NUnitXml' +$config.TestResult.OutputPath = Join-Path $OutputPath 'test-results.xml' + +if ($CodeCoverage) { + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.OutputFormat = 'JaCoCo' + $config.CodeCoverage.OutputPath = Join-Path $OutputPath 'coverage.xml' + $config.CodeCoverage.Path = $Path +} + +$config.Filter.ExcludeTag = @('Integration', 'Slow') + +return $config From 97882322b21b11cb9d1d1018abc14dc837596a60 Mon Sep 17 00:00:00 2001 From: Bill Berry Date: Tue, 31 Mar 2026 18:09:36 -0700 Subject: [PATCH 2/2] fix(workflows): resolve PR review issues and PSScriptAnalyzer violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - set Run.Exit to $false preventing premature process termination - pin Pester to RequiredVersion 5.7.1 preventing 6.x breakage - pin PSScriptAnalyzer to RequiredVersion 1.22.0 - fix artifact upload path to match working-directory layout - add missing -Path parameter to Invoke-Pester call - fix test summary resultsFile path in github-script step - add DefaultParameterSetName and Position=0 to Write-CIStepSummary - add PSScriptAnalyzer suppression attributes for approved patterns - add comment-based help to LintingHelpers module functions 🔧 - Generated by Copilot --- .github/workflows/resource-provider-pwsh-tests.yml | 6 +++--- scripts/Invoke-Pester.ps1 | 8 ++++---- scripts/ci/Modules/CIHelpers.psm1 | 8 ++++++-- scripts/linting/Invoke-PSScriptAnalyzer.ps1 | 2 +- scripts/linting/Modules/LintingHelpers.psm1 | 9 +++++++++ scripts/tests/pester.config.ps1 | 2 +- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/workflows/resource-provider-pwsh-tests.yml b/.github/workflows/resource-provider-pwsh-tests.yml index 46f80226..d06f24e9 100644 --- a/.github/workflows/resource-provider-pwsh-tests.yml +++ b/.github/workflows/resource-provider-pwsh-tests.yml @@ -75,7 +75,7 @@ jobs: shell: pwsh working-directory: ${{ inputs.working-directory }} run: | - ../../scripts/Invoke-Pester.ps1 -CI -OutputPath '${{ inputs.test-results-output }}' + ../../scripts/Invoke-Pester.ps1 -CI -Path '.' -OutputPath '${{ inputs.test-results-output }}' # Publish the Pester test results - name: Publish Test Results @@ -83,7 +83,7 @@ jobs: uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: rp-pester-test-results - path: ${{ inputs.test-results-output }}/ + path: ${{ inputs.working-directory }}/${{ inputs.test-results-output }}/ - name: Publish test summary to GitHub if: ${{ !cancelled() }} @@ -91,7 +91,7 @@ jobs: with: script: | const fs = require('fs'); - const resultsFile = '${{ inputs.test-results-output }}'; + const resultsFile = '${{ inputs.working-directory }}/${{ inputs.test-results-output }}/test-results.xml'; if (!fs.existsSync(resultsFile)) { core.warning('Test results file not found: ' + resultsFile); diff --git a/scripts/Invoke-Pester.ps1 b/scripts/Invoke-Pester.ps1 index d01edab0..e63367ce 100644 --- a/scripts/Invoke-Pester.ps1 +++ b/scripts/Invoke-Pester.ps1 @@ -13,14 +13,14 @@ $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'ci/Modules/CIHelpers.psm1') -Force $pesterModule = Get-Module -ListAvailable -Name Pester | - Sort-Object Version -Descending | + Where-Object { $_.Version -eq [version]'5.7.1' } | Select-Object -First 1 -if (-not $pesterModule -or $pesterModule.Version -lt [version]'5.0.0') { - Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck -MinimumVersion '5.4.0' +if (-not $pesterModule) { + Install-Module -Name Pester -RequiredVersion '5.7.1' -Force -Scope CurrentUser -SkipPublisherCheck } -Import-Module Pester -MinimumVersion '5.4.0' -Force +Import-Module Pester -RequiredVersion '5.7.1' -Force $configParams = @{} if ($CI) { $configParams['CI'] = $true } diff --git a/scripts/ci/Modules/CIHelpers.psm1 b/scripts/ci/Modules/CIHelpers.psm1 index ed55de1e..a172ebd9 100644 --- a/scripts/ci/Modules/CIHelpers.psm1 +++ b/scripts/ci/Modules/CIHelpers.psm1 @@ -160,6 +160,7 @@ function Set-CIOutput { .PARAMETER IsOutput For Azure DevOps, marks the variable as an output variable. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param( [Parameter(Mandatory = $true)] @@ -212,6 +213,7 @@ function Set-CIEnv { .PARAMETER Value The environment variable value. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param( [Parameter(Mandatory = $true)] @@ -267,9 +269,9 @@ function Write-CIStepSummary { .PARAMETER Path Path to a file containing markdown content. #> - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'Content')] param( - [Parameter(Mandatory = $true, ParameterSetName = 'Content')] + [Parameter(Mandatory = $true, ParameterSetName = 'Content', Position = 0)] [string]$Content, [Parameter(Mandatory = $true, ParameterSetName = 'Path')] @@ -409,6 +411,7 @@ function Write-CIAnnotations { .PARAMETER Summary Summary object containing Results with Issues and file metadata. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] [CmdletBinding()] param( [Parameter(Mandatory = $true)] @@ -468,6 +471,7 @@ function Set-CITaskResult { .PARAMETER Result The result status: Succeeded, SucceededWithIssues, or Failed. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param( [Parameter(Mandatory = $true)] diff --git a/scripts/linting/Invoke-PSScriptAnalyzer.ps1 b/scripts/linting/Invoke-PSScriptAnalyzer.ps1 index b2df8171..9b38cf6c 100644 --- a/scripts/linting/Invoke-PSScriptAnalyzer.ps1 +++ b/scripts/linting/Invoke-PSScriptAnalyzer.ps1 @@ -12,7 +12,7 @@ Import-Module (Join-Path $PSScriptRoot '../ci/Modules/CIHelpers.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'Modules/LintingHelpers.psm1') -Force if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { - Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -AllowClobber + Install-Module -Name PSScriptAnalyzer -RequiredVersion '1.22.0' -Force -Scope CurrentUser -AllowClobber } Import-Module PSScriptAnalyzer -Force diff --git a/scripts/linting/Modules/LintingHelpers.psm1 b/scripts/linting/Modules/LintingHelpers.psm1 index 67f2f69f..a3484fa6 100644 --- a/scripts/linting/Modules/LintingHelpers.psm1 +++ b/scripts/linting/Modules/LintingHelpers.psm1 @@ -1,4 +1,9 @@ function Get-ChangedFilesFromGit { + <# + .SYNOPSIS + Returns changed files of specified extensions relative to a base branch. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')] [CmdletBinding()] param( [string[]]$Extension = @('.ps1', '.psm1', '.psd1'), @@ -18,6 +23,10 @@ function Get-ChangedFilesFromGit { } function Get-FilesRecursive { + <# + .SYNOPSIS + Returns all files matching specified extensions, excluding configured patterns. + #> [CmdletBinding()] param( [string]$Path = '.', diff --git a/scripts/tests/pester.config.ps1 b/scripts/tests/pester.config.ps1 index a9ee58c0..0544ab13 100644 --- a/scripts/tests/pester.config.ps1 +++ b/scripts/tests/pester.config.ps1 @@ -8,7 +8,7 @@ param( $config = New-PesterConfiguration $config.Run.Path = $Path -$config.Run.Exit = $CI.IsPresent +$config.Run.Exit = $false $config.Run.PassThru = $true $config.Output.Verbosity = if ($CI) { 'Detailed' } else { 'Normal' }