diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 41f180d..5213a58 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -1,19 +1,21 @@ # Sequential PR validation workflow with coverage gating # Stage 1: Linux tests with 90% coverage requirement -# Stage 2: Windows and macOS tests (only if Linux passes) -# Stage 3: .NET Framework 4.x tests on Windows (only if Stage 2 passes) +# Stage 2: Windows .NET (5.0-10.0) and .NET Framework (4.6.2-4.8.1) tests (only if Linux passes) +# Stage 3: macOS tests (only if Stage 2 passes) # # SECURITY NOTE: -# - Uses pull_request (not pull_request_target) to avoid the "pwn request" attack vector -# (pull_request_target runs from the trusted main branch with elevated permissions, and checking -# out untrusted PR code in that context can allow attackers to exfiltrate secrets or abuse write access) -# - 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) +# - 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 # - After checkout, configuration files (.editorconfig, BannedSymbols.txt, etc.) are fetched from # the main branch to prevent malicious PRs from disabling analyzers or bypassing code quality checks +# - If a PR changes any of these protected configuration files, CI explicitly fails with instructions +# for a maintainer to manually review and verify the changes before merging +# - 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 v2 (Gated) +name: PR Checks v3 (Gated) permissions: contents: read @@ -22,10 +24,9 @@ env: CODECOV_MINIMUM: 90 on: - pull_request: + pull_request_target: # Runs from the main branch, not from PR branch branches: - main - - develop concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -57,25 +58,153 @@ jobs: shell: bash # ============================================================================ + # 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: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " ✓ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " ✓ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ℹ️ $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "✅ Configuration files secured - using versions from main branch" + + - name: Detect protected configuration file changes + run: | + echo "Checking for changes to protected configuration files in this PR..." + + # Verify main-branch ref is available (it was fetched in the previous step) + if ! git cat-file -e main-branch 2>/dev/null; then + echo "❌ main-branch ref not found - cannot detect configuration file changes" + exit 1 + fi + + changed_files=() + + # Check exact file matches against main branch git objects + # 2>/dev/null suppresses output when a file doesn't exist in one ref (new/deleted file), + # which git diff handles correctly via its exit code + exact_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + ) + + for config_file in "${exact_files[@]}"; do + if ! git diff --quiet main-branch HEAD -- "$config_file" 2>/dev/null; then + changed_files+=("$config_file") + fi + done + + # Check .globalconfig and .ruleset files using the same git diff approach + # --diff-filter=AMRC: Added, Modified, Renamed, Copied (excludes Deleted) + while IFS= read -r file; do + changed_files+=("$file") + done < <(git diff --name-only --diff-filter=AMRC main-branch HEAD 2>/dev/null | grep -E '\.(globalconfig|ruleset)$' || true) + + if [ ${#changed_files[@]} -gt 0 ]; then + echo "" + echo "⚠️ PROTECTED CONFIGURATION FILES CHANGED IN THIS PR:" + for file in "${changed_files[@]}"; do + echo " - $file" + done + echo "" + echo "❌ CI uses the main branch version of these files to prevent security bypasses." + echo " The PR's changes to these files were NOT tested by CI." + echo " A maintainer must manually review and verify these changes before merging." + echo "" + echo "To proceed, a maintainer should:" + echo " 1. Review the configuration changes in this PR carefully" + echo " 2. Test the changes locally to confirm they work correctly" + echo " 3. Merge with awareness that CI did not validate these configuration changes" + exit 1 + else + echo "✅ No protected configuration files changed - CI fully validates this PR" + fi + + - 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 - if: github.repository != 'Chris-Wolfgang/repo-template' - + 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 + - name: Fetch trusted configuration files from main branch run: | echo "Fetching configuration files from main branch to prevent malicious overrides..." - + # Fetch the main branch git fetch origin main:main-branch - + # List of configuration files that should come from trusted main branch config_files=( ".editorconfig" @@ -85,7 +214,7 @@ jobs: "*.globalconfig" "*.ruleset" ) - + # Copy each configuration file from main branch if it exists for config_file in "${config_files[@]}"; do # Handle glob patterns @@ -108,13 +237,10 @@ jobs: fi fi done - + echo "" echo "✅ Configuration files secured - using versions from main branch" - # 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 @@ -133,16 +259,15 @@ jobs: 9.0.x 10.0.x - - name: Restore and build (exclude .NET Framework 4.x projects) + - name: Restore and build (exclude .NET Framework-only projects) run: | - echo "Finding all projects in solution..." - - # Find all .csproj, .vbproj, and .fsproj files + 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 @@ -155,52 +280,53 @@ jobs: 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 (single) and (multiple) # Collapse newlines so multi-line values are handled correctly frameworks=$(tr '\n' ' ' < "$proj" | grep -oP '\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" @@ -215,49 +341,49 @@ jobs: 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 + # 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 (single) and (multiple) frameworks=$(grep -oP '\K[^<]+' "$test_proj" | 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 .NET 5.0-10.0 target frameworks found" echo "" continue fi - + echo "Target frameworks: $(echo "$frameworks" | tr '\n' ' ')" echo "" - + # Test each framework that the project actually targets while IFS= read -r fw; do [ -z "$fw" ] && continue echo "Testing framework: $fw" - + dotnet test "$test_proj" \ --configuration Release \ --framework "$fw" \ @@ -268,10 +394,23 @@ jobs: echo "" done + - name: Check for coverage files + id: check-coverage + run: | + if find TestResults -type f -name "coverage.cobertura.xml" 2>/dev/null | grep -q .; then + echo "has-coverage=true" >> $GITHUB_OUTPUT + echo "✅ Coverage files found" + else + echo "has-coverage=false" >> $GITHUB_OUTPUT + echo "ℹ️ No coverage files found - skipping coverage report generation" + fi + - 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' run: | reportgenerator \ -reports:"TestResults/**/coverage.cobertura.xml" \ @@ -279,6 +418,7 @@ jobs: -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" - name: Enforce 90% coverage threshold + if: steps.check-coverage.outputs.has-coverage == 'true' run: | if [ ! -f CoverageReport/Summary.txt ]; then echo "❌ Coverage report not generated!" @@ -288,18 +428,18 @@ jobs: echo "Coverage Summary:" cat CoverageReport/Summary.txt echo "" - + failed_projects="" threshold=${CODECOV_MINIMUM:-90} - + while read -r line; do # Match lines with module names and percentages if echo "$line" | grep -qE '^[^ ].*[0-9]+%$' && ! echo "$line" | grep -q '^Summary'; then module=$(echo "$line" | awk '{print $1}') percent=$(echo "$line" | awk '{print $NF}' | tr -d '%') - + echo "Checking module: '$module' - Coverage: ${percent}%" - + if [ "$percent" -lt "$threshold" ]; then echo " ❌ FAIL: Below ${threshold}% threshold" failed_projects="$failed_projects $module (${percent}%)" @@ -345,26 +485,29 @@ jobs: tests/**/bin/Release # ============================================================================ - # STAGE 2: Windows & macOS - .NET Core/5+ Tests (Gated by Stage 1) + # STAGE 2: Windows - All .NET Tests (Gated by Stage 1) # ============================================================================ - test-windows-core: - name: "Stage 2a: Windows Tests (.NET 5.0-10.0)" + test-windows: + name: "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" runs-on: windows-latest - needs: test-linux-core - if: github.repository != 'Chris-Wolfgang/repo-template' - + needs: [detect-projects, test-linux-core] + 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 + - name: Fetch trusted configuration files from main branch shell: pwsh run: | Write-Host "Fetching configuration files from main branch to prevent malicious overrides..." - + # Fetch the main branch git fetch origin main:main-branch - + # List of configuration files that should come from trusted main branch $configFiles = @( ".editorconfig", @@ -372,7 +515,7 @@ jobs: "Directory.Build.targets", "BannedSymbols.txt" ) - + # Copy each configuration file from main branch if it exists foreach ($configFile in $configFiles) { # Check if file exists in main branch @@ -384,7 +527,7 @@ jobs: Write-Host " ℹ️ $configFile not found in main branch, skipping" } } - + # Handle glob patterns for .globalconfig and .ruleset files $globPatterns = @("*.globalconfig", "*.ruleset") foreach ($pattern in $globPatterns) { @@ -398,7 +541,7 @@ jobs: } } } - + Write-Host "" Write-Host "✅ Configuration files secured - using versions from main branch" @@ -420,53 +563,56 @@ jobs: - name: Build solution run: dotnet build --no-restore --configuration Release - - name: Run tests (.NET Core 5.0 - 10.0) + - 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 (single) and (multiple) $content = Get-Content $testProj.FullName -Raw $tfmMatch = [regex]::Match($content, '([^<]+)') - + 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|coreapp3\.1)$' } - + $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 .NET 5.0-10.0 target frameworks found" -ForegroundColor Yellow + Write-Host "⊘ Skipping: No compatible .NET 5.0-10.0 or Framework 4.6.2-4.8.1 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 @@ -557,7 +703,7 @@ jobs: Write-Host "==========================================" -ForegroundColor Red Write-Host "Projects below ${threshold}% coverage: $($failedProjects -join ', ')" -ForegroundColor Red Write-Host "" - Write-Host "Stage 2a failed." + Write-Host "Stage 2 failed. macOS tests will NOT run." exit 1 } @@ -566,6 +712,7 @@ jobs: 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() @@ -576,23 +723,29 @@ jobs: TestResults/ CoverageReport/ + # ============================================================================ + # STAGE 3: macOS Tests (Gated by Stage 2) + # ============================================================================ test-macos-core: - name: "Stage 2b: macOS Tests (.NET 6.0-10.0)" + name: "Stage 3: macOS Tests (.NET 6.0-10.0)" runs-on: macos-latest - needs: test-linux-core - if: github.repository != 'Chris-Wolfgang/repo-template' - + needs: [detect-projects, test-windows] + if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true' + steps: - - name: Checkout code + - name: Checkout code uses: actions/checkout@v4 - + with: + ref: refs/pull/${{ github.event.pull_request.number }}/head + persist-credentials: false + - name: Fetch trusted configuration files from main branch run: | echo "Fetching configuration files from main branch to prevent malicious overrides..." - + # Fetch the main branch git fetch origin main:main-branch - + # List of configuration files that should come from trusted main branch config_files=( ".editorconfig" @@ -602,7 +755,7 @@ jobs: "*.globalconfig" "*.ruleset" ) - + # Copy each configuration file from main branch if it exists for config_file in "${config_files[@]}"; do # Handle glob patterns @@ -625,7 +778,7 @@ jobs: fi fi done - + echo "" echo "✅ Configuration files secured - using versions from main branch" @@ -639,68 +792,135 @@ jobs: 9.0.x 10.0.x - - name: Restore and build (exclude .NET Framework 4.x projects) + - name: Restore and build (exclude .NET Framework-only projects) run: | - echo "Finding all projects in solution..." - - # Find all .csproj, .vbproj, and .fsproj files - # Exclude those with 'dotnet4' or 'DotNet4' in the path (case-insensitive) - projects=$(find . -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) | grep -iv "dotnet4") - - if [ -z "$projects" ]; then - echo "No projects found!" + 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 elements are matched correctly + if tr $'\n' ' ' < "$proj" | grep -qE '[^<]*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 4.x):" + echo "Projects to build (excluding .NET Framework-only projects):" echo "==========================================" - echo "$projects" + printf '%s\n' "${projects[@]}" echo "" - + # Restore each project echo "Restoring projects..." - for proj in $projects; do + for proj in "${projects[@]}"; do echo "Restoring: $proj" dotnet restore "$proj" || exit 1 done - + echo "" echo "Building projects..." - # Build each project - for proj in $projects; do + # 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" - dotnet build "$proj" --no-restore --configuration Release || exit 1 + + # Extract target frameworks from the project file + # Support both (single) and (multiple) + # Trim whitespace from each framework before filtering + frameworks=$(tr -d '\n\r' < "$proj" | sed -n -E 's/.*[[: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 - test_projects=$(find ./tests -type f -name "*.csproj") - - if [ -z "$test_projects" ]; then + # 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 "==========================================" - echo "$test_projects" + printf '%s\n' "${test_projects[@]}" echo "" - - # Skip .NET Core 5.0 and .NET 5.0 - no ARM64 support on macOS - frameworks=(net6.0 net7.0 net8.0 net9.0 net10.0) - - for test_proj in $test_projects; do + + for test_proj in "${test_projects[@]}"; do echo "==========================================" echo "Testing project: $test_proj" echo "==========================================" - - for fw in "${frameworks[@]}"; do + + # Extract target frameworks from the project file + # Support both (single) and (multiple) + # Only include .NET 6.0+ (ARM64 compatible on macOS) + # Normalize line endings to handle multi-line / elements + frameworks=$(tr -d '\n\r' < "$test_proj" | grep -Eo '[^<]+' | sed -E 's///' | 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" \ @@ -709,7 +929,7 @@ jobs: --collect:"XPlat Code Coverage" \ --results-directory "./TestResults" \ --logger "console;verbosity=normal" || exit 1 - done + done <<< "$frameworks" echo "" done @@ -761,7 +981,7 @@ jobs: echo "❌ COVERAGE GATE FAILED" echo "==========================================" echo "One or more modules are below ${THRESHOLD}% coverage." - echo "Stage 2b failed." + echo "Stage 3 failed." exit 1 fi @@ -800,145 +1020,41 @@ jobs: echo " - .NET 10.0 ✅" echo "" echo ".NET Core 5.0 are tested on Linux and Windows" + echo "" - # ============================================================================ - # STAGE 3: Windows - .NET Framework 4.x Tests (Gated by Stage 2) - # ============================================================================ - test-windows-framework: - name: "Stage 3: Windows .NET Framework Tests (4.6.2-4.8.1)" - runs-on: windows-latest - needs: [test-windows-core, test-macos-core] - if: github.repository != 'Chris-Wolfgang/repo-template' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Fetch trusted configuration files from main branch - shell: pwsh - run: | - Write-Host "Fetching configuration files from main branch to prevent malicious overrides..." - - # Fetch the main branch - git fetch origin main:main-branch - - # List of configuration files that should come from trusted main branch - $configFiles = @( - ".editorconfig", - "Directory.Build.props", - "Directory.Build.targets", - "BannedSymbols.txt" - ) - - # Copy each configuration file from main branch if it exists - foreach ($configFile in $configFiles) { - # Check if file exists in main branch - $exists = git cat-file -e "main-branch:$configFile" 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host " ✓ Copying $configFile from main branch" - git show "main-branch:$configFile" | Out-File -FilePath $configFile -Encoding UTF8 -NoNewline - } else { - Write-Host " ℹ️ $configFile not found in main branch, skipping" - } - } - - # Handle glob patterns for .globalconfig and .ruleset files - $globPatterns = @("*.globalconfig", "*.ruleset") - foreach ($pattern in $globPatterns) { - $files = git ls-tree -r --name-only main-branch | Select-String -Pattern $pattern.Replace("*", ".*") - foreach ($file in $files) { - if ($file) { - Write-Host " ✓ Copying $file from main branch" - $dir = Split-Path -Parent $file - if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } - git show "main-branch:$file" | Out-File -FilePath $file -Encoding UTF8 -NoNewline - } - } - } - - Write-Host "" - Write-Host "✅ Configuration files secured - using versions from main branch" - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 8.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Build solution - run: dotnet build --no-restore --configuration Release - - - name: Run .NET Framework tests (4.6.2 - 4.8.1) - shell: pwsh + - name: Summarize pipeline result run: | - $testProjects = Get-ChildItem -Path './tests' -Recurse -Filter '*.csproj' - - 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 "" - - $frameworks = @('net462', 'net472', 'net48', 'net481') - - foreach ($testProj in $testProjects) { - Write-Host "==========================================" -ForegroundColor Cyan - Write-Host "Testing project: $($testProj.FullName)" -ForegroundColor Cyan - Write-Host "==========================================" -ForegroundColor Cyan - - foreach ($fw in $frameworks) { - Write-Host "Testing framework: $fw" -ForegroundColor Yellow - - 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 "" - } - - Write-Host "" - Write-Host "==========================================" -ForegroundColor Green - Write-Host "✅ ALL STAGES PASSED" -ForegroundColor Green - Write-Host "==========================================" -ForegroundColor Green - Write-Host "Stage 1: Linux tests + 90% coverage ✅" -ForegroundColor Green - Write-Host "Stage 2: Windows & macOS tests ✅" -ForegroundColor Green - Write-Host "Stage 3: .NET Framework 4.x tests ✅" -ForegroundColor Green - Write-Host "" - Write-Host "PR is ready to merge! 🎉" -ForegroundColor Green + echo "==========================================" + echo "✅ ALL STAGES PASSED" + echo "==========================================" + echo "Stage 1: Linux tests + 90% coverage ✅" + echo "Stage 2: Windows .NET Core & .NET Framework tests ✅" + echo "Stage 3: macOS tests ✅" + echo "" + echo "PR is ready to merge! 🎉" # ============================================================================ - # Security Scan (Runs in parallel with tests) + # 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: Fetch trusted configuration files from main branch run: | echo "Fetching configuration files from main branch to prevent malicious overrides..." - + # Fetch the main branch git fetch origin main:main-branch - + # List of configuration files that should come from trusted main branch config_files=( ".editorconfig" @@ -948,7 +1064,7 @@ jobs: "*.globalconfig" "*.ruleset" ) - + # Copy each configuration file from main branch if it exists for config_file in "${config_files[@]}"; do # Handle glob patterns @@ -971,7 +1087,7 @@ jobs: fi fi done - + echo "" echo "✅ Configuration files secured - using versions from main branch" @@ -996,7 +1112,7 @@ jobs: echo "==========================================" cat devskim-results.txt echo "" - + if grep -qi "error\|critical\|high" devskim-results.txt; then echo "❌ Security issues detected - review required" exit 1