feat(ci): Enhanced pre-release validation v2 #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Pre-Release Validation v2 | ||
| # ========================= | ||
| # Comprehensive test suite for release candidates | ||
| # | ||
| # This workflow runs EVERYTHING before a release: | ||
| # - Unit tests on all platforms (Ubuntu, Windows, macOS) | ||
| # - Unit tests on all PowerShell versions (5.1, 7.x) | ||
| # - Code coverage analysis with threshold enforcement | ||
| # - Integration tests against all supported Netbox versions | ||
| # - PSScriptAnalyzer code quality checks | ||
| # - Documentation completeness validation | ||
| # - Breaking change detection vs PSGallery release | ||
| # - Module import verification | ||
| # - Generates a comprehensive release readiness report | ||
| # | ||
| # Trigger: Manual only (workflow_dispatch) | ||
| # Duration: ~12-15 minutes | ||
| # Use: Before merging to main / creating a release | ||
| name: Pre-Release Validation | ||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| version: | ||
| description: 'Version being validated (e.g., 4.5.0)' | ||
| required: true | ||
| type: string | ||
| skip_integration: | ||
| description: 'Skip integration tests (faster, unit tests only)' | ||
| required: false | ||
| default: false | ||
| type: boolean | ||
| coverage_threshold: | ||
| description: 'Minimum code coverage percentage (default: 70)' | ||
| required: false | ||
| default: '70' | ||
| type: string | ||
| env: | ||
| NETBOX_TOKEN: "0123456789abcdef0123456789abcdef01234567" | ||
| COMPOSE_PROJECT_NAME: "powernetbox" | ||
| jobs: | ||
| # ============================================================ | ||
| # Stage 1: Unit Tests - Full Platform Matrix | ||
| # ============================================================ | ||
| unit-tests: | ||
| name: Unit [${{ matrix.os }}, ${{ matrix.pwsh && 'PS7' || 'PS5.1' }}] | ||
| runs-on: ${{ matrix.os }} | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| os: [ubuntu-latest, windows-latest, macos-latest] | ||
| pwsh: [true] | ||
| include: | ||
| - os: windows-latest | ||
| pwsh: false | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
| - name: Install dependencies (pwsh) | ||
| if: matrix.pwsh | ||
| shell: pwsh | ||
| run: | | ||
| if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { | ||
| Register-PSRepository -Default | ||
| } | ||
| Set-PSRepository PSGallery -InstallationPolicy Trusted | ||
| Install-Module Pester -Force -Scope CurrentUser -MinimumVersion 5.0.0 | ||
| Install-Module PSScriptAnalyzer -Force -Scope CurrentUser | ||
| - name: Install dependencies (powershell 5.1) | ||
| if: '!matrix.pwsh' | ||
| shell: powershell | ||
| run: | | ||
| if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { | ||
| Register-PSRepository -Default | ||
| } | ||
| Set-PSRepository PSGallery -InstallationPolicy Trusted | ||
| Install-Module Pester -Force -Scope CurrentUser -MinimumVersion 5.0.0 | ||
| Install-Module PSScriptAnalyzer -Force -Scope CurrentUser | ||
| - name: Build module (pwsh) | ||
| if: matrix.pwsh | ||
| shell: pwsh | ||
| run: ./deploy.ps1 -Environment dev -SkipVersion | ||
| - name: Build module (powershell 5.1) | ||
| if: '!matrix.pwsh' | ||
| shell: powershell | ||
| run: ./deploy.ps1 -Environment dev -SkipVersion | ||
| - name: Run unit tests (pwsh) | ||
| if: matrix.pwsh | ||
| shell: pwsh | ||
| run: | | ||
| $config = New-PesterConfiguration | ||
| $config.Run.Path = './Tests/*.Tests.ps1' | ||
| $config.Run.Exit = $true | ||
| $config.Filter.ExcludeTag = @('Integration', 'Live') | ||
| $config.TestResult.Enabled = $true | ||
| $config.TestResult.OutputPath = 'TestResults.xml' | ||
| $config.TestResult.OutputFormat = 'NUnitXml' | ||
| $config.Output.Verbosity = 'Normal' | ||
| Invoke-Pester -Configuration $config | ||
| - name: Run unit tests (powershell 5.1) | ||
| if: '!matrix.pwsh' | ||
| shell: powershell | ||
| run: | | ||
| $config = New-PesterConfiguration | ||
| $config.Run.Path = './Tests/*.Tests.ps1' | ||
| $config.Run.Exit = $true | ||
| $config.Filter.ExcludeTag = @('Integration', 'Live') | ||
| $config.TestResult.Enabled = $true | ||
| $config.TestResult.OutputPath = 'TestResults.xml' | ||
| $config.TestResult.OutputFormat = 'NUnitXml' | ||
| $config.Output.Verbosity = 'Normal' | ||
| Invoke-Pester -Configuration $config | ||
| - name: Upload test results | ||
| uses: actions/upload-artifact@v4 | ||
| if: always() | ||
| with: | ||
| name: unit-tests-${{ matrix.os }}-${{ matrix.pwsh && 'ps7' || 'ps51' }} | ||
| path: TestResults.xml | ||
| retention-days: 30 | ||
| # ============================================================ | ||
| # Stage 2: Code Coverage Analysis | ||
| # ============================================================ | ||
| code-coverage: | ||
| name: Code Coverage | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| coverage_percent: ${{ steps.coverage.outputs.coverage_percent }} | ||
| covered_commands: ${{ steps.coverage.outputs.covered_commands }} | ||
| total_commands: ${{ steps.coverage.outputs.total_commands }} | ||
| missed_functions: ${{ steps.coverage.outputs.missed_functions }} | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
| - name: Install dependencies | ||
| shell: pwsh | ||
| run: | | ||
| Set-PSRepository PSGallery -InstallationPolicy Trusted | ||
| Install-Module Pester -Force -Scope CurrentUser -MinimumVersion 5.0.0 | ||
| - name: Build module | ||
| shell: pwsh | ||
| run: ./deploy.ps1 -Environment dev -SkipVersion | ||
| - name: Run tests with code coverage | ||
| id: coverage | ||
| shell: pwsh | ||
| run: | | ||
| Write-Host "=== Running Tests with Code Coverage ===" -ForegroundColor Cyan | ||
| Write-Host "" | ||
| # Get all function files for coverage | ||
| $functionFiles = Get-ChildItem -Path ./Functions -Filter *.ps1 -Recurse | | ||
| Where-Object { $_.Name -notmatch '\.Tests\.ps1$' } | | ||
| Select-Object -ExpandProperty FullName | ||
| Write-Host "Analyzing coverage for $($functionFiles.Count) function files..." -ForegroundColor Yellow | ||
| $config = New-PesterConfiguration | ||
| $config.Run.Path = './Tests/*.Tests.ps1' | ||
| $config.Run.Exit = $false | ||
| $config.Filter.ExcludeTag = @('Integration', 'Live') | ||
| $config.Output.Verbosity = 'Normal' | ||
| # Enable code coverage | ||
| $config.CodeCoverage.Enabled = $true | ||
| $config.CodeCoverage.Path = $functionFiles | ||
| $config.CodeCoverage.OutputPath = 'coverage.xml' | ||
| $config.CodeCoverage.OutputFormat = 'JaCoCo' | ||
| $result = Invoke-Pester -Configuration $config | ||
| # Calculate coverage | ||
| $coverage = $result.CodeCoverage | ||
| $coveredCommands = $coverage.CommandsExecutedCount | ||
| $totalCommands = $coverage.CommandsAnalyzedCount | ||
| $missedCommands = $coverage.CommandsMissedCount | ||
| if ($totalCommands -gt 0) { | ||
| $coveragePercent = [math]::Round(($coveredCommands / $totalCommands) * 100, 2) | ||
| } else { | ||
| $coveragePercent = 0 | ||
| } | ||
| Write-Host "" | ||
| Write-Host "========================================" -ForegroundColor Cyan | ||
| Write-Host " CODE COVERAGE RESULTS" -ForegroundColor Cyan | ||
| Write-Host "========================================" -ForegroundColor Cyan | ||
| Write-Host "" | ||
| Write-Host "Coverage: $coveragePercent%" -ForegroundColor $(if ($coveragePercent -ge 70) { 'Green' } elseif ($coveragePercent -ge 50) { 'Yellow' } else { 'Red' }) | ||
| Write-Host "Executed: $coveredCommands commands" | ||
| Write-Host "Analyzed: $totalCommands commands" | ||
| Write-Host "Missed: $missedCommands commands" | ||
| Write-Host "" | ||
| # Find functions with 0% coverage | ||
| $missedFiles = $coverage.CommandsMissed | | ||
| Group-Object File | | ||
| ForEach-Object { | ||
| $file = $_.Name | ||
| $fileName = Split-Path $file -Leaf | ||
| $totalInFile = ($coverage.CommandsAnalyzed | Where-Object { $_.File -eq $file }).Count | ||
| $missedInFile = $_.Count | ||
| if ($totalInFile -eq $missedInFile -and $totalInFile -gt 0) { | ||
| $fileName -replace '\.ps1$', '' | ||
| } | ||
| } | Where-Object { $_ } | Select-Object -First 20 | ||
| $missedFunctionsStr = if ($missedFiles.Count -gt 0) { | ||
| ($missedFiles | Select-Object -First 10) -join ', ' | ||
| } else { | ||
| "None" | ||
| } | ||
| if ($missedFiles.Count -gt 0) { | ||
| Write-Host "Functions with 0% coverage (first 10):" -ForegroundColor Yellow | ||
| $missedFiles | Select-Object -First 10 | ForEach-Object { | ||
| Write-Host " - $_" -ForegroundColor Yellow | ||
| } | ||
| if ($missedFiles.Count -gt 10) { | ||
| Write-Host " ... and $($missedFiles.Count - 10) more" -ForegroundColor Yellow | ||
| } | ||
| } | ||
| # Set outputs | ||
| "coverage_percent=$coveragePercent" >> $env:GITHUB_OUTPUT | ||
| "covered_commands=$coveredCommands" >> $env:GITHUB_OUTPUT | ||
| "total_commands=$totalCommands" >> $env:GITHUB_OUTPUT | ||
| "missed_functions=$missedFunctionsStr" >> $env:GITHUB_OUTPUT | ||
| # Export detailed report | ||
| @{ | ||
| CoveragePercent = $coveragePercent | ||
| CoveredCommands = $coveredCommands | ||
| TotalCommands = $totalCommands | ||
| MissedCommands = $missedCommands | ||
| Threshold = ${{ inputs.coverage_threshold }} | ||
| MissedFunctions = $missedFiles | ||
| } | ConvertTo-Json -Depth 5 | Out-File "coverage-report.json" | ||
| # Check threshold | ||
| $threshold = [int]"${{ inputs.coverage_threshold }}" | ||
| if ($coveragePercent -lt $threshold) { | ||
| Write-Host "" | ||
| Write-Host "::warning::Code coverage ($coveragePercent%) is below threshold ($threshold%)" | ||
| } | ||
| - name: Upload coverage report | ||
| uses: actions/upload-artifact@v4 | ||
| if: always() | ||
| with: | ||
| name: code-coverage | ||
| path: | | ||
| coverage.xml | ||
| coverage-report.json | ||
| retention-days: 30 | ||
| # ============================================================ | ||
| # Stage 3: Code Quality - PSScriptAnalyzer | ||
| # ============================================================ | ||
| code-quality: | ||
| name: Code Quality | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
| - name: Install PSScriptAnalyzer | ||
| shell: pwsh | ||
| run: | | ||
| Set-PSRepository PSGallery -InstallationPolicy Trusted | ||
| Install-Module PSScriptAnalyzer -Force -Scope CurrentUser | ||
| - name: Run PSScriptAnalyzer | ||
| id: pssa | ||
| shell: pwsh | ||
| run: | | ||
| Write-Host "Running PSScriptAnalyzer on Functions/..." -ForegroundColor Cyan | ||
| $results = Invoke-ScriptAnalyzer -Path ./Functions -Recurse -Settings PSGallery | ||
| $errors = $results | Where-Object { $_.Severity -eq 'Error' } | ||
| $warnings = $results | Where-Object { $_.Severity -eq 'Warning' } | ||
| $info = $results | Where-Object { $_.Severity -eq 'Information' } | ||
| Write-Host "" | ||
| Write-Host "=== PSScriptAnalyzer Results ===" -ForegroundColor Cyan | ||
| Write-Host "Errors: $($errors.Count)" -ForegroundColor $(if ($errors.Count -gt 0) { 'Red' } else { 'Green' }) | ||
| Write-Host "Warnings: $($warnings.Count)" -ForegroundColor $(if ($warnings.Count -gt 0) { 'Yellow' } else { 'Green' }) | ||
| Write-Host "Info: $($info.Count)" -ForegroundColor Gray | ||
| if ($errors.Count -gt 0) { | ||
| Write-Host "" | ||
| Write-Host "=== Errors ===" -ForegroundColor Red | ||
| $errors | ForEach-Object { | ||
| Write-Host " $($_.ScriptName):$($_.Line) - $($_.RuleName): $($_.Message)" -ForegroundColor Red | ||
| } | ||
| } | ||
| $results | ConvertTo-Json | Out-File "pssa-results.json" | ||
| if ($errors.Count -gt 0) { | ||
| Write-Host "" | ||
| Write-Host "::error::PSScriptAnalyzer found $($errors.Count) error(s)" | ||
| exit 1 | ||
| } | ||
| - name: Upload PSSA results | ||
| uses: actions/upload-artifact@v4 | ||
| if: always() | ||
| with: | ||
| name: code-quality-pssa | ||
| path: pssa-results.json | ||
| retention-days: 30 | ||
| # ============================================================ | ||
| # Stage 4: Documentation Validation | ||
| # ============================================================ | ||
| documentation-validation: | ||
| name: Documentation Validation | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| total_functions: ${{ steps.docs.outputs.total_functions }} | ||
| documented_functions: ${{ steps.docs.outputs.documented_functions }} | ||
| documentation_percent: ${{ steps.docs.outputs.documentation_percent }} | ||
| missing_docs: ${{ steps.docs.outputs.missing_docs }} | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
| - name: Validate documentation | ||
| id: docs | ||
| shell: pwsh | ||
| run: | | ||
| Write-Host "=== Validating Function Documentation ===" -ForegroundColor Cyan | ||
| Write-Host "" | ||
| # Build module first | ||
| ./deploy.ps1 -Environment prod -SkipVersion | ||
| Import-Module ./PowerNetbox/PowerNetbox.psd1 -Force | ||
| # Get all public functions | ||
| $publicFunctions = Get-Command -Module PowerNetbox | Where-Object { $_.Name -match '-' } | ||
| Write-Host "Checking documentation for $($publicFunctions.Count) public functions..." -ForegroundColor Yellow | ||
| Write-Host "" | ||
| $results = @() | ||
| $missingDocs = @() | ||
| foreach ($func in $publicFunctions) { | ||
| $help = Get-Help $func.Name -ErrorAction SilentlyContinue | ||
| $hasSynopsis = $help.Synopsis -and | ||
| $help.Synopsis -ne $func.Name -and | ||
| $help.Synopsis -notmatch '^[\s\r\n]*$' | ||
| $hasDescription = $help.Description -and | ||
| $help.Description.Text -and | ||
| $help.Description.Text.Trim() -ne '' | ||
| $hasExamples = $help.Examples -and | ||
| $help.Examples.Example -and | ||
| $help.Examples.Example.Count -gt 0 | ||
| $hasParameters = $true # Less strict on parameters | ||
| $isDocumented = $hasSynopsis -and $hasDescription -and $hasExamples | ||
| $results += [PSCustomObject]@{ | ||
| Function = $func.Name | ||
| Synopsis = $hasSynopsis | ||
| Description = $hasDescription | ||
| Examples = $hasExamples | ||
| Documented = $isDocumented | ||
| } | ||
| if (-not $isDocumented) { | ||
| $missing = @() | ||
| if (-not $hasSynopsis) { $missing += "Synopsis" } | ||
| if (-not $hasDescription) { $missing += "Description" } | ||
| if (-not $hasExamples) { $missing += "Examples" } | ||
| $missingDocs += "$($func.Name) (missing: $($missing -join ', '))" | ||
| } | ||
| } | ||
| $totalFunctions = $results.Count | ||
| $documentedFunctions = ($results | Where-Object { $_.Documented }).Count | ||
| $documentationPercent = if ($totalFunctions -gt 0) { | ||
| [math]::Round(($documentedFunctions / $totalFunctions) * 100, 1) | ||
| } else { 0 } | ||
| Write-Host "========================================" -ForegroundColor Cyan | ||
| Write-Host " DOCUMENTATION VALIDATION RESULTS" -ForegroundColor Cyan | ||
| Write-Host "========================================" -ForegroundColor Cyan | ||
| Write-Host "" | ||
| Write-Host "Total functions: $totalFunctions" | ||
| Write-Host "Fully documented: $documentedFunctions" | ||
| Write-Host "Documentation: $documentationPercent%" -ForegroundColor $(if ($documentationPercent -ge 90) { 'Green' } elseif ($documentationPercent -ge 70) { 'Yellow' } else { 'Red' }) | ||
| Write-Host "" | ||
| if ($missingDocs.Count -gt 0 -and $missingDocs.Count -le 20) { | ||
| Write-Host "Functions with incomplete documentation:" -ForegroundColor Yellow | ||
| $missingDocs | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow } | ||
| } elseif ($missingDocs.Count -gt 20) { | ||
| Write-Host "Functions with incomplete documentation: $($missingDocs.Count) functions" -ForegroundColor Yellow | ||
| Write-Host "(First 10 shown)" -ForegroundColor Gray | ||
| $missingDocs | Select-Object -First 10 | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow } | ||
| } | ||
| # Set outputs | ||
| $missingDocsStr = if ($missingDocs.Count -gt 0) { | ||
| ($missingDocs | Select-Object -First 5) -join '; ' | ||
| } else { "None" } | ||
| "total_functions=$totalFunctions" >> $env:GITHUB_OUTPUT | ||
| "documented_functions=$documentedFunctions" >> $env:GITHUB_OUTPUT | ||
| "documentation_percent=$documentationPercent" >> $env:GITHUB_OUTPUT | ||
| "missing_docs=$missingDocsStr" >> $env:GITHUB_OUTPUT | ||
| # Export full report | ||
| @{ | ||
| TotalFunctions = $totalFunctions | ||
| DocumentedFunctions = $documentedFunctions | ||
| DocumentationPercent = $documentationPercent | ||
| MissingDocumentation = $missingDocs | ||
| Details = $results | ||
| } | ConvertTo-Json -Depth 5 | Out-File "documentation-report.json" | ||
| - name: Upload documentation report | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: documentation-validation | ||
| path: documentation-report.json | ||
| retention-days: 30 | ||
| # ============================================================ | ||
| # Stage 5: Breaking Change Detection | ||
| # ============================================================ | ||
| breaking-changes: | ||
| name: Breaking Change Detection | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| removed_commands: ${{ steps.compare.outputs.removed_commands }} | ||
| added_commands: ${{ steps.compare.outputs.added_commands }} | ||
| removed_count: ${{ steps.compare.outputs.removed_count }} | ||
| added_count: ${{ steps.compare.outputs.added_count }} | ||
| has_breaking_changes: ${{ steps.compare.outputs.has_breaking_changes }} | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
| - name: Compare with PSGallery release | ||
| id: compare | ||
| shell: pwsh | ||
| run: | | ||
| Write-Host "=== Breaking Change Detection ===" -ForegroundColor Cyan | ||
| Write-Host "" | ||
| # Build current module | ||
| Write-Host "Building current module..." -ForegroundColor Yellow | ||
| ./deploy.ps1 -Environment prod -SkipVersion | ||
| Import-Module ./PowerNetbox/PowerNetbox.psd1 -Force -DisableNameChecking | ||
| $currentCommands = Get-Command -Module PowerNetbox | | ||
| Where-Object { $_.Name -match '-' } | | ||
| Select-Object -ExpandProperty Name | | ||
| Sort-Object | ||
| Write-Host "Current module has $($currentCommands.Count) commands" | ||
| # Try to get PSGallery version | ||
| Write-Host "" | ||
| Write-Host "Fetching latest PSGallery release..." -ForegroundColor Yellow | ||
| $galleryCommands = @() | ||
| try { | ||
| # Save to temp location | ||
| $tempPath = Join-Path $env:RUNNER_TEMP 'PSGalleryModule' | ||
| New-Item -ItemType Directory -Path $tempPath -Force | Out-Null | ||
| Save-Module -Name PowerNetbox -Path $tempPath -Repository PSGallery -ErrorAction Stop | ||
| # Find and import | ||
| $galleryManifest = Get-ChildItem -Path $tempPath -Filter 'PowerNetbox.psd1' -Recurse | Select-Object -First 1 | ||
| if ($galleryManifest) { | ||
| Import-Module $galleryManifest.FullName -Force -DisableNameChecking -Prefix 'Gallery' | ||
| $galleryCommands = Get-Command -Module PowerNetbox | | ||
| Where-Object { $_.Name -match '-Gallery' } | | ||
| ForEach-Object { $_.Name -replace '-Gallery', '-' } | | ||
| Sort-Object | ||
| # Actually get commands without prefix trick | ||
| Remove-Module PowerNetbox -Force -ErrorAction SilentlyContinue | ||
| Import-Module $galleryManifest.FullName -Force -DisableNameChecking | ||
| $galleryCommands = Get-Command -Module PowerNetbox | | ||
| Where-Object { $_.Name -match '-' } | | ||
| Select-Object -ExpandProperty Name | | ||
| Sort-Object | ||
| $galleryVersion = (Get-Module PowerNetbox).Version | ||
| Write-Host "PSGallery version: $galleryVersion with $($galleryCommands.Count) commands" -ForegroundColor Green | ||
| # Reimport current | ||
| Remove-Module PowerNetbox -Force | ||
| Import-Module ./PowerNetbox/PowerNetbox.psd1 -Force -DisableNameChecking | ||
| } | ||
| } catch { | ||
| Write-Host "Could not fetch PSGallery module: $_" -ForegroundColor Yellow | ||
| Write-Host "Skipping breaking change detection (no baseline)" -ForegroundColor Yellow | ||
| } | ||
| # Compare | ||
| $removedCommands = @() | ||
| $addedCommands = @() | ||
| if ($galleryCommands.Count -gt 0) { | ||
| $removedCommands = $galleryCommands | Where-Object { $_ -notin $currentCommands } | ||
| $addedCommands = $currentCommands | Where-Object { $_ -notin $galleryCommands } | ||
| Write-Host "" | ||
| Write-Host "========================================" -ForegroundColor Cyan | ||
| Write-Host " BREAKING CHANGE ANALYSIS" -ForegroundColor Cyan | ||
| Write-Host "========================================" -ForegroundColor Cyan | ||
| Write-Host "" | ||
| if ($removedCommands.Count -gt 0) { | ||
| Write-Host "⚠️ REMOVED COMMANDS (Breaking Changes): $($removedCommands.Count)" -ForegroundColor Red | ||
| $removedCommands | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } | ||
| } else { | ||
| Write-Host "✅ No removed commands" -ForegroundColor Green | ||
| } | ||
| Write-Host "" | ||
| if ($addedCommands.Count -gt 0) { | ||
| Write-Host "➕ NEW COMMANDS: $($addedCommands.Count)" -ForegroundColor Green | ||
| if ($addedCommands.Count -le 20) { | ||
| $addedCommands | ForEach-Object { Write-Host " + $_" -ForegroundColor Green } | ||
| } else { | ||
| $addedCommands | Select-Object -First 10 | ForEach-Object { Write-Host " + $_" -ForegroundColor Green } | ||
| Write-Host " ... and $($addedCommands.Count - 10) more" -ForegroundColor Gray | ||
| } | ||
| } else { | ||
| Write-Host "No new commands" -ForegroundColor Gray | ||
| } | ||
| } | ||
| # Set outputs | ||
| $removedStr = if ($removedCommands.Count -gt 0) { ($removedCommands | Select-Object -First 5) -join ', ' } else { "None" } | ||
| $addedStr = if ($addedCommands.Count -gt 0) { ($addedCommands | Select-Object -First 5) -join ', ' } else { "None" } | ||
| $hasBreaking = if ($removedCommands.Count -gt 0) { "true" } else { "false" } | ||
| "removed_commands=$removedStr" >> $env:GITHUB_OUTPUT | ||
| "added_commands=$addedStr" >> $env:GITHUB_OUTPUT | ||
| "removed_count=$($removedCommands.Count)" >> $env:GITHUB_OUTPUT | ||
| "added_count=$($addedCommands.Count)" >> $env:GITHUB_OUTPUT | ||
| "has_breaking_changes=$hasBreaking" >> $env:GITHUB_OUTPUT | ||
| # Export report | ||
| @{ | ||
| CurrentCommandCount = $currentCommands.Count | ||
| GalleryCommandCount = $galleryCommands.Count | ||
| RemovedCommands = $removedCommands | ||
| AddedCommands = $addedCommands | ||
| HasBreakingChanges = ($removedCommands.Count -gt 0) | ||
| } | ConvertTo-Json -Depth 5 | Out-File "breaking-changes-report.json" | ||
| - name: Upload breaking changes report | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: breaking-changes | ||
| path: breaking-changes-report.json | ||
| retention-days: 30 | ||
| # ============================================================ | ||
| # Stage 6: Integration Tests - All Netbox Versions | ||
| # ============================================================ | ||
| integration-tests: | ||
| name: Integration [Netbox ${{ matrix.netbox_short }}] | ||
| runs-on: ubuntu-latest | ||
| if: ${{ !inputs.skip_integration }} | ||
| needs: [unit-tests] | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| include: | ||
| - netbox: "v4.1.11-3.0.2" | ||
| netbox_short: "4.1.11" | ||
| - netbox: "v4.2.9-3.2.1" | ||
| netbox_short: "4.2.9" | ||
| - netbox: "v4.3.7-3.3.0" | ||
| netbox_short: "4.3.7" | ||
| - netbox: "v4.4.9-3.4.2" | ||
| netbox_short: "4.4.9" | ||
| env: | ||
| NETBOX_VERSION: ${{ matrix.netbox }} | ||
| NETBOX_HOST: "localhost:8000" | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
| - name: Set up Docker Buildx | ||
| uses: docker/setup-buildx-action@v3 | ||
| - name: Start Netbox ${{ matrix.netbox_short }} | ||
| run: | | ||
| echo "Starting Netbox ${{ matrix.netbox_short }}..." | ||
| docker compose -f docker-compose.ci.yml pull | ||
| docker compose -f docker-compose.ci.yml up -d | ||
| - name: Wait for Netbox | ||
| run: | | ||
| echo "Waiting for Netbox to be ready..." | ||
| MAX_WAIT=300 | ||
| ELAPSED=0 | ||
| while [ $ELAPSED -lt $MAX_WAIT ]; do | ||
| HEALTH=$(docker inspect --format='{{.State.Health.Status}}' powernetbox-netbox-1 2>/dev/null || echo "starting") | ||
| echo "[${ELAPSED}s] Status: $HEALTH" | ||
| if [ "$HEALTH" = "healthy" ]; then | ||
| curl -sf http://localhost:8000/login/ > /dev/null 2>&1 && exit 0 | ||
| fi | ||
| [ "$HEALTH" = "unhealthy" ] && docker compose -f docker-compose.ci.yml logs netbox --tail 50 && exit 1 | ||
| sleep 10 | ||
| ELAPSED=$((ELAPSED + 10)) | ||
| done | ||
| exit 1 | ||
| - name: Install PowerShell dependencies | ||
| shell: pwsh | ||
| run: | | ||
| Set-PSRepository PSGallery -InstallationPolicy Trusted | ||
| Install-Module Pester -Force -Scope CurrentUser -MinimumVersion 5.0.0 | ||
| - name: Build module | ||
| shell: pwsh | ||
| run: | | ||
| ./deploy.ps1 -Environment dev -SkipVersion | ||
| Import-Module ./PowerNetbox/PowerNetbox.psd1 -Force | ||
| - name: Run integration tests | ||
| shell: pwsh | ||
| env: | ||
| NETBOX_HOST: ${{ env.NETBOX_HOST }} | ||
| NETBOX_TOKEN: ${{ env.NETBOX_TOKEN }} | ||
| run: | | ||
| $config = New-PesterConfiguration | ||
| $config.Run.Path = './Tests/Integration.Tests.ps1' | ||
| $config.Run.Exit = $true | ||
| $config.Filter.Tag = @('Integration') | ||
| $config.TestResult.Enabled = $true | ||
| $config.TestResult.OutputPath = 'IntegrationResults.xml' | ||
| $config.TestResult.OutputFormat = 'NUnitXml' | ||
| $config.Output.Verbosity = 'Detailed' | ||
| Invoke-Pester -Configuration $config | ||
| - name: Upload integration results | ||
| uses: actions/upload-artifact@v4 | ||
| if: always() | ||
| with: | ||
| name: integration-tests-${{ matrix.netbox_short }} | ||
| path: IntegrationResults.xml | ||
| retention-days: 30 | ||
| - name: Cleanup | ||
| if: always() | ||
| run: docker compose -f docker-compose.ci.yml down -v --remove-orphans | ||
| # ============================================================ | ||
| # Stage 7: Module Verification | ||
| # ============================================================ | ||
| module-verification: | ||
| name: Module Verification | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| command_count: ${{ steps.verify.outputs.command_count }} | ||
| module_version: ${{ steps.verify.outputs.module_version }} | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
| - name: Build and verify module | ||
| id: verify | ||
| shell: pwsh | ||
| run: | | ||
| Write-Host "=== Building Module ===" -ForegroundColor Cyan | ||
| ./deploy.ps1 -Environment prod -SkipVersion | ||
| Write-Host "" | ||
| Write-Host "=== Importing Module ===" -ForegroundColor Cyan | ||
| Import-Module ./PowerNetbox/PowerNetbox.psd1 -Force -ErrorAction Stop | ||
| $module = Get-Module PowerNetbox | ||
| Write-Host "Name: $($module.Name)" | ||
| Write-Host "Version: $($module.Version)" | ||
| $commands = Get-Command -Module PowerNetbox | ||
| $publicCommands = $commands | Where-Object { $_.Name -match '-' } | ||
| Write-Host "" | ||
| Write-Host "=== Command Statistics ===" -ForegroundColor Cyan | ||
| Write-Host "Total commands: $($commands.Count)" | ||
| Write-Host "Public commands: $($publicCommands.Count)" | ||
| $expectedMinimum = 400 | ||
| if ($publicCommands.Count -lt $expectedMinimum) { | ||
| Write-Host "::error::Expected at least $expectedMinimum public commands, found $($publicCommands.Count)" | ||
| exit 1 | ||
| } | ||
| Write-Host "" | ||
| Write-Host "=== Command Breakdown ===" -ForegroundColor Cyan | ||
| $breakdown = $publicCommands | Group-Object { ($_.Name -split '-')[0] } | Sort-Object Count -Descending | ||
| foreach ($group in $breakdown) { | ||
| Write-Host " $($group.Name): $($group.Count)" | ||
| } | ||
| # Set outputs | ||
| "command_count=$($publicCommands.Count)" >> $env:GITHUB_OUTPUT | ||
| "module_version=$($module.Version)" >> $env:GITHUB_OUTPUT | ||
| $publicCommands | Select-Object Name | ConvertTo-Json | Out-File "command-list.json" | ||
| - name: Upload command list | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: module-verification | ||
| path: command-list.json | ||
| retention-days: 30 | ||
| # ============================================================ | ||
| # Final: Enhanced Release Readiness Report | ||
| # ============================================================ | ||
| release-report: | ||
| name: Release Readiness Report | ||
| runs-on: ubuntu-latest | ||
| needs: [unit-tests, code-coverage, code-quality, documentation-validation, breaking-changes, integration-tests, module-verification] | ||
| if: always() | ||
| steps: | ||
| - name: Download all artifacts | ||
| uses: actions/download-artifact@v4 | ||
| with: | ||
| path: artifacts | ||
| - name: Generate enhanced release report | ||
| shell: pwsh | ||
| run: | | ||
| $version = "${{ inputs.version }}" | ||
| $skipIntegration = "${{ inputs.skip_integration }}" -eq 'true' | ||
| $coverageThreshold = [int]"${{ inputs.coverage_threshold }}" | ||
| # Collect job results | ||
| $unitTests = "${{ needs.unit-tests.result }}" | ||
| $codeCoverage = "${{ needs.code-coverage.result }}" | ||
| $codeQuality = "${{ needs.code-quality.result }}" | ||
| $docsValidation = "${{ needs.documentation-validation.result }}" | ||
| $breakingChanges = "${{ needs.breaking-changes.result }}" | ||
| $integration = if ($skipIntegration) { "skipped" } else { "${{ needs.integration-tests.result }}" } | ||
| $moduleVerify = "${{ needs.module-verification.result }}" | ||
| # Get metrics from job outputs | ||
| $coveragePercent = "${{ needs.code-coverage.outputs.coverage_percent }}" | ||
| $coveredCommands = "${{ needs.code-coverage.outputs.covered_commands }}" | ||
| $totalCommands = "${{ needs.code-coverage.outputs.total_commands }}" | ||
| $missedFunctions = "${{ needs.code-coverage.outputs.missed_functions }}" | ||
| $totalFunctions = "${{ needs.documentation-validation.outputs.total_functions }}" | ||
| $documentedFunctions = "${{ needs.documentation-validation.outputs.documented_functions }}" | ||
| $docsPercent = "${{ needs.documentation-validation.outputs.documentation_percent }}" | ||
| $missingDocs = "${{ needs.documentation-validation.outputs.missing_docs }}" | ||
| $removedCommands = "${{ needs.breaking-changes.outputs.removed_commands }}" | ||
| $addedCommands = "${{ needs.breaking-changes.outputs.added_commands }}" | ||
| $removedCount = "${{ needs.breaking-changes.outputs.removed_count }}" | ||
| $addedCount = "${{ needs.breaking-changes.outputs.added_count }}" | ||
| $hasBreaking = "${{ needs.breaking-changes.outputs.has_breaking_changes }}" | ||
| $commandCount = "${{ needs.module-verification.outputs.command_count }}" | ||
| $moduleVersion = "${{ needs.module-verification.outputs.module_version }}" | ||
| # Determine overall status | ||
| $coverageOk = [double]$coveragePercent -ge $coverageThreshold | ||
| $allPassed = ($unitTests -eq 'success') -and | ||
| ($codeCoverage -eq 'success') -and | ||
| ($codeQuality -eq 'success') -and | ||
| ($docsValidation -eq 'success') -and | ||
| ($moduleVerify -eq 'success') -and | ||
| ($skipIntegration -or $integration -eq 'success') | ||
| $overallStatus = if ($allPassed) { "READY" } else { "NOT READY" } | ||
| $statusEmoji = if ($allPassed) { "✅" } else { "❌" } | ||
| # Coverage status | ||
| $coverageStatus = if ([double]$coveragePercent -ge $coverageThreshold) { "✅" } else { "⚠️" } | ||
| $docsStatus = if ([double]$docsPercent -ge 80) { "✅" } elseif ([double]$docsPercent -ge 60) { "⚠️" } else { "❌" } | ||
| $breakingStatus = if ($hasBreaking -eq 'true') { "⚠️ BREAKING" } else { "✅ None" } | ||
| $report = @" | ||
| # $statusEmoji Pre-Release Validation Report | ||
| **Version:** $version | ||
| **Module Version:** $moduleVersion | ||
| **Date:** $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss UTC') | ||
| **Status:** **$overallStatus** | ||
| --- | ||
| ## 📊 Quality Metrics | ||
| | Metric | Value | Status | | ||
| |--------|-------|--------| | ||
| | **Code Coverage** | $coveragePercent% ($coveredCommands/$totalCommands commands) | $coverageStatus (threshold: $coverageThreshold%) | | ||
| | **Documentation** | $docsPercent% ($documentedFunctions/$totalFunctions functions) | $docsStatus | | ||
| | **Breaking Changes** | $removedCount removed, $addedCount added | $breakingStatus | | ||
| | **Public Commands** | $commandCount | ✅ | | ||
| --- | ||
| ## 🧪 Test Results | ||
| | Stage | Result | Details | | ||
| |-------|--------|---------| | ||
| | Unit Tests (4 platforms) | $(if ($unitTests -eq 'success') { '✅ PASS' } else { '❌ FAIL' }) | Ubuntu, Windows, macOS × PS 5.1/7.x | | ||
| | Code Coverage | $(if ($codeCoverage -eq 'success') { '✅ PASS' } else { '❌ FAIL' }) | $coveragePercent% coverage | | ||
| | Code Quality (PSSA) | $(if ($codeQuality -eq 'success') { '✅ PASS' } else { '❌ FAIL' }) | PSScriptAnalyzer | | ||
| | Documentation | $(if ($docsValidation -eq 'success') { '✅ PASS' } else { '❌ FAIL' }) | $docsPercent% documented | | ||
| | Breaking Changes | $(if ($breakingChanges -eq 'success') { '✅ PASS' } else { '❌ FAIL' }) | $removedCount breaking, $addedCount new | | ||
| | Integration Tests | $(if ($skipIntegration) { '⏭️ SKIPPED' } elseif ($integration -eq 'success') { '✅ PASS' } else { '❌ FAIL' }) | Netbox 4.1, 4.2, 4.3, 4.4 | | ||
| | Module Verification | $(if ($moduleVerify -eq 'success') { '✅ PASS' } else { '❌ FAIL' }) | Import, command count | | ||
| --- | ||
| ## 🖥️ Platform Matrix | ||
| | Platform | PowerShell | Status | | ||
| |----------|------------|--------| | ||
| | Ubuntu | 7.x | $(if ($unitTests -eq 'success') { '✅' } else { '❓' }) | | ||
| | Windows | 7.x | $(if ($unitTests -eq 'success') { '✅' } else { '❓' }) | | ||
| | Windows | 5.1 | $(if ($unitTests -eq 'success') { '✅' } else { '❓' }) | | ||
| | macOS | 7.x | $(if ($unitTests -eq 'success') { '✅' } else { '❓' }) | | ||
| --- | ||
| ## 🔌 Netbox Compatibility | ||
| | Version | Status | | ||
| |---------|--------| | ||
| | 4.1.11 | $(if ($skipIntegration) { '⏭️' } elseif ($integration -eq 'success') { '✅' } else { '❓' }) | | ||
| | 4.2.9 | $(if ($skipIntegration) { '⏭️' } elseif ($integration -eq 'success') { '✅' } else { '❓' }) | | ||
| | 4.3.7 | $(if ($skipIntegration) { '⏭️' } elseif ($integration -eq 'success') { '✅' } else { '❓' }) | | ||
| | 4.4.9 | $(if ($skipIntegration) { '⏭️' } elseif ($integration -eq 'success') { '✅' } else { '❓' }) | | ||
| --- | ||
| ## 📋 Coverage Details | ||
| **Functions with 0% coverage (sample):** $missedFunctions | ||
| **Functions missing documentation (sample):** $missingDocs | ||
| $(if ($hasBreaking -eq 'true') { | ||
| @" | ||
| --- | ||
| ## ⚠️ Breaking Changes Detected | ||
| **Removed commands:** $removedCommands | ||
| These commands exist in the PSGallery release but are missing from the current build. | ||
| Consider if this is intentional and document in release notes. | ||
| "@ | ||
| }) | ||
| --- | ||
| ## ✅ Release Checklist | ||
| - [$(if ($unitTests -eq 'success') { 'x' } else { ' ' })] All unit tests pass (4 platforms) | ||
| - [$(if ($coverageOk) { 'x' } else { ' ' })] Code coverage ≥ $coverageThreshold% | ||
| - [$(if ($codeQuality -eq 'success') { 'x' } else { ' ' })] No PSScriptAnalyzer errors | ||
| - [$(if ($docsValidation -eq 'success') { 'x' } else { ' ' })] Documentation validated | ||
| - [$(if ($hasBreaking -ne 'true') { 'x' } else { ' ' })] No breaking changes (or documented) | ||
| - [$(if ($skipIntegration -or $integration -eq 'success') { 'x' } else { ' ' })] Integration tests pass | ||
| - [$(if ($moduleVerify -eq 'success') { 'x' } else { ' ' })] Module loads correctly | ||
| - [ ] Version updated in PowerNetbox.psd1 | ||
| - [ ] CHANGELOG updated | ||
| - [ ] PR approved | ||
| --- | ||
| ## 🚀 Next Steps | ||
| $(if ($allPassed) { | ||
| @" | ||
| 1. Merge to main branch | ||
| 2. Create release tag: ``v$version`` | ||
| 3. GitHub Actions will publish to PSGallery | ||
| "@ | ||
| } else { | ||
| @" | ||
| 1. Review failed checks above | ||
| 2. Fix issues and push updates | ||
| 3. Re-run this validation workflow | ||
| "@ | ||
| }) | ||
| --- | ||
| *Generated by Pre-Release Validation v2* | ||
| "@ | ||
| Write-Host $report | ||
| $report | Out-File "release-report.md" | ||
| $report | Out-File -FilePath $env:GITHUB_STEP_SUMMARY | ||
| if (-not $allPassed) { | ||
| Write-Host "" | ||
| Write-Host "::error::Release validation FAILED - see report above" | ||
| exit 1 | ||
| } | ||
| - name: Upload release report | ||
| uses: actions/upload-artifact@v4 | ||
| if: always() | ||
| with: | ||
| name: release-report | ||
| path: release-report.md | ||
| retention-days: 90 | ||