From c4b95579b68e824f749bc255922502d36ad66a49 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 09:35:33 -0400 Subject: [PATCH 01/71] Re-sync protected config + workflow files to canonical (C1 drift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Template-drift resolution for IEnumerable-Extensions — the protected half. These files trip the pr.yaml "Detect .NET Projects" guard, so they are split into their own PR that a maintainer merges via admin bypass: - .editorconfig, BannedSymbols.txt - .github/workflows/pr.yaml, codeql.yaml, docfx.yaml Part of #106. Co-Authored-By: Claude Opus 4.7 (1M context) --- .editorconfig | 13 +- .github/workflows/codeql.yaml | 20 +++ .github/workflows/docfx.yaml | 275 ++++++++++++++++------------------ .github/workflows/pr.yaml | 193 +++++++++++++++++------- BannedSymbols.txt | 2 +- 5 files changed, 292 insertions(+), 211 deletions(-) diff --git a/.editorconfig b/.editorconfig index c055ade..8b3044e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,13 +25,12 @@ indent_size = 2 [*.{yml,yaml}] indent_size = 2 -# PowerShell files -# PowerShell uses CRLF to maintain compatibility with Windows and PowerShell conventions -# This overrides the global end_of_line = lf setting and aligns with .gitattributes line 14 -[*.ps1] -indent_size = 4 -end_of_line = crlf -charset = utf-8-bom +# PowerShell scripts inherit LF + UTF-8 (no BOM) + 4-space indent from +# the global [*] section above — no per-language override is needed. +# LF + no-BOM is required for the `#!/usr/bin/env pwsh` shebang (where +# present) on scripts under scripts/ to work on Linux/macOS — CR +# breaks the kernel's exec lookup, and a leading BOM prevents shebang +# recognition entirely. # C# files [*.cs] diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index c3dfa77..0165e39 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -87,6 +87,26 @@ jobs: with: dotnet-version: '10.0.x' + - name: Restore .NET workloads + if: steps.check-csharp.outputs.has-csharp == 'true' + shell: pwsh + run: | + # Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads + # via their TFMs (e.g. net10.0-android). Install whatever the solution needs + # before restore. For pure libraries this is a fast no-op. + $solution = Get-ChildItem -Path . -Recurse -Depth 2 -Include "*.sln", "*.slnx" | Select-Object -First 1 + if ($solution) { + Write-Host "Restoring workloads for $($solution.FullName)" + dotnet workload restore $solution.FullName + } else { + Write-Host "No solution found; restoring workloads for all projects" + dotnet workload restore + } + if ($LASTEXITCODE -ne 0) { + Write-Error "dotnet workload restore failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + - name: Build for CodeQL Analysis id: build if: steps.check-csharp.outputs.has-csharp == 'true' diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 27c7f8f..3a36257 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -225,66 +225,6 @@ jobs: Set-Content -Path 'docfx_project/_site/versions.json' -Encoding utf8NoBOM Write-Host "Generated versions.json with $($versions.Count) version(s): $($versions | ForEach-Object { $_.version })" - - name: Clean up stale root files from gh-pages - # Before deploying the latest docs to the site root, remove any pre-existing - # root-level files and folders from the gh-pages branch (except the versions/ - # directory, CNAME, and .nojekyll) so that stale DocFX assets from a previous - # build do not linger on the live site. - # The versions/ folder is preserved so that all versioned docs remain accessible - # while the root is refreshed with the new build. - if: inputs.deploy_to_pages != false && inputs.deploy_as_latest != false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: pwsh - run: | - $branchExists = git ls-remote --heads origin gh-pages - if (-not $branchExists) { - Write-Host "ℹ️ gh-pages branch does not exist yet – skipping stale-file cleanup." - exit 0 - } - - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git remote set-url origin "https://x-access-token:$($env:GITHUB_TOKEN)@github.com/$($env:GITHUB_REPOSITORY).git" - - git fetch origin gh-pages - # Create a local tracking branch only if it does not already exist - git show-ref --verify --quiet refs/heads/gh-pages - if ($LASTEXITCODE -ne 0) { - git branch gh-pages origin/gh-pages - } - - $WORK_DIR = Join-Path $env:RUNNER_TEMP 'gh-pages-clean' - # Remove a leftover worktree from a previous failed run, if any - git worktree remove "$WORK_DIR" --force 2>&1 | Out-Null - if (Test-Path $WORK_DIR) { Remove-Item $WORK_DIR -Recurse -Force } - git worktree add "$WORK_DIR" gh-pages - - # Remove all root-level items EXCEPT: - # .git – Git metadata (worktree pointer file) - # CNAME – Custom domain config (if present) - # .nojekyll – Tells GitHub Pages not to run Jekyll - # versions/ – All versioned docs (v1.0.0/, latest/, etc.) - Get-ChildItem -Path $WORK_DIR -Force | Where-Object { - $_.Name -ne '.git' -and - $_.Name -ne 'CNAME' -and - $_.Name -ne '.nojekyll' -and - $_.Name -ne 'versions' - } | Remove-Item -Recurse -Force - - git -C "$WORK_DIR" add -A - git -C "$WORK_DIR" diff --cached --quiet - if ($LASTEXITCODE -ne 0) { - git -C "$WORK_DIR" commit ` - -m "chore: clean up stale root DocFX assets before redeploy [skip ci]" - git -C "$WORK_DIR" push origin HEAD:gh-pages - Write-Host "✅ Stale root files removed from gh-pages." - } else { - Write-Host "ℹ️ No stale files found in gh-pages root – nothing to clean." - } - - git worktree remove "$WORK_DIR" --force - - name: Compute destination directory # Determines the versioned subfolder name for the docs deployment (e.g. /v1.2.3/). # Uses the explicit 'version' input when provided; otherwise falls back to @@ -333,101 +273,144 @@ jobs: run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" - git remote set-url origin "https://x-access-token:$($env:GITHUB_TOKEN)@github.com/$($env:GITHUB_REPOSITORY).git" $WORK_DIR = Join-Path $env:RUNNER_TEMP 'gh-pages-deploy' - $siteDir = Resolve-Path 'docfx_project/_site' - - # Set up gh-pages worktree (or start fresh if the branch does not exist yet) - $branchExists = git ls-remote --heads origin gh-pages - $useWorktree = [bool]$branchExists - if ($useWorktree) { - git fetch origin gh-pages - git show-ref --verify --quiet refs/heads/gh-pages - if ($LASTEXITCODE -ne 0) { git branch gh-pages origin/gh-pages } - git worktree remove $WORK_DIR --force 2>&1 | Out-Null - if (Test-Path $WORK_DIR) { Remove-Item $WORK_DIR -Recurse -Force } - git worktree add $WORK_DIR gh-pages - } else { - Write-Host "ℹ️ gh-pages does not exist yet — starting fresh." - New-Item -ItemType Directory -Force -Path $WORK_DIR | Out-Null - # Initialize an empty repo on the gh-pages branch so the later - # add/commit/push commands have a real git repository to operate on. - git -C $WORK_DIR init --initial-branch=gh-pages - git -C $WORK_DIR remote add origin "https://x-access-token:$($env:GITHUB_TOKEN)@github.com/$($env:GITHUB_REPOSITORY).git" - } - - # Remove stale root files; preserve versions/, .git, .nojekyll, CNAME - Get-ChildItem -Path $WORK_DIR -Force | Where-Object { - $_.Name -notin @('.git', 'CNAME', '.nojekyll', 'versions') - } | Remove-Item -Recurse -Force - - # Ensure .nojekyll exists so GitHub Pages does not run Jekyll - New-Item -ItemType File -Path (Join-Path $WORK_DIR '.nojekyll') -Force | Out-Null - - # Deploy versioned docs (real DocFX index.html — before version picker overwrites it) - $versionedDir = Join-Path $WORK_DIR "versions/$($env:VERSION_DIR)" - New-Item -ItemType Directory -Force -Path $versionedDir | Out-Null - Copy-Item -Path "$siteDir/*" -Destination $versionedDir -Recurse -Force - Write-Host "✅ Copied docs to versions/$($env:VERSION_DIR)/" - - if ($env:DEPLOY_AS_LATEST -eq 'true') { - # Deploy to versions/latest/ (real DocFX index.html) - $latestDir = Join-Path $WORK_DIR 'versions/latest' - New-Item -ItemType Directory -Force -Path $latestDir | Out-Null - Copy-Item -Path "$siteDir/*" -Destination $latestDir -Recurse -Force - Write-Host "✅ Copied docs to versions/latest/" - - # Generate version-picker index.html and overwrite _site/index.html. - # This happens AFTER the versioned copies above, so those directories - # retain the real DocFX landing page while the root gets the picker. - $repoName = ($env:GITHUB_REPOSITORY -split '/')[-1] - $title = if ($repoName) { "$repoName Documentation" } else { "Documentation" } - $versions = Get-Content "$siteDir/versions.json" -Raw | ConvertFrom-Json - - $listItems = foreach ($v in $versions) { - $liClass = if ($v.version -eq 'latest') { ' class="latest"' } else { '' } - $label = if ($v.version -eq 'latest') { 'latest (stable)' } else { $v.version } - " $label" - } - $listHtml = $listItems -join "`n" - - if (-not (Test-Path '.github/version-picker-template.html')) { - Write-Error "Error: .github/version-picker-template.html not found; cannot generate root index.html." + # Default to $false so the finally block uses the safe (Remove-Item) + # cleanup if the try block fails before $useWorktree is assigned. + $useWorktree = $false + $deployExitCode = 0 + + try { + # Authenticate via http.extraheader rather than embedding the token in + # the remote URL. This keeps the token out of `git remote -v` and + # error-message output, and avoids writing it into per-repo .git/config. + # The finally block ALWAYS unsets this so an early `exit` (e.g. failed + # ls-remote, missing template) cannot leave the token in the runner's + # global git config for subsequent steps. + $basicAuth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("x-access-token:$($env:GITHUB_TOKEN)")) + git config --global http."https://github.com/".extraheader "AUTHORIZATION: basic $basicAuth" + git remote set-url origin "https://github.com/$($env:GITHUB_REPOSITORY).git" + + $siteDir = Resolve-Path 'docfx_project/_site' + + # Set up gh-pages worktree (or start fresh if the branch does not exist yet). + # ls-remote can succeed-with-empty-output (branch missing) or fail + # (auth/network). Distinguish the two via $LASTEXITCODE so a transient + # failure does not silently route into the bootstrap path and clobber + # an existing gh-pages branch. + $branchExists = git ls-remote --heads origin gh-pages + if ($LASTEXITCODE -ne 0) { + Write-Error "git ls-remote --heads origin gh-pages failed with exit code $LASTEXITCODE — aborting before we accidentally bootstrap over an existing gh-pages branch." exit 1 } + $useWorktree = [bool]$branchExists + if ($useWorktree) { + git fetch origin gh-pages + git show-ref --verify --quiet refs/heads/gh-pages + if ($LASTEXITCODE -ne 0) { git branch gh-pages origin/gh-pages } + git worktree remove $WORK_DIR --force 2>&1 | Out-Null + if (Test-Path -LiteralPath $WORK_DIR) { Remove-Item -LiteralPath $WORK_DIR -Recurse -Force } + git worktree add $WORK_DIR gh-pages + } else { + Write-Host "ℹ️ gh-pages does not exist yet — starting fresh." + New-Item -ItemType Directory -Force -Path $WORK_DIR | Out-Null + # Initialize an empty repo on the gh-pages branch so the later + # add/commit/push commands have a real git repository to operate on. + # Auth is provided by the global http.extraheader configured above, + # so the remote URL does not embed the token. + git -C $WORK_DIR init --initial-branch=gh-pages + git -C $WORK_DIR remote add origin "https://github.com/$($env:GITHUB_REPOSITORY).git" + } - $template = Get-Content '.github/version-picker-template.html' -Raw - $html = $template -replace '\{\{TITLE\}\}', $title ` - -replace '\{\{VERSION_LIST\}\}', $listHtml - $html | Set-Content -Path "$siteDir/index.html" -Encoding utf8NoBOM - Write-Host "Generated version-picker index.html with $($versions.Count) version link(s)." + # Remove stale root files; preserve versions/, .git, .nojekyll, CNAME, dev + # ('dev' is where benchmark-action/github-action-benchmark stores its + # chart + accumulated data.js — wiping it loses chart history on every release.) + Get-ChildItem -Path $WORK_DIR -Force | Where-Object { + $_.Name -notin @('.git', 'CNAME', '.nojekyll', 'versions', 'dev') + } | Remove-Item -Recurse -Force + + # Ensure .nojekyll exists so GitHub Pages does not run Jekyll + New-Item -ItemType File -Path (Join-Path $WORK_DIR '.nojekyll') -Force | Out-Null + + # Deploy versioned docs (real DocFX index.html — before version picker overwrites it) + $versionedDir = Join-Path $WORK_DIR "versions/$($env:VERSION_DIR)" + New-Item -ItemType Directory -Force -Path $versionedDir | Out-Null + Copy-Item -Path "$siteDir/*" -Destination $versionedDir -Recurse -Force + Write-Host "✅ Copied docs to versions/$($env:VERSION_DIR)/" + + if ($env:DEPLOY_AS_LATEST -eq 'true') { + # Deploy to versions/latest/ (real DocFX index.html) + $latestDir = Join-Path $WORK_DIR 'versions/latest' + New-Item -ItemType Directory -Force -Path $latestDir | Out-Null + Copy-Item -Path "$siteDir/*" -Destination $latestDir -Recurse -Force + Write-Host "✅ Copied docs to versions/latest/" + + # Generate version-picker index.html and overwrite _site/index.html. + # This happens AFTER the versioned copies above, so those directories + # retain the real DocFX landing page while the root gets the picker. + $repoName = ($env:GITHUB_REPOSITORY -split '/')[-1] + $title = if ($repoName) { "$repoName Documentation" } else { "Documentation" } + $versions = Get-Content "$siteDir/versions.json" -Raw | ConvertFrom-Json + + $listItems = foreach ($v in $versions) { + $liClass = if ($v.version -eq 'latest') { ' class="latest"' } else { '' } + $label = if ($v.version -eq 'latest') { 'latest (stable)' } else { $v.version } + " $label" + } + $listHtml = $listItems -join "`n" - # Deploy version picker + shared assets to site root - Copy-Item -Path "$siteDir/*" -Destination $WORK_DIR -Recurse -Force - Write-Host "✅ Copied version picker to site root" - } + if (-not (Test-Path '.github/version-picker-template.html')) { + Write-Error "Error: .github/version-picker-template.html not found; cannot generate root index.html." + exit 1 + } + + $template = Get-Content '.github/version-picker-template.html' -Raw + $html = $template -replace '\{\{TITLE\}\}', $title ` + -replace '\{\{VERSION_LIST\}\}', $listHtml + $html | Set-Content -Path "$siteDir/index.html" -Encoding utf8NoBOM + Write-Host "Generated version-picker index.html with $($versions.Count) version link(s)." - # Single commit and push - git -C $WORK_DIR add -A - git -C $WORK_DIR diff --cached --quiet - if ($LASTEXITCODE -ne 0) { - $msg = if ($env:DEPLOY_AS_LATEST -eq 'true') { - "docs: deploy $($env:VERSION_DIR) and update latest" + # Deploy version picker + shared assets to site root + Copy-Item -Path "$siteDir/*" -Destination $WORK_DIR -Recurse -Force + Write-Host "✅ Copied version picker to site root" + } + + # Single commit and push + git -C $WORK_DIR add -A + git -C $WORK_DIR diff --cached --quiet + if ($LASTEXITCODE -ne 0) { + $msg = if ($env:DEPLOY_AS_LATEST -eq 'true') { + "docs: deploy $($env:VERSION_DIR) and update latest" + } else { + "docs: deploy $($env:VERSION_DIR)" + } + git -C $WORK_DIR commit -m $msg + git -C $WORK_DIR push origin HEAD:gh-pages + Write-Host "✅ Documentation deployed in a single commit." } else { - "docs: deploy $($env:VERSION_DIR)" + Write-Host "ℹ️ No documentation changes to deploy." } - git -C $WORK_DIR commit -m $msg - git -C $WORK_DIR push origin HEAD:gh-pages - Write-Host "✅ Documentation deployed in a single commit." - } else { - Write-Host "ℹ️ No documentation changes to deploy." + + # Capture the deploy outcome before the finally block runs cleanup + # (which might overwrite $LASTEXITCODE with a benign value). + $deployExitCode = $LASTEXITCODE + } + finally { + if ($useWorktree) { + git worktree remove $WORK_DIR --force 2>&1 | Out-Null + } elseif (Test-Path -LiteralPath $WORK_DIR) { + Remove-Item -LiteralPath $WORK_DIR -Recurse -Force -ErrorAction SilentlyContinue + } + + # ALWAYS unset the http.extraheader so the token (in encoded form) + # does not linger in the runner's global git config for any later + # step in this job, even if the try block hit an early `exit`. + git config --global --unset-all http."https://github.com/".extraheader 2>&1 | Out-Null } - if ($useWorktree) { - git worktree remove $WORK_DIR --force 2>&1 | Out-Null - } else { - Remove-Item $WORK_DIR -Recurse -Force -ErrorAction SilentlyContinue + # PowerShell will not propagate $LASTEXITCODE from a script block as + # the step's exit code reliably; explicitly `exit` so a failed + # commit/push fails the workflow step. + if ($deployExitCode -ne 0) { + exit $deployExitCode } - # Reset $LASTEXITCODE so cleanup noise does not fail the step. - $global:LASTEXITCODE = 0 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 4a67bf2..b83c648 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -50,12 +50,26 @@ jobs: persist-credentials: false fetch-depth: 0 + - name: Fetch trusted gitleaks config from main + # Prevent PR from modifying .gitleaks.toml to bypass the scan + run: | + git fetch origin main --depth=1 + git checkout origin/main -- .gitleaks.toml 2>/dev/null || true + shell: bash + - 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" - 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_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 @@ -77,10 +91,9 @@ jobs: 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 analyzer references) are legitimate. The - # threat model is human PR authors disabling analyzers in their own PRs, - # not a trusted GitHub-controlled bot whose only action is package bumps. + # 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..." @@ -127,10 +140,10 @@ jobs: echo "✅ Configuration files secured - using versions from main branch" - name: Detect protected configuration file changes - # Skip for Dependabot — its package-version bumps to protected files - # (e.g. Directory.Build.props analyzer references) are legitimate. The - # threat model is human PR authors disabling analyzers in their own PRs, - # not a trusted GitHub-controlled bot whose only action is package bumps. + # 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..." @@ -213,10 +226,9 @@ jobs: 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 analyzer references) are legitimate. The - # threat model is human PR authors disabling analyzers in their own PRs, - # not a trusted GitHub-controlled bot whose only action is package bumps. + # 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..." @@ -262,6 +274,8 @@ jobs: 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 @@ -287,6 +301,12 @@ jobs: 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 libraries it's a fast no-op. + run: dotnet workload restore + - name: Restore and build (exclude .NET Framework-only projects) run: | echo "Finding .NET project files in repository (via find command)..." @@ -342,10 +362,17 @@ jobs: for proj in "${projects[@]}"; do echo "Building: $proj" - # Extract target frameworks from the project file - # Support both (single) and (multiple) - # Collapse newlines so multi-line values are handled correctly - frameworks=$(tr '\n' ' ' < "$proj" | grep -oP '\s*\K[^<]+' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp[0-9.]+|netstandard[0-9.]+)$' || true) + # Extract target frameworks via MSBuild property evaluation. + # This handles multi-line XML, conditional property groups, + # and TFMs inherited from Directory.Build.props — all of which break grep-based parsing. + # Falls back from (multiple) to (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" @@ -375,12 +402,20 @@ jobs: - name: Run tests with coverage (.NET Core 5.0 - 10.0) run: | - # Find all test projects (C#, VB.NET, F#) + # 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. + if [ ! -d ./tests ]; then + echo "ℹ️ No ./tests directory — skipping test stage." + 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 - echo "❌ No test projects found in ./tests directory!" - exit 1 + echo "ℹ️ No test projects found under ./tests — skipping test stage." + exit 0 fi echo "==========================================" @@ -394,9 +429,16 @@ jobs: echo "Testing project: $test_proj" echo "==========================================" - # Extract target frameworks from the project file - # Support both (single) and (multiple) - frameworks=$(grep -oP '\K[^<]+' "$test_proj" | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp3\.1)$' || true) + # Extract target frameworks via MSBuild property evaluation (handles multi-line XML + # and Directory.Build.props inheritance — both break grep-based parsing). + # Falls back from (multiple) to (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" @@ -416,6 +458,7 @@ jobs: --configuration Release \ --framework "$fw" \ --collect:"XPlat Code Coverage" \ + --settings coverlet.runsettings \ --results-directory "./TestResults" \ --logger "console;verbosity=minimal" || exit 1 done <<< "$frameworks" @@ -529,10 +572,9 @@ jobs: 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 analyzer references) are legitimate. The - # threat model is human PR authors disabling analyzers in their own PRs, - # not a trusted GitHub-controlled bot whose only action is package bumps. + # 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: | @@ -555,7 +597,7 @@ jobs: $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 + git show "main-branch:$configFile" | Out-File -FilePath $configFile -Encoding UTF8NoBOM -NoNewline } else { Write-Host " ℹ️ $configFile not found in main branch, skipping" } @@ -570,7 +612,7 @@ jobs: 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 + git show "main-branch:$file" | Out-File -FilePath $file -Encoding UTF8NoBOM -NoNewline } } } @@ -590,6 +632,12 @@ jobs: 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 libraries it's a fast no-op. + run: dotnet workload restore + - name: Restore dependencies run: dotnet restore @@ -600,12 +648,19 @@ jobs: 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). + if (-not (Test-Path -Path './tests' -PathType Container)) { + Write-Host "ℹ️ No ./tests directory — skipping test stage." + exit 0 + } + $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 "ℹ️ No test projects found under ./tests — skipping test stage." + exit 0 } Write-Host "==========================================" -ForegroundColor Cyan @@ -654,6 +709,7 @@ jobs: --configuration Release ` --framework $fw ` --collect:"XPlat Code Coverage" ` + --settings coverlet.runsettings ` --results-directory "./TestResults" ` --logger "console;verbosity=normal" } else { @@ -772,10 +828,9 @@ jobs: 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 analyzer references) are legitimate. The - # threat model is human PR authors disabling analyzers in their own PRs, - # not a trusted GitHub-controlled bot whose only action is package bumps. + # 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..." @@ -831,6 +886,12 @@ jobs: 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 libraries it's a fast no-op. + run: dotnet workload restore + - name: Restore and build (exclude .NET Framework-only projects) run: | echo "Enumerating tracked .NET project files (git ls-files)..." @@ -886,10 +947,16 @@ jobs: for proj in "${projects[@]}"; do echo "Building: $proj" - # Extract target frameworks from the project file - # Support both (single) and (multiple) - # Trim whitespace from each framework before filtering - frameworks=$(tr -d '\n\r' < "$proj" | sed -n -E 's/.*[[:space:]]*>([^<]+)<\/TargetFrameworks?>.*/\1/p' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) + # 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 (multiple) to (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" @@ -919,15 +986,22 @@ jobs: - name: Run tests (.NET 6.0 - 10.0 only - ARM64 compatible) run: | - # Find all test projects (C#, VB.NET, F#) + # 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). + if [ ! -d ./tests ]; then + echo "ℹ️ No ./tests directory — skipping test stage." + 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) - + 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 + echo "ℹ️ No test projects found under ./tests — skipping test stage." + exit 0 fi echo "==========================================" @@ -941,11 +1015,16 @@ jobs: echo "Testing project: $test_proj" echo "==========================================" - # Extract target frameworks from the project file - # Support both (single) and (multiple) - # Only include .NET 6.0+ (ARM64 compatible on macOS) - # Normalize line endings to handle multi-line / elements - frameworks=$(tr -d '\n\r' < "$test_proj" | grep -Eo '[^<]+' | sed -E 's///' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) + # 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 (multiple) to (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)" @@ -966,6 +1045,7 @@ jobs: --configuration Release \ --framework "$fw" \ --collect:"XPlat Code Coverage" \ + --settings coverlet.runsettings \ --results-directory "./TestResults" \ --logger "console;verbosity=normal" || exit 1 done <<< "$frameworks" @@ -1088,10 +1168,9 @@ jobs: 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 analyzer references) are legitimate. The - # threat model is human PR authors disabling analyzers in their own PRs, - # not a trusted GitHub-controlled bot whose only action is package bumps. + # 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..." diff --git a/BannedSymbols.txt b/BannedSymbols.txt index 6680201..804798a 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -1,4 +1,4 @@ -# BannedSymbols.txt - Async-First Enforcement for Wolfgang.Extensions.IEnumerable +# BannedSymbols.txt - Async-First Enforcement for {{PROJECT_NAME}} # Format: ; # T: = Type, M: = Method, P: = Property, F: = Field # Task.Wait() - All overloads - Absolutely NOT allowed in async code From 3965c94871c26bffeb28fcf2c53e3047cec3d3f9 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 15:27:03 -0400 Subject: [PATCH 02/71] Hoist enable to Directory.Build.props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add enable to the root Directory.Build.props and remove the now-redundant per-project lines from every SDK-style csproj. Nullable reference types are configured in one place; a newly added project inherits the setting automatically. Both halves ride this protected branch so they merge atomically — the Directory.Build.props addition and the csproj removals land together, so there is never a window where nullable reference types are off. Legacy non-SDK project files do not import Directory.Build.props and are left untouched with their explicit settings. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 1 + .../Wolfgang.Extensions.IEnumerable.Benchmarks.csproj | 1 - examples/Do.Example/Do.Example.csproj | 1 - examples/ForEach.Example/ForEach.Example.csproj | 1 - examples/IsEmpty.Example/IsEmpty.Example.csproj | 1 - examples/IsNullOrEmpty.Example/IsNullOrEmpty.Example.csproj | 1 - examples/None.Example/None.Example.csproj | 1 - examples/Shuffle.Example/Shuffle.Example.csproj | 1 - .../Wolfgang.Extensions.IEnumerable.csproj | 1 - .../Wolfgang.Extensions.IEnumerable.Tests.Unit.csproj | 1 - 10 files changed, 1 insertion(+), 9 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 4dd8716..49b9b95 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,7 @@ latest + enable true diff --git a/benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks.csproj b/benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks.csproj index 0f6f16e..2f4371b 100644 --- a/benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks.csproj +++ b/benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks.csproj @@ -5,7 +5,6 @@ net8.0 14 - enable enable diff --git a/examples/Do.Example/Do.Example.csproj b/examples/Do.Example/Do.Example.csproj index 97c401d..9fdd1a3 100644 --- a/examples/Do.Example/Do.Example.csproj +++ b/examples/Do.Example/Do.Example.csproj @@ -4,7 +4,6 @@ Exe net8.0 latest - enable enable diff --git a/examples/ForEach.Example/ForEach.Example.csproj b/examples/ForEach.Example/ForEach.Example.csproj index 97c401d..9fdd1a3 100644 --- a/examples/ForEach.Example/ForEach.Example.csproj +++ b/examples/ForEach.Example/ForEach.Example.csproj @@ -4,7 +4,6 @@ Exe net8.0 latest - enable enable diff --git a/examples/IsEmpty.Example/IsEmpty.Example.csproj b/examples/IsEmpty.Example/IsEmpty.Example.csproj index 97c401d..9fdd1a3 100644 --- a/examples/IsEmpty.Example/IsEmpty.Example.csproj +++ b/examples/IsEmpty.Example/IsEmpty.Example.csproj @@ -4,7 +4,6 @@ Exe net8.0 latest - enable enable diff --git a/examples/IsNullOrEmpty.Example/IsNullOrEmpty.Example.csproj b/examples/IsNullOrEmpty.Example/IsNullOrEmpty.Example.csproj index 97c401d..9fdd1a3 100644 --- a/examples/IsNullOrEmpty.Example/IsNullOrEmpty.Example.csproj +++ b/examples/IsNullOrEmpty.Example/IsNullOrEmpty.Example.csproj @@ -4,7 +4,6 @@ Exe net8.0 latest - enable enable diff --git a/examples/None.Example/None.Example.csproj b/examples/None.Example/None.Example.csproj index 97c401d..9fdd1a3 100644 --- a/examples/None.Example/None.Example.csproj +++ b/examples/None.Example/None.Example.csproj @@ -4,7 +4,6 @@ Exe net8.0 latest - enable enable diff --git a/examples/Shuffle.Example/Shuffle.Example.csproj b/examples/Shuffle.Example/Shuffle.Example.csproj index 97c401d..9fdd1a3 100644 --- a/examples/Shuffle.Example/Shuffle.Example.csproj +++ b/examples/Shuffle.Example/Shuffle.Example.csproj @@ -4,7 +4,6 @@ Exe net8.0 latest - enable enable diff --git a/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj b/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj index 5891e9b..3cab894 100644 --- a/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj +++ b/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj @@ -2,7 +2,6 @@ net462;netstandard2.0;net8.0;net10.0 latest - enable 1.2.1 $(AssemblyName) Chris Wolfgang diff --git a/tests/Wolfgang.Extensions.IEnumerable.Tests.Unit/Wolfgang.Extensions.IEnumerable.Tests.Unit.csproj b/tests/Wolfgang.Extensions.IEnumerable.Tests.Unit/Wolfgang.Extensions.IEnumerable.Tests.Unit.csproj index 8ba8332..6e1eb79 100644 --- a/tests/Wolfgang.Extensions.IEnumerable.Tests.Unit/Wolfgang.Extensions.IEnumerable.Tests.Unit.csproj +++ b/tests/Wolfgang.Extensions.IEnumerable.Tests.Unit/Wolfgang.Extensions.IEnumerable.Tests.Unit.csproj @@ -5,7 +5,6 @@ 1.2.0 latest enable - enable false true Copyright 2026 Chris Wolfgang From 14c19cc0bcc7f72e422b2fcc59da05e8787c904c Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 15:54:49 -0400 Subject: [PATCH 03/71] Scope the nullable hoist so it cannot break legacy / F# projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy non-SDK .csproj files explicitly import Microsoft.Common.props, so they DO inherit Directory.Build.props — the earlier unconditional enable reached projects it should not have: * F# (.fsproj) / VB (.vbproj) projects — now excluded by conditioning the property on '$(MSBuildProjectExtension)' == '.csproj'. * legacy non-SDK C# example projects (C# 7.3, no nullable support) — given an explicit disable opt-out, restoring their pre-hoist state. These are the documented C5 carve-outs. SDK-style C# projects are unaffected — they still inherit enable from the single Directory.Build.props. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 49b9b95..317270d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ latest - enable + enable true From 6063abd2bdaf78c383839548780aaa9cb62d58f7 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 21:05:18 -0400 Subject: [PATCH 04/71] Enable CodeQL security-extended query pack (S1) Adds queries: security-extended to the CodeQL init step so the broader security query pack runs on top of the default queries. Slightly longer scans, materially more security coverage. Initiative S1. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/codeql.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 0165e39..ae5f1e3 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -80,6 +80,9 @@ jobs: uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} + # security-extended adds the broader security query pack on top of the + # default queries (more rules, slightly longer scans). + queries: security-extended - name: Setup .NET if: steps.check-csharp.outputs.has-csharp == 'true' From bcb404b93816ffdaa6ae4ea948e1ed811c1ba1cf Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 21:09:44 -0400 Subject: [PATCH 05/71] Add Stryker mutation-testing workflow (T3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New .github/workflows/stryker.yaml runs Stryker.NET against the repo's test projects on workflow_dispatch and a weekly schedule. The workflow is a no-op until a stryker-config.json is added at repo root or under tests// — this commit is the canonical infrastructure; per-repo Stryker config is the follow-up. Initiative T3. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/stryker.yaml | 80 ++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/stryker.yaml diff --git a/.github/workflows/stryker.yaml b/.github/workflows/stryker.yaml new file mode 100644 index 0000000..5309e9f --- /dev/null +++ b/.github/workflows/stryker.yaml @@ -0,0 +1,80 @@ +# Stryker mutation testing +# +# Runs the Stryker.NET mutation tester against the repo's test projects to +# measure mutation score. Mutation runs are slow — triggered manually +# (workflow_dispatch) and on a weekly schedule, not on every PR. +# +# The workflow looks for a stryker-config.json at the repo root or under +# tests/**/. If none is present the run is a no-op (Stryker setup is a +# per-repo follow-up; this file is the canonical infrastructure). +name: Stryker (mutation testing) + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 0' # weekly Sunday 06:00 UTC + +permissions: + contents: read + +jobs: + stryker: + name: Run Stryker + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Detect stryker-config.json + id: check + shell: bash + run: | + shopt -s globstar nullglob + configs=(stryker-config.json tests/**/stryker-config.json) + if (( ${#configs[@]} )); then + printf 'found=true\n' >> "$GITHUB_OUTPUT" + printf 'configs<> "$GITHUB_OUTPUT" + else + echo "::notice::No stryker-config.json found — skipping Stryker run. Add one at repo root or under tests// to enable mutation testing." + printf 'found=false\n' >> "$GITHUB_OUTPUT" + fi + + - name: Setup .NET + if: steps.check.outputs.found == 'true' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Install dotnet-stryker + if: steps.check.outputs.found == 'true' + run: dotnet tool install -g dotnet-stryker + + - name: Run Stryker + if: steps.check.outputs.found == 'true' + shell: bash + run: | + set -e + shopt -s globstar nullglob + if [ -f stryker-config.json ]; then + dotnet stryker --config-file stryker-config.json + else + for cfg in tests/**/stryker-config.json; do + dir=$(dirname "$cfg") + echo "::group::Stryker in $dir" + (cd "$dir" && dotnet stryker) + echo "::endgroup::" + done + fi + + - name: Upload Stryker report + if: always() && steps.check.outputs.found == 'true' + uses: actions/upload-artifact@v4 + with: + name: stryker-report-${{ github.run_id }} + path: | + **/StrykerOutput/** + if-no-files-found: ignore + retention-days: 30 From fb6ad11e9cc30bd61bbb6f1ebf79ee7d45aee94d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 21:18:06 -0400 Subject: [PATCH 06/71] Verify documentation builds before publishing release (D8) Add a verify-docs-build job to release.yaml that runs DocFX without deploying (metadata + build + output check). publish-nuget now needs [pack-and-validate, verify-docs-build] so a broken docs build blocks the release before the NuGet package goes live. If a repo has no docfx_project/docfx.json, the job no-ops with a notice; this is the canonical infrastructure, with per-repo docs coverage tracked separately. Initiative D8. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yaml | 70 +++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9a2ae63..5a26513 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -563,9 +563,77 @@ jobs: if-no-files-found: warn # Publish to NuGet (only if validation passed) + # Verify the documentation builds cleanly BEFORE publishing the release — + # initiative D8. Builds DocFX without deploying so a docs failure blocks + # publish-nuget rather than landing after the package is already live. + verify-docs-build: + name: Verify Documentation Builds + runs-on: windows-latest + if: github.repository != 'Chris-Wolfgang/repo-template' + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Detect docfx project + id: check + shell: pwsh + run: | + if (Test-Path "docfx_project/docfx.json") { + "found=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + } else { + Write-Host "::notice::No docfx_project/docfx.json - skipping docs verification." + "found=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + } + + - name: Setup .NET + if: steps.check.outputs.found == 'true' + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Restore dependencies + if: steps.check.outputs.found == 'true' + run: dotnet restore + + - name: Build solution (Release) + if: steps.check.outputs.found == 'true' + run: dotnet build --no-restore --configuration Release + + - name: Install DocFX + if: steps.check.outputs.found == 'true' + run: dotnet tool update docfx --global || dotnet tool install docfx --global + + - name: Build DocFX metadata + if: steps.check.outputs.found == 'true' + run: docfx metadata + working-directory: docfx_project + + - name: Build documentation (no deploy) + if: steps.check.outputs.found == 'true' + run: docfx build + working-directory: docfx_project + + - name: Verify documentation output + if: steps.check.outputs.found == 'true' + shell: pwsh + run: | + if (-Not (Test-Path "docfx_project/_site")) { + Write-Error "docfx_project/_site not found after build" + exit 1 + } + if (-Not (Test-Path "docfx_project/_site/api")) { + Write-Error "docfx_project/_site/api not found - API metadata generation may have failed" + exit 1 + } + Write-Host "Documentation built successfully - release may proceed" + publish-nuget: name: Publish to NuGet.org - needs: pack-and-validate + needs: [pack-and-validate, verify-docs-build] if: needs.pack-and-validate.outputs.has-packages == 'true' runs-on: windows-latest steps: From ab19f94258a34ea63e97eba4a7a0eae9f9e1dd80 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 13:25:30 -0400 Subject: [PATCH 07/71] Verify previous versions preserved in docfx versions.json (D6) Add a guard step to docfx.yaml that fetches the currently-deployed versions.json from gh-pages and confirms the newly-generated one has at least as many entries AND retains every previously-published version label. Aborts the deploy if the version selector would shrink or lose entries. If no existing versions.json is found (first deploy), the step no-ops with a notice. Initiative D6. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 3a36257..4abc4cd 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -225,6 +225,48 @@ jobs: Set-Content -Path 'docfx_project/_site/versions.json' -Encoding utf8NoBOM Write-Host "Generated versions.json with $($versions.Count) version(s): $($versions | ForEach-Object { $_.version })" + - name: Verify previous versions preserved in versions.json + # Initiative D6 — guard against accidentally wiping the version selector. + # Fetches the currently-deployed versions.json from gh-pages and confirms + # the newly-generated one has at least as many entries AND retains every + # previously-published version label. If anything shrunk or went missing, + # abort the deploy so the version selector cannot be wiped by accident. + shell: pwsh + run: | + $newPath = 'docfx_project/_site/versions.json' + if (-Not (Test-Path $newPath)) { + Write-Host "::notice::No new versions.json produced - skipping preservation check." + exit 0 + } + $existingUrl = "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/versions.json" + try { + $existingRaw = (Invoke-WebRequest -Uri $existingUrl -UseBasicParsing -ErrorAction Stop).Content + } catch { + Write-Host "::notice::No existing versions.json at $existingUrl - first deploy, skipping preservation check." + exit 0 + } + try { + $existing = $existingRaw | ConvertFrom-Json + $new = Get-Content $newPath -Raw | ConvertFrom-Json + } catch { + Write-Error "Failed to parse versions.json: $($_.Exception.Message)" + exit 1 + } + $existingCount = @($existing).Count + $newCount = @($new).Count + if ($newCount -lt $existingCount) { + Write-Error "versions.json would lose entries: existing=$existingCount, new=$newCount. Aborting deploy." + exit 1 + } + $existingVersions = @($existing) | ForEach-Object { $_.version } + $newVersions = @($new) | ForEach-Object { $_.version } + $missing = @($existingVersions | Where-Object { $_ -notin $newVersions }) + if ($missing.Count -gt 0) { + Write-Error "Previous versions missing from new versions.json: $($missing -join ', '). Aborting deploy." + exit 1 + } + Write-Host "versions.json preservation OK: existing=$existingCount, new=$newCount." + - name: Compute destination directory # Determines the versioned subfolder name for the docs deployment (e.g. /v1.2.3/). # Uses the explicit 'version' input when provided; otherwise falls back to From 7aebf153f6eff20f919730aa311f5231baf5d383 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 14:41:51 -0400 Subject: [PATCH 08/71] Scaffold PublicApiAnalyzers infrastructure (A1) Add the Microsoft.CodeAnalysis.PublicApiAnalyzers package plus opt-in AdditionalFiles globbing for PublicAPI.Shipped.txt / Unshipped.txt to the root Directory.Build.props. The AdditionalFiles use Exists() conditions, so the analyzer activates per-project only when those files are present. Library projects opt in by dropping the two text files into the src directory; test, example, and benchmark projects stay dormant. Per-repo enablement (populate Unshipped.txt with the current public API surface) is tracked as a separate follow-up maintenance issue. Initiative A1. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index 317270d..92e5df5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -58,4 +58,24 @@ + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + From b1b3e609d37a543465b1b4f5393b045d6d04c93d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 15:19:05 -0400 Subject: [PATCH 09/71] Canonical NuGet package metadata + SourceLink + symbol packages (CI3) Add fleet-canonical defaults to root Directory.Build.props: - Authors / Company / Copyright (uniform across the fleet; per-csproj values still win where set explicitly). - RepositoryType=git, PublishRepositoryUrl=true. - IncludeSymbols=true + SymbolPackageFormat=snupkg so .snupkg ships with every .nupkg. - EmbedUntrackedSources=true to capture generated sources in PDBs. - ContinuousIntegrationBuild=true under $(CI)=true (deterministic build flag, set by GitHub Actions). - Microsoft.SourceLink.GitHub package so debuggers can step from NuGet-installed code straight to GitHub source. Repo-specific NuGet fields (Description, PackageTags, PackageProjectUrl, RepositoryUrl, PackageLicenseExpression, PackageReadmeFile) stay in per-src csproj where they belong and are tracked as per-repo follow-up maintenance issues. Initiative CI3. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index 92e5df5..bcb3f16 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -78,4 +78,29 @@ + + + + Chris Wolfgang + Chris Wolfgang + Copyright (c) Chris Wolfgang + git + true + true + true + snupkg + true + + + + + + all + + + From da126a5d318e052ee7c370f74c090409b456f5b4 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 15:22:02 -0400 Subject: [PATCH 10/71] Publish code-coverage report to docs/coverage/ (T1) Add a docfx.yaml step that runs the test suite with Cobertura coverage collection and generates a ReportGenerator HTML report into docfx_project/_site/coverage/ before the deploy step. The published docs site gains a /coverage/ subpath alongside the existing /api/. continue-on-error keeps a coverage failure from blocking the docs deploy; if no test projects are present the step no-ops. Initiative T1. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 4abc4cd..67442aa 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -101,6 +101,30 @@ jobs: Get-ChildItem "docfx_project/_site/api" shell: pwsh + - name: Generate code-coverage report into docs site (T1) + # Initiative T1 — publish coverage report to gh-pages alongside docs. + # Runs the test suite with Cobertura coverage collection, then uses + # ReportGenerator to render an HTML report into _site/coverage/. + # The published docs site gains a /coverage/ subpath. + # continue-on-error means a coverage failure (no tests, flaky tests, + # report generation issues) does not block the docs deploy. + if: hashFiles('tests/**/*.csproj') != '' + continue-on-error: true + shell: pwsh + run: | + dotnet test --configuration Release --no-build --collect:"XPlat Code Coverage" --results-directory ./coverage-raw 2>&1 | Out-Host + dotnet tool install -g dotnet-reportgenerator-globaltool 2>$null + $coverageFiles = @(Get-ChildItem -Path ./coverage-raw -Recurse -Filter "coverage.cobertura.xml" -ErrorAction SilentlyContinue) + if ($coverageFiles.Count -eq 0) { + Write-Host "::notice::No coverage files generated - skipping coverage report step" + exit 0 + } + $reports = ($coverageFiles | ForEach-Object { $_.FullName }) -join ';' + $outDir = "docfx_project/_site/coverage" + New-Item -ItemType Directory -Force -Path $outDir | Out-Null + reportgenerator "-reports:$reports" "-targetdir:$outDir" "-reporttypes:Html;TextSummary" + Write-Host "Coverage report written to $outDir" + - name: Generate versions.json # Produces versions.json consumed by the DocFX version-switcher dropdown. # Site layout: From 705d4fde99cc65fdfb0ed83ba5becf296736c0c0 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 15:37:47 -0400 Subject: [PATCH 11/71] =?UTF-8?q?Add=20BenchmarkDotNet=20=E2=86=92=20gh-pa?= =?UTF-8?q?ges=20chart=20workflow=20(P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/benchmarks.yaml that runs BenchmarkDotNet (ShortRun) after every push to main and publishes results to the gh-pages branch under dev/bench/, rendered as an interactive line chart by benchmark-action/github-action-benchmark. Decoupled from PR validation and release — this workflow does not gate merges or publishes; it just appends one data point per push. The canonical docfx.yaml already preserves dev/ in its gh-pages cleanup allow-list, so docs deploys will not wipe the chart history. Initiative P2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/benchmarks.yaml | 86 +++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/benchmarks.yaml diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml new file mode 100644 index 0000000..1e109be --- /dev/null +++ b/.github/workflows/benchmarks.yaml @@ -0,0 +1,86 @@ +name: Benchmarks + +# Runs BenchmarkDotNet after a merge to main, then publishes the results to +# the gh-pages branch under /dev/bench/ where benchmark-action/github-action-benchmark +# renders an interactive line chart. +# +# This workflow is decoupled from PR validation and release: it does NOT gate +# merging or publishing — it just adds one data point per qualifying push. + +on: + push: + branches: [main] + paths: + - 'src/**' + - 'benchmarks/**' + - '.github/workflows/benchmarks.yaml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + benchmark: + name: Run BenchmarkDotNet & publish chart + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + + - name: Restore + run: dotnet restore benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks.csproj + + - name: Build (Release) + run: dotnet build -c Release --no-restore benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks.csproj + + - name: Run benchmarks + working-directory: benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks + run: dotnet run -c Release --no-build -- --filter "*" --job short --memory --exporters json + + - name: Merge per-class BDN reports + id: locate + working-directory: benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks + run: | + shopt -s nullglob + reports=(BenchmarkDotNet.Artifacts/results/*-report-full-compressed.json) + if [ ${#reports[@]} -eq 0 ]; then + echo "::error::No BenchmarkDotNet JSON report found" + exit 1 + fi + echo "Merging ${#reports[@]} report(s):" + printf ' %s\n' "${reports[@]}" + jq -s '{ + Title: "BenchmarkDotNet combined report", + HostEnvironmentInfo: .[0].HostEnvironmentInfo, + Benchmarks: [.[].Benchmarks[]] + }' "${reports[@]}" > benchmarks-result.json + echo "report=benchmarks/Wolfgang.Extensions.IEnumerable.Benchmarks/benchmarks-result.json" >> "$GITHUB_OUTPUT" + + - name: Publish chart to gh-pages + uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 + with: + name: BenchmarkDotNet + tool: 'benchmarkdotnet' + output-file-path: ${{ steps.locate.outputs.report }} + gh-pages-branch: gh-pages + benchmark-data-dir-path: dev/bench + auto-push: true + github-token: ${{ secrets.GITHUB_TOKEN }} + alert-threshold: '200%' + fail-on-alert: false From 34c861cd08654ce4a000d2ec657d8a0049c4f26e Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 17:55:32 -0400 Subject: [PATCH 12/71] Address PR review round 2 (canonical-protected) - BannedSymbols.txt: replace {{PROJECT_NAME}} placeholder with the repo's package name (skipped on repo-template where the placeholder is the intended template artifact). - docfx.yaml: 'exit 1' inside the deploy try-block changed to 'throw' so the outer finally always unsets the global http.extraheader token; added $LASTEXITCODE checks after git fetch / git worktree add / git init / git remote add so a setup failure surfaces a clear error. Fan-out of the round-2 Copilot fixes verified against DateTime-Extensions (#178 / #179 pilot). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 13 +++++++++---- BannedSymbols.txt | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 67442aa..9ef3730 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -366,17 +366,20 @@ jobs: # an existing gh-pages branch. $branchExists = git ls-remote --heads origin gh-pages if ($LASTEXITCODE -ne 0) { - Write-Error "git ls-remote --heads origin gh-pages failed with exit code $LASTEXITCODE — aborting before we accidentally bootstrap over an existing gh-pages branch." - exit 1 + # `throw` (not `exit 1`) so the outer try/finally cleanup runs and + # the global http.extraheader auth header is always unset. + throw "git ls-remote --heads origin gh-pages failed with exit code $LASTEXITCODE — aborting before we accidentally bootstrap over an existing gh-pages branch." } $useWorktree = [bool]$branchExists if ($useWorktree) { git fetch origin gh-pages + if ($LASTEXITCODE -ne 0) { throw "git fetch origin gh-pages failed with exit code $LASTEXITCODE" } git show-ref --verify --quiet refs/heads/gh-pages if ($LASTEXITCODE -ne 0) { git branch gh-pages origin/gh-pages } git worktree remove $WORK_DIR --force 2>&1 | Out-Null if (Test-Path -LiteralPath $WORK_DIR) { Remove-Item -LiteralPath $WORK_DIR -Recurse -Force } git worktree add $WORK_DIR gh-pages + if ($LASTEXITCODE -ne 0) { throw "git worktree add $WORK_DIR gh-pages failed with exit code $LASTEXITCODE" } } else { Write-Host "ℹ️ gh-pages does not exist yet — starting fresh." New-Item -ItemType Directory -Force -Path $WORK_DIR | Out-Null @@ -385,7 +388,9 @@ jobs: # Auth is provided by the global http.extraheader configured above, # so the remote URL does not embed the token. git -C $WORK_DIR init --initial-branch=gh-pages + if ($LASTEXITCODE -ne 0) { throw "git init in $WORK_DIR failed with exit code $LASTEXITCODE" } git -C $WORK_DIR remote add origin "https://github.com/$($env:GITHUB_REPOSITORY).git" + if ($LASTEXITCODE -ne 0) { throw "git remote add origin failed with exit code $LASTEXITCODE" } } # Remove stale root files; preserve versions/, .git, .nojekyll, CNAME, dev @@ -426,8 +431,8 @@ jobs: $listHtml = $listItems -join "`n" if (-not (Test-Path '.github/version-picker-template.html')) { - Write-Error "Error: .github/version-picker-template.html not found; cannot generate root index.html." - exit 1 + # `throw` (not `exit 1`) so the outer try/finally cleanup runs. + throw ".github/version-picker-template.html not found; cannot generate root index.html." } $template = Get-Content '.github/version-picker-template.html' -Raw diff --git a/BannedSymbols.txt b/BannedSymbols.txt index 804798a..6680201 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -1,4 +1,4 @@ -# BannedSymbols.txt - Async-First Enforcement for {{PROJECT_NAME}} +# BannedSymbols.txt - Async-First Enforcement for Wolfgang.Extensions.IEnumerable # Format: ; # T: = Type, M: = Method, P: = Property, F: = Field # Task.Wait() - All overloads - Absolutely NOT allowed in async code From f10c6fca03326c9d8c50e5657eec14230a002a33 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 18:00:06 -0400 Subject: [PATCH 13/71] Address PR review round 2 (t3-stryker) - .github/workflows/stryker.yaml: replace literal-in-array config detection with explicit [ -f ] checks. nullglob only drops words that look like globs (contain *, ?, [); the bare literal 'stryker-config.json' was preserved unconditionally, so the workflow would mark found=true and attempt to install Stryker even on repos with no config. - actions/checkout@v4 -> @v6 and setup-dotnet@v4 -> @v5 for consistency with the rest of the fleet's workflows. Fan-out of the round-2 Copilot fixes verified against DateTime-Extensions (#181 pilot). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/stryker.yaml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stryker.yaml b/.github/workflows/stryker.yaml index 5309e9f..62ab03f 100644 --- a/.github/workflows/stryker.yaml +++ b/.github/workflows/stryker.yaml @@ -24,14 +24,23 @@ jobs: timeout-minutes: 60 steps: - name: Check out repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Detect stryker-config.json id: check shell: bash run: | + # Explicit existence checks rather than globbing — `nullglob` only + # drops words that look like patterns (contain *, ?, [). The bare + # literal `stryker-config.json` has no glob characters, so it would + # be preserved as a literal even when the file doesn't exist, and + # the workflow would mistakenly think a config was present. shopt -s globstar nullglob - configs=(stryker-config.json tests/**/stryker-config.json) + configs=() + [ -f stryker-config.json ] && configs+=(stryker-config.json) + for cfg in tests/**/stryker-config.json; do + [ -f "$cfg" ] && configs+=("$cfg") + done if (( ${#configs[@]} )); then printf 'found=true\n' >> "$GITHUB_OUTPUT" printf 'configs<> "$GITHUB_OUTPUT" @@ -42,7 +51,7 @@ jobs: - name: Setup .NET if: steps.check.outputs.found == 'true' - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 8.0.x From 03db5c0d21293ea4635ffbd6e8e7e04c096624bd Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 10:13:55 -0400 Subject: [PATCH 14/71] Tighten condition to SDK-style C# projects only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add '$(UsingMicrosoftNETSdk)' == 'true' to the existing .csproj-only condition so the property only applies to SDK-style projects. Legacy non-SDK csproj files no longer pick up enable by inheritance; they would have to opt in explicitly. Verified safe by piloting on IComparable-Extensions — full Release build + 540-test run (54 tests x 10 TFMs) passed cleanly with the tightened condition. Resolves the PR-#390 review thread that asked for this addition. The per-repo explicit disable opt-outs already in place on Try-Pattern (examples/CSharp.DotNet462.Example) and D20-Dice (examples/Net4.8/Example1-Console) become redundant but harmless under this condition — they can be cleaned up later, or left as belt-and- suspenders documentation. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index bcb3f16..52bf60d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ latest - enable + enable true From 4ccb20297795abdc13a85d30b3edd61e0e2135bc Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 10:31:22 -0400 Subject: [PATCH 15/71] Add SDK-aware explanatory comment to property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the canonical Directory.Build.props in line with repo-template and D20-Dice, which carry an explanatory comment above the line describing what the .csproj + $(UsingMicrosoftNETSdk) condition excludes and where the remaining opt-out path lives. No behavior change — comment only. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index 52bf60d..f217f94 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,13 @@ latest + enable From a14dd19e33846db50888f0d82ecb745a9bfe641c Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 10:47:45 -0400 Subject: [PATCH 16/71] Pin stryker.yaml's upload-artifact to v7 to match rest of fleet T3's stryker.yaml was cherry-picked carrying actions/upload-artifact@v4, while pr.yaml/release.yaml/codeql.yaml all use @v7. Bumping for consistency with the fleet's canonical action versions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/stryker.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stryker.yaml b/.github/workflows/stryker.yaml index 62ab03f..ef29f50 100644 --- a/.github/workflows/stryker.yaml +++ b/.github/workflows/stryker.yaml @@ -80,7 +80,7 @@ jobs: - name: Upload Stryker report if: always() && steps.check.outputs.found == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stryker-report-${{ github.run_id }} path: | From 8d26fea07421354c1160fc5f9add104f60d9332e Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 10:53:37 -0400 Subject: [PATCH 17/71] Drop -UseBasicParsing from Invoke-WebRequest in docfx.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag is a Windows-PowerShell-5.1-only switch; pwsh (PowerShell 7+) treats it as unsupported and errors. The step runs under shell: pwsh, so the call must omit it. (Already correct in the alternate path elsewhere in this workflow — bringing this one in line.) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 9ef3730..2681a34 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -264,7 +264,7 @@ jobs: } $existingUrl = "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/versions.json" try { - $existingRaw = (Invoke-WebRequest -Uri $existingUrl -UseBasicParsing -ErrorAction Stop).Content + $existingRaw = (Invoke-WebRequest -Uri $existingUrl -ErrorAction Stop).Content } catch { Write-Host "::notice::No existing versions.json at $existingUrl - first deploy, skipping preservation check." exit 0 From 482cea42bc2d9a3ab92f5afaa1068fea3e483957 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 10:57:54 -0400 Subject: [PATCH 18/71] Make ReportGenerator install idempotent in docfx.yaml T1 step `dotnet tool install -g` errors with a non-zero exit code if the tool is already installed (common on self-hosted runners and after prior steps). Even with stderr redirected, the exit code can break subsequent invocations. Switch to update-or-install: try update first (succeeds if installed), fall back to install if not. The step runs under shell: pwsh so the pwsh 7 || operator is available. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 2681a34..8feb098 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -113,7 +113,7 @@ jobs: shell: pwsh run: | dotnet test --configuration Release --no-build --collect:"XPlat Code Coverage" --results-directory ./coverage-raw 2>&1 | Out-Host - dotnet tool install -g dotnet-reportgenerator-globaltool 2>$null + dotnet tool update -g dotnet-reportgenerator-globaltool 2>$null || dotnet tool install -g dotnet-reportgenerator-globaltool 2>$null $coverageFiles = @(Get-ChildItem -Path ./coverage-raw -Recurse -Filter "coverage.cobertura.xml" -ErrorAction SilentlyContinue) if ($coverageFiles.Count -eq 0) { Write-Host "::notice::No coverage files generated - skipping coverage report step" From 13e5b12752c3859726dfe6ad597a5930a747417a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 21:41:51 -0400 Subject: [PATCH 19/71] Align .gitattributes *.ps1 with .editorconfig (LF, not CRLF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .editorconfig dropped the [*.ps1] override on the rationale that pwsh shebangs (#!/usr/bin/env pwsh) on scripts under scripts/ require LF + no-BOM to be recognized by the Linux/macOS kernel. .gitattributes still forced *.ps1 to CRLF, with a comment claiming the two files matched — that comment is now stale, and editors honoring .editorconfig would format LF while git normalized to CRLF on commit. Switching .gitattributes to eol=lf for *.ps1 and updating the comment. .editorconfig [*] section already provides LF + UTF-8 (no BOM) + 4-space indent for PowerShell scripts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitattributes | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.gitattributes b/.gitattributes index 85e6969..8e85f11 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,11 +9,12 @@ *.fsx text eol=lf # Scripts -# PowerShell scripts: CRLF line endings (intentional override) -# Both .gitattributes and .editorconfig consistently configure PowerShell files -# to use CRLF (Windows-style) line endings for PowerShell convention compliance. -# See .editorconfig [*.ps1] section for the matching configuration. -*.ps1 text eol=crlf +# PowerShell scripts: LF line endings (no override beyond the * text=auto eol=lf above). +# Required so the `#!/usr/bin/env pwsh` shebang on scripts under scripts/ works +# on Linux/macOS — CR breaks the kernel's exec lookup, and a leading BOM +# prevents shebang recognition entirely. Matches the .editorconfig [*] section, +# which intentionally has no [*.ps1] override. +*.ps1 text eol=lf # Build and configuration files From aa0df7a5b5ae78e561c69dba72b8f978fead05b2 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 21:45:08 -0400 Subject: [PATCH 20/71] Add coverlet.runsettings to match repo-template The pr.yaml workflow passes --settings coverlet.runsettings to dotnet test but this file was missing from the repo. CI would fail with 'settings file not found' on every test step. Copying the canonical file from repo-template (Cobertura format, excludes bin/obj/*.cs and ExcludeFromCodeCoverageAttribute). Co-Authored-By: Claude Opus 4.7 (1M context) --- coverlet.runsettings | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 coverlet.runsettings diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..87e8fe4 --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,14 @@ + + + + + + + cobertura + **/bin/**/*.cs;**/obj/**/*.cs + ExcludeFromCodeCoverageAttribute + + + + + From d2a81a04bfe47e6acf933d6c6d10304a270a4a1d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 21:46:58 -0400 Subject: [PATCH 21/71] Detect .slnx solutions in build-all-versions.yaml Get-ChildItem -Filter '*.sln' missed .slnx solutions, causing the restore/build warm-up to silently skip in repos using the newer solution format (e.g. IComparable-Extensions). Switching to a filter that accepts both extensions so DocFX gets a compiled solution to extract metadata from. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-all-versions.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-all-versions.yaml b/.github/workflows/build-all-versions.yaml index 890b5ae..21cb479 100644 --- a/.github/workflows/build-all-versions.yaml +++ b/.github/workflows/build-all-versions.yaml @@ -119,7 +119,7 @@ jobs: try { # Attempt dotnet restore + build; failures are non-fatal because # DocFX can still extract metadata from source files. - $slnFile = Get-ChildItem -Filter '*.sln' -ErrorAction SilentlyContinue | + $slnFile = Get-ChildItem -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.sln','.slnx' } | Select-Object -First 1 if ($slnFile) { Write-Host "Restoring $($slnFile.Name)..." @@ -207,7 +207,7 @@ jobs: Push-Location $latestWorkDir try { - $slnFile = Get-ChildItem -Filter '*.sln' -ErrorAction SilentlyContinue | + $slnFile = Get-ChildItem -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.sln','.slnx' } | Select-Object -First 1 if ($slnFile) { Write-Host "Restoring $($slnFile.Name)..." From 166545f7852e5f3d239fad6f02db6a5a27c8034f Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 21:50:29 -0400 Subject: [PATCH 22/71] Filter versions.json to tags whose docs were actually built build-all-versions.yaml built versions.json from every SemVer tag in the repo, regardless of whether the per-tag build succeeded. A failed worktree add or empty DocFX output would silently leave the version-picker linking to /versions// paths that never existed on gh-pages. Now filter $orderedTags against the directories actually present under $outDir/versions/. Missing tags get a ::notice:: log entry so the skip is visible in workflow output, but versions.json only references real paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-all-versions.yaml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-all-versions.yaml b/.github/workflows/build-all-versions.yaml index 21cb479..c891c94 100644 --- a/.github/workflows/build-all-versions.yaml +++ b/.github/workflows/build-all-versions.yaml @@ -285,9 +285,22 @@ jobs: Sort-Object -Property Major, Minor, Patch, Stable -Descending | Select-Object -ExpandProperty Tag + # Only emit version-picker entries for tags whose docs were actually + # built and copied under $outDir/versions//. Skipped tags (worktree + # add failed, DocFX produced no _site, etc.) would otherwise appear in + # versions.json as links to 404s on gh-pages. + $versionsDir = Join-Path $outDir 'versions' + $builtTags = if (Test-Path $versionsDir) { + Get-ChildItem -Path $versionsDir -Directory | Select-Object -ExpandProperty Name + } else { @() } + [array]$versions = @([PSCustomObject]@{ version = 'latest'; url = "${base}versions/latest/" }) foreach ($t in $orderedTags) { - $versions += [PSCustomObject]@{ version = $t; url = "${base}versions/$t/" } + if ($builtTags -contains $t) { + $versions += [PSCustomObject]@{ version = $t; url = "${base}versions/$t/" } + } else { + Write-Host "::notice::Skipping versions.json entry for $t — no built docs under versions/$t/" + } } $versionsJson = ConvertTo-Json -InputObject $versions -Depth 3 From c13a2d6dd2db0a7fc634119f258b16ba266a30a9 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 21:52:45 -0400 Subject: [PATCH 23/71] D6 preservation guard: only treat 404 as first deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch block was treating ANY Invoke-WebRequest failure as "first deploy, skipping preservation check" — transient network/DNS/Pages outages, auth issues, redirect loops, etc. all silently bypassed the safety check, defeating its purpose. A deploy that drops versions from the picker could slip through any of those scenarios. Now inspect $_.Exception.Response.StatusCode: - 404 → genuine "first deploy" case, skip preservation check (exit 0) - anything else → abort the deploy (exit 1) so a real issue surfaces instead of silently weakening the guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 8feb098..8d6c14f 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -266,8 +266,18 @@ jobs: try { $existingRaw = (Invoke-WebRequest -Uri $existingUrl -ErrorAction Stop).Content } catch { - Write-Host "::notice::No existing versions.json at $existingUrl - first deploy, skipping preservation check." - exit 0 + # Only treat a true 404 as "first deploy". Other errors (network, + # DNS, Pages outage, auth/redirect) must NOT silently bypass the + # preservation check — they could let a deploy that drops version + # entries from the picker slip through. + $status = $null + if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } + if ($status -eq 404) { + Write-Host "::notice::No existing versions.json at $existingUrl (404) - first deploy, skipping preservation check." + exit 0 + } + Write-Error "Failed to fetch existing versions.json from $existingUrl (status=$status): $($_.Exception.Message). Aborting deploy to avoid masking a transient error." + exit 1 } try { $existing = $existingRaw | ConvertFrom-Json From 56c706ad68665828c40121ff80ce3b0970c73460 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:01:43 -0400 Subject: [PATCH 24/71] Disable persisted credentials in stryker.yaml checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the rest of the fleet's workflows. Stryker only reads repo contents — no push or write access is needed. Without persist-credentials: false, actions/checkout leaves the GITHUB_TOKEN configured in the runner's git config for subsequent steps, which is an unnecessary credential-in-config surface for a read-only workflow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/stryker.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/stryker.yaml b/.github/workflows/stryker.yaml index ef29f50..932531a 100644 --- a/.github/workflows/stryker.yaml +++ b/.github/workflows/stryker.yaml @@ -25,6 +25,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@v6 + with: + persist-credentials: false - name: Detect stryker-config.json id: check From c11bc0b225f83822dbd8f10bf93c25083d235875 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:05:00 -0400 Subject: [PATCH 25/71] Make dotnet-stryker install idempotent in stryker.yaml dotnet tool install -g errors with a non-zero exit code if the tool is already present (cached runners, re-runs, future runner images with preinstalled tools). Use update-or-install so the step is idempotent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/stryker.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stryker.yaml b/.github/workflows/stryker.yaml index 932531a..04abf24 100644 --- a/.github/workflows/stryker.yaml +++ b/.github/workflows/stryker.yaml @@ -61,7 +61,7 @@ jobs: - name: Install dotnet-stryker if: steps.check.outputs.found == 'true' - run: dotnet tool install -g dotnet-stryker + run: dotnet tool update -g dotnet-stryker || dotnet tool install -g dotnet-stryker - name: Run Stryker if: steps.check.outputs.found == 'true' From 99bf028c2b1d699468b46026a3698c8ba998cb80 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:10:05 -0400 Subject: [PATCH 26/71] Gate dotnet workload restore on workload-bearing TFM detection `dotnet workload restore` was running unconditionally in every pr.yaml test job and codeql.yaml even for repos that have no workload TFMs (no *-android/-ios/-maccatalyst/-maui/-tvos/-tizen/-browser/-windows). On pure-library repos that added ~5-15s of network-dependent setup and an extra failure mode for zero benefit. Now detect workload-bearing TFMs in any csproj first and skip the restore entirely when none exist. Repos that legitimately need workloads (e.g. Hawsey with MAUI Android/iOS targets) still get the install. Also quote $solution.FullName in codeql.yaml so a checkout path containing spaces no longer splits the argument. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/codeql.yaml | 14 ++++++++++---- .github/workflows/pr.yaml | 36 +++++++++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index ae5f1e3..5bb7e9a 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -94,13 +94,19 @@ jobs: if: steps.check-csharp.outputs.has-csharp == 'true' shell: pwsh run: | - # Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads - # via their TFMs (e.g. net10.0-android). Install whatever the solution needs - # before restore. For pure libraries this is a fast no-op. + # Skip entirely if no csproj declares a workload-bearing TFM (android/ios/ + # maccatalyst/maui/tvos/tizen/browser/windows). Saves ~5-15s on pure-library + # repos and removes a network-dependent failure mode. + $hasWorkloadTfm = @(Get-ChildItem -Recurse -Filter *.csproj | + Select-String -Pattern 'net\d+\.\d+-(android|ios|maccatalyst|maui|tvos|tizen|browser|windows)' -List).Count -gt 0 + if (-not $hasWorkloadTfm) { + Write-Host "No workload-bearing TFMs — skipping dotnet workload restore" + exit 0 + } $solution = Get-ChildItem -Path . -Recurse -Depth 2 -Include "*.sln", "*.slnx" | Select-Object -First 1 if ($solution) { Write-Host "Restoring workloads for $($solution.FullName)" - dotnet workload restore $solution.FullName + dotnet workload restore "$($solution.FullName)" } else { Write-Host "No solution found; restoring workloads for all projects" dotnet workload restore diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b83c648..a5dc22b 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -304,8 +304,16 @@ jobs: - 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 libraries it's a fast no-op. - run: dotnet workload restore + # 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|windows)' {} \; | 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: | @@ -635,8 +643,16 @@ jobs: - 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 libraries it's a fast no-op. - run: dotnet workload restore + # 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|windows)' {} \; | 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 @@ -889,8 +905,16 @@ jobs: - 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 libraries it's a fast no-op. - run: dotnet workload restore + # 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|windows)' {} \; | 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: | From 1d90e13b62580dcbb7068b7b220acad87f871e63 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:13:42 -0400 Subject: [PATCH 27/71] release.yaml: explicit Out-File -Encoding utf8 + DocFX shell: pwsh Two small consistency/safety fixes in verify-docs-build: - The docfx detect step's two Out-File calls were missing -Encoding utf8 (other Out-File writes to GITHUB_OUTPUT in this workflow specify it). On Windows PowerShell 5.1 the default is UTF-16 which GITHUB_OUTPUT parses incorrectly. pwsh 7+ defaults to UTF-8 so this works today, but explicit matches the rest of the workflow and removes the risk. - Install DocFX step had no explicit shell, leaving the || operator's semantics dependent on the runner's default (which is pwsh on windows-latest, but being explicit removes ambiguity for any future runner image change). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5a26513..36f3c65 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -581,10 +581,10 @@ jobs: shell: pwsh run: | if (Test-Path "docfx_project/docfx.json") { - "found=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "found=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 } else { Write-Host "::notice::No docfx_project/docfx.json - skipping docs verification." - "found=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "found=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 } - name: Setup .NET @@ -605,6 +605,7 @@ jobs: - name: Install DocFX if: steps.check.outputs.found == 'true' + shell: pwsh run: dotnet tool update docfx --global || dotnet tool install docfx --global - name: Build DocFX metadata From c267c0d854ccb54454ed3d79e9cdde5ec4a80757 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:17:43 -0400 Subject: [PATCH 28/71] docfx.yaml T1 coverage: pass --settings coverlet.runsettings The docs-site coverage report (rendered by ReportGenerator into docfx_project/_site/coverage/) was being generated without the runsettings file that pr.yaml uses for its coverage gate. As a result, the two coverage reports could disagree on excluded files (bin/obj/*.cs, ExcludeFromCodeCoverageAttribute), confusing readers who compare them. Align with pr.yaml's invocation so coverage exclusions are consistent across the PR gate and the published docs report. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 8d6c14f..c456b68 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -112,7 +112,7 @@ jobs: continue-on-error: true shell: pwsh run: | - dotnet test --configuration Release --no-build --collect:"XPlat Code Coverage" --results-directory ./coverage-raw 2>&1 | Out-Host + dotnet test --configuration Release --no-build --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage-raw 2>&1 | Out-Host dotnet tool update -g dotnet-reportgenerator-globaltool 2>$null || dotnet tool install -g dotnet-reportgenerator-globaltool 2>$null $coverageFiles = @(Get-ChildItem -Path ./coverage-raw -Recurse -Filter "coverage.cobertura.xml" -ErrorAction SilentlyContinue) if ($coverageFiles.Count -eq 0) { From 230a4dd278fff21b08b8ce20b190664f89e9cb94 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:21:09 -0400 Subject: [PATCH 29/71] Gate verify-docs-build on validate-release + pack-and-validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-docs-build had no `needs:`, so it ran in parallel with validate-release and pack-and-validate. When either of those failed, verify-docs-build still spun up a Windows runner and burned ~5-10 minutes building docs that would never ship — publish-nuget already declares both as prerequisites and skips on failure. Adding needs: [validate-release, pack-and-validate] makes the docs verification skip on a failed release, matching publish-nuget's gate and saving runner time on failure paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 36f3c65..c4cba1e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -569,6 +569,9 @@ jobs: verify-docs-build: name: Verify Documentation Builds runs-on: windows-latest + # Gate on the prior validation jobs so we don't burn ~5-10 min of Windows + # runner time on a release that's already failing earlier in the pipeline. + needs: [validate-release, pack-and-validate] if: github.repository != 'Chris-Wolfgang/repo-template' steps: - name: Checkout code From 85c732c648ac8f26f1cdb81bfd21a146696c010d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:24:22 -0400 Subject: [PATCH 30/71] Gate gh-pages root cleanup on DEPLOY_AS_LATEST=true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy step wiped the gh-pages site root (everything except .git/CNAME/.nojekyll/versions/dev) unconditionally, but only repopulated the root (version-picker index.html, root versions.json, shared assets) when DEPLOY_AS_LATEST=true. Result: rebuilding an older version with deploy_as_latest=false — the documented use case in the workflow_dispatch UI — silently stripped the site root and left only the versioned subdirectories accessible. The root version picker, root index.html, and shared assets would disappear. Now the cleanup runs only when DEPLOY_AS_LATEST=true (the same branch that repopulates). Non-latest rebuilds preserve the existing root, matching the workflow's documented intent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index c456b68..6908fa9 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -403,12 +403,20 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "git remote add origin failed with exit code $LASTEXITCODE" } } - # Remove stale root files; preserve versions/, .git, .nojekyll, CNAME, dev + # Remove stale root files; preserve versions/, .git, .nojekyll, CNAME, dev. # ('dev' is where benchmark-action/github-action-benchmark stores its # chart + accumulated data.js — wiping it loses chart history on every release.) - Get-ChildItem -Path $WORK_DIR -Force | Where-Object { - $_.Name -notin @('.git', 'CNAME', '.nojekyll', 'versions', 'dev') - } | Remove-Item -Recurse -Force + # Gated on DEPLOY_AS_LATEST=true because the root is only repopulated + # (version-picker index.html, root versions.json, shared assets) in that + # branch below — a rebuild of an older version (deploy_as_latest=false) + # would otherwise strip the root and leave only /versions// paths. + if ($env:DEPLOY_AS_LATEST -eq 'true') { + Get-ChildItem -Path $WORK_DIR -Force | Where-Object { + $_.Name -notin @('.git', 'CNAME', '.nojekyll', 'versions', 'dev') + } | Remove-Item -Recurse -Force + } else { + Write-Host "Skipping root cleanup — DEPLOY_AS_LATEST is not 'true', preserving existing site root." + } # Ensure .nojekyll exists so GitHub Pages does not run Jekyll New-Item -ItemType File -Path (Join-Path $WORK_DIR '.nojekyll') -Force | Out-Null From 079209c6456dfefb0452c11a76eebc4d5bb0ec26 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:30:15 -0400 Subject: [PATCH 31/71] Drop -windows from workload-TFM detection regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit netX.Y-windows[.x] TFMs (WPF / WinForms / WindowsDesktop) use SDK-bundled projection assemblies, not the .NET workload installer — `dotnet workload restore` has no work to do for them. Including `windows` in the detection regex caused the workload restore to run on every WPF/WinForms repo despite providing no value (defeating the gating I just added). Removing `windows` so only the genuinely workload-bearing TFMs trigger the install: android, ios, maccatalyst, maui, tvos, tizen, browser. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/codeql.yaml | 2 +- .github/workflows/pr.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 5bb7e9a..d7d4662 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -98,7 +98,7 @@ jobs: # maccatalyst/maui/tvos/tizen/browser/windows). Saves ~5-15s on pure-library # repos and removes a network-dependent failure mode. $hasWorkloadTfm = @(Get-ChildItem -Recurse -Filter *.csproj | - Select-String -Pattern 'net\d+\.\d+-(android|ios|maccatalyst|maui|tvos|tizen|browser|windows)' -List).Count -gt 0 + Select-String -Pattern 'net\d+\.\d+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' -List).Count -gt 0 if (-not $hasWorkloadTfm) { Write-Host "No workload-bearing TFMs — skipping dotnet workload restore" exit 0 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index a5dc22b..eaa7566 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -308,7 +308,7 @@ jobs: # 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|windows)' {} \; | grep -q .; then + 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 @@ -647,7 +647,7 @@ jobs: # 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|windows)' {} \; | grep -q .; then + 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 @@ -909,7 +909,7 @@ jobs: # 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|windows)' {} \; | grep -q .; then + 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 From 217f3ee4014fc7e0cb129b99a37aa2bdc938dab5 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:36:21 -0400 Subject: [PATCH 32/71] verify-docs-build: install full SDK set to match validate-release verify-docs-build was installing only .NET 8 and .NET 10 SDKs, but it runs `dotnet restore` + `dotnet build` on the full solution. In repos whose test projects multi-target older TFMs (netcoreapp3.1, net5/6/7/9), the build fails because the required SDK isn't installed. validate-release already installs the broad SDK set (3.1 through 10); aligning verify-docs-build to match removes the inconsistency and lets the docs verify step build whatever validate-release just built. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c4cba1e..ac52bd9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -594,8 +594,17 @@ jobs: if: steps.check.outputs.found == 'true' uses: actions/setup-dotnet@v5 with: + # Install the same SDK set as validate-release so dotnet build can + # compile every TFM the solution targets (some test projects span + # netcoreapp3.1 → net10.0). Without these, the docs verify step + # fails on repos with broad multi-targeting. 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 From 45ccbf9ce1caf1f6c62324462f684d5e2758be67 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:41:27 -0400 Subject: [PATCH 33/71] D6 guard: skip on dry-runs (inputs.deploy_to_pages == false) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Verify previous versions preserved in versions.json" step ran even when this reusable workflow was invoked as a dry-run (inputs.deploy_to_pages == false). In that mode the deploy step itself is skipped, but the verify step would still call out to the live Pages URL and could fail the dry-run on a transient network/Pages error — even though nothing was being deployed. Adding `if: inputs.deploy_to_pages != false` so the guard runs only when there's an actual deploy to guard, matching the if-condition on the downstream Deploy step. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 6908fa9..c4de583 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -255,6 +255,9 @@ jobs: # the newly-generated one has at least as many entries AND retains every # previously-published version label. If anything shrunk or went missing, # abort the deploy so the version selector cannot be wiped by accident. + # Skipped on dry-runs (inputs.deploy_to_pages == false) — nothing is being + # deployed, so a transient Pages fetch failure shouldn't fail the workflow. + if: inputs.deploy_to_pages != false shell: pwsh run: | $newPath = 'docfx_project/_site/versions.json' From 3452ad2741dbf64cad3c22bea9723480d2add36f Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:45:52 -0400 Subject: [PATCH 34/71] Fix invalid PowerShell in docfx.yaml SemVer prerelease comparator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[int]::TryParse($aId, [ref]([int]$null))` is invalid PowerShell — the [ref] cast must wrap a variable, not an expression. At runtime this throws "Cannot resolve attribute or type [ref]: the variable name was not provided correctly", which would break the SemVer prerelease ordering used to rank tags for the versions.json picker. Switch to a proper [ref] to a $null-initialized scratch variable. The out value isn't used downstream (the numeric comparison re-parses via [int]$aId), so the scratch variable's value is irrelevant — we just need a real variable to bind [ref] to. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index c4de583..8c7b8ce 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -205,8 +205,11 @@ jobs: $aId = $aIds[$i] $bId = $bIds[$i] - $aIsNum = [int]::TryParse($aId, [ref]([int]$null)) - $bIsNum = [int]::TryParse($bId, [ref]([int]$null)) + # TryParse needs [ref] to a real variable, not an expression. + # We don't use the out value (we re-parse below via [int]$aId). + $aOut = 0; $bOut = 0 + $aIsNum = [int]::TryParse($aId, [ref]$aOut) + $bIsNum = [int]::TryParse($bId, [ref]$bOut) if ($aIsNum -and $bIsNum) { $aVal = [int]$aId From eed78d809f7556435f4816e291ad75e6d6617d40 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:48:46 -0400 Subject: [PATCH 35/71] Protected-file guard: detect deletions too (--diff-filter=AMRCD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protected-config detector used --diff-filter=AMRC (Added, Modified, Renamed, Copied) which excludes Deleted. A PR that *removed* a protected workflow file or .globalconfig/.ruleset silently bypassed the maintainer-review gate, since the guard's grep saw no matching lines in the diff output. Including D ensures a deletion of a protected file also fires the gate. A deletion is at least as security-relevant as a silent edit — both sidestep the CI validation that runs against the main-branch version. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index eaa7566..1cc05a9 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -173,10 +173,13 @@ jobs: done # Check .globalconfig, .ruleset, and workflow files using the same git diff approach - # --diff-filter=AMRC: Added, Modified, Renamed, Copied (excludes Deleted) + # --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=AMRC main-branch HEAD 2>/dev/null | grep -E '(\.(globalconfig|ruleset)|^\.github/workflows/.*\.ya?ml)$' || true) + 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 "" From 008ddc61acdb48197e8dea72d90c182dad83e87a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:52:52 -0400 Subject: [PATCH 36/71] Exclude *.Tests.Integration.* from pr.yaml test-discovery loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration test projects in this fleet are by convention container/DB-dependent (Testcontainers) and intended for separate integration runs — not the per-PR Linux/Windows/macOS unit-test stages, where Docker may not be available (macOS hosted runners don't ship Docker; Stage 2 Windows is similarly not the place to spin up a containerized DB). Currently this matters for Etl-DbClient (which has *.Tests.Integration.* projects). Adding the exclude fleet-wide keeps the workflow forward-safe for any other repo that introduces an integration project later. Repos without matching test projects are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 1cc05a9..ae5a742 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -422,7 +422,7 @@ jobs: exit 0 fi - mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) + mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -not -name "*.Tests.Integration.*" -print0) if [ ${#test_projects[@]} -eq 0 ]; then echo "ℹ️ No test projects found under ./tests — skipping test stage." @@ -1024,7 +1024,7 @@ jobs: 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) + done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -not -name "*.Tests.Integration.*" -print0) if [ ${#test_projects[@]} -eq 0 ]; then echo "ℹ️ No test projects found under ./tests — skipping test stage." From 1bd7d810490afda2a831b13c61886713c24cd2dd Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:57:26 -0400 Subject: [PATCH 37/71] Fail job when protected-config fetch/copy fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Fetch trusted configuration files from main branch" step silently fell back to the PR's version of protected configs when its git operations failed: - `git fetch origin main:main-branch` had no error handling; a network hiccup left main-branch absent and later git show calls also failed silently. - `git show "main-branch:$file" > "$file" || echo "...Failed..."` masked copy failures so the job continued with whatever was already in the workspace — i.e. the PR's potentially-tampered version of a protected configuration file. Both paths must abort the job — silently using PR-supplied protected configs defeats the entire purpose of the trusted-main fetch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index ae5a742..cd126dd 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -122,7 +122,10 @@ jobs: 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" + 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 else @@ -260,7 +263,10 @@ jobs: 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" + 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 else @@ -878,7 +884,10 @@ jobs: 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" + 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 else @@ -1226,7 +1235,10 @@ jobs: 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" + 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 else From 33d4967ee2b328343c2289480af13dc40ec31fc3 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:00:36 -0400 Subject: [PATCH 38/71] Gitleaks fetch: abort on transient failure, fall back only on missing file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Fetch trusted gitleaks config from main" step used `2>/dev/null || true`, which masked every kind of failure indiscriminately. A network/fetch hiccup or a transient git error would silently leave gitleaks scanning against the PR's (potentially loosened) version of .gitleaks.toml — defeating the entire point of the trusted-main fetch. Now the step distinguishes the two cases: - .gitleaks.toml doesn't exist in main → log a ::notice:: and let gitleaks use its built-in defaults. - .gitleaks.toml exists but checkout failed → abort with ::error:: so a transient error can't silently weaken secret scanning. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index cd126dd..e80c2ff 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -51,11 +51,24 @@ jobs: fetch-depth: 0 - name: Fetch trusted gitleaks config from main - # Prevent PR from modifying .gitleaks.toml to bypass the scan - run: | - git fetch origin main --depth=1 - git checkout origin/main -- .gitleaks.toml 2>/dev/null || true + # 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 From 68375d43f68bb95a4b690238685c5162a613fd08 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:05:35 -0400 Subject: [PATCH 39/71] verify-docs-build: gated dotnet workload restore before restore/build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-docs-build ran `dotnet restore` + `dotnet build` without first restoring .NET workloads. In repos with workload-bearing TFMs (MAUI / Android / iOS / maccatalyst / tvos / tizen / browser — e.g. Hawsey's net10.0-android and net10.0-ios targets), the build failed because the SDK couldn't resolve the workload assemblies. Added a gated workload-restore step matching the same detection pattern used in pr.yaml and codeql.yaml: only invokes `dotnet workload restore` when at least one csproj declares a workload-bearing TFM. For pure-library repos this is a fast no-op with one find/grep — no extra network round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ac52bd9..9a80306 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -607,6 +607,22 @@ jobs: 9.0.x 10.0.x + - name: Restore .NET workloads + # Same gated probe as pr.yaml — only run dotnet workload restore when + # at least one csproj declares a workload-bearing TFM. Required for + # repos with MAUI/Android/iOS targets (e.g. Hawsey); fast no-op + # otherwise. Without this, dotnet build below fails on workload-bearing + # projects because the SDK can't resolve the workload assemblies. + if: steps.check.outputs.found == 'true' + 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 if: steps.check.outputs.found == 'true' run: dotnet restore From d717864ec7367b6287c3336f6dbea2b8eb533c4c Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:14:06 -0400 Subject: [PATCH 40/71] pr.yaml: drop duplicated "configuration files from main" header bullet The SECURITY NOTE block listed the trusted-main config fetch twice (lines 10-11 and again at 16-17). Removing the second copy keeps the header concise and prevents the two bullets from drifting over time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e80c2ff..33e3a2b 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -13,8 +13,6 @@ # for a maintainer to manually review and verify the changes before merging # - persist-credentials: false prevents the checkout token from being written to git config for subsequent git commands # (it does NOT, by itself, prevent steps from accessing github.token / GITHUB_TOKEN if you explicitly expose it) -# - After checkout, configuration files (.editorconfig, BannedSymbols.txt, etc.) are fetched from -# the main branch to prevent malicious PRs from disabling analyzers or bypassing code quality checks # - Default GITHUB_TOKEN permissions are restricted to read-only repository contents to limit impact if exposed name: PR Checks v3 (Gated) From 71195b61fb3a80d22fa0e818c090d0d9d0afafae Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:17:17 -0400 Subject: [PATCH 41/71] Stage 3 coverage gate: skip when no coverage files were produced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS "Enforce 90% coverage threshold" step ran unconditionally even though the preceding "Generate coverage report" step explicitly skips when no `coverage.cobertura.xml` files exist. The result: any job with no tests / no compatible TFMs / skipped test discovery failed the gate with "Coverage report not generated!" — contradicting the prior step's "skipping report generation" notice. Now the gate inlines the same coverage-files-exist check the report step uses. If no cobertura files were produced, the gate exits 0 (coverage genuinely wasn't attempted). If cobertura files exist but Summary.txt doesn't, that's a real ReportGenerator failure and we still fail loudly. Stage 1 and Stage 2 already had this gating via steps.check-coverage.outputs.has-coverage — Stage 3 only inlined the detection in the report step but not in the gate. Aligning them. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 33e3a2b..82363a4 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -1115,8 +1115,17 @@ jobs: - 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 report not generated!" + echo "❌ Coverage files exist but Summary.txt is missing — ReportGenerator failed." exit 1 fi From d9bdbe49b25e6e6c943c1d0e65e8a0eec7e26ed4 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:20:37 -0400 Subject: [PATCH 42/71] Stage 2 coverage parse: accept extra columns + fail on zero matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows coverage parse regex required Summary.txt rows to be exactly "%". ReportGenerator commonly emits additional metrics columns (line + branch + method coverage) on the same row, so the strict regex matched nothing and the gate silently passed with $failedProjects empty — even if real coverage was below threshold. Two changes: 1. Loosen the regex to "...%" so extra columns between module and the trailing percent are accepted. The non-greedy ".*?" plus anchored trailing percent ensures the captured number is the LAST percent on the line (the overall figure, not a sub-metric). 2. Track matched-row count and fail loudly when zero matched. A parser that finds no modules is broken — silently passing the gate in that case defeats the threshold entirely. Stage 1 (bash) already uses a more permissive awk-based parser; this brings Stage 2 in line. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 510 +++++++++++++++++++++++++++++++++++++- 1 file changed, 509 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 82363a4..8fe0759 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -803,11 +803,511 @@ jobs: $threshold = if ($env:CODECOV_MINIMUM) { [int]$env:CODECOV_MINIMUM } else { 90 } $failedProjects = @() + $matchedCount = 0 foreach ($line in (Get-Content "CoverageReport/Summary.txt")) { - if ($line -match '^\s*(\S+)\s+(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^\s*Summary') { + # Accept extra columns between the module name and the final + # percent (ReportGenerator Summary.txt commonly has line + + # branch + method coverage on the same row). Take the LAST + # percent on the line — that's the overall figure. + if ($line -match '^\s*(\S+)\s+.*?(\d+(?:\.\d+)?)%\s* + 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@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@v6 + 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 + 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")" + 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 + 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@v5 + 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 elements are matched correctly + if tr $'\n' ' ' < "$proj" | grep -qE '[^<]*net(6\.0|7\.0|8\.0|9\.0|10\.0)'; then + projects+=("$proj") + echo "✓ Including: $proj (has .NET 6+ target)" + else + echo "⊘ Excluding: $proj (no .NET 6+ target, incompatible with macOS ARM64)" + fi + done < <(git ls-files -z -- '*.csproj' '*.vbproj' '*.fsproj') + + if [ "$project_found" = false ]; then + echo "❌ No .NET projects found." + echo "This should not occur as detect-projects already verified project existence." + exit 1 + fi + + if [ ${#projects[@]} -eq 0 ]; then + echo "❌ No compatible .NET projects found." + echo "All projects lack .NET 6+ targets, which are required for macOS ARM64." + exit 1 + fi + + echo "" + echo "==========================================" + echo "Projects to build (excluding .NET Framework-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 (multiple) to (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). + if [ ! -d ./tests ]; then + echo "ℹ️ No ./tests directory — skipping test stage." + 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" \) -not -name "*.Tests.Integration.*" -print0) + + if [ ${#test_projects[@]} -eq 0 ]; then + echo "ℹ️ No test projects found under ./tests — skipping test stage." + 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 (multiple) to (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" \ + --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@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@v6 + 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 + 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")" + 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 + 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@v7 + with: + name: devskim-results + path: devskim-results.txt + if-no-files-found: warn + -and $line -notmatch '^\s*Summary') { $module = $Matches[1] $percent = [int][math]::Floor([double]$Matches[2]) + $matchedCount++ Write-Host "Checking module: '$module' - Coverage: ${percent}%" @@ -820,6 +1320,14 @@ jobs: } } + # 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 From 2492006807236926f40ad913796c2b4efeb5b729 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:24:08 -0400 Subject: [PATCH 43/71] dotnet test: add --no-build --no-restore in all stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every dotnet test invocation in pr.yaml was forcing a per-TFM rebuild and restore even though the preceding "Restore and build" step in each stage already builds every compatible test project for every target framework. This: - Doubles per-stage CI time (build cost is paid twice — once in the explicit build step, again implicitly per dotnet test call). - Re-runs Roslyn analyzers per test invocation, multiplying analyzer overhead by the framework count. - Risks subtle non-reproducibility if file timestamps change between the build and test steps and projects get partially rebuilt. Adding --no-build --no-restore tells dotnet test to use the artifacts the earlier step produced. Applied to all five dotnet test invocations (Stages 1/3 bash, Stage 2 pwsh coverage variant, Stage 2 pwsh .NET Framework variant). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 8fe0759..ab0f462 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -485,6 +485,7 @@ jobs: dotnet test "$test_proj" \ --configuration Release \ --framework "$fw" \ + --no-build --no-restore \ --collect:"XPlat Code Coverage" \ --settings coverlet.runsettings \ --results-directory "./TestResults" \ @@ -744,6 +745,7 @@ jobs: dotnet test $testProj.FullName ` --configuration Release ` --framework $fw ` + --no-build --no-restore ` --collect:"XPlat Code Coverage" ` --settings coverlet.runsettings ` --results-directory "./TestResults" ` @@ -752,6 +754,7 @@ jobs: dotnet test $testProj.FullName ` --configuration Release ` --framework $fw ` + --no-build --no-restore ` --logger "console;verbosity=normal" } @@ -1081,6 +1084,7 @@ jobs: dotnet test "$test_proj" \ --configuration Release \ --framework "$fw" \ + --no-build --no-restore \ --collect:"XPlat Code Coverage" \ --settings coverlet.runsettings \ --results-directory "./TestResults" \ @@ -1599,6 +1603,7 @@ jobs: dotnet test "$test_proj" \ --configuration Release \ --framework "$fw" \ + --no-build --no-restore \ --collect:"XPlat Code Coverage" \ --settings coverlet.runsettings \ --results-directory "./TestResults" \ From e6ababf19f57ac06774d0fc938a06df1aefbd277 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:27:05 -0400 Subject: [PATCH 44/71] stryker.yaml: drop dead/broken configs< --- .github/workflows/stryker.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stryker.yaml b/.github/workflows/stryker.yaml index 04abf24..a858d53 100644 --- a/.github/workflows/stryker.yaml +++ b/.github/workflows/stryker.yaml @@ -45,7 +45,11 @@ jobs: done if (( ${#configs[@]} )); then printf 'found=true\n' >> "$GITHUB_OUTPUT" - printf 'configs<> "$GITHUB_OUTPUT" + # NOTE: previously also wrote a configs</ to enable mutation testing." printf 'found=false\n' >> "$GITHUB_OUTPUT" From 9301b32c87ecf3f2363f15a8e6176ee3bc1c837a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:30:58 -0400 Subject: [PATCH 45/71] Remove duplicated NuGet metadata defaults from per-project csprojs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI3 centralized Authors/Company/Copyright in Directory.Build.props: Chris Wolfgang Chris Wolfgang Copyright (c) Chris Wolfgang But the same three properties remained set to identical values in the per-project csprojs that pre-dated CI3, so each csproj was effectively shadowing the inherited default with the same value. That works today, but two failure modes: - If we ever change the canonical defaults, csprojs that still set their own override would silently keep the old value. - The duplication is dead-weight maintenance — one rename in Directory.Build.props wouldn't propagate to every src csproj. Stripping the three canonical-default properties only when their csproj value exactly matches the centralized one. Per-csproj fields that vary (Description, PackageTags, PackageProjectUrl, RepositoryUrl, PackageLicenseExpression, PackageReadmeFile, Version) are untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Wolfgang.Extensions.IEnumerable.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj b/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj index 3cab894..1f6b610 100644 --- a/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj +++ b/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj @@ -4,7 +4,6 @@ latest 1.2.1 $(AssemblyName) - Chris Wolfgang A collection of extension methods for types that implement IEnumerable Copyright 2026 Chris Wolfgang https://github.com/Chris-Wolfgang/Wolfgang.Extensions.IEnumerable From 0109a386c05777681f0751397f4f784354802ffb Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:33:40 -0400 Subject: [PATCH 46/71] docfx.yaml: align D6 comment with the actual fetch source The step comment said "Fetches the currently-deployed versions.json from gh-pages", but the implementation fetches it from the published GitHub Pages URL via Invoke-WebRequest, not from the gh-pages branch directly. The two are usually equivalent but can diverge if Pages hasn't redeployed yet, custom domains are involved, or Pages is disabled. Be precise in the comment so the actual behavior is clear. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 8c7b8ce..4f63d5a 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -254,7 +254,8 @@ jobs: - name: Verify previous versions preserved in versions.json # Initiative D6 — guard against accidentally wiping the version selector. - # Fetches the currently-deployed versions.json from gh-pages and confirms + # Fetches the currently-deployed versions.json from the published + # GitHub Pages URL (https://.github.io//versions.json) and confirms # the newly-generated one has at least as many entries AND retains every # previously-published version label. If anything shrunk or went missing, # abort the deploy so the version selector cannot be wiped by accident. From fe2cfd41122a3c845974c73a33e77f2ff72b370c Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:35:28 -0400 Subject: [PATCH 47/71] Sync workload-TFM comment with regex (drop -windows mention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An earlier commit dropped `windows` from the workload-bearing TFM detection regex (netX.Y-windows TFMs are WPF/WinForms/WindowsDesktop, which use SDK-bundled projection assemblies and don't need the .NET workload installer). The accompanying comment still listed `windows` in the bracket-enumeration, which contradicted the regex below. Update the comment to match — `browser` is now the last entry, and a short trailing note explains why `-windows` is intentionally absent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/codeql.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index d7d4662..b0a09fc 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -95,7 +95,9 @@ jobs: shell: pwsh run: | # Skip entirely if no csproj declares a workload-bearing TFM (android/ios/ - # maccatalyst/maui/tvos/tizen/browser/windows). Saves ~5-15s on pure-library + # maccatalyst/maui/tvos/tizen/browser) — netX.Y-windows TFMs use + # SDK-bundled projection assemblies, not the workload installer, so + # they're intentionally excluded. Saves ~5-15s on pure-library # repos and removes a network-dependent failure mode. $hasWorkloadTfm = @(Get-ChildItem -Recurse -Filter *.csproj | Select-String -Pattern 'net\d+\.\d+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' -List).Count -gt 0 From e4df4dec5606ba38d10617c88caf1e631dd57374 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 09:59:02 -0400 Subject: [PATCH 48/71] docfx.yaml T1 coverage: pin to a single TFM (net10.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage step ran dotnet test without --framework, so the docs deploy job re-ran the entire multi-TFM test suite every time. That's redundant — pr.yaml already exercises every TFM across Stages 1/2/3, and the coverage report only needs one TFM's worth of runs. Pinning to net10.0 (the modern target always present in this fleet): - Cuts docfx job time substantially - Removes failure surface from older targets (transient quirks that wouldn't block a release but would block the docs deploy) - Keeps the published coverage report focused on the current target Verified locally on DateTime-Extensions: dotnet build -c Release clean, dotnet test --framework net10.0 → 66/66 tests passed, cobertura generated. Fanning out to the rest of the fleet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 4f63d5a..bcf7a7e 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -112,7 +112,12 @@ jobs: continue-on-error: true shell: pwsh run: | - dotnet test --configuration Release --no-build --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage-raw 2>&1 | Out-Host + # Coverage report only needs one TFM's worth of runs. The per-PR + # pr.yaml workflow already exercises every TFM across Stages 1/2/3, + # so re-running the full matrix during docs deploy multiplies job + # time and adds extra failure surface for older targets. Pin to + # net10.0 — the modern target that's always present in this fleet. + dotnet test --configuration Release --no-build --framework net10.0 --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage-raw 2>&1 | Out-Host dotnet tool update -g dotnet-reportgenerator-globaltool 2>$null || dotnet tool install -g dotnet-reportgenerator-globaltool 2>$null $coverageFiles = @(Get-ChildItem -Path ./coverage-raw -Recurse -Filter "coverage.cobertura.xml" -ErrorAction SilentlyContinue) if ($coverageFiles.Count -eq 0) { From 7e26e8101ccfa9b83e608bc4b4bcfa2876189e96 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 10:18:38 -0400 Subject: [PATCH 49/71] pr.yaml: fail (not skip) when ./tests is missing in a repo with src/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "gracefully skip when no tests" branches in Stages 1/2/3 let a PR remove the entire ./tests directory (or all test projects within it) and still pass CI — even when CODECOV_MINIMUM=90 nominally enforces a coverage gate downstream. Copilot flagged this as a coverage-gate bypass. New rule: - If ./src contains *.csproj/*.vbproj/*.fsproj, missing/empty ./tests fails the stage with a clear error. Real source code must be covered by real tests. - If ./src is also empty (template-pack repos, in-dev repos with no source yet), the stage still skips gracefully. That preserves the carve-out the original "graceful skip" was added for. Applied to all four test-discovery blocks: Stage 1 Linux bash, Stage 2 Windows pwsh, Stage 3 macOS bash, and the macOS-ARM64 bash variant. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 66 ++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index ab0f462..9c391f4 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -434,15 +434,27 @@ jobs: # 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 - echo "ℹ️ No ./tests directory — skipping test stage." + 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" \) -not -name "*.Tests.Integration.*" -print0) if [ ${#test_projects[@]} -eq 0 ]; then - echo "ℹ️ No test projects found under ./tests — skipping test stage." + 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 @@ -688,15 +700,29 @@ jobs: # 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)) { - Write-Host "ℹ️ No ./tests directory — skipping test stage." + 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) { - Write-Host "ℹ️ No test projects found under ./tests — skipping test stage." + 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 } @@ -1029,8 +1055,16 @@ jobs: # 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 - echo "ℹ️ No ./tests directory — skipping test stage." + 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 @@ -1040,7 +1074,11 @@ jobs: done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -not -name "*.Tests.Integration.*" -print0) if [ ${#test_projects[@]} -eq 0 ]; then - echo "ℹ️ No test projects found under ./tests — skipping test stage." + 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 @@ -1548,8 +1586,16 @@ jobs: # 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 - echo "ℹ️ No ./tests directory — skipping test stage." + 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 @@ -1559,7 +1605,11 @@ jobs: done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -not -name "*.Tests.Integration.*" -print0) if [ ${#test_projects[@]} -eq 0 ]; then - echo "ℹ️ No test projects found under ./tests — skipping test stage." + 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 From a3fd88b323b5223cd0271d0a573ca00d5792851e Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 10:28:14 -0400 Subject: [PATCH 50/71] Repair pr.yaml after broken Stage 2 coverage-regex fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier "Stage 2 coverage parse: accept extra columns + fail on zero matches" commit corrupted pr.yaml on every canonical-protected branch. The JS replace's replacement string contained a literal `$\'` inside the regex, which String.prototype.replace interprets as the "right-context" replacement token — so it inserted the rest of the file inline after the regex line. The result was a YAML with duplicate `security-scan:` jobs and an unterminated regex string, which would have failed to parse at next workflow run. Recovery: 1. Restore pr.yaml from the parent of the broken commit (last known-good state). 2. Re-apply the three post-broken-commit changes that legitimately touched pr.yaml: --no-build --no-restore on dotnet test, header bullet dedup, and tests-gate strict mode (fail when src has projects but tests don't). 3. Re-apply the Stage 2 coverage regex fix correctly, using a replacer function instead of a literal NEW string so `$\'` and other `$` tokens are not interpolated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 509 +------------------------------------- 1 file changed, 1 insertion(+), 508 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 9c391f4..e968c16 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -839,514 +839,7 @@ jobs: # percent (ReportGenerator Summary.txt commonly has line + # branch + method coverage on the same row). Take the LAST # percent on the line — that's the overall figure. - if ($line -match '^\s*(\S+)\s+.*?(\d+(?:\.\d+)?)%\s* - 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@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@v6 - 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 - 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")" - 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 - 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@v5 - 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 elements are matched correctly - if tr $'\n' ' ' < "$proj" | grep -qE '[^<]*net(6\.0|7\.0|8\.0|9\.0|10\.0)'; then - projects+=("$proj") - echo "✓ Including: $proj (has .NET 6+ target)" - else - echo "⊘ Excluding: $proj (no .NET 6+ target, incompatible with macOS ARM64)" - fi - done < <(git ls-files -z -- '*.csproj' '*.vbproj' '*.fsproj') - - if [ "$project_found" = false ]; then - echo "❌ No .NET projects found." - echo "This should not occur as detect-projects already verified project existence." - exit 1 - fi - - if [ ${#projects[@]} -eq 0 ]; then - echo "❌ No compatible .NET projects found." - echo "All projects lack .NET 6+ targets, which are required for macOS ARM64." - exit 1 - fi - - echo "" - echo "==========================================" - echo "Projects to build (excluding .NET Framework-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 (multiple) to (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" \) -not -name "*.Tests.Integration.*" -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 (multiple) to (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@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@v6 - 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 - 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")" - 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 - 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@v7 - with: - name: devskim-results - path: devskim-results.txt - if-no-files-found: warn - -and $line -notmatch '^\s*Summary') { + if ($line -match '^\s*(\S+)\s+.*?(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^\s*Summary') { $module = $Matches[1] $percent = [int][math]::Floor([double]$Matches[2]) $matchedCount++ From 749be57ef490f0519f4cf50c62dc7b8e7d6bc772 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:14:16 -0400 Subject: [PATCH 51/71] build-all-versions.yaml: pipe to Out-Host, not Write-Host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write-Host isn't a pipeline sink — piping a native command's combined stdout/stderr to it (`dotnet restore ... 2>&1 | Write-Host`) can produce parameter-binding errors and interfere with surfacing the command's actual output. Out-Host is the correct sink: it writes to the host stream just like Write-Host but properly accepts pipeline input. Replaced 4 occurrences in the per-tag build try-block. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-all-versions.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-all-versions.yaml b/.github/workflows/build-all-versions.yaml index c891c94..b2b546c 100644 --- a/.github/workflows/build-all-versions.yaml +++ b/.github/workflows/build-all-versions.yaml @@ -123,16 +123,16 @@ jobs: Select-Object -First 1 if ($slnFile) { Write-Host "Restoring $($slnFile.Name)..." - dotnet restore $slnFile.FullName 2>&1 | Write-Host + dotnet restore $slnFile.FullName 2>&1 | Out-Host Write-Host "Building $($slnFile.Name)..." - dotnet build $slnFile.FullName --configuration Release --no-restore 2>&1 | Write-Host + dotnet build $slnFile.FullName --configuration Release --no-restore 2>&1 | Out-Host } Write-Host "Running docfx metadata..." - docfx metadata docfx_project/docfx.json 2>&1 | Write-Host + docfx metadata docfx_project/docfx.json 2>&1 | Out-Host Write-Host "Running docfx build..." - docfx build docfx_project/docfx.json 2>&1 | Write-Host + docfx build docfx_project/docfx.json 2>&1 | Out-Host if (Test-Path 'docfx_project/_site') { $dest = Join-Path $outDir 'versions' $version From 71e45ebef13b06f632a5ca745c39a29d701f5bf5 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:41:47 -0400 Subject: [PATCH 52/71] pr.yaml: run protected-config copy loop in parent shell, not subshell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier "Fail job when protected-config fetch/copy fails" commit fixed the visible silent-fallback bug, but introduced a subtler one: the `while read` loop was fed by a pipeline (`git ls-tree | grep | while`), so it ran in a subshell. `exit 1` from inside the loop terminated only the subshell — the outer step continued, defeating the very guarantee I'd just added. Switched to process substitution (`done < <(git ls-tree ... | grep ...)`). The loop now runs in the parent shell; `exit 1` reliably aborts the step on a `git show` failure, preserving the trusted-main contract. Applied to all three stage copies of the pattern (Stages 1/2/3). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 48 +++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e968c16..6f88d42 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -128,8 +128,14 @@ jobs: 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 + # 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")" @@ -138,7 +144,7 @@ jobs: exit 1 fi fi - done + done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") else # Check if file exists in main branch if git cat-file -e "main-branch:$config_file" 2>/dev/null; then @@ -269,8 +275,14 @@ jobs: 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 + # 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")" @@ -279,7 +291,7 @@ jobs: exit 1 fi fi - done + done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") else # Check if file exists in main branch if git cat-file -e "main-branch:$config_file" 2>/dev/null; then @@ -933,8 +945,14 @@ jobs: 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 + # 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")" @@ -943,7 +961,7 @@ jobs: exit 1 fi fi - done + done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") else # Check if file exists in main branch if git cat-file -e "main-branch:$config_file" 2>/dev/null; then @@ -1306,8 +1324,14 @@ jobs: 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 + # 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")" @@ -1316,7 +1340,7 @@ jobs: exit 1 fi fi - done + done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") else # Check if file exists in main branch if git cat-file -e "main-branch:$config_file" 2>/dev/null; then From 60581da74fcc3fda8490b98bbecea194598ee295 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:44:26 -0400 Subject: [PATCH 53/71] Stage 2 coverage regex: use greedy match to capture LAST percent The earlier "accept extra columns" fix used `.*?` (non-greedy) between the module name and the percent capture group. That matches the FIRST `%` on the line, not the last as the comment claimed. ReportGenerator Summary.txt rows look like: Foo.csproj 85.2% 92.1% With the non-greedy regex, $Matches[2] captured 85.2 (line coverage) instead of 92.1 (the overall figure we wanted to gate on). Switching to greedy `.*` so the regex engine consumes as much as possible before backtracking to satisfy the trailing `(\d+...)%\s*$`. That captures the last percent before end-of-line. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 545 +++++++++++++++++++++++++++++++++++++- 1 file changed, 544 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 6f88d42..d832da6 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -851,7 +851,550 @@ jobs: # percent (ReportGenerator Summary.txt commonly has line + # branch + method coverage on the same row). Take the LAST # percent on the line — that's the overall figure. - if ($line -match '^\s*(\S+)\s+.*?(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^\s*Summary') { + if ($line -match '^\s*(\S+)\s+.*(\d+(?:\.\d+)?)%\s* -and $line -notmatch '^\s*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@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@v6 + 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 + done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") + 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@v5 + 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 elements are matched correctly + if tr $'\n' ' ' < "$proj" | grep -qE '[^<]*net(6\.0|7\.0|8\.0|9\.0|10\.0)'; then + projects+=("$proj") + echo "✓ Including: $proj (has .NET 6+ target)" + else + echo "⊘ Excluding: $proj (no .NET 6+ target, incompatible with macOS ARM64)" + fi + done < <(git ls-files -z -- '*.csproj' '*.vbproj' '*.fsproj') + + if [ "$project_found" = false ]; then + echo "❌ No .NET projects found." + echo "This should not occur as detect-projects already verified project existence." + exit 1 + fi + + if [ ${#projects[@]} -eq 0 ]; then + echo "❌ No compatible .NET projects found." + echo "All projects lack .NET 6+ targets, which are required for macOS ARM64." + exit 1 + fi + + echo "" + echo "==========================================" + echo "Projects to build (excluding .NET Framework-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 (multiple) to (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" \) -not -name "*.Tests.Integration.*" -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 (multiple) to (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@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@v6 + 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 + done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") + 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@v7 + with: + name: devskim-results + path: devskim-results.txt + if-no-files-found: warn + -and $line -notmatch '^\s*Summary') { $module = $Matches[1] $percent = [int][math]::Floor([double]$Matches[2]) $matchedCount++ From 2047ee9c5b9ec583d7b920dd9f4879c9d99c2927 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:52:15 -0400 Subject: [PATCH 54/71] Repair pr.yaml after second \$'-token corruption of Stage 2 regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Stage 2 coverage regex: use greedy match to capture LAST percent" commit re-corrupted pr.yaml the same way the original "accept extra columns" commit did: the JS replacement string contained `$'` inside the regex literal, which String.prototype.replace interprets as the right-context replacement token — splicing half the file inline. Restored pr.yaml from the parent of the broken commit (the "run protected-config copy loop in parent shell" commit, which was known-good) and re-applied the .*? -> .* change using a REPLACER FUNCTION instead of a literal replacement string. The replacer function form (`body.replace(needle, () => replacement)`) does NOT interpret $ tokens in the replacement. Note for future: any time the NEW string in a body.replace contains $' / $` / $& / $N etc., either use a replacer function or escape each $ as $$. Bitten by this twice now. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 545 +------------------------------------- 1 file changed, 1 insertion(+), 544 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index d832da6..96824b1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -851,550 +851,7 @@ jobs: # percent (ReportGenerator Summary.txt commonly has line + # branch + method coverage on the same row). Take the LAST # percent on the line — that's the overall figure. - if ($line -match '^\s*(\S+)\s+.*(\d+(?:\.\d+)?)%\s* -and $line -notmatch '^\s*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@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@v6 - 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 - done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") - 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@v5 - 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 elements are matched correctly - if tr $'\n' ' ' < "$proj" | grep -qE '[^<]*net(6\.0|7\.0|8\.0|9\.0|10\.0)'; then - projects+=("$proj") - echo "✓ Including: $proj (has .NET 6+ target)" - else - echo "⊘ Excluding: $proj (no .NET 6+ target, incompatible with macOS ARM64)" - fi - done < <(git ls-files -z -- '*.csproj' '*.vbproj' '*.fsproj') - - if [ "$project_found" = false ]; then - echo "❌ No .NET projects found." - echo "This should not occur as detect-projects already verified project existence." - exit 1 - fi - - if [ ${#projects[@]} -eq 0 ]; then - echo "❌ No compatible .NET projects found." - echo "All projects lack .NET 6+ targets, which are required for macOS ARM64." - exit 1 - fi - - echo "" - echo "==========================================" - echo "Projects to build (excluding .NET Framework-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 (multiple) to (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" \) -not -name "*.Tests.Integration.*" -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 (multiple) to (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@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@v6 - 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 - done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") - 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@v7 - with: - name: devskim-results - path: devskim-results.txt - if-no-files-found: warn - -and $line -notmatch '^\s*Summary') { + if ($line -match '^\s*(\S+)\s+.*(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^\s*Summary') { $module = $Matches[1] $percent = [int][math]::Floor([double]$Matches[2]) $matchedCount++ From cbb6f03bb44cd00ace3f6d6e47dafa7d310e3c41 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:09:23 -0400 Subject: [PATCH 55/71] Strip year-specific overrides from src csprojs The earlier CI3 dedupe only matched the exact canonical value ("Copyright (c) Chris Wolfgang"), so year-specific variants like "Copyright 2026 Chris Wolfgang" (sometimes with trailing whitespace) slipped through and continued to shadow the centralized default. Stripping any csproj line that matches "Copyright YYYY Chris Wolfgang" so the canonical year-less value in Directory.Build.props flows through. Per-csproj fields that legitimately vary (Description, PackageTags, PackageProjectUrl, RepositoryUrl, PackageLicenseExpression, PackageReadmeFile, Version) remain untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Wolfgang.Extensions.IEnumerable.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj b/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj index 1f6b610..afca8e8 100644 --- a/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj +++ b/src/Wolfgang.Extensions.IEnumerable/Wolfgang.Extensions.IEnumerable.csproj @@ -5,7 +5,6 @@ 1.2.1 $(AssemblyName) A collection of extension methods for types that implement IEnumerable - Copyright 2026 Chris Wolfgang https://github.com/Chris-Wolfgang/Wolfgang.Extensions.IEnumerable README.md 1.0.0 From c01a2b8c5930527b5075377fd8c27b8b969f7bda Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:16:29 -0400 Subject: [PATCH 56/71] D6 preservation guard: fail when newly-generated versions.json is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard runs only when deploy_to_pages != false (real deploy), and DEPLOY_AS_LATEST=true wipes the gh-pages root before depositing the new content. If `docfx_project/_site/versions.json` doesn't exist when we reach this step, the docfx build is broken — and silently `exit 0`-ing let the downstream deploy proceed and overwrite the root with whatever state (possibly losing previously-published versions in the picker). Failing the step instead. A missing versions.json now blocks the deploy with a clear ::error:: pointing at docfx generation, which is safer than allowing a partial / unverified publish. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index bcf7a7e..ba5aecf 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -271,8 +271,14 @@ jobs: run: | $newPath = 'docfx_project/_site/versions.json' if (-Not (Test-Path $newPath)) { - Write-Host "::notice::No new versions.json produced - skipping preservation check." - exit 0 + # This step only runs when deploy_to_pages != false, so a real + # deploy is about to happen. Missing newly-generated versions.json + # means the docfx generate step is broken — we can't verify that + # we're preserving previously-published versions. Failing here is + # safer than letting the deploy proceed and wipe the root with + # whatever (possibly empty) state. + Write-Error "::error::Newly-generated docfx_project/_site/versions.json is missing — docfx generation is broken. Refusing to deploy without a verified version manifest." + exit 1 } $existingUrl = "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/versions.json" try { From cc8cd73d86348297679277c3e6e2764f84bbf25b Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:22:41 -0400 Subject: [PATCH 57/71] release.yaml: attach .snupkg symbol packages alongside .nupkg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directory.Build.props sets IncludeSymbols=true + SymbolPackageFormat=snupkg (CI3), so dotnet pack produces .snupkg symbol packages next to each .nupkg. The attach-to-release step globbed only *.nupkg, leaving symbol packages off the GitHub Release page. Adding *.snupkg to the file list so users who download artifacts from the Release can grab matching symbol packages too. Publishing to nuget.org is unaffected — `dotnet nuget push *.nupkg` already auto- detects and uploads adjacent .snupkg files to NuGet's symbol server. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9a80306..c3d64fc 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -771,6 +771,7 @@ jobs: tag_name: ${{ github.event.release.tag_name }} files: | ./nuget-packages/*.nupkg + ./nuget-packages/*.snupkg ./nuget-packages/*.bom.json release-coverage.zip From 8d1299e4bef36ab81e25ef14b9ab91fc49c84995 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:31:14 -0400 Subject: [PATCH 58/71] docfx.yaml deploy: precise exit-code handling on diff/commit/push Two related bugs in the single-commit-and-push block: 1. `git diff --cached --quiet` returns 0 (no changes), 1 (changes exist), or >1 (error reading the staged index). The current `if ($LASTEXITCODE -ne 0)` treated >1 (error) the same as 1 (changes), so an index-read failure would route into the commit/push branch and try to commit anyway. 2. After `git commit`, the script unconditionally ran `git push` with no $LASTEXITCODE check. A commit failure (config/auth/empty commit) would let the push proceed against a stale HEAD, and $deployExitCode at the end of the try block reflected only the push's exit code, masking the commit failure. Now: explicit numeric branches on diff exit, abort on >1, and $LASTEXITCODE checks between commit and push that throw on failure (so the finally block's cleanup still runs). The deploy now fails loudly and visibly when any step in the chain errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index ba5aecf..4a8b438 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -482,20 +482,37 @@ jobs: Write-Host "✅ Copied version picker to site root" } - # Single commit and push + # Single commit and push. + # `git diff --cached --quiet` exits 0 (no changes), 1 (changes + # exist), or >1 (error — e.g. cannot read index). Treat the + # three cases explicitly: commit only when there are changes + # (exit 1); on >1 abort the deploy with a clear error; on 0 + # report "nothing to deploy" and exit cleanly. git -C $WORK_DIR add -A git -C $WORK_DIR diff --cached --quiet - if ($LASTEXITCODE -ne 0) { + $diffExit = $LASTEXITCODE + if ($diffExit -eq 1) { $msg = if ($env:DEPLOY_AS_LATEST -eq 'true') { "docs: deploy $($env:VERSION_DIR) and update latest" } else { "docs: deploy $($env:VERSION_DIR)" } git -C $WORK_DIR commit -m $msg + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ git commit failed with exit code $LASTEXITCODE — aborting deploy." + throw "git commit failed" + } git -C $WORK_DIR push origin HEAD:gh-pages + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ git push origin HEAD:gh-pages failed with exit code $LASTEXITCODE — deploy did not land." + throw "git push failed" + } Write-Host "✅ Documentation deployed in a single commit." - } else { + } elseif ($diffExit -eq 0) { Write-Host "ℹ️ No documentation changes to deploy." + } else { + Write-Error "❌ git diff --cached --quiet exited with $diffExit (error reading the staged index) — aborting deploy." + throw "git diff failed" } # Capture the deploy outcome before the finally block runs cleanup From 7dd5d9f0325d9c6543586a7ad693ed90cba64ff1 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:37:18 -0400 Subject: [PATCH 59/71] stryker.yaml: run BOTH root and per-test-project configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Run step had an if/else that ran EITHER stryker-config.json at repo root OR any tests/**/stryker-config.json — never both. But the Detect step's "found" output is true when EITHER (or both) exist, so a repo with a root umbrella config plus per-suite configs would only get the umbrella scanned; per-suite configs were silently skipped. Now both branches run independently. Added a "ran=0/1" sentinel that errors out if neither shape was actually present (defensive guard against Detect/Run drift). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/stryker.yaml | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/stryker.yaml b/.github/workflows/stryker.yaml index a858d53..82f96d2 100644 --- a/.github/workflows/stryker.yaml +++ b/.github/workflows/stryker.yaml @@ -73,15 +73,27 @@ jobs: run: | set -e shopt -s globstar nullglob + # Run BOTH a root stryker-config.json (if present) AND any + # tests/**/stryker-config.json suites. The Detect step collects + # both shapes; running only one leaves per-test suites unscanned + # in repos that have both an umbrella and per-suite configs. + ran=0 if [ -f stryker-config.json ]; then + echo "::group::Stryker with root stryker-config.json" dotnet stryker --config-file stryker-config.json - else - for cfg in tests/**/stryker-config.json; do - dir=$(dirname "$cfg") - echo "::group::Stryker in $dir" - (cd "$dir" && dotnet stryker) - echo "::endgroup::" - done + echo "::endgroup::" + ran=1 + fi + for cfg in tests/**/stryker-config.json; do + dir=$(dirname "$cfg") + echo "::group::Stryker in $dir" + (cd "$dir" && dotnet stryker) + echo "::endgroup::" + ran=1 + done + if [ "$ran" -eq 0 ]; then + echo "::error::Detect step said stryker-config.json was present, but Run found neither root nor tests/**/stryker-config.json. Bailing." + exit 1 fi - name: Upload Stryker report From e13395c5b9c23d7b50f591a4662fc6f37c7b28af Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:27:35 -0400 Subject: [PATCH 60/71] Stage 1 (Linux) coverage parser: fail when 0 modules match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 (Windows) coverage parser was hardened earlier — greedy regex + matched-count guard that fails when no modules parse. Stage 1 (Linux bash) still ran the original loose awk parser without the guard, so if Summary.txt format changes (or the report is malformed in a way that bypasses the line regex), Stage 1 would silently pass with an empty failed_projects list — exact opposite of Stage 2's new behavior. Adding the same matched_count guard so Stage 1 matches Stage 2 semantically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 96824b1..37dcc67 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -555,15 +555,21 @@ jobs: failed_projects="" threshold=${CODECOV_MINIMUM:-90} - + matched_count=0 + while read -r line; do - # Match lines with module names and percentages + # 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]+%$' && ! echo "$line" | grep -q '^Summary'; then module=$(echo "$line" | awk '{print $1}') percent=$(echo "$line" | awk '{print $NF}' | tr -d '%') - + 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}%)" @@ -573,6 +579,14 @@ jobs: 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 "==========================================" From 186687d3fdf00d4230f3770284b885467cde480d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:36:08 -0400 Subject: [PATCH 61/71] pr.yaml trusted-config-fetch: three correctness fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. (Stage 1/3 bash, exact-file branch) Added an explicit exit-code check around `git show > "$config_file"`. The globbed branch was hardened earlier; the exact-file branch had been left silently falling back to the PR version on any git show failure. 2. (Stage 1/3 bash, globbed branch) The process substitution `done < <(... | grep -E ...)` runs under `set -eo pipefail`, and grep exits 1 when no entries match. For repos that don't ship a `*.ruleset` (or any other optional pattern), the previously-fine "no matches → skip" behavior was actually failing the step. Wrapped the grep in `{ ... || true; }` so an empty match is treated as zero iterations, not a failure. 3. (Stage 2 pwsh) `Out-File ... -NoNewline` strips trailing newlines from the copied workflow/globalconfig/ruleset/editorconfig files, producing malformed copies. Removed -NoNewline so the file's trailing newline is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 52 +++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 37dcc67..a438d29 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -144,12 +144,20 @@ jobs: exit 1 fi fi - done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") + # 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" - git show "main-branch:$config_file" > "$config_file" + 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 @@ -291,12 +299,20 @@ jobs: exit 1 fi fi - done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") + # 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" - git show "main-branch:$config_file" > "$config_file" + 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 @@ -664,7 +680,7 @@ jobs: $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 -NoNewline + git show "main-branch:$configFile" | Out-File -FilePath $configFile -Encoding UTF8NoBOM } else { Write-Host " ℹ️ $configFile not found in main branch, skipping" } @@ -679,7 +695,7 @@ jobs: 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 -NoNewline + git show "main-branch:$file" | Out-File -FilePath $file -Encoding UTF8NoBOM } } } @@ -975,12 +991,20 @@ jobs: exit 1 fi fi - done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") + # 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" - git show "main-branch:$config_file" > "$config_file" + 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 @@ -1354,12 +1378,20 @@ jobs: exit 1 fi fi - done < <(git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}") + # 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" - git show "main-branch:$config_file" > "$config_file" + 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 From 65653e713185aef774ceb1c213fbd41cb52b5c04 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:38:41 -0400 Subject: [PATCH 62/71] docfx.yaml deploy: clear versions/ and versions/latest before copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy-Item -Force overwrites files that exist in both source and destination, but doesn't remove destination-only files. Over multiple releases — if a docs page or asset is dropped from docfx output — the stale file lingers indefinitely in gh-pages, served alongside the current docs. Adding a clear-before-copy step for both versions/ and versions/latest. Preserves the directory entry itself so per-release git diffs stay clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 4a8b438..f7c7ed5 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -439,16 +439,28 @@ jobs: # Ensure .nojekyll exists so GitHub Pages does not run Jekyll New-Item -ItemType File -Path (Join-Path $WORK_DIR '.nojekyll') -Force | Out-Null - # Deploy versioned docs (real DocFX index.html — before version picker overwrites it) + # Deploy versioned docs (real DocFX index.html — before version picker + # overwrites it). Clear the destination first so files that were + # dropped from docfx output between releases don't linger forever. $versionedDir = Join-Path $WORK_DIR "versions/$($env:VERSION_DIR)" - New-Item -ItemType Directory -Force -Path $versionedDir | Out-Null + if (Test-Path -LiteralPath $versionedDir) { + Get-ChildItem -LiteralPath $versionedDir -Force | Remove-Item -Recurse -Force + } else { + New-Item -ItemType Directory -Force -Path $versionedDir | Out-Null + } Copy-Item -Path "$siteDir/*" -Destination $versionedDir -Recurse -Force Write-Host "✅ Copied docs to versions/$($env:VERSION_DIR)/" if ($env:DEPLOY_AS_LATEST -eq 'true') { - # Deploy to versions/latest/ (real DocFX index.html) + # Deploy to versions/latest/ (real DocFX index.html). Same clear- + # before-copy pattern as versions/ above to prevent stale + # files from previous releases lingering when docfx output shrinks. $latestDir = Join-Path $WORK_DIR 'versions/latest' - New-Item -ItemType Directory -Force -Path $latestDir | Out-Null + if (Test-Path -LiteralPath $latestDir) { + Get-ChildItem -LiteralPath $latestDir -Force | Remove-Item -Recurse -Force + } else { + New-Item -ItemType Directory -Force -Path $latestDir | Out-Null + } Copy-Item -Path "$siteDir/*" -Destination $latestDir -Recurse -Force Write-Host "✅ Copied docs to versions/latest/" From 7f4a93eb271687de5d141cd323dc18fd22a8f70a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:40:46 -0400 Subject: [PATCH 63/71] docfx coverage: add --no-restore to dotnet test The earlier "Restore dependencies" step already does the restore, so the implicit restore inside dotnet test is wasted I/O. Adding --no-restore alongside the existing --no-build to skip both. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index f7c7ed5..420140b 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -117,7 +117,7 @@ jobs: # so re-running the full matrix during docs deploy multiplies job # time and adds extra failure surface for older targets. Pin to # net10.0 — the modern target that's always present in this fleet. - dotnet test --configuration Release --no-build --framework net10.0 --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage-raw 2>&1 | Out-Host + dotnet test --configuration Release --no-build --no-restore --framework net10.0 --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage-raw 2>&1 | Out-Host dotnet tool update -g dotnet-reportgenerator-globaltool 2>$null || dotnet tool install -g dotnet-reportgenerator-globaltool 2>$null $coverageFiles = @(Get-ChildItem -Path ./coverage-raw -Recurse -Filter "coverage.cobertura.xml" -ErrorAction SilentlyContinue) if ($coverageFiles.Count -eq 0) { From 7ebafa558f1d194578c2eeb6a090b1dcd8370e7c Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:42:49 -0400 Subject: [PATCH 64/71] D6 guard: also skip when deploy_as_latest is false (rebuild mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D6 "Verify previous versions preserved in versions.json" guard ran whenever deploy_to_pages != false. But the deploy step only touches the root versions.json when deploy_as_latest is ALSO true: - deploy_to_pages=false → dry-run, nothing deploys - deploy_to_pages=true + deploy_as_latest=false → rebuild a single older version; deploy writes only versions//, doesn't touch the root - deploy_to_pages=true + deploy_as_latest=true → full deploy that overwrites the root versions.json The guard exists to protect that root file. In the rebuild-an-older- version case there's nothing for the guard to protect, but it still fetched the live Pages versions.json and compared — meaning a transient Pages fetch/parse error would block a legitimate rebuild. Tightening the if condition so the guard only runs in the third case (both inputs true). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 420140b..c709d02 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -264,9 +264,14 @@ jobs: # the newly-generated one has at least as many entries AND retains every # previously-published version label. If anything shrunk or went missing, # abort the deploy so the version selector cannot be wiped by accident. - # Skipped on dry-runs (inputs.deploy_to_pages == false) — nothing is being - # deployed, so a transient Pages fetch failure shouldn't fail the workflow. - if: inputs.deploy_to_pages != false + # Only runs when an actual root-touching deploy is happening: + # - inputs.deploy_to_pages != false (otherwise it's a dry-run and + # nothing deploys at all) + # - inputs.deploy_as_latest != false (otherwise the deploy writes + # only versions// and never touches the root versions.json, + # so there's nothing for the preservation guard to protect — a + # transient Pages-fetch failure would block a legitimate rebuild) + if: inputs.deploy_to_pages != false && inputs.deploy_as_latest != false shell: pwsh run: | $newPath = 'docfx_project/_site/versions.json' From 9d8d66a754276d70fe5206e1827af0b023abda93 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:55:24 -0400 Subject: [PATCH 65/71] D6 guard: emit ::error:: via Write-Host so Actions parses the annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write-Error prefixes its output with PowerShell error formatting (file/line/category metadata), so a literal ::error::... payload no longer starts the line — and Actions' workflow-command parser looks for ::error:: at the BEGINNING of a stream line. The result was that the missing-versions.json failure produced a red error in the log but no annotation marker on the run summary. Switching to Write-Host (literal output, no PowerShell prefixing) plus the existing exit 1 keeps the annotation visible and still fails the step. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docfx.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index c709d02..7ce4e27 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -282,7 +282,7 @@ jobs: # we're preserving previously-published versions. Failing here is # safer than letting the deploy proceed and wipe the root with # whatever (possibly empty) state. - Write-Error "::error::Newly-generated docfx_project/_site/versions.json is missing — docfx generation is broken. Refusing to deploy without a verified version manifest." + Write-Host "::error::Newly-generated docfx_project/_site/versions.json is missing — docfx generation is broken. Refusing to deploy without a verified version manifest." exit 1 } $existingUrl = "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/versions.json" From 4f3fcae0ddb38148b397d5247d09d494320e00d1 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:56:57 -0400 Subject: [PATCH 66/71] Stage 1 coverage parser: accept decimal percents + floor before compare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My earlier Stage 1 hardening kept the integer-only regex [0-9]+% and the bash integer comparator -lt. ReportGenerator typically emits decimals like "92.3%" — the regex missed those, and the new matched_count=0 guard would then fail jobs whose Summary.txt had only decimal rows. Even after broadening the regex to accept decimals, bash [ errors with "integer expression expected" on a non-integer value. Two fixes: 1. Regex now matches [0-9]+(\.[0-9]+)?% so decimal percents count toward matched_count. 2. Percent is floored to an integer via awk '{print int($1)}' before the -lt comparison. Matches Stage 2 pwsh's [int][math]::Floor([double]$Matches[2]) semantically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index a438d29..db76554 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -579,9 +579,12 @@ jobs: # 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]+%$' && ! echo "$line" | grep -q '^Summary'; then + if echo "$line" | grep -qE '^[^ ].*[0-9]+(\.[0-9]+)?%$' && ! echo "$line" | grep -q '^Summary'; then module=$(echo "$line" | awk '{print $1}') - percent=$(echo "$line" | awk '{print $NF}' | tr -d '%') + # 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}%" From 9137e4517667024e304295f3220b7a27ba62ca25 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Tue, 26 May 2026 12:49:26 -0400 Subject: [PATCH 67/71] =?UTF-8?q?pr.yaml:=20fix=20Stage=202=20coverage=20p?= =?UTF-8?q?arser=20=E2=80=94=20greedy=20.*=20turned=20100%=20into=200%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stage 2 (Windows) coverage gate regex ^\s*(\S+)\s+.*(\d+(?:\.\d+)?)%\s*$ had a greedy `.*` between the module name and the trailing `\d+%`, which ate all but the last digit of the percent. On lines like Wolfgang.Extensions. 100% the regex captured percent=0 (the last "0" of "100"), reported the module as failing the 90% threshold, and tanked the entire gate even on 100%-covered code. First surfaced on DateTime-Extensions vNext — PR #189 has the original fix. Two changes to align with Stage 1 (Linux), whose awk-based parser is correct: - Anchor on `^(\S+)` to skip indented sub-class rows (Stage 1's `^[^ ]` does the same — assembly rows carry the aggregate percent, so nothing is lost). - Drop the `.*`; let `\s+` separate the module from the final `\d+%` directly, so there is no greedy region to swallow digits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index db76554..b0e53ac 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -880,11 +880,28 @@ jobs: $matchedCount = 0 foreach ($line in (Get-Content "CoverageReport/Summary.txt")) { - # Accept extra columns between the module name and the final - # percent (ReportGenerator Summary.txt commonly has line + - # branch + method coverage on the same row). Take the LAST - # percent on the line — that's the overall figure. - if ($line -match '^\s*(\S+)\s+.*(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^\s*Summary') { + # 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++ From fd9058633038044bc335c8546634444c0b1a5d34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 17:13:30 +0000 Subject: [PATCH 68/71] Bump coverlet.collector from 10.0.0 to 10.0.1 --- updated-dependencies: - dependency-name: coverlet.collector dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .../Wolfgang.Extensions.IEnumerable.Tests.Unit.csproj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Wolfgang.Extensions.IEnumerable.Tests.Unit/Wolfgang.Extensions.IEnumerable.Tests.Unit.csproj b/tests/Wolfgang.Extensions.IEnumerable.Tests.Unit/Wolfgang.Extensions.IEnumerable.Tests.Unit.csproj index 6e1eb79..fcb61f4 100644 --- a/tests/Wolfgang.Extensions.IEnumerable.Tests.Unit/Wolfgang.Extensions.IEnumerable.Tests.Unit.csproj +++ b/tests/Wolfgang.Extensions.IEnumerable.Tests.Unit/Wolfgang.Extensions.IEnumerable.Tests.Unit.csproj @@ -29,7 +29,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -46,7 +46,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -63,7 +63,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -80,7 +80,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -97,7 +97,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From e68b6d0f92a21e7f43be242587713c8ae9116e9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 17:20:09 +0000 Subject: [PATCH 69/71] Bump Meziantou.Analyzer from 3.0.85 to 3.0.93 --- updated-dependencies: - dependency-name: Meziantou.Analyzer dependency-version: 3.0.93 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index f217f94..ef33bd7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -53,7 +53,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From e0f3bf602403fb6eba5c00dc0777d9811e230edf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 17:26:54 +0000 Subject: [PATCH 70/71] Bump SonarAnalyzer.CSharp from 10.24.0.138807 to 10.26.0.140279 --- updated-dependencies: - dependency-name: SonarAnalyzer.CSharp dependency-version: 10.26.0.140279 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index ef33bd7..66c6fc7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -59,7 +59,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 96ba9dfbc1bd40545ce5e3e19ff8984d5c089795 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Thu, 28 May 2026 22:45:10 -0400 Subject: [PATCH 71/71] pr.yaml: add coverlet.runsettings to trusted-config protection lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow consumes coverlet.runsettings via dotnet test --settings coverlet.runsettings in all three test stages (Linux/Windows/macOS), but the file was not in any of the trusted-config protection lists: 1. detect-projects exact_files (guard) — a PR could modify coverlet.runsettings without triggering the maintainer-review gate. 2. The four fetch-from-main lists (one per test/scan job) — under pull_request_target, the workflow YAML is trusted but the checkout uses the PR head. Without fetching coverlet.runsettings from main, the PR-controlled version is what dotnet test reads. A PR could add **/* to coverlet.runsettings to exclude all source files from coverage, then everything that does run through the parser shows 100% coverage (or the matched-modules guard fires, depending on parser layout). Either way the coverage gate is weakened. Adding coverlet.runsettings to all 6 lists (5 fetch + 1 exact_files guard) closes the gap. The file is present in this repo and will be fetched from main in every CI job; PRs that modify it will now also trigger the maintainer-review path. Surfaced by Copilot review on PR #135. Fleet-wide canonical workflow fix — the next template-sync wave fans this out to the other 25 repos. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b0e53ac..0838a62 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -118,6 +118,7 @@ jobs: "Directory.Build.props" "Directory.Build.targets" "BannedSymbols.txt" + "coverlet.runsettings" "*.globalconfig" "*.ruleset" ".github/workflows/*.yml" @@ -192,6 +193,7 @@ jobs: "Directory.Build.props" "Directory.Build.targets" "BannedSymbols.txt" + "coverlet.runsettings" ) for config_file in "${exact_files[@]}"; do @@ -273,6 +275,7 @@ jobs: "Directory.Build.props" "Directory.Build.targets" "BannedSymbols.txt" + "coverlet.runsettings" "*.globalconfig" "*.ruleset" ".github/workflows/*.yml" @@ -674,7 +677,8 @@ jobs: ".editorconfig", "Directory.Build.props", "Directory.Build.targets", - "BannedSymbols.txt" + "BannedSymbols.txt", + "coverlet.runsettings" ) # Copy each configuration file from main branch if it exists @@ -985,6 +989,7 @@ jobs: "Directory.Build.props" "Directory.Build.targets" "BannedSymbols.txt" + "coverlet.runsettings" "*.globalconfig" "*.ruleset" ".github/workflows/*.yml" @@ -1372,6 +1377,7 @@ jobs: "Directory.Build.props" "Directory.Build.targets" "BannedSymbols.txt" + "coverlet.runsettings" "*.globalconfig" "*.ruleset" ".github/workflows/*.yml"