ci: SHA-pin all 46 action refs + fix 5 template-injection findings #195
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) | |
| # - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| fetch-depth: 0 | |
| - name: Fetch trusted gitleaks config from main | |
| # Prevent PR from modifying .gitleaks.toml to bypass the scan. | |
| # Distinguish "file doesn't exist in main" (fine — gitleaks uses | |
| # defaults) from "checkout failed for any other reason" (abort — | |
| # silently using the PR version would defeat the guard). | |
| shell: bash | |
| run: | | |
| if ! git fetch origin main --depth=1; then | |
| echo "::error::Failed to fetch origin/main — aborting before gitleaks scan." | |
| exit 1 | |
| fi | |
| if git cat-file -e origin/main:.gitleaks.toml 2>/dev/null; then | |
| if ! git checkout origin/main -- .gitleaks.toml; then | |
| echo "::error::Failed to checkout origin/main:.gitleaks.toml — aborting to prevent silent fall-back to PR version." | |
| exit 1 | |
| fi | |
| else | |
| echo "::notice::.gitleaks.toml not present in origin/main — gitleaks will use defaults." | |
| fi | |
| - name: Run gitleaks | |
| # gitleaks-action@v2 does not support pull_request_target, so invoke the CLI directly | |
| # Pinned to a specific version with SHA256 checksum verification for supply-chain safety | |
| run: | | |
| GITLEAKS_VERSION="8.24.0" | |
| GITLEAKS_SHA256="cb49b7de5ee986510fe8666ca0273a6cc15eb82571f2f14832c9e8920751f3a4" | |
| mkdir -p "$HOME/.local/bin" | |
| TARBALL="$(mktemp)" | |
| curl -sSfL -o "$TARBALL" "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | |
| echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c - || { echo "Checksum verification failed!"; exit 1; } | |
| tar xzf "$TARBALL" -C "$HOME/.local/bin" gitleaks | |
| rm -f "$TARBALL" | |
| export PATH="$HOME/.local/bin:$PATH" | |
| 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| - name: Fetch trusted configuration files from main branch | |
| # Skip for Dependabot — its package-version bumps to protected files (e.g. | |
| # Directory.Build.props) are legitimate and should not be overwritten by main's | |
| # older versions. Dependabot's identity is GitHub-controlled and not spoofable. | |
| if: github.event.pull_request.user.login != 'dependabot[bot]' | |
| 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" | |
| "*.DotSettings" | |
| ".github/workflows/*.yml" | |
| ".github/workflows/*.yaml" | |
| ) | |
| # 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. | |
| # NOTE: use process substitution (`done < <(...)`) instead of a | |
| # plain pipeline. A piped `while` runs in a subshell — an | |
| # `exit 1` from inside would only kill the subshell, not the | |
| # outer step, letting a failed copy silently fall back to the | |
| # PR-supplied protected config. Process substitution runs the | |
| # loop in the parent shell so exit actually terminates the job. | |
| while read -r file; do | |
| if [ -n "$file" ]; then | |
| echo " ✓ Copying $file from main branch" | |
| mkdir -p "$(dirname "$file")" | |
| if ! git show "main-branch:$file" > "$file"; then | |
| echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." | |
| exit 1 | |
| fi | |
| fi | |
| # Mask grep's exit 1 on zero matches with `|| true` — under | |
| # `set -eo pipefail`, an empty match would otherwise fail the step, | |
| # but a pattern like `*.ruleset` legitimately has no matches in | |
| # repos that don't ship one. The empty stream is fine; the while | |
| # loop simply doesn't iterate. | |
| done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; }) | |
| 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" | |
| if ! git show "main-branch:$config_file" > "$config_file"; then | |
| echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." | |
| exit 1 | |
| fi | |
| 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 | |
| # Skip for Dependabot — its bumps to protected files (e.g. Directory.Build.props) | |
| # are legitimate. The guard's threat model is human PR authors disabling analyzers | |
| # in their own PRs; it does not apply to a trusted GitHub-controlled bot whose | |
| # only action is package-version updates. | |
| if: github.event.pull_request.user.login != 'dependabot[bot]' | |
| 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, .ruleset, and workflow files using the same git diff approach | |
| # --diff-filter=AMRCD: Added, Modified, Renamed, Copied, Deleted. | |
| # Including D so a PR that *deletes* a protected file (workflow, | |
| # .globalconfig, .ruleset) also triggers the maintainer-review gate | |
| # — a silent deletion is just as security-relevant as a silent edit. | |
| while IFS= read -r file; do | |
| changed_files+=("$file") | |
| done < <(git diff --name-only --diff-filter=AMRCD main-branch HEAD 2>/dev/null | grep -E '(\.(globalconfig|ruleset)|^\.github/workflows/.*\.ya?ml)$' || 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 | |
| # ============================================================================ | |
| # INSPECTCODE: JetBrains ReSharper InspectCode parallel to the test stages. | |
| # Runs concurrently with Stage 1 / 2 / 3 (no `needs:` on a test job) so it | |
| # doesn't extend wall-clock — typically 3–5 min vs the slowest stage. | |
| # SARIF uploads to GitHub Code Scanning (Security tab + inline PR | |
| # annotations). `error`-severity findings fail the job; `warning`-severity | |
| # surfaces in Code Scanning but doesn't fail (gated by --severity=WARNING | |
| # filter). Tune the noise floor via .DotSettings at the repo root. | |
| # ============================================================================ | |
| inspectcode: | |
| name: "ReSharper InspectCode" | |
| # Runs on Windows so .NET Framework reference assemblies (System, | |
| # System.Xml.Linq, etc.) resolve natively for any net462 / net472 / net48 | |
| # projects the solution includes. On ubuntu-latest InspectCode fails | |
| # with 60+ MSB3245 assembly-resolution errors on Framework-target | |
| # projects unless we install mono — Windows has them out of the box. | |
| # The build + test stages parallel this on Linux/Windows/macOS, so the | |
| # Windows load here is not additive to the wall-clock the way an extra | |
| # test stage would be. | |
| runs-on: windows-latest | |
| # All step scripts below use bash syntax (process substitution, [[ ]], | |
| # etc.) — pin the default shell so it works uniformly on windows-latest | |
| # (which defaults to pwsh) and not accidentally on any future runner | |
| # swap. | |
| defaults: | |
| run: | |
| shell: bash | |
| needs: detect-projects | |
| if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true' | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| security-events: write # required for upload-sarif | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| # Same defense-in-depth pattern as detect-projects and the test stages: | |
| # jobs don't share workspaces under pull_request_target, so each build/ | |
| # analyzer job re-fetches protected config from main after checkout. | |
| # Without this, a malicious PR could ship a permissive .editorconfig / | |
| # BannedSymbols.txt / .DotSettings that would silence InspectCode | |
| # findings on the PR's own code. | |
| - name: Fetch trusted configuration files from main branch | |
| # Skip for Dependabot — its package-version bumps to protected files are | |
| # legitimate and should not be overwritten by main's older versions. | |
| if: github.event.pull_request.user.login != 'dependabot[bot]' | |
| run: | | |
| echo "Fetching configuration files from main branch to prevent malicious overrides..." | |
| git fetch origin main:main-branch | |
| config_files=( | |
| ".editorconfig" | |
| "Directory.Build.props" | |
| "Directory.Build.targets" | |
| "BannedSymbols.txt" | |
| "*.globalconfig" | |
| "*.ruleset" | |
| ".github/workflows/*.yml" | |
| ".github/workflows/*.yaml" | |
| ) | |
| for config_file in "${config_files[@]}"; do | |
| if [[ "$config_file" == *"*"* ]]; then | |
| # See Stage 1 for the process-substitution rationale (avoids a | |
| # subshell that would swallow `exit 1` on failed copies). | |
| while read -r file; do | |
| if [ -n "$file" ]; then | |
| echo " ✓ Copying $file from main branch" | |
| mkdir -p "$(dirname "$file")" | |
| if ! git show "main-branch:$file" > "$file"; then | |
| echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." | |
| exit 1 | |
| fi | |
| fi | |
| done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; }) | |
| else | |
| if git cat-file -e "main-branch:$config_file" 2>/dev/null; then | |
| echo " ✓ Copying $config_file from main branch" | |
| if ! git show "main-branch:$config_file" > "$config_file"; then | |
| echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." | |
| exit 1 | |
| fi | |
| 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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 | |
| with: | |
| dotnet-version: '10.0.x' | |
| # Restore + build everything the solution knows about. On | |
| # windows-latest .NET Framework 4.x reference assemblies are bundled, | |
| # so net462 / net472 / net48 projects (e.g. examples/CSharp.DotNet462.Example) | |
| # build natively alongside net5+ / netstandard / netcoreapp projects | |
| # — no filter loop needed. InspectCode then reads the built outputs | |
| # (with `--no-build`) for the whole solution. | |
| - name: Restore and build | |
| run: | | |
| dotnet restore | |
| dotnet build -c Release --no-restore | |
| - name: Install JetBrains.ReSharper.GlobalTools | |
| run: dotnet tool install -g JetBrains.ReSharper.GlobalTools | |
| - name: Run InspectCode | |
| run: | | |
| # Find a solution to inspect. Prefer .slnx (new format) then .sln. | |
| # InspectCode requires SOMETHING solution-shaped — fail loudly if | |
| # neither exists. | |
| # | |
| # Uses direct glob iteration (not `ls | head`). Under the workflow's | |
| # `-e -o pipefail` shell, on Git Bash for Windows, an | |
| # `ls *.slnx 2>/dev/null | head -n1` where no `*.slnx` exists exits | |
| # the whole step with code 2 (pipefail propagates ls's non-zero | |
| # exit through the pipe) BEFORE the `if [ -z ]` fallback even runs. | |
| # Direct globbing with `for f in *.slnx *.sln` sidesteps the pipe | |
| # entirely — no ls, no pipefail interaction. | |
| SLN="" | |
| for candidate in *.slnx *.sln; do | |
| if [ -f "$candidate" ]; then | |
| SLN="$candidate" | |
| break | |
| fi | |
| done | |
| if [ -z "$SLN" ]; then | |
| echo "::error::No .slnx or .sln found at repo root — InspectCode needs one to run." | |
| exit 1 | |
| fi | |
| echo "Inspecting: $SLN" | |
| jb inspectcode "$SLN" \ | |
| --output=inspect.sarif \ | |
| --format=sarif \ | |
| --severity=WARNING \ | |
| --no-build | |
| - name: Upload SARIF to Code Scanning | |
| uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 | |
| with: | |
| sarif_file: inspect.sarif | |
| - name: Gate on error-severity findings | |
| # PowerShell (native on windows-latest) so we don't depend on jq | |
| # being preinstalled. Parses the SARIF via ConvertFrom-Json and | |
| # counts results at level=error. Warnings still upload (visible in | |
| # Security → Code scanning) but don't gate merge — raise to `error` | |
| # later when the noise floor is acceptable. | |
| shell: pwsh | |
| run: | | |
| $sarif = Get-Content inspect.sarif -Raw | ConvertFrom-Json | |
| $count = @( | |
| foreach ($run in $sarif.runs) { | |
| foreach ($result in $run.results) { | |
| if ($result.level -eq 'error') { $result } | |
| } | |
| } | |
| ).Count | |
| if ($count -gt 0) { | |
| Write-Host "::error::$count InspectCode error-severity finding(s) — see Security → Code scanning" | |
| exit 1 | |
| } | |
| Write-Host "✅ No error-severity InspectCode findings." | |
| # ============================================================================ | |
| # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| - name: Fetch trusted configuration files from main branch | |
| # Skip for Dependabot — its package-version bumps to protected files (e.g. | |
| # Directory.Build.props) are legitimate and should not be overwritten by main's | |
| # older versions. Dependabot's identity is GitHub-controlled and not spoofable. | |
| if: github.event.pull_request.user.login != 'dependabot[bot]' | |
| 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" | |
| ".github/workflows/*.yml" | |
| ".github/workflows/*.yaml" | |
| ) | |
| # 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. | |
| # NOTE: use process substitution (`done < <(...)`) instead of a | |
| # plain pipeline. A piped `while` runs in a subshell — an | |
| # `exit 1` from inside would only kill the subshell, not the | |
| # outer step, letting a failed copy silently fall back to the | |
| # PR-supplied protected config. Process substitution runs the | |
| # loop in the parent shell so exit actually terminates the job. | |
| while read -r file; do | |
| if [ -n "$file" ]; then | |
| echo " ✓ Copying $file from main branch" | |
| mkdir -p "$(dirname "$file")" | |
| if ! git show "main-branch:$file" > "$file"; then | |
| echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." | |
| exit 1 | |
| fi | |
| fi | |
| # Mask grep's exit 1 on zero matches with `|| true` — under | |
| # `set -eo pipefail`, an empty match would otherwise fail the step, | |
| # but a pattern like `*.ruleset` legitimately has no matches in | |
| # repos that don't ship one. The empty stream is fine; the while | |
| # loop simply doesn't iterate. | |
| done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; }) | |
| 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" | |
| if ! git show "main-branch:$config_file" > "$config_file"; then | |
| echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." | |
| exit 1 | |
| fi | |
| 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: | | |
| # signed-by= points apt at the Canonical archive keyring that ships on all | |
| # GitHub-hosted Ubuntu runners. It contains the same signing key Canonical | |
| # uses across releases (focal, jammy, noble), so it can verify focal-security | |
| # packages from a non-focal runner without disabling signature checking. | |
| # Earlier iteration used [trusted=yes] (skipping verification) as a quick | |
| # unblock; this restores end-to-end signature verification. | |
| echo "deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] 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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 | |
| 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 .NET workloads | |
| # Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads via | |
| # their TFMs (e.g. net10.0-android). For workload-bearing repos this installs them | |
| # before restore; for pure-library repos with no workload TFMs, skip entirely to | |
| # avoid ~5-15s of network-dependent setup and an extra failure mode. | |
| shell: bash | |
| run: | | |
| if find . -name '*.csproj' -type f -exec grep -lE 'net[0-9]+\.[0-9]+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' {} \; | grep -q .; then | |
| echo "Workload-bearing TFMs detected — running dotnet workload restore" | |
| dotnet workload restore | |
| else | |
| echo "No workload-bearing TFMs in any csproj — skipping dotnet workload restore" | |
| fi | |
| - 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 via MSBuild property evaluation. | |
| # This handles multi-line <TargetFrameworks> XML, conditional property groups, | |
| # and TFMs inherited from Directory.Build.props — all of which break grep-based parsing. | |
| # Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single). | |
| tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ | |
| | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') | |
| if [ -z "$tfm_raw" ]; then | |
| tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ | |
| | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') | |
| fi | |
| frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | 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#). | |
| # Gracefully skip if there is no ./tests directory (e.g. template-publishing | |
| # repos or library repos in early development that have no tests yet). | |
| # The downstream coverage steps already handle the no-coverage-files case. | |
| # Fail loudly if the repo HAS src/ projects — the coverage gate | |
| # exists to enforce test coverage on shipping code, so silently | |
| # passing when tests are missing is the wrong default. Skip only | |
| # for template-pack / in-dev repos with no source projects yet. | |
| if [ ! -d ./tests ]; then | |
| if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then | |
| echo "❌ ./tests directory is missing but ./src contains projects — refusing to silently skip the coverage gate." | |
| exit 1 | |
| fi | |
| echo "ℹ️ No ./tests directory and no ./src projects — skipping test stage (template-pack / in-dev shape)." | |
| exit 0 | |
| fi | |
| mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) | |
| if [ ${#test_projects[@]} -eq 0 ]; then | |
| if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then | |
| echo "❌ No test projects under ./tests but ./src contains projects — refusing to silently skip the coverage gate." | |
| exit 1 | |
| fi | |
| echo "ℹ️ No test projects found under ./tests and no ./src projects — skipping test stage (template-pack / in-dev shape)." | |
| exit 0 | |
| 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 via MSBuild property evaluation (handles multi-line XML | |
| # and Directory.Build.props inheritance — both break grep-based parsing). | |
| # Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single). | |
| tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ | |
| | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') | |
| if [ -z "$tfm_raw" ]; then | |
| tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ | |
| | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') | |
| fi | |
| frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | 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" \ | |
| --no-build --no-restore \ | |
| --collect:"XPlat Code Coverage" \ | |
| --settings coverlet.runsettings \ | |
| --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} | |
| matched_count=0 | |
| # IFS= is load-bearing: a bare `read -r` strips leading whitespace, | |
| # which erases the indentation that distinguishes ReportGenerator's | |
| # per-class rows from assembly rows — the ^[^ ] filter below then | |
| # matches every class row and gates classes as if they were | |
| # projects (surfaced on ETL-SqlBulkCopy's 0.7.0 release; see #157). | |
| while IFS= read -r line; do | |
| # Match lines with module names and percentages. The percent | |
| # capture is the LAST %-suffixed number on the line, matching | |
| # Stage 2's behavior — ReportGenerator Summary.txt rows often | |
| # have line/branch/method columns and the overall figure is at | |
| # end-of-line. | |
| if echo "$line" | grep -qE '^[^ ].*[0-9]+(\.[0-9]+)?%$' && ! echo "$line" | grep -q '^Summary'; then | |
| module=$(echo "$line" | awk '{print $1}') | |
| # Floor the percent to int (matches Stage 2 pwsh's [int][math]::Floor) | |
| # so we can use bash's integer -lt comparator below without | |
| # erroring on decimals like "90.4". | |
| percent=$(echo "$line" | awk '{print $NF}' | tr -d '%' | awk '{print int($1)}') | |
| matched_count=$((matched_count + 1)) | |
| 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 | |
| # Fail loudly when 0 modules matched - the regex is wrong or | |
| # Summary.txt format changed. Silently passing the gate when we | |
| # couldn't parse coverage is worse than failing. | |
| if [ "$matched_count" -eq 0 ]; then | |
| echo "❌ Coverage parser matched 0 modules in Summary.txt - regex or report format is out of sync. Refusing to silently pass the gate." | |
| exit 1 | |
| fi | |
| 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: coverage-linux | |
| path: | | |
| TestResults/ | |
| CoverageReport/ | |
| - name: Upload build artifacts | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| - name: Fetch trusted configuration files from main branch | |
| # Skip for Dependabot — its package-version bumps to protected files (e.g. | |
| # Directory.Build.props) are legitimate and should not be overwritten by main's | |
| # older versions. Dependabot's identity is GitHub-controlled and not spoofable. | |
| if: github.event.pull_request.user.login != 'dependabot[bot]' | |
| 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 UTF8NoBOM | |
| } else { | |
| Write-Host " ℹ️ $configFile not found in main branch, skipping" | |
| } | |
| } | |
| # Handle glob patterns for .globalconfig, .ruleset, and workflow files | |
| $globPatterns = @("*.globalconfig", "*.ruleset", ".github/workflows/*.yml", ".github/workflows/*.yaml") | |
| 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 UTF8NoBOM | |
| } | |
| } | |
| } | |
| Write-Host "" | |
| Write-Host "✅ Configuration files secured - using versions from main branch" | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 | |
| 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 .NET workloads | |
| # Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads via | |
| # their TFMs (e.g. net10.0-android). For workload-bearing repos this installs them | |
| # before restore; for pure-library repos with no workload TFMs, skip entirely to | |
| # avoid ~5-15s of network-dependent setup and an extra failure mode. | |
| shell: bash | |
| run: | | |
| if find . -name '*.csproj' -type f -exec grep -lE 'net[0-9]+\.[0-9]+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' {} \; | grep -q .; then | |
| echo "Workload-bearing TFMs detected — running dotnet workload restore" | |
| dotnet workload restore | |
| else | |
| echo "No workload-bearing TFMs in any csproj — skipping dotnet workload restore" | |
| fi | |
| - 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' | |
| # Gracefully skip if there is no ./tests directory (e.g. template-publishing | |
| # repos or library repos in early development that have no tests yet). | |
| # The coverage gate exists to enforce test coverage on shipping | |
| # code. If ./src has projects but ./tests doesn't, fail loudly | |
| # instead of silently passing the gate. Skip only for template- | |
| # pack / in-dev repos that have no source projects yet. | |
| $srcHasProjects = @(Get-ChildItem -Path './src' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj' -ErrorAction SilentlyContinue).Count -gt 0 | |
| if (-not (Test-Path -Path './tests' -PathType Container)) { | |
| if ($srcHasProjects) { | |
| Write-Error "❌ ./tests directory is missing but ./src contains projects — refusing to silently skip the coverage gate." | |
| exit 1 | |
| } | |
| Write-Host "ℹ️ No ./tests directory and no ./src projects — skipping test stage (template-pack / in-dev shape)." | |
| exit 0 | |
| } | |
| $testProjects = @(Get-ChildItem -Path './tests/*' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj') | |
| if (@($testProjects).Count -eq 0) { | |
| if ($srcHasProjects) { | |
| Write-Error "❌ No test projects under ./tests but ./src contains projects — refusing to silently skip the coverage gate." | |
| exit 1 | |
| } | |
| Write-Host "ℹ️ No test projects found under ./tests and no ./src projects — skipping test stage (template-pack / in-dev shape)." | |
| exit 0 | |
| } | |
| 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 ` | |
| --no-build --no-restore ` | |
| --collect:"XPlat Code Coverage" ` | |
| --settings coverlet.runsettings ` | |
| --results-directory "./TestResults" ` | |
| --logger "console;verbosity=normal" | |
| } else { | |
| dotnet test $testProj.FullName ` | |
| --configuration Release ` | |
| --framework $fw ` | |
| --no-build --no-restore ` | |
| --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 = @() | |
| $matchedCount = 0 | |
| foreach ($line in (Get-Content "CoverageReport/Summary.txt")) { | |
| # Only consider top-level assembly rows: non-space first char, | |
| # then anything, then whitespace + the final percent at EOL. | |
| # Matches Stage 1's `^[^ ].*[0-9]+(\.[0-9]+)?%$` filter (which | |
| # uses awk $NF for the percent — robust to extra columns like | |
| # line/branch/method that ReportGenerator can emit on the same | |
| # row). | |
| # | |
| # Bug previously here: a `.*` between the module name and the | |
| # trailing `(\d+)%` was greedy and could eat all but the last | |
| # digit of the percent — turning "100" into "0" and failing | |
| # the gate on actually-100%-covered modules. Two changes: | |
| # - Anchor on `^(\S+)` so indented sub-class rows are skipped | |
| # (their parent assembly row carries the same number, so | |
| # nothing is lost — and Stage 1 ignores them too). | |
| # - Require whitespace immediately before the final `\d+%` | |
| # (`\s(\d+...)%\s*$`). This still allows intermediate | |
| # columns between the module name and the final percent | |
| # (the `.*` consumes them), but `.*` can't terminate | |
| # mid-digit-run — the regex engine MUST place `\s` before | |
| # the digits, which forces the last %-suffixed number on | |
| # the line to be captured intact. | |
| if ($line -match '^(\S+).*\s(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^Summary') { | |
| $module = $Matches[1] | |
| $percent = [int][math]::Floor([double]$Matches[2]) | |
| $matchedCount++ | |
| 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 | |
| } | |
| } | |
| } | |
| # Fail loudly when 0 modules matched — the regex is wrong or | |
| # Summary.txt format changed. Silently passing the gate when we | |
| # couldn't read coverage is worse than failing. | |
| if ($matchedCount -eq 0) { | |
| Write-Error "❌ Coverage parser matched 0 modules in Summary.txt — regex or report format is out of sync. Refusing to silently pass the gate." | |
| exit 1 | |
| } | |
| 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| - name: Fetch trusted configuration files from main branch | |
| # Skip for Dependabot — its package-version bumps to protected files (e.g. | |
| # Directory.Build.props) are legitimate and should not be overwritten by main's | |
| # older versions. Dependabot's identity is GitHub-controlled and not spoofable. | |
| if: github.event.pull_request.user.login != 'dependabot[bot]' | |
| 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" | |
| ".github/workflows/*.yml" | |
| ".github/workflows/*.yaml" | |
| ) | |
| # 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. | |
| # NOTE: use process substitution (`done < <(...)`) instead of a | |
| # plain pipeline. A piped `while` runs in a subshell — an | |
| # `exit 1` from inside would only kill the subshell, not the | |
| # outer step, letting a failed copy silently fall back to the | |
| # PR-supplied protected config. Process substitution runs the | |
| # loop in the parent shell so exit actually terminates the job. | |
| while read -r file; do | |
| if [ -n "$file" ]; then | |
| echo " ✓ Copying $file from main branch" | |
| mkdir -p "$(dirname "$file")" | |
| if ! git show "main-branch:$file" > "$file"; then | |
| echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." | |
| exit 1 | |
| fi | |
| fi | |
| # Mask grep's exit 1 on zero matches with `|| true` — under | |
| # `set -eo pipefail`, an empty match would otherwise fail the step, | |
| # but a pattern like `*.ruleset` legitimately has no matches in | |
| # repos that don't ship one. The empty stream is fine; the while | |
| # loop simply doesn't iterate. | |
| done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; }) | |
| 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" | |
| if ! git show "main-branch:$config_file" > "$config_file"; then | |
| echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." | |
| exit 1 | |
| fi | |
| 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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 | |
| with: | |
| dotnet-version: | | |
| 6.0.x | |
| 7.0.x | |
| 8.0.x | |
| 9.0.x | |
| 10.0.x | |
| - name: Restore .NET workloads | |
| # Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads via | |
| # their TFMs (e.g. net10.0-android). For workload-bearing repos this installs them | |
| # before restore; for pure-library repos with no workload TFMs, skip entirely to | |
| # avoid ~5-15s of network-dependent setup and an extra failure mode. | |
| shell: bash | |
| run: | | |
| if find . -name '*.csproj' -type f -exec grep -lE 'net[0-9]+\.[0-9]+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' {} \; | grep -q .; then | |
| echo "Workload-bearing TFMs detected — running dotnet workload restore" | |
| dotnet workload restore | |
| else | |
| echo "No workload-bearing TFMs in any csproj — skipping dotnet workload restore" | |
| fi | |
| - 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 via MSBuild property evaluation (handles multi-line XML | |
| # and Directory.Build.props inheritance). Filter to .NET 6+ for macOS ARM64 compatibility. | |
| # Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single). | |
| tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ | |
| | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') | |
| if [ -z "$tfm_raw" ]; then | |
| tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ | |
| | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') | |
| fi | |
| frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | 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#). | |
| # Gracefully skip if there is no ./tests directory (e.g. template-publishing | |
| # repos or library repos in early development that have no tests yet). | |
| # Fail loudly if the repo HAS src/ projects — the coverage gate | |
| # exists to enforce test coverage on shipping code, so silently | |
| # passing when tests are missing is the wrong default. Skip only | |
| # for template-pack / in-dev repos with no source projects yet. | |
| if [ ! -d ./tests ]; then | |
| if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then | |
| echo "❌ ./tests directory is missing but ./src contains projects — refusing to silently skip the coverage gate." | |
| exit 1 | |
| fi | |
| echo "ℹ️ No ./tests directory and no ./src projects — skipping test stage (template-pack / in-dev shape)." | |
| exit 0 | |
| fi | |
| 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 | |
| if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then | |
| echo "❌ No test projects under ./tests but ./src contains projects — refusing to silently skip the coverage gate." | |
| exit 1 | |
| fi | |
| echo "ℹ️ No test projects found under ./tests and no ./src projects — skipping test stage (template-pack / in-dev shape)." | |
| exit 0 | |
| 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 via MSBuild property evaluation (handles multi-line XML | |
| # and Directory.Build.props inheritance). Filter to .NET 6+ for macOS ARM64 compatibility. | |
| # Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single). | |
| tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ | |
| | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') | |
| if [ -z "$tfm_raw" ]; then | |
| tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ | |
| | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') | |
| fi | |
| frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | 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" \ | |
| --no-build --no-restore \ | |
| --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 no cobertura files were produced (no tests, all test projects | |
| # skipped, etc.), the preceding step explicitly skipped report | |
| # generation. Mirror that here — gating only when coverage was | |
| # actually collected — instead of failing with "Coverage report | |
| # not generated!" on jobs that legitimately had nothing to cover. | |
| if ! find ./TestResults -name "coverage.cobertura.xml" -print -quit 2>/dev/null | grep -q .; then | |
| echo "ℹ️ No coverage files produced — skipping coverage gate (consistent with the prior 'skipping report generation' notice)." | |
| exit 0 | |
| fi | |
| if [ ! -f "CoverageReport/Summary.txt" ]; then | |
| echo "❌ Coverage files exist but Summary.txt is missing — ReportGenerator failed." | |
| 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| ref: refs/pull/${{ github.event.pull_request.number }}/head | |
| persist-credentials: false | |
| - name: Fetch trusted configuration files from main branch | |
| # Skip for Dependabot — its package-version bumps to protected files (e.g. | |
| # Directory.Build.props) are legitimate and should not be overwritten by main's | |
| # older versions. Dependabot's identity is GitHub-controlled and not spoofable. | |
| if: github.event.pull_request.user.login != 'dependabot[bot]' | |
| 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" | |
| ".github/workflows/*.yml" | |
| ".github/workflows/*.yaml" | |
| ) | |
| # 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. | |
| # NOTE: use process substitution (`done < <(...)`) instead of a | |
| # plain pipeline. A piped `while` runs in a subshell — an | |
| # `exit 1` from inside would only kill the subshell, not the | |
| # outer step, letting a failed copy silently fall back to the | |
| # PR-supplied protected config. Process substitution runs the | |
| # loop in the parent shell so exit actually terminates the job. | |
| while read -r file; do | |
| if [ -n "$file" ]; then | |
| echo " ✓ Copying $file from main branch" | |
| mkdir -p "$(dirname "$file")" | |
| if ! git show "main-branch:$file" > "$file"; then | |
| echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." | |
| exit 1 | |
| fi | |
| fi | |
| # Mask grep's exit 1 on zero matches with `|| true` — under | |
| # `set -eo pipefail`, an empty match would otherwise fail the step, | |
| # but a pattern like `*.ruleset` legitimately has no matches in | |
| # repos that don't ship one. The empty stream is fine; the while | |
| # loop simply doesn't iterate. | |
| done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; }) | |
| 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" | |
| if ! git show "main-branch:$config_file" > "$config_file"; then | |
| echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." | |
| exit 1 | |
| fi | |
| 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: devskim-results | |
| path: devskim-results.txt | |
| if-no-files-found: warn |