Skip to content

Feature/testkit changes #127

Feature/testkit changes

Feature/testkit changes #127

Workflow file for this run

# 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