Merge develop into main #263
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Sequential PR validation workflow with coverage gating | |
| # Stage 1: Linux tests with 90% coverage requirement | |
| # 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_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 v3 (Gated) | |
| permissions: | |
| contents: read | |
| env: | |
| CODECOV_MINIMUM: 90 | |
| on: | |
| pull_request_target: # Runs from the main branch, not from PR branch | |
| branches: | |
| - main | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # ============================================================================ | |
| # SECRETS SCAN: Detect leaked credentials before merge | |
| # ============================================================================ | |
| secrets-scan: | |
| name: "Secrets Scan (gitleaks)" | |
| 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 | |
| fetch-depth: 0 | |
| - name: Run gitleaks | |
| # gitleaks-action@v2 does not support pull_request_target, so invoke the CLI directly | |
| run: | | |
| GITLEAKS_VERSION="8.24.0" | |
| curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ | |
| | tar xz -C /usr/local/bin gitleaks | |
| gitleaks detect --source . --verbose --redact | |
| 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 | |
| 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: | | |
| 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 | |
| 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) | |
| frameworks=$(grep -oP '<TargetFramework[s]?>\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" \ | |
| --collect:"XPlat Code Coverage" \ | |
| --results-directory "./TestResults" \ | |
| --logger "console;verbosity=minimal" || exit 1 | |
| done <<< "$frameworks" | |
| 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" \ | |
| -targetdir:"CoverageReport" \ | |
| -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!" | |
| exit 1 | |
| fi | |
| 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}%)" | |
| else | |
| echo " ✅ PASS: Meets ${threshold}% threshold" | |
| fi | |
| fi | |
| done < CoverageReport/Summary.txt | |
| if [ -n "$failed_projects" ]; then | |
| echo "" | |
| echo "==========================================" | |
| echo "❌ COVERAGE GATE FAILED" | |
| echo "==========================================" | |
| echo "Projects below ${threshold}% coverage: $failed_projects" | |
| echo "" | |
| echo "Stage 1 failed. Windows, macOS, and .NET Framework tests will NOT run." | |
| exit 1 | |
| else | |
| echo "" | |
| echo "==========================================" | |
| echo "✅ COVERAGE GATE PASSED" | |
| echo "==========================================" | |
| echo "All projects meet ${threshold}% coverage threshold." | |
| echo "Proceeding to Stage 2 (Windows and macOS tests)." | |
| fi | |
| - 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: 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 | |
| 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: | | |
| 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 .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 | |
| 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 '^\s*(\S+)\s+(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^\s*Summary') { | |
| $module = $Matches[1] | |
| $percent = [int][math]::Floor([double]$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' | |
| 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: | | |
| 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: | |
| 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]+(\.[0-9]+)?%' | tail -1 | grep -oE '^[0-9]+') | |
| 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 + 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, 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" | |
| "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: | | |
| 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 | |
| - 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 |