From 42953f44960a08a9762f078aee19527ead2201d8 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:30:35 -0400 Subject: [PATCH 1/2] Upgrade pr.yaml to v3 (Gated) Sync with repo-template: multi-stage gated CI with security hardening, trusted config fetch from main, and 90% coverage gate. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr.yaml | 728 ++++++++++++++++++++++++++------------ 1 file changed, 511 insertions(+), 217 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 41f180d..fe0ef48 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -1,31 +1,34 @@ # 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) +# - 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) # - 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 # - 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 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,17 +60,188 @@ 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: + 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" + "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: Fetch trusted configuration files from main branch run: | @@ -133,16 +307,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 +328,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 +389,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 +442,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 +466,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 +476,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,17 +533,65 @@ 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", + "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: Fetch trusted configuration files from main branch shell: pwsh @@ -420,53 +656,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 @@ -475,7 +714,6 @@ jobs: --configuration Release ` --framework $fw ` --collect:"XPlat Code Coverage" ` - --settings coverlet.runsettings ` --results-directory "./TestResults" ` --logger "console;verbosity=normal" } else { @@ -557,7 +795,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 +804,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,15 +815,64 @@ 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" + "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: Fetch trusted configuration files from main branch run: | @@ -639,68 +927,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 +1064,7 @@ jobs: --collect:"XPlat Code Coverage" \ --results-directory "./TestResults" \ --logger "console;verbosity=normal" || exit 1 - done + done <<< "$frameworks" echo "" done @@ -761,7 +1116,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,137 +1155,76 @@ jobs: 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 + 90% coverage ✅" + echo "Stage 2: Windows .NET Core & .NET Framework tests ✅" + echo "Stage 3: macOS tests ✅" + echo "" + echo "PR is ready to merge! 🎉" # ============================================================================ - # STAGE 3: Windows - .NET Framework 4.x Tests (Gated by Stage 2) + # Security Scan (Runs in parallel, independently of .NET jobs) # ============================================================================ - 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] + 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 - shell: pwsh run: | - Write-Host "Fetching configuration files from main branch to prevent malicious overrides..." - + 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 - $configFiles = @( - ".editorconfig", - "Directory.Build.props", - "Directory.Build.targets", + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" ) - + # 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 - 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 - - # ============================================================================ - # Security Scan (Runs in parallel with tests) - # ============================================================================ - 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 + 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: Fetch trusted configuration files from main branch run: | @@ -996,7 +1290,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 From 121dcae7e1838586347c2c3037d3cab0029fa5e2 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:07:23 -0400 Subject: [PATCH 2/2] Fix duplicate fetch steps, header comment, and restore coverlet.runsettings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate "Fetch trusted configuration files from main branch" step from all 5 jobs (detect-projects, test-linux-core, test-windows, test-macos-core, security-scan) — each had the step twice - Remove duplicate security note comment about configuration files in header - Restore --settings coverlet.runsettings in Windows Stage 2 test step - Fix trailing whitespace on test-linux-core and env lines Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr.yaml | 184 +------------------------------------- 1 file changed, 3 insertions(+), 181 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index fe0ef48..5213a58 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -13,8 +13,6 @@ # 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) -# - 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 # - Default GITHUB_TOKEN permissions are restricted to read-only repository contents to limit impact if exposed name: PR Checks v3 (Gated) @@ -24,7 +22,7 @@ permissions: env: CODECOV_MINIMUM: 90 - + on: pull_request_target: # Runs from the main branch, not from PR branch branches: @@ -187,7 +185,7 @@ jobs: # ============================================================================ # STAGE 1: Linux - .NET Core/5+ Tests with Coverage Gate # ============================================================================ - test-linux-core: + test-linux-core: name: "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" runs-on: ubuntu-latest needs: detect-projects @@ -243,52 +241,6 @@ jobs: echo "" echo "✅ Configuration files secured - using versions from main branch" - - 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" - - # 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 @@ -593,51 +545,6 @@ jobs: Write-Host "" Write-Host "✅ Configuration files secured - using versions from main branch" - - 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: @@ -714,6 +621,7 @@ jobs: --configuration Release ` --framework $fw ` --collect:"XPlat Code Coverage" ` + --settings coverlet.runsettings ` --results-directory "./TestResults" ` --logger "console;verbosity=normal" } else { @@ -874,49 +782,6 @@ jobs: echo "" echo "✅ Configuration files secured - using versions from main branch" - - 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: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -1226,49 +1091,6 @@ jobs: echo "" echo "✅ Configuration files secured - using versions from main branch" - - 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: Install DevSkim CLI run: dotnet tool install --global Microsoft.CST.DevSkim.CLI