Feature/testkit changes #127
Workflow file for this run
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
| # Sequential PR validation workflow with coverage gating | |
| # Stage 1: Linux tests (.NET 5.0-10.0 and netcoreapp3.1) — coverage collected for net5.0+ + 90% gate | |
| # Stage 2: Windows tests (all TFMs, only if Stage 1 passes) — coverage collected for net5.0+ + 90% gate | |
| # Stage 3: macOS tests (.NET 6.0-10.0, only if Stage 2 passes) — coverage collected for net6.0+ + 90% gate | |
| # | |
| # SECURITY NOTE: | |
| # - Uses pull_request_target to run workflow from the trusted main branch, not from the PR branch | |
| # - This prevents malicious workflow YAML changes in untrusted PR branches from taking effect | |
| # - All checkout steps use PR refs (refs/pull/*/head) to check out PR code from the base repo | |
| # - persist-credentials: false prevents the checkout token from being written to git config for subsequent git commands | |
| # (it does NOT, by itself, prevent steps from accessing github.token / GITHUB_TOKEN if you explicitly expose it) | |
| # - Default GITHUB_TOKEN permissions are restricted to read-only repository contents to limit impact if exposed | |
| name: PR Checks v3 (Gated) | |
| permissions: | |
| contents: read | |
| env: | |
| CODECOV_MINIMUM: 90 | |
| on: | |
| pull_request_target: # Runs from the main branch, not from PR branch | |
| branches: | |
| - main | |
| jobs: | |
| # ============================================================================ | |
| # DETECTION: Check if .csproj files exist | |
| # ============================================================================ | |
| detect-projects: | |
| name: "Detect .NET Projects" | |
| runs-on: ubuntu-latest | |
| if: github.repository != 'Chris-Wolfgang/repo-template' | |
| outputs: | |
| has-projects: ${{ steps.check-projects.outputs.has-projects }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| - name: Check for .NET project files | |
| id: check-projects | |
| run: | | |
| if git ls-files '*.csproj' '*.vbproj' '*.fsproj' | grep -q .; then | |
| echo "has-projects=true" >> $GITHUB_OUTPUT | |
| echo "✅ Found .NET project files - .NET build and test jobs will run" | |
| else | |
| echo "has-projects=false" >> $GITHUB_OUTPUT | |
| echo "ℹ️ No .NET project files found - skipping .NET build and test jobs" | |
| fi | |
| # ============================================================================ | |
| # STAGE 1: Linux - .NET Core/5+ Tests with Coverage Gate | |
| # ============================================================================ | |
| test-linux-core: | |
| name: "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" | |
| runs-on: ubuntu-latest | |
| needs: detect-projects | |
| if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| # Fix for .NET 5.0 on Ubuntu 22.04+ - install libssl1.1 from the focal-security | |
| # repository so APT verifies the package via GPG instead of a plain wget download. | |
| - name: Install OpenSSL 1.1 for .NET 5.0 | |
| run: | | |
| echo "deb https://security.ubuntu.com/ubuntu focal-security main" | sudo tee /etc/apt/sources.list.d/focal-security.list | |
| sudo apt-get update -q | |
| sudo apt-get install --yes libssl1.1 | |
| sudo rm /etc/apt/sources.list.d/focal-security.list | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: | | |
| 3.1.x | |
| 5.0.x | |
| 6.0.x | |
| 7.0.x | |
| 8.0.x | |
| 9.0.x | |
| 10.0.x | |
| - name: Restore and build (exclude .NET Framework-only projects) | |
| run: | | |
| echo "Finding .NET project files in repository (via find command)..." | |
| # Filter out projects that ONLY target .NET Framework 4.x | |
| # Multi-targeting projects (e.g., net8.0;net48) will be INCLUDED | |
| projects=() | |
| project_found=false | |
| while IFS= read -r -d '' proj; do | |
| project_found=true | |
| # Check if project has any .NET 5+ target framework | |
| # Look for: net5.0, net6.0, net7.0, net8.0, net9.0, net10.0, or netcoreapp, netstandard | |
| # Normalize line endings to handle multi-line <TargetFramework> / <TargetFrameworks> elements | |
| if tr -d '\n\r' < "$proj" | grep -qE '<TargetFramework[s]?>.*(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp|netstandard)'; then | |
| projects+=("$proj") | |
| echo "✓ Including: $proj (has .NET 5+ or .NET Core target)" | |
| else | |
| echo "⊘ Excluding: $proj (Framework-only, incompatible with Linux)" | |
| fi | |
| done < <(find . -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) | |
| if [ "$project_found" = false ]; then | |
| echo "❌ No .NET projects found." | |
| echo "This should not occur as detect-projects already verified project existence." | |
| exit 1 | |
| fi | |
| if [ ${#projects[@]} -eq 0 ]; then | |
| echo "❌ No compatible .NET projects found." | |
| echo "All projects target only .NET Framework 4.x, which is incompatible with Linux." | |
| exit 1 | |
| fi | |
| echo "" | |
| echo "==========================================" | |
| echo "Projects to build:" | |
| echo "==========================================" | |
| printf '%s\n' "${projects[@]}" | |
| echo "" | |
| # Restore each project | |
| echo "Restoring projects..." | |
| for proj in "${projects[@]}"; do | |
| echo "Restoring: $proj" | |
| dotnet restore "$proj" || exit 1 | |
| done | |
| echo "" | |
| echo "Building projects..." | |
| # Build each project, handling multi-targeting projects | |
| # For multi-targeting projects, build only Linux-compatible frameworks (.NET 5.0+, .NET Core, .NET Standard) | |
| for proj in "${projects[@]}"; do | |
| echo "Building: $proj" | |
| # Extract target frameworks from the project file | |
| # Support both <TargetFramework> (single) and <TargetFrameworks> (multiple) | |
| # Collapse newlines so multi-line <TargetFrameworks> values are handled correctly | |
| frameworks=$(tr '\n' ' ' < "$proj" | grep -oP '<TargetFramework[s]?>\s*\K[^<]+' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp[0-9.]+|netstandard[0-9.]+)$' || true) | |
| if [ -z "$frameworks" ]; then | |
| echo "⚠️ No Linux-compatible frameworks found in $proj" | |
| continue | |
| fi | |
| # Check if this is a multi-targeting project | |
| framework_count=$(echo "$frameworks" | wc -l) | |
| if [ "$framework_count" -eq 1 ]; then | |
| # Single target framework - build normally | |
| echo " Target framework: $frameworks" | |
| dotnet build "$proj" --no-restore --configuration Release || exit 1 | |
| else | |
| # Multi-targeting project - build each compatible framework separately | |
| echo " Target frameworks (multi-targeting): $(echo "$frameworks" | tr '\n' ' ')" | |
| while IFS= read -r fw; do | |
| [ -z "$fw" ] && continue | |
| echo " Building framework: $fw" | |
| dotnet build "$proj" --no-restore --configuration Release --framework "$fw" || exit 1 | |
| done <<< "$frameworks" | |
| fi | |
| done | |
| echo "" | |
| echo "✅ All compatible projects built successfully" | |
| - name: Run tests with coverage (.NET Core 5.0 - 10.0) | |
| run: | | |
| # Find all test projects (C#, VB.NET, F#) | |
| mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) | |
| if [ ${#test_projects[@]} -eq 0 ]; then | |
| echo "❌ No test projects found in ./tests directory!" | |
| exit 1 | |
| fi | |
| echo "==========================================" | |
| echo "Found test projects:" | |
| echo "==========================================" | |
| printf '%s\n' "${test_projects[@]}" | |
| echo "" | |
| for test_proj in "${test_projects[@]}"; do | |
| echo "==========================================" | |
| echo "Testing project: $test_proj" | |
| echo "==========================================" | |
| # Extract target frameworks from the project file | |
| # Support both <TargetFramework> (single) and <TargetFrameworks> (multiple), including multi-line values | |
| frameworks=$(tr '\n' ' ' < "$test_proj" | grep -oP '<TargetFrameworks?\s*>\K[^<]+' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp3\.1)$' || true) | |
| if [ -z "$frameworks" ]; then | |
| echo "⊘ Skipping: No compatible target frameworks found" | |
| echo "" | |
| continue | |
| fi | |
| echo "Target frameworks: $(echo "$frameworks" | tr '\n' ' ')" | |
| echo "" | |
| # Test each framework that the project actually targets | |
| # Collect coverage for net5.0+ only; netcoreapp3.1 is tested but excluded | |
| while IFS= read -r fw; do | |
| [ -z "$fw" ] && continue | |
| echo "Testing framework: $fw" | |
| if echo "$fw" | grep -qE '^net([5-9]|[1-9][0-9]+)\.'; then | |
| dotnet test "$test_proj" \ | |
| --configuration Release \ | |
| --framework "$fw" \ | |
| --collect:"XPlat Code Coverage" \ | |
| --settings coverlet.runsettings \ | |
| --results-directory "./TestResults" \ | |
| --logger "console;verbosity=minimal" || exit 1 | |
| else | |
| dotnet test "$test_proj" \ | |
| --configuration Release \ | |
| --framework "$fw" \ | |
| --logger "console;verbosity=minimal" || exit 1 | |
| fi | |
| done <<< "$frameworks" | |
| echo "" | |
| done | |
| - name: Install ReportGenerator | |
| run: dotnet tool install -g dotnet-reportgenerator-globaltool | |
| - name: Generate coverage report | |
| run: | | |
| if find ./TestResults -name "coverage.cobertura.xml" -print -quit 2>/dev/null | grep -q .; then | |
| reportgenerator \ | |
| -reports:"TestResults/**/coverage.cobertura.xml" \ | |
| -targetdir:"CoverageReport" \ | |
| -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" | |
| else | |
| echo "ℹ️ No coverage files found - skipping report generation" | |
| fi | |
| - name: Enforce 90% coverage threshold | |
| run: | | |
| if [ ! -f "CoverageReport/Summary.txt" ]; then | |
| echo "❌ Coverage report not generated!" | |
| exit 1 | |
| fi | |
| echo "Coverage Summary:" | |
| cat CoverageReport/Summary.txt | |
| echo "" | |
| THRESHOLD=${CODECOV_MINIMUM:-90} | |
| FAILED=0 | |
| while IFS= read -r line; do | |
| if echo "$line" | grep -qE '^[^ ]+.*[0-9]+%$' && ! echo "$line" | grep -q '^Summary'; then | |
| MODULE=$(echo "$line" | grep -oP '^[^ ]+') | |
| PERCENT=$(echo "$line" | grep -oP '[0-9]+(?=%)' | tail -1) | |
| echo "Checking module: '$MODULE' - Coverage: ${PERCENT}%" | |
| if [ "$PERCENT" -lt "$THRESHOLD" ]; then | |
| echo " ❌ FAIL: Below ${THRESHOLD}% threshold" | |
| FAILED=1 | |
| else | |
| echo " ✅ PASS: Meets ${THRESHOLD}% threshold" | |
| fi | |
| fi | |
| done < CoverageReport/Summary.txt | |
| if [ "$FAILED" -ne 0 ]; then | |
| echo "" | |
| echo "==========================================" | |
| echo "❌ COVERAGE GATE FAILED" | |
| echo "==========================================" | |
| echo "One or more modules are below ${THRESHOLD}% coverage." | |
| echo "Stage 1 failed. Windows and macOS tests will NOT run." | |
| exit 1 | |
| fi | |
| echo "" | |
| echo "==========================================" | |
| echo "✅ COVERAGE GATE PASSED" | |
| echo "==========================================" | |
| echo "All modules meet ${THRESHOLD}% coverage threshold." | |
| echo "Proceeding to Stage 2 (Windows tests)." | |
| - name: Upload Linux coverage results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-linux | |
| path: | | |
| TestResults/ | |
| CoverageReport/ | |
| - name: Upload build artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: build-output | |
| path: | | |
| src/**/bin/Release | |
| tests/**/bin/Release | |
| # ============================================================================ | |
| # STAGE 2: Windows - All .NET Tests (Gated by Stage 1) | |
| # ============================================================================ | |
| test-windows: | |
| name: "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" | |
| runs-on: windows-latest | |
| needs: [detect-projects, test-linux-core] | |
| if: success() && github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: | | |
| 3.1.x | |
| 5.0.x | |
| 6.0.x | |
| 7.0.x | |
| 8.0.x | |
| 9.0.x | |
| 10.0.x | |
| - name: Restore dependencies | |
| run: dotnet restore | |
| - name: Build solution | |
| run: dotnet build --no-restore --configuration Release | |
| - name: Run all .NET tests (.NET 5.0-10.0 and Framework 4.6.2-4.8.1) | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $testProjects = @(Get-ChildItem -Path './tests/*' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj') | |
| if (@($testProjects).Count -eq 0) { | |
| Write-Error "❌ No test projects found in ./tests directory!" | |
| exit 1 | |
| } | |
| Write-Host "==========================================" -ForegroundColor Cyan | |
| Write-Host "Found test projects:" -ForegroundColor Cyan | |
| Write-Host "==========================================" -ForegroundColor Cyan | |
| $testProjects | ForEach-Object { Write-Host $_.FullName -ForegroundColor White } | |
| Write-Host "" | |
| foreach ($testProj in $testProjects) { | |
| Write-Host "==========================================" -ForegroundColor Cyan | |
| Write-Host "Testing project: $($testProj.FullName)" -ForegroundColor Cyan | |
| Write-Host "==========================================" -ForegroundColor Cyan | |
| # Extract target frameworks from the project file | |
| # Support both <TargetFramework> (single) and <TargetFrameworks> (multiple) | |
| $content = Get-Content $testProj.FullName -Raw | |
| $tfmMatch = [regex]::Match($content, '<TargetFramework[s]?>([^<]+)</TargetFramework[s]?>') | |
| if (-not $tfmMatch.Success) { | |
| Write-Host "⊘ Skipping: No target frameworks found" -ForegroundColor Yellow | |
| Write-Host "" | |
| continue | |
| } | |
| # Split by semicolon for multi-targeting projects | |
| $frameworks = $tfmMatch.Groups[1].Value -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0|462|47|471|472|48|481|coreapp3\.1)$' } | |
| if ($frameworks.Count -eq 0) { | |
| Write-Host "⊘ Skipping: No compatible target frameworks found" -ForegroundColor Yellow | |
| Write-Host "" | |
| continue | |
| } | |
| Write-Host "Target frameworks: $($frameworks -join ', ')" -ForegroundColor White | |
| Write-Host "" | |
| # Test each framework; collect coverage only for .NET 5.0+ TFMs. | |
| # netcoreapp3.1 and net4x are tested but excluded from coverage: | |
| # netcoreapp3.1 has no matching test TFM on Linux (Stage 1) so its numbers | |
| # would not be comparable; net4x cannot use the XPlat collector on Windows. | |
| foreach ($fw in $frameworks) { | |
| Write-Host "Testing framework: $fw" -ForegroundColor Yellow | |
| if ($fw -match '^net([5-9]|[1-9][0-9]+)\.') { | |
| dotnet test $testProj.FullName ` | |
| --configuration Release ` | |
| --framework $fw ` | |
| --collect:"XPlat Code Coverage" ` | |
| --settings coverlet.runsettings ` | |
| --results-directory "./TestResults" ` | |
| --logger "console;verbosity=normal" | |
| } else { | |
| dotnet test $testProj.FullName ` | |
| --configuration Release ` | |
| --framework $fw ` | |
| --logger "console;verbosity=normal" | |
| } | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error "Tests failed for $fw in $($testProj.Name)" | |
| exit 1 | |
| } | |
| } | |
| Write-Host "" | |
| } | |
| - name: Check for coverage files | |
| id: check-coverage | |
| run: | | |
| if (Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue) { | |
| echo "has-coverage=true" >> $env:GITHUB_OUTPUT | |
| Write-Host "✅ Coverage files found" | |
| } else { | |
| echo "has-coverage=false" >> $env:GITHUB_OUTPUT | |
| Write-Host "ℹ️ No coverage files found - skipping coverage report generation" | |
| } | |
| shell: pwsh | |
| - name: Install ReportGenerator | |
| if: steps.check-coverage.outputs.has-coverage == 'true' | |
| run: dotnet tool install -g dotnet-reportgenerator-globaltool | |
| - name: Generate coverage report | |
| if: steps.check-coverage.outputs.has-coverage == 'true' | |
| shell: pwsh | |
| run: | | |
| reportgenerator ` | |
| -reports:"TestResults/**/coverage.cobertura.xml" ` | |
| -targetdir:"CoverageReport" ` | |
| -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" | |
| - name: Enforce 90% coverage threshold | |
| if: steps.check-coverage.outputs.has-coverage == 'true' | |
| shell: pwsh | |
| run: | | |
| if (-not (Test-Path "CoverageReport/Summary.txt")) { | |
| Write-Error "❌ Coverage report not generated!" | |
| exit 1 | |
| } | |
| Write-Host "Coverage Summary:" | |
| Get-Content "CoverageReport/Summary.txt" | |
| Write-Host "" | |
| $threshold = if ($env:CODECOV_MINIMUM) { [int]$env:CODECOV_MINIMUM } else { 90 } | |
| $failedProjects = @() | |
| foreach ($line in (Get-Content "CoverageReport/Summary.txt")) { | |
| if ($line -match '^([^ ]+).*?(\d+)%$' -and $line -notmatch '^Summary') { | |
| $module = $Matches[1] | |
| $percent = [int]$Matches[2] | |
| Write-Host "Checking module: '$module' - Coverage: ${percent}%" | |
| if ($percent -lt $threshold) { | |
| Write-Host " ❌ FAIL: Below ${threshold}% threshold" -ForegroundColor Red | |
| $failedProjects += "$module (${percent}%)" | |
| } else { | |
| Write-Host " ✅ PASS: Meets ${threshold}% threshold" -ForegroundColor Green | |
| } | |
| } | |
| } | |
| if ($failedProjects.Count -gt 0) { | |
| Write-Host "" | |
| Write-Host "==========================================" -ForegroundColor Red | |
| Write-Host "❌ COVERAGE GATE FAILED" -ForegroundColor Red | |
| Write-Host "==========================================" -ForegroundColor Red | |
| Write-Host "Projects below ${threshold}% coverage: $($failedProjects -join ', ')" -ForegroundColor Red | |
| Write-Host "" | |
| Write-Host "Stage 2 failed. macOS tests will NOT run." | |
| exit 1 | |
| } | |
| Write-Host "" | |
| Write-Host "==========================================" -ForegroundColor Green | |
| Write-Host "✅ COVERAGE GATE PASSED" -ForegroundColor Green | |
| Write-Host "==========================================" -ForegroundColor Green | |
| Write-Host "All projects meet ${threshold}% coverage threshold." | |
| Write-Host "Proceeding to Stage 3 (macOS tests)." | |
| - name: Upload Windows coverage results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-windows | |
| path: | | |
| TestResults/ | |
| CoverageReport/ | |
| # ============================================================================ | |
| # STAGE 3: macOS Tests (Gated by Stage 2) | |
| # ============================================================================ | |
| test-macos-core: | |
| name: "Stage 3: macOS Tests (.NET 6.0-10.0)" | |
| runs-on: macos-latest | |
| needs: [detect-projects, test-windows] | |
| if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true' && needs.test-windows.result == 'success' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: | | |
| 6.0.x | |
| 7.0.x | |
| 8.0.x | |
| 9.0.x | |
| 10.0.x | |
| - name: Restore and build (exclude .NET Framework-only projects) | |
| run: | | |
| echo "Enumerating tracked .NET project files (git ls-files)..." | |
| # Filter out projects that ONLY target .NET Framework 4.x | |
| # Multi-targeting projects (e.g., net8.0;net48) will be INCLUDED | |
| projects=() | |
| project_found=false | |
| while IFS= read -r -d '' proj; do | |
| project_found=true | |
| # Check if project has any .NET 6+ target framework (macOS ARM64 compatible) | |
| # Look for: net6.0, net7.0, net8.0, net9.0, net10.0 | |
| # Normalize newlines to spaces so multi-line <TargetFrameworks> elements are matched correctly | |
| if tr $'\n' ' ' < "$proj" | grep -qE '<TargetFramework[s]?>[^<]*net(6\.0|7\.0|8\.0|9\.0|10\.0)'; then | |
| projects+=("$proj") | |
| echo "✓ Including: $proj (has .NET 6+ target)" | |
| else | |
| echo "⊘ Excluding: $proj (no .NET 6+ target, incompatible with macOS ARM64)" | |
| fi | |
| done < <(git ls-files -z -- '*.csproj' '*.vbproj' '*.fsproj') | |
| if [ "$project_found" = false ]; then | |
| echo "❌ No .NET projects found." | |
| echo "This should not occur as detect-projects already verified project existence." | |
| exit 1 | |
| fi | |
| if [ ${#projects[@]} -eq 0 ]; then | |
| echo "❌ No compatible .NET projects found." | |
| echo "All projects lack .NET 6+ targets, which are required for macOS ARM64." | |
| exit 1 | |
| fi | |
| echo "" | |
| echo "==========================================" | |
| echo "Projects to build (excluding .NET Framework-only projects):" | |
| echo "==========================================" | |
| printf '%s\n' "${projects[@]}" | |
| echo "" | |
| # Restore each project | |
| echo "Restoring projects..." | |
| for proj in "${projects[@]}"; do | |
| echo "Restoring: $proj" | |
| dotnet restore "$proj" || exit 1 | |
| done | |
| echo "" | |
| echo "Building projects..." | |
| # Build each project, handling multi-targeting projects | |
| # For multi-targeting projects, build only macOS ARM64-compatible frameworks (net6.0-10.0) | |
| for proj in "${projects[@]}"; do | |
| echo "Building: $proj" | |
| # Extract target frameworks from the project file | |
| # Support both <TargetFramework> (single) and <TargetFrameworks> (multiple) | |
| # Trim whitespace from each framework before filtering | |
| frameworks=$(tr -d '\n\r' < "$proj" | sed -n -E 's/.*<TargetFrameworks?>[[:space:]]*>([^<]+)<\/TargetFrameworks?>.*/\1/p' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) | |
| if [ -z "$frameworks" ]; then | |
| echo "⚠️ No macOS ARM64-compatible frameworks found in $proj" | |
| continue | |
| fi | |
| # Check if this is a multi-targeting project | |
| framework_count=$(echo "$frameworks" | wc -l) | |
| if [ "$framework_count" -eq 1 ]; then | |
| # Single target framework - build normally | |
| echo " Target framework: $frameworks" | |
| dotnet build "$proj" --no-restore --configuration Release || exit 1 | |
| else | |
| # Multi-targeting project - build each compatible framework separately | |
| echo " Target frameworks (multi-targeting): $(echo "$frameworks" | tr '\n' ' ')" | |
| while IFS= read -r fw; do | |
| [ -z "$fw" ] && continue | |
| echo " Building framework: $fw" | |
| dotnet build "$proj" --no-restore --configuration Release --framework "$fw" || exit 1 | |
| done <<< "$frameworks" | |
| fi | |
| done | |
| echo "" | |
| echo "✅ All compatible projects built successfully" | |
| - name: Run tests (.NET 6.0 - 10.0 only - ARM64 compatible) | |
| run: | | |
| # Find all test projects (C#, VB.NET, F#) | |
| test_projects=() | |
| while IFS= read -r -d '' file; do | |
| test_projects+=("$file") | |
| done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) | |
| if [ ${#test_projects[@]} -eq 0 ]; then | |
| echo "❌ No test projects found in ./tests directory!" | |
| exit 1 | |
| fi | |
| echo "==========================================" | |
| echo "Found test projects:" | |
| echo "==========================================" | |
| printf '%s\n' "${test_projects[@]}" | |
| echo "" | |
| for test_proj in "${test_projects[@]}"; do | |
| echo "==========================================" | |
| echo "Testing project: $test_proj" | |
| echo "==========================================" | |
| # Extract target frameworks from the project file | |
| # Support both <TargetFramework> (single) and <TargetFrameworks> (multiple) | |
| # Only include .NET 6.0+ (ARM64 compatible on macOS) | |
| # Normalize line endings to handle multi-line <TargetFramework> / <TargetFrameworks> elements | |
| frameworks=$(tr -d '\n\r' < "$test_proj" | grep -Eo '<TargetFrameworks?>[^<]+' | sed -E 's/<TargetFrameworks?>//' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) | |
| if [ -z "$frameworks" ]; then | |
| echo "⊘ Skipping: No compatible .NET 6.0-10.0 target frameworks found (ARM64 required)" | |
| echo "" | |
| continue | |
| fi | |
| echo "Target frameworks: $(echo "$frameworks" | tr '\n' ' ')" | |
| echo "" | |
| # Test each framework that the project actually targets | |
| # All frameworks here are net6.0+ so all get coverage | |
| while IFS= read -r fw; do | |
| [ -z "$fw" ] && continue | |
| echo "Testing framework: $fw" | |
| dotnet test "$test_proj" \ | |
| --configuration Release \ | |
| --framework "$fw" \ | |
| --collect:"XPlat Code Coverage" \ | |
| --settings coverlet.runsettings \ | |
| --results-directory "./TestResults" \ | |
| --logger "console;verbosity=normal" || exit 1 | |
| done <<< "$frameworks" | |
| echo "" | |
| done | |
| - name: Install ReportGenerator | |
| run: dotnet tool install -g dotnet-reportgenerator-globaltool | |
| - name: Generate coverage report | |
| run: | | |
| if find ./TestResults -name "coverage.cobertura.xml" -print -quit 2>/dev/null | grep -q .; then | |
| reportgenerator \ | |
| -reports:"TestResults/**/coverage.cobertura.xml" \ | |
| -targetdir:"CoverageReport" \ | |
| -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" | |
| else | |
| echo "ℹ️ No coverage files found - skipping report generation" | |
| fi | |
| - name: Enforce 90% coverage threshold | |
| run: | | |
| if [ ! -f "CoverageReport/Summary.txt" ]; then | |
| echo "❌ Coverage report not generated!" | |
| exit 1 | |
| fi | |
| echo "Coverage Summary:" | |
| cat CoverageReport/Summary.txt | |
| echo "" | |
| THRESHOLD=${CODECOV_MINIMUM:-90} | |
| FAILED=0 | |
| while IFS= read -r line; do | |
| if echo "$line" | grep -qE '^[^ ]+.*[0-9]+%$' && ! echo "$line" | grep -q '^Summary'; then | |
| MODULE=$(echo "$line" | awk '{print $1}') | |
| PERCENT=$(echo "$line" | grep -oE '[0-9]+%' | tail -1 | tr -d '%') | |
| echo "Checking module: '$MODULE' - Coverage: ${PERCENT}%" | |
| if [ "$PERCENT" -lt "$THRESHOLD" ]; then | |
| echo " ❌ FAIL: Below ${THRESHOLD}% threshold" | |
| FAILED=1 | |
| else | |
| echo " ✅ PASS: Meets ${THRESHOLD}% threshold" | |
| fi | |
| fi | |
| done < CoverageReport/Summary.txt | |
| if [ "$FAILED" -ne 0 ]; then | |
| echo "" | |
| echo "==========================================" | |
| echo "❌ COVERAGE GATE FAILED" | |
| echo "==========================================" | |
| echo "One or more modules are below ${THRESHOLD}% coverage." | |
| echo "Stage 3 failed." | |
| exit 1 | |
| fi | |
| echo "" | |
| echo "==========================================" | |
| echo "✅ COVERAGE GATE PASSED" | |
| echo "==========================================" | |
| echo "All modules meet ${THRESHOLD}% coverage threshold." | |
| - name: Upload macOS coverage results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-macos | |
| path: | | |
| TestResults/ | |
| CoverageReport/ | |
| - name: Display macOS architecture info | |
| if: always() | |
| run: | | |
| echo "" | |
| echo "==========================================" | |
| echo "ℹ️ macOS Testing Notes" | |
| echo "==========================================" | |
| echo "Architecture: $(uname -m)" | |
| echo "" | |
| echo "Skipped frameworks (no ARM64 support):" | |
| echo " - .NET 5.0 ❌" | |
| echo "" | |
| echo "Tested frameworks (ARM64 compatible):" | |
| echo " - .NET 6.0 ✅" | |
| echo " - .NET 7.0 ✅" | |
| echo " - .NET 8.0 ✅" | |
| echo " - .NET 9.0 ✅" | |
| echo " - .NET 10.0 ✅" | |
| echo "" | |
| echo ".NET Core 5.0 are tested on Linux and Windows" | |
| echo "" | |
| - name: Summarize pipeline result | |
| run: | | |
| echo "==========================================" | |
| echo "✅ ALL STAGES PASSED" | |
| echo "==========================================" | |
| echo "Stage 1: Linux tests (.NET 5.0-10.0, netcoreapp3.1) + coverage (net5.0+) + 90% gate ✅" | |
| echo "Stage 2: Windows tests (all TFMs) + coverage (net5.0+) + 90% gate ✅" | |
| echo "Stage 3: macOS tests (.NET 6.0-10.0) + coverage (net6.0+) + 90% gate ✅" | |
| echo "" | |
| echo "PR is ready to merge! 🎉" | |
| # ============================================================================ | |
| # Security Scan (Runs in parallel, independently of .NET jobs) | |
| # ============================================================================ | |
| security-scan: | |
| name: "Security Scan (DevSkim)" | |
| runs-on: ubuntu-latest | |
| if: github.repository != 'Chris-Wolfgang/repo-template' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| - name: Install DevSkim CLI | |
| run: dotnet tool install --global Microsoft.CST.DevSkim.CLI | |
| - name: Run DevSkim security scan | |
| run: | | |
| devskim analyze \ | |
| --source-code . \ | |
| --file-format text \ | |
| --output-file devskim-results.txt \ | |
| --ignore-rule-ids DS176209 \ | |
| --ignore-globs "**/api/**,**/CoverageReport/**,**/TestResults/**" | |
| - name: Display security scan results | |
| if: always() | |
| run: | | |
| if [ -f devskim-results.txt ]; then | |
| echo "==========================================" | |
| echo "DevSkim Security Scan Results" | |
| echo "==========================================" | |
| cat devskim-results.txt | |
| echo "" | |
| if grep -qi "error\|critical\|high" devskim-results.txt; then | |
| echo "❌ Security issues detected - review required" | |
| exit 1 | |
| else | |
| echo "✅ No critical security issues found" | |
| fi | |
| else | |
| echo "✅ No security issues found" | |
| fi | |
| - name: Upload security scan results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: devskim-results | |
| path: devskim-results.txt | |
| if-no-files-found: warn |