From 01304bce61f4a0d21cba37f23464f0bd3041b0bb Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 11:29:48 -0400 Subject: [PATCH 01/99] Re-sync unprotected template-tracked files to canonical (C1 drift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Template-drift resolution for ETL-Abstractions — the non-protected half. Re-syncs 15 template-tracked infrastructure files to the canonical repo-template. Excludes docfx_project/* (repo-specific docs), protected config/workflow files (separate PR), and .gitkeep / expected-noise files. Part of #155. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitattributes | 22 +- .github/ISSUE_TEMPLATE/maintenance-task.yaml | 76 +++++ .github/pull_request_template.md | 12 + CONTRIBUTING.md | 9 +- docs/README-FORMATTING.md | 6 +- docs/RELEASE-WORKFLOW-SETUP.md | 2 +- docs/WORKFLOW_SECURITY.md | 2 + scripts/Fix-BranchRuleset.ps1 | 258 +++++++++++++++ scripts/Setup-BranchRuleset.ps1 | 329 +++++++++++++++++++ scripts/Setup-Labels.ps1 | 32 +- scripts/Setup-Maintenance.ps1 | 159 +++++++++ scripts/Validate-DocsDeploy.sh | 280 ++++++++++++++++ scripts/format.ps1 | 10 +- scripts/setup.ps1 | 9 +- scripts/templates/maintenance-parent-body.md | 52 +++ 15 files changed, 1228 insertions(+), 30 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/maintenance-task.yaml create mode 100644 scripts/Fix-BranchRuleset.ps1 create mode 100644 scripts/Setup-BranchRuleset.ps1 create mode 100644 scripts/Setup-Maintenance.ps1 create mode 100644 scripts/Validate-DocsDeploy.sh create mode 100644 scripts/templates/maintenance-parent-body.md diff --git a/.gitattributes b/.gitattributes index 6c9e3ad8..7d7d1a92 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,15 +9,19 @@ *.fsx text eol=lf # Scripts -# PowerShell scripts: LF line endings in the index. Required for the -# `#!/usr/bin/env pwsh` shebang to work on Linux/macOS — the kernel -# parses CR as part of the interpreter name (looking for `pwsh\r`). -# BOM avoidance is enforced separately via `.editorconfig` -# (charset = utf-8 in the global [*] section); git attributes can -# only normalize line endings, not byte-order marks. -# Modern PowerShell 7+ handles LF on Windows transparently; Git's -# autocrlf still gives Windows users CRLF in their working tree if -# desired without forcing CRLF into the index. +# PowerShell scripts: enforce LF line endings in both the index AND +# the working tree (the explicit `eol=lf` overrides any user-level +# `core.autocrlf=true` setting that would otherwise convert to CRLF +# on checkout). BOM-free UTF-8 encoding is enforced separately by +# .editorconfig's global `charset = utf-8` — `.gitattributes` can +# normalize line endings (and control text/binary, diff, and merge +# behavior) but has no attribute for byte-order marks. +# +# LF is required for the `#!/usr/bin/env pwsh` shebang to work on +# Linux/macOS — the kernel parses CR as part of the interpreter name +# (looking for `pwsh\r`), and a UTF-8 BOM before `#!` would prevent +# shebang recognition entirely. Modern PowerShell 7+ handles LF on +# Windows transparently. *.ps1 text eol=lf diff --git a/.github/ISSUE_TEMPLATE/maintenance-task.yaml b/.github/ISSUE_TEMPLATE/maintenance-task.yaml new file mode 100644 index 00000000..9c8695d2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/maintenance-task.yaml @@ -0,0 +1,76 @@ +name: "✨ Maintenance task" +description: "Track an actionable improvement under the Maintenance framework (security, performance, testing, cleanup, docs, API, or CI/CD)." +title: "[Maintenance] : " +labels: [maintenance-task] +assignees: [] +body: + - type: markdown + attributes: + value: | + ## Maintenance framework sub-issue + + This template creates a **`maintenance-task`** sub-issue under this repo's parent `Maintenance: ` issue. The new issue will auto-appear in the cross-repo Maintenance project board (URL listed in the parent `Maintenance: ` issue body). + + - Pick exactly **one** category in the dropdown below. The corresponding `maintenance - ` label will need to be added manually after creation (issue forms don't yet support dynamic label addition). + - Fill in **Scope** (what's done & why), **Acceptance** (when do we close this?), and **Links** (PRs, scan output, related issues). + - If you're closing this issue via a PR, include `Fixes #` in the PR body so the auto-add workflow marks the project item as Done. + + - type: dropdown + id: category + attributes: + label: Category + description: "Pick exactly one. After creation, also add the corresponding `maintenance - ` label." + options: + - "security — scans, finding fixes, dependency vulnerability audit" + - "performance — profile, benchmark, optimize, validate gains" + - "testing — coverage, integration/smoke/mutation tests, fixtures" + - "cleanup — refactor for reuse / quality / efficiency" + - "docs — XML doc coverage, README, CHANGELOG, samples" + - "API — public/internal surface audit, breaking-change vigilance" + - "CI/CD — Docker, CI workflow, build/publish pipeline" + validations: + required: true + + - type: textarea + id: scope + attributes: + label: Scope + description: "What needs to be done? What's the motivation?" + placeholder: | + e.g. + - Profile the hot path in `Extractor.ExtractAsync` and identify the dominant allocations. + - Goal: reduce per-record allocations to enable streaming larger inputs. + validations: + required: true + + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: "When do we close this issue? Concrete observable outcomes." + placeholder: | + e.g. + - Benchmark X shows <50% of current allocations. + - No regression in existing benchmark suite (>5%). + - Updated benchmark numbers committed. + validations: + required: true + + - type: textarea + id: links + attributes: + label: Links + description: "Related PRs, scan output, prior issues, external references." + placeholder: | + - Related PR: #... + - Scan output: ... + - Related issue: #... + validations: + required: false + + - type: markdown + attributes: + value: | + --- + + 💡 **Tip for agents (Copilot etc.):** When opening a maintenance-task sub-issue, use this template and pick the matching category. After creation, add the `maintenance - ` label manually. See `.github/copilot-instructions.md` for the full Maintenance framework convention. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7f072991..f3c82434 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,6 +4,18 @@ Fixes/Complete # (issue) + + + ## Type of change Please delete options that are not relevant. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87188fd2..8ec5c98e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to Wolfgang.Etl.Abstractions +# Contributing to {{PROJECT_NAME}} -Thank you for your interest in contributing to **Wolfgang.Etl.Abstractions**! We welcome contributions to help improve this project. +Thank you for your interest in contributing to **{{PROJECT_NAME}}**! We welcome contributions to help improve this project. ## How Can You Contribute? @@ -182,10 +182,10 @@ dotnet format dotnet format --verify-no-changes # PowerShell formatting script -pwsh ./scripts/format.ps1 +pwsh ./format.ps1 ``` -See [README-FORMATTING.md](docs/README-FORMATTING.md) for detailed formatting rules. +See [README-FORMATTING.md](README-FORMATTING.md) for detailed formatting rules. --- @@ -237,4 +237,3 @@ Please be respectful and considerate in all interactions. See [CODE_OF_CONDUCT.m --- Thank you for contributing! 🎉 - diff --git a/docs/README-FORMATTING.md b/docs/README-FORMATTING.md index 9ddea552..b420e1ac 100644 --- a/docs/README-FORMATTING.md +++ b/docs/README-FORMATTING.md @@ -4,7 +4,7 @@ This repository uses `dotnet format` to enforce consistent C# code style. ## Prerequisites -The `dotnet format` command is **built into the .NET SDK** starting with .NET 6 and later. Since this project requires .NET 8.0 SDK or later, you already have `dotnet format` available — no separate tool installation is needed. +The `dotnet format` command is **built into the .NET SDK** starting with .NET 6 — no separate tool installation is needed. In practice `dotnet format` still has to load and evaluate the project, so you need an SDK new enough to handle this repo's target frameworks: use the SDK version(s) installed by `.github/workflows/pr.yaml` (and `global.json` if present). The latest stable .NET SDK is generally a safe choice. > **Note:** The standalone `dotnet-format` global tool was deprecated when `dotnet format` was integrated into the .NET 6 SDK in August 2021. @@ -56,10 +56,10 @@ Most IDEs automatically read `.editorconfig`: ## Formatting Rules -Authoritative rules live in `.editorconfig` (and `.gitattributes` for line endings, which may override the `.editorconfig` defaults for specific file types — e.g. forcing CRLF on `*.ps1`). The list below is a quick orientation; check those files for the binding values: +Authoritative rules live in `.editorconfig` (and `.gitattributes` for line endings, which enforces LF across all text file types in this repo — including `*.ps1`, which historically used CRLF but is now LF for cross-platform shebang compatibility). The list below is a quick orientation; check those files for the binding values: - **Indentation**: 4 spaces for C#, 2 for XML/JSON (per `.editorconfig`) - **Braces**: Opening brace on its own line -- **Line endings**: LF for source/docs, with file-type overrides in `.gitattributes` (e.g. CRLF for `*.ps1`) +- **Line endings**: LF for all text files (per `.gitattributes`), including PowerShell scripts - **Trailing whitespace**: Removed - **Using directives**: System namespaces first, sorted alphabetically diff --git a/docs/RELEASE-WORKFLOW-SETUP.md b/docs/RELEASE-WORKFLOW-SETUP.md index 45eeb1d9..3aa8fba6 100644 --- a/docs/RELEASE-WORKFLOW-SETUP.md +++ b/docs/RELEASE-WORKFLOW-SETUP.md @@ -212,7 +212,7 @@ Before creating a production GitHub Release (e.g., `v1.0.0`): If you encounter issues not covered in this guide: -1. Check the [Actions tab](../../actions) for detailed logs +1. Check the Actions tab of this repository on GitHub for detailed logs 2. Review artifacts uploaded by failed jobs 3. Consult the [GitHub Actions documentation](https://docs.github.com/en/actions) 4. Open an issue in this repository with: diff --git a/docs/WORKFLOW_SECURITY.md b/docs/WORKFLOW_SECURITY.md index e27dea70..12c06a88 100644 --- a/docs/WORKFLOW_SECURITY.md +++ b/docs/WORKFLOW_SECURITY.md @@ -62,6 +62,8 @@ In addition to the overwrite step, a separate "Detect protected configuration fi "BannedSymbols.txt" "*.globalconfig" "*.ruleset" + ".github/workflows/*.yml" + ".github/workflows/*.yaml" ) # Copy each configuration file from main branch if it exists diff --git a/scripts/Fix-BranchRuleset.ps1 b/scripts/Fix-BranchRuleset.ps1 new file mode 100644 index 00000000..d9cdb881 --- /dev/null +++ b/scripts/Fix-BranchRuleset.ps1 @@ -0,0 +1,258 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Fixes branch rulesets by disabling existing ones and recreating with the correct configuration. + +.DESCRIPTION + This script inspects the existing branch rulesets for a repository, disables all of them, + and renames any ruleset named "Protect main branch" to "Protect main branch (old)" so that + Setup-BranchRuleset.ps1 can create a fresh ruleset without conflicts. + + The script presents a plan of all changes before executing and prompts for confirmation. + +.PARAMETER Repository + The repository in owner/repo format. If not provided, uses the current repository. + +.PARAMETER Force + Skip the confirmation prompt and proceed automatically. Alias: -y + +.EXAMPLE + .\Fix-BranchRuleset.ps1 + Inspects and fixes rulesets for the current repository with interactive confirmation + +.EXAMPLE + .\Fix-BranchRuleset.ps1 -Force + Inspects and fixes rulesets without prompting for confirmation + +.EXAMPLE + .\Fix-BranchRuleset.ps1 -Repository "Chris-Wolfgang/my-repo" + Inspects and fixes rulesets for a specific repository + +.NOTES + Requires: GitHub CLI (gh) authenticated with admin permissions + Install gh: https://cli.github.com/ +#> + +[CmdletBinding()] +param( + [Parameter()] + [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", + + [Parameter()] + [Alias("y")] + [switch]$Force +) + +# Check if gh CLI is installed +try { + $null = gh --version +} catch { + Write-Error "GitHub CLI (gh) is not installed or not in PATH." + Write-Host "Install from: https://cli.github.com/" -ForegroundColor Yellow + exit 1 +} + +# Check if authenticated +try { + $null = gh auth status 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "Not authenticated with GitHub CLI." + Write-Host "Run: gh auth login" -ForegroundColor Yellow + exit 1 + } +} catch { + Write-Error "Failed to check GitHub CLI authentication status." + exit 1 +} + +# Determine repository +if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { + Write-Host "Detecting current repository..." -ForegroundColor Cyan + try { + $repoInfo = gh repo view --json nameWithOwner | ConvertFrom-Json + $Repository = $repoInfo.nameWithOwner + Write-Host "Using repository: $Repository" -ForegroundColor Green + } catch { + if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { + Write-Error "Could not detect repository. Please run the setup script first to replace placeholders, or specify -Repository parameter." + } else { + Write-Error "Could not detect repository. Please run from within a git repository or specify -Repository parameter." + } + exit 1 + } +} else { + Write-Host "Using specified repository: $Repository" -ForegroundColor Green +} + +# Fetch all rulesets +Write-Host "`nFetching existing rulesets..." -ForegroundColor Cyan + +try { + $rulesetsJson = gh api ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/rulesets" ` + --paginate 2>&1 + + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to fetch rulesets: $rulesetsJson" + exit 1 + } + + $rulesets = $rulesetsJson | ConvertFrom-Json +} catch { + Write-Error "Failed to fetch rulesets: $($_.Exception.Message)" + exit 1 +} + +if (-not $rulesets -or $rulesets.Count -eq 0) { + Write-Host "No rulesets found for $Repository. Nothing to fix." -ForegroundColor Green + exit 0 +} + +# Build the plan +$plan = @() +$targetRulesetName = "Protect main branch" + +Write-Host "`nFound $($rulesets.Count) ruleset(s):" -ForegroundColor Cyan +Write-Host "" + +foreach ($ruleset in $rulesets) { + $status = if ($ruleset.enforcement -eq "disabled") { "disabled" } else { $ruleset.enforcement } + Write-Host " [$($ruleset.id)] $($ruleset.name) (enforcement: $status)" -ForegroundColor Gray + + $actions = @() + + # If this is the target name, rename it + if ($ruleset.name -eq $targetRulesetName) { + $actions += @{ + type = "rename" + description = "Rename '$($ruleset.name)' -> '$($ruleset.name) (old)'" + newName = "$($ruleset.name) (old)" + } + } + + # If not already disabled, disable it + if ($ruleset.enforcement -ne "disabled") { + $actions += @{ + type = "disable" + description = "Disable '$($ruleset.name)' (currently: $status)" + } + } + + if ($actions.Count -gt 0) { + $plan += @{ + ruleset = $ruleset + actions = $actions + } + } +} + +Write-Host "" + +# Present the plan +if ($plan.Count -eq 0) { + Write-Host "All rulesets are already disabled and none need renaming. Nothing to do." -ForegroundColor Green + exit 0 +} + +Write-Host "Planned changes:" -ForegroundColor Yellow +Write-Host "" + +$stepNumber = 1 +foreach ($item in $plan) { + foreach ($action in $item.actions) { + Write-Host " $stepNumber. $($action.description)" -ForegroundColor White + $stepNumber++ + } +} + +Write-Host "" + +# Prompt for confirmation +if ($Force) { + Write-Host "Auto-confirmed via -Force flag." -ForegroundColor Green +} else { + $response = Read-Host "Proceed with these changes? (y/N)" + if ($response -ne 'y' -and $response -ne 'Y') { + Write-Host "Cancelled. No changes were made." -ForegroundColor Yellow + exit 0 + } +} + +Write-Host "" + +# Execute the plan +$errors = 0 + +foreach ($item in $plan) { + $ruleset = $item.ruleset + $rulesetId = $ruleset.id + + # Build the update payload — apply rename and disable together in one API call + $updatePayload = @{} + + foreach ($action in $item.actions) { + switch ($action.type) { + "rename" { + $updatePayload["name"] = $action.newName + } + "disable" { + $updatePayload["enforcement"] = "disabled" + } + } + } + + if ($updatePayload.Count -gt 0) { + $descriptions = ($item.actions | ForEach-Object { $_.description }) -join " + " + Write-Host " Updating ruleset [$rulesetId]: $descriptions..." -ForegroundColor Cyan + + $jsonPayload = $updatePayload | ConvertTo-Json -Depth 5 + $tempFile = [System.IO.Path]::GetTempFileName() + $jsonPayload | Out-File -FilePath $tempFile -Encoding utf8NoBOM + + try { + $result = gh api ` + --method PUT ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/rulesets/$rulesetId" ` + --input $tempFile 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Host " Done." -ForegroundColor Green + } else { + Write-Host " Failed: $result" -ForegroundColor Red + $errors++ + } + } catch { + Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red + $errors++ + } finally { + if (Test-Path $tempFile) { + Remove-Item $tempFile -Force + } + } + } +} + +Write-Host "" + +if ($errors -gt 0) { + Write-Host "$errors action(s) failed. Review the errors above." -ForegroundColor Red + exit 1 +} else { + Write-Host "All changes applied successfully." -ForegroundColor Green + Write-Host "" + + # Invoke Setup-BranchRuleset.ps1 to create a fresh ruleset + $setupScript = Join-Path $PSScriptRoot "Setup-BranchRuleset.ps1" + if (Test-Path $setupScript) { + Write-Host "Running Setup-BranchRuleset.ps1 to create a fresh ruleset..." -ForegroundColor Cyan + Write-Host "" + & $setupScript -Repository $Repository + } else { + Write-Host "Setup-BranchRuleset.ps1 not found. Run it manually to create a fresh ruleset." -ForegroundColor Yellow + Write-Host "View rulesets at: https://github.com/$Repository/settings/rules" -ForegroundColor Cyan + } +} diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1 new file mode 100644 index 00000000..c5e8ab91 --- /dev/null +++ b/scripts/Setup-BranchRuleset.ps1 @@ -0,0 +1,329 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Creates a branch protection ruleset for the main branch in the current repository. + +.DESCRIPTION + This script uses the GitHub CLI (gh) to create a repository ruleset that protects + the main branch with pull request requirements, required status checks, security + scanning rules, and automatic Copilot code review. + Run this locally after creating a new repo from the template. + + The script will prompt you to choose between single-developer or multi-developer + repository settings: + - Single Developer: No PR approvals required (you can merge your own PRs) + - Multi-Developer: Requires 1+ approval and code owner review + + The ruleset includes: + - Pull request reviews with configurable approval requirements + - Required status checks (tests, security scans) + - Force push and deletion protection + +.PARAMETER Repository + The repository in owner/repo format. If not provided, uses the current repository. + +.PARAMETER BranchName + The branch to protect. Default is "main". + +.EXAMPLE + .\Setup-BranchRuleset.ps1 + Creates the ruleset for the current repository with interactive prompts + +.EXAMPLE + .\Setup-BranchRuleset.ps1 -Repository "Chris-Wolfgang/my-repo" + Creates the ruleset for a specific repository + +.NOTES + Requires: GitHub CLI (gh) authenticated with sufficient permissions + Install gh: https://cli.github.com/ + + Required Permissions: + - Admin access to the repository, OR + - Write access with "Administration" permission enabled + + These permissions are necessary to create and modify repository rulesets. +#> + +[CmdletBinding()] +param( + [Parameter()] + [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", + + [Parameter()] + [string]$BranchName = "main" +) + +# Check if gh CLI is installed +try { + $null = gh --version +} catch { + Write-Error "❌ GitHub CLI (gh) is not installed or not in PATH." + Write-Host "Install from: https://cli.github.com/" -ForegroundColor Yellow + exit 1 +} + +# Check if authenticated +try { + $null = gh auth status 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ Not authenticated with GitHub CLI." + Write-Host "Run: gh auth login" -ForegroundColor Yellow + exit 1 + } +} catch { + Write-Error "❌ Failed to check GitHub CLI authentication status." + exit 1 +} + +# Determine repository +if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { + # Placeholders not replaced or no repository specified - auto-detect + Write-Host "🔍 Detecting current repository..." -ForegroundColor Cyan + try { + $repoInfo = gh repo view --json nameWithOwner | ConvertFrom-Json + $Repository = $repoInfo.nameWithOwner + Write-Host "✅ Using repository: $Repository" -ForegroundColor Green + } catch { + if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { + Write-Error "❌ Could not detect repository. Please run the setup script (pwsh ./scripts/setup.ps1) first to replace placeholders, or specify -Repository parameter." + } else { + Write-Error "❌ Could not detect repository. Please run from within a git repository or specify -Repository parameter." + } + exit 1 + } +} else { + Write-Host "✅ Using specified repository: $Repository" -ForegroundColor Green +} + +Write-Host "`n🛡️ Setting up branch protection ruleset for: $Repository" -ForegroundColor Cyan +Write-Host "📌 Protected branch: $BranchName`n" -ForegroundColor Cyan + +# Check if ruleset already exists +Write-Host "🔍 Checking for existing rulesets..." -ForegroundColor Yellow +try { + $rulesetOutput = gh api ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/rulesets" ` + --paginate ` + --jq '.[] | select(.name == "Protect main branch")' 2>&1 + + if ($LASTEXITCODE -ne 0) { + Write-Warning "⚠️ Could not check for existing rulesets (API returned exit code $LASTEXITCODE). Continuing..." + } elseif ($rulesetOutput) { + $matchingRulesets = $rulesetOutput | ConvertFrom-Json + $existingRuleset = $matchingRulesets | Select-Object -First 1 + + if ($existingRuleset) { + Write-Host "✅ Ruleset 'Protect main branch' already exists!" -ForegroundColor Green + Write-Host " View it at: https://github.com/$Repository/settings/rules" -ForegroundColor Cyan + $response = Read-Host "`nDo you want to continue anyway? This may fail. (y/N)" + if ($response -ne 'y' -and $response -ne 'Y') { + Write-Host "Exiting." -ForegroundColor Yellow + exit 0 + } + } + } else { + Write-Host "ℹ️ Ruleset 'Protect main branch' does not exist yet." -ForegroundColor Gray + } +} catch { + Write-Warning "⚠️ Could not check for existing rulesets: $($_.Exception.Message). Continuing..." +} + +# Prompt for repository type +Write-Host "`n👥 Repository Type Configuration" -ForegroundColor Cyan +Write-Host "" +Write-Host "Is this a single-developer or multi-developer repository?" -ForegroundColor Yellow +Write-Host "" +Write-Host " [1] Single Developer - No PR approvals required (you can merge your own PRs)" -ForegroundColor Gray +Write-Host " [2] Multi-Developer - Requires 1+ approval and code owner review" -ForegroundColor Gray +Write-Host "" +$repoTypeChoice = Read-Host "Enter your choice (1 or 2) [default: 1]" + +# Set defaults based on choice +$requireApprovals = 0 +$requireCodeOwnerReview = $false + +if ($repoTypeChoice -eq "2") { + $requireApprovals = 1 + $requireCodeOwnerReview = $true + Write-Host "✅ Configured for multi-developer repository (1 approval required)" -ForegroundColor Green +} else { + Write-Host "✅ Configured for single-developer repository (no approvals required)" -ForegroundColor Green +} + +# Create ruleset configuration +Write-Host "`n📝 Creating ruleset configuration..." -ForegroundColor Cyan + +$rulesetConfig = @{ + name = "Protect main branch" + target = "branch" + enforcement = "active" + conditions = @{ + ref_name = @{ + include = @("refs/heads/$BranchName") + exclude = @() + } + } + # No bypass actors allowed - all users (including admins) must follow branch protection rules + bypass_actors = @() + rules = @( + @{ + type = "pull_request" + parameters = @{ + required_approving_review_count = $requireApprovals + dismiss_stale_reviews_on_push = $true + require_code_owner_review = $requireCodeOwnerReview + require_last_push_approval = $false + required_review_thread_resolution = $true + } + }, + @{ + type = "required_status_checks" + parameters = @{ + strict_required_status_checks_policy = $true + # IMPORTANT: Workflows providing these required checks (specifically .github/workflows/pr.yaml) + # must NOT have path filters (paths/paths-ignore). If a workflow is path-filtered + # and doesn't run for a PR, GitHub will treat the required check as missing and + # block the merge. All required status checks must run on every PR. + required_status_checks = @( + @{ context = "Detect .NET Projects" }, + @{ context = "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" }, + @{ context = "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" }, + @{ context = "Stage 3: macOS Tests (.NET 6.0-10.0)" }, + @{ context = "Security Scan (DevSkim)" }, + @{ context = "Security Scan (CodeQL) (csharp)" }, + @{ context = "Secrets Scan (gitleaks)" } + ) + } + }, + @{ + type = "non_fast_forward" + }, + @{ + type = "deletion" + }, + # The CodeQL alerts-dashboard gate. Only blocks merges when the alerts + # threshold is exceeded; the underlying CodeQL workflow already runs as + # a required status check above, so this is the second-tier "results" + # gate. Activate it only AFTER the CodeQL workflow has completed at + # least one successful run — without prior analyses it blocks all PRs. + @{ + type = "code_scanning" + parameters = @{ + code_scanning_tools = @( + @{ + alerts_threshold = "errors" + security_alerts_threshold = "high_or_higher" + tool = "CodeQL" + } + ) + } + }, + # Auto-request a Copilot review on every PR, including drafts and on + # subsequent pushes. The rulesets API now supports this rule type + # (earlier versions of this script left the toggle to the UI). + @{ + type = "copilot_code_review" + parameters = @{ + review_draft_pull_requests = $true + review_on_push = $true + } + }, + # Block merges when the code-quality check (analyzer / formatter) emits + # errors. Severity matches the canonical libraries (errors only — warnings + # don't block, the build itself already promotes them in Release mode). + @{ + type = "code_quality" + parameters = @{ + severity = "errors" + } + } + ) +} + +# Convert to JSON +$jsonConfig = $rulesetConfig | ConvertTo-Json -Depth 10 + +# Save to temporary file +$tempFile = [System.IO.Path]::GetTempFileName() +$jsonConfig | Out-File -FilePath $tempFile -Encoding utf8NoBOM + +try { + Write-Host "🚀 Creating branch ruleset..." -ForegroundColor Cyan + + # Create the ruleset + $response = gh api ` + --method POST ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/rulesets" ` + --input $tempFile 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Host "`n✅ Successfully created branch ruleset 'Protect main branch'!" -ForegroundColor Green + Write-Host "`n🛡️ Protection Rules Enabled:" -ForegroundColor Cyan + Write-Host " ✅ Pull requests required before merging" -ForegroundColor Gray + if ($requireApprovals -gt 0) { + Write-Host " ✅ Required approvals: $requireApprovals" -ForegroundColor Gray + Write-Host " ✅ Code owner review required" -ForegroundColor Gray + } else { + Write-Host " ✅ No approvals required (single-developer mode)" -ForegroundColor Gray + } + Write-Host " ✅ Required status checks (must pass before merging):" -ForegroundColor Gray + Write-Host " - Detect .NET Projects" -ForegroundColor DarkGray + Write-Host " - Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" -ForegroundColor DarkGray + Write-Host " - Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" -ForegroundColor DarkGray + Write-Host " - Stage 3: macOS Tests (.NET 6.0-10.0)" -ForegroundColor DarkGray + Write-Host " - Security Scan (DevSkim)" -ForegroundColor DarkGray + Write-Host " - Security Scan (CodeQL) (csharp)" -ForegroundColor DarkGray + Write-Host " - Secrets Scan (gitleaks)" -ForegroundColor DarkGray + Write-Host " ✅ Branches must be up to date before merging" -ForegroundColor Gray + Write-Host " ✅ Conversation resolution required before merging" -ForegroundColor Gray + Write-Host " ✅ Stale reviews dismissed when new commits are pushed" -ForegroundColor Gray + Write-Host " ✅ Force pushes blocked on $BranchName branch" -ForegroundColor Gray + Write-Host " ✅ Branch deletion prevented for $BranchName" -ForegroundColor Gray + Write-Host " ✅ Code scanning: CodeQL alerts gate (errors / high+)" -ForegroundColor Gray + Write-Host " ✅ Copilot code review: auto-requested on every PR (incl. drafts, on push)" -ForegroundColor Gray + Write-Host " ✅ Code quality gate: blocks on analyzer / formatter errors" -ForegroundColor Gray + Write-Host " ✅ No bypass allowed - all users must follow these rules" -ForegroundColor Gray + + Write-Host "`n🔗 View ruleset at:" -ForegroundColor Cyan + Write-Host " https://github.com/$Repository/settings/rules" -ForegroundColor Blue + } else { + Write-Error "❌ Failed to create ruleset" + Write-Host $response -ForegroundColor Red + + if ($response -like "*403*" -or $response -like "*Resource not accessible*") { + Write-Host "`n💡 This error usually means:" -ForegroundColor Yellow + Write-Host " 1. You don't have admin access to this repository, OR" -ForegroundColor Yellow + Write-Host " 2. Your GitHub authentication doesn't have the required scopes" -ForegroundColor Yellow + Write-Host "`n🔧 Try re-authenticating with:" -ForegroundColor Cyan + Write-Host " gh auth login" -ForegroundColor Gray + Write-Host " For more information about required scopes, see: https://cli.github.com/manual/gh_auth_login" -ForegroundColor Gray + } + + if ($response -like "*422*" -or $response -like "*Validation Failed*") { + Write-Host "`n💡 This validation error usually means:" -ForegroundColor Yellow + Write-Host " 1. The repository doesn't meet the requirements for rulesets (e.g., needs to be a GitHub Pro/Team/Enterprise repo)" -ForegroundColor Yellow + Write-Host " 2. Some configuration in the ruleset is invalid for this repository type" -ForegroundColor Yellow + Write-Host " 3. Required workflows or status checks might not exist yet" -ForegroundColor Yellow + Write-Host "`n🔧 Possible solutions:" -ForegroundColor Cyan + Write-Host " - Verify this is a GitHub Pro, Team, or Enterprise repository" -ForegroundColor Gray + Write-Host " - Check that the required workflows exist in .github/workflows/" -ForegroundColor Gray + Write-Host " - Ensure you have admin permissions on the repository" -ForegroundColor Gray + } + + exit 1 + } +} catch { + Write-Error "❌ An error occurred: $_" + exit 1 +} finally { + # Clean up temp file + if (Test-Path $tempFile) { + Remove-Item $tempFile -Force + } +} + +Write-Host "`n🎉 Setup complete!" -ForegroundColor Green diff --git a/scripts/Setup-Labels.ps1 b/scripts/Setup-Labels.ps1 index bb3a6419..09da36d8 100644 --- a/scripts/Setup-Labels.ps1 +++ b/scripts/Setup-Labels.ps1 @@ -1,3 +1,4 @@ +#!/usr/bin/env pwsh <# .SYNOPSIS Creates custom GitHub labels for the repository. @@ -7,10 +8,16 @@ other workflows. Run this locally once after creating a new repo from the template. Labels created: - - dependabot - security (red) - - dependabot-dependencies (orange) - - dependencies (blue) - - dotnet (purple) + - dependencies (blue) — applied automatically by Dependabot to every update PR + - maintenance (steel) — kind label, applied to the per-repo parent Maintenance issue + - maintenance-task (steel) — kind label, applied to every Maintenance sub-issue + - maintenance - security (red) — category: scans, finding fixes, dependency vuln audit + - maintenance - performance (green) — category: profile, benchmark, optimize, validate + - maintenance - testing (gold) — category: coverage, integration/smoke/mutation tests + - maintenance - cleanup (brown) — category: refactor for reuse / quality / efficiency + - maintenance - docs (blue) — category: XML docs, README, CHANGELOG, samples + - maintenance - API (orange) — category: public/internal surface audit + - maintenance - CI/CD (pink) — category: Docker, CI workflow, build/publish pipeline .PARAMETER Repository The repository in owner/repo format. If not provided, uses the current repository. @@ -74,10 +81,21 @@ if (-not $Repository) { Write-Host "`n🏷️ Creating labels for: $Repository`n" -ForegroundColor Cyan $labels = @( - @{ name = "dependabot - security"; color = "b60205"; description = "Security update from Dependabot" }, - @{ name = "dependabot-dependencies"; color = "d93f0b"; description = "Dependency update from Dependabot" }, + # Dependabot — applies `dependencies` automatically per .github/dependabot.yml @{ name = "dependencies"; color = "0366d6"; description = "Pull requests that update a dependency file" }, - @{ name = "dotnet"; color = "512bd4"; description = ".NET related changes" } + + # Maintenance framework — kind labels (neutral steel: the meta is colorless) + @{ name = "maintenance"; color = "9aa7b3"; description = "Per-repo parent Maintenance issue (living improvement menu)" }, + @{ name = "maintenance-task"; color = "5a6c7d"; description = "A Maintenance sub-issue — actionable improvement work" }, + + # Maintenance framework — category labels (applied to sub-issues) + @{ name = "maintenance - security"; color = "c4161c"; description = "Maintenance: scans, finding fixes, dependency vulnerability audit" }, + @{ name = "maintenance - performance"; color = "2cbe4e"; description = "Maintenance: profile, benchmark, optimize, validate gains" }, + @{ name = "maintenance - testing"; color = "f9c513"; description = "Maintenance: coverage %, integration/smoke/mutation tests, fixtures" }, + @{ name = "maintenance - cleanup"; color = "a2845e"; description = "Maintenance: refactor for reuse, quality, efficiency" }, + @{ name = "maintenance - docs"; color = "0075ca"; description = "Maintenance: XML doc coverage, README, CHANGELOG, samples" }, + @{ name = "maintenance - API"; color = "ed7d31"; description = "Maintenance: public/internal surface audit, breaking-change vigilance" }, + @{ name = "maintenance - CI/CD"; color = "ec6cb9"; description = "Maintenance: Docker, CI workflow, build/publish pipeline" } ) $created = 0 diff --git a/scripts/Setup-Maintenance.ps1 b/scripts/Setup-Maintenance.ps1 new file mode 100644 index 00000000..ae884871 --- /dev/null +++ b/scripts/Setup-Maintenance.ps1 @@ -0,0 +1,159 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Creates the per-repo parent "Maintenance" issue that anchors the Maintenance framework. + +.DESCRIPTION + The Maintenance framework tracks ongoing improvement work (security, performance, testing, + cleanup, docs, API, CI/CD) across all Chris-Wolfgang .NET code repos. Each repo has + one parent Maintenance issue (this script creates it) that documents candidate work by + category. Actual tracked work lives in sub-issues labeled `maintenance-task` plus a + `maintenance - ` label, and rolls up into a cross-repo GitHub Projects v2 board. + + This script is idempotent — if a `Maintenance: ` issue with the `maintenance` label + already exists in the target repository, the script reports it and exits 0. + + Requires that the labels `maintenance` and `maintenance-task` (plus the 7 category labels) + already exist in the target repo. Run Setup-Labels.ps1 first. + +.PARAMETER Repository + The repository in owner/repo format. If not provided, uses the current repository. + +.PARAMETER MaintenanceProjectUrl + The URL of the cross-repo Maintenance Projects v2 board (e.g. + https://github.com/users/Chris-Wolfgang/projects/N). Substituted into the issue body. + +.EXAMPLE + .\Setup-Maintenance.ps1 -MaintenanceProjectUrl 'https://github.com/users/Chris-Wolfgang/projects/5' + Creates the parent Maintenance issue for the current repository. + +.EXAMPLE + .\Setup-Maintenance.ps1 -Repository 'Chris-Wolfgang/my-repo' -MaintenanceProjectUrl 'https://github.com/users/Chris-Wolfgang/projects/5' + Creates the parent Maintenance issue for a specific repository. + +.NOTES + Requires: GitHub CLI (gh) authenticated with sufficient permissions. + Install gh: https://cli.github.com/ +#> + +[CmdletBinding()] +param( + [Parameter()] + [string]$Repository, + + [Parameter(Mandatory = $true)] + [string]$MaintenanceProjectUrl +) + +# Check gh CLI +try { + $null = gh --version +} catch { + Write-Error "❌ GitHub CLI (gh) is not installed or not in PATH." + Write-Host "Install from: https://cli.github.com/" -ForegroundColor Yellow + exit 1 +} + +try { + $null = gh auth status 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ Not authenticated with GitHub CLI." + Write-Host "Run: gh auth login" -ForegroundColor Yellow + exit 1 + } +} catch { + Write-Error "❌ Failed to check GitHub CLI authentication status." + exit 1 +} + +# Determine repository +if (-not $Repository) { + Write-Host "🔍 Detecting current repository..." -ForegroundColor Cyan + try { + $repoInfo = gh repo view --json nameWithOwner | ConvertFrom-Json + $Repository = $repoInfo.nameWithOwner + Write-Host "✅ Using repository: $Repository" -ForegroundColor Green + } catch { + Write-Error "❌ Could not detect repository. Please run from within a git repository or specify -Repository parameter." + exit 1 + } +} + +# Repository's bare name (after the /) +$repoName = ($Repository -split '/')[-1] +$issueTitle = "Maintenance: $repoName" + +# Idempotency: check if a parent Maintenance issue already exists. +# Limit is large (1000) so accidental over-use of the `maintenance` label can't cause +# the check to miss the actual parent and create a duplicate. After fetching, +# filter to exact title match. +Write-Host "`n🔍 Checking for existing parent Maintenance issue..." -ForegroundColor Cyan +# Capture stdout and stderr separately so JSON parsing isn't corrupted by +# any warnings gh emits to stderr. Only stdout is fed to ConvertFrom-Json. +$stderrFile = Join-Path ([IO.Path]::GetTempPath()) "setup-maintenance-stderr-$([guid]::NewGuid()).txt" +try { + $existing = gh issue list ` + --repo $Repository ` + --label 'maintenance' ` + --state all ` + --json number,title,state ` + --limit 1000 2> $stderrFile + + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ Failed to query existing issues. Verify the 'maintenance' label exists in $Repository (run Setup-Labels.ps1 first)." + if (Test-Path $stderrFile) { Write-Host (Get-Content $stderrFile -Raw) -ForegroundColor Red } + exit 1 + } +} finally { + Remove-Item -Path $stderrFile -ErrorAction SilentlyContinue +} + +$matches = $existing | ConvertFrom-Json | Where-Object { $_.title -eq $issueTitle } +if ($matches) { + $match = $matches | Select-Object -First 1 + Write-Host "⏭️ Parent Maintenance issue already exists: #$($match.number) [$($match.state)]" -ForegroundColor Gray + Write-Host " https://github.com/$Repository/issues/$($match.number)" -ForegroundColor Gray + exit 0 +} + +# Read canonical body template +$scriptDir = Split-Path -Parent $PSCommandPath +$templatePath = Join-Path $scriptDir 'templates/maintenance-parent-body.md' + +if (-not (Test-Path $templatePath)) { + Write-Error "❌ Canonical body template not found at: $templatePath" + Write-Host "Expected the file at scripts/templates/maintenance-parent-body.md relative to this script." -ForegroundColor Yellow + exit 1 +} + +$body = Get-Content -Path $templatePath -Raw + +# Literal string replacement (.Replace) rather than -replace, since +# -replace's right-hand-side honors regex tokens like '$' and we don't want +# to alter the URL the caller passed in. +$body = $body.Replace('{{MAINTENANCE_PROJECT_URL}}', $MaintenanceProjectUrl) + +# Write body to a temp file to avoid command-line length / quoting issues. +# Use [IO.Path]::GetTempPath() so this works on Linux/macOS (where $env:TEMP +# can be unset) as well as Windows. +$bodyFile = Join-Path ([IO.Path]::GetTempPath()) "maintenance-parent-body-$([guid]::NewGuid()).md" +Set-Content -Path $bodyFile -Value $body -Encoding utf8NoBOM + +try { + Write-Host "`n📝 Creating parent Maintenance issue in $Repository..." -ForegroundColor Cyan + $createResult = gh issue create ` + --repo $Repository ` + --title $issueTitle ` + --body-file $bodyFile ` + --label 'maintenance' 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Host "✅ Created: $createResult" -ForegroundColor Green + } else { + Write-Error "❌ Failed to create parent Maintenance issue." + Write-Host $createResult -ForegroundColor Red + exit 1 + } +} finally { + Remove-Item -Path $bodyFile -ErrorAction SilentlyContinue +} diff --git a/scripts/Validate-DocsDeploy.sh b/scripts/Validate-DocsDeploy.sh new file mode 100644 index 00000000..1f89fea1 --- /dev/null +++ b/scripts/Validate-DocsDeploy.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash +# Validate-DocsDeploy.sh +# +# Validates the gh-pages branch contents after a DocFX deployment. +# Checks that the root contains index.html and versions.json, that +# versions.json is correctly structured, that every referenced version +# folder exists with an index.html, and that no known stale DocFX root +# artifacts remain. +# +# Usage: +# bash scripts/Validate-DocsDeploy.sh +# +# Requirements: git, python3 + +set -euo pipefail + +PASS=0 +FAIL=0 + +check_pass() { echo " ✅ $1"; PASS=$((PASS + 1)); } +check_fail() { echo " ❌ $1"; FAIL=$((FAIL + 1)); } +check_warn() { echo " ⚠️ $1"; } + +echo "" +echo "╔══════════════════════════════════════════════════════╗" +echo "║ DocFX Deployment Validation ║" +echo "╚══════════════════════════════════════════════════════╝" +echo "" + +# ------------------------------------------------------------------ +# 1. Verify the gh-pages branch exists on the remote +# ------------------------------------------------------------------ +echo "1. Checking gh-pages branch..." +if ! ls_remote_output=$(git ls-remote --heads origin gh-pages 2>&1); then + check_fail "Could not query 'origin' for the gh-pages branch — \`git ls-remote\` exited non-zero" + echo " $ls_remote_output" + echo "" + echo "Total: $PASS passed, $FAIL failed" + exit 1 +fi +if ! echo "$ls_remote_output" | grep -q gh-pages; then + check_fail "gh-pages branch does not exist on remote" + echo "" + echo "Total: $PASS passed, $FAIL failed" + exit 1 +fi +check_pass "gh-pages branch exists on remote" + +# ------------------------------------------------------------------ +# 2. Set up a temporary worktree to inspect the branch contents +# ------------------------------------------------------------------ +# Use an explicit template so this works on BSD/macOS mktemp (which rejects +# `mktemp -d` with no template), not only GNU coreutils. +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gh-pages-validate.XXXXXX") +cleanup() { + git worktree remove "$WORK_DIR" --force 2>/dev/null || true + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +# Always fetch the latest gh-pages from origin so we validate what's actually +# deployed, not a stale local copy. Use a detached worktree pointing at +# `origin/gh-pages` directly so we don't depend on (and don't update) any +# local `gh-pages` branch the caller might have around. +if ! git fetch origin gh-pages; then + check_fail "Failed to fetch origin gh-pages" + exit 1 +fi +git worktree add --detach "$WORK_DIR" origin/gh-pages + +echo "" +echo "2. Checking required root files..." + +if [ -f "$WORK_DIR/index.html" ]; then + check_pass "index.html exists at root" +else + check_fail "index.html is MISSING from root" +fi + +if [ -f "$WORK_DIR/versions.json" ]; then + check_pass "versions.json exists at root" +else + check_fail "versions.json is MISSING from root (version picker will not work)" +fi + +if [ -f "$WORK_DIR/.nojekyll" ]; then + check_pass ".nojekyll exists (Jekyll processing disabled)" +else + # The canonical DocFX deploy workflow always creates .nojekyll; missing means + # the deploy was botched, not a soft warning. + check_fail ".nojekyll is MISSING from root (GitHub Pages will apply Jekyll processing)" +fi + +# ------------------------------------------------------------------ +# 3. Validate versions.json structure +# ------------------------------------------------------------------ +echo "" +echo "3. Validating versions.json..." + +STEP3_OK=0 +if [ -f "$WORK_DIR/versions.json" ]; then + if python3 - "$WORK_DIR/versions.json" <<'PYEOF' +import json, sys + +path = sys.argv[1] +try: + with open(path) as f: + data = json.load(f) +except json.JSONDecodeError as e: + print(f" ❌ versions.json is not valid JSON: {e}") + sys.exit(1) + +if not isinstance(data, list): + print(" ❌ versions.json must be a JSON array") + sys.exit(1) + +for i, entry in enumerate(data): + if not isinstance(entry, dict): + print(f" ❌ Entry [{i}] is not a JSON object: {entry!r}") + sys.exit(1) + version = entry.get("version") + url = entry.get("url") + if not isinstance(version, str) or not version: + print(f" ❌ Entry [{i}] has missing or non-string 'version': {entry!r}") + sys.exit(1) + if not isinstance(url, str) or not url: + print(f" ❌ Entry [{i}] has missing or non-string 'url': {entry!r}") + sys.exit(1) + +print(f" ✅ versions.json is valid ({len(data)} version(s))") +for v in data: + print(f" {v['version']:20s} -> {v['url']}") +PYEOF + then + PASS=$((PASS + 1)) + STEP3_OK=1 + else + FAIL=$((FAIL + 1)) + fi +fi + +# ------------------------------------------------------------------ +# 4. Verify every version entry has a matching folder with index.html +# ------------------------------------------------------------------ +echo "" +echo "4. Checking version folders match versions.json..." + +# Derive the repository name from the origin remote URL so we can strip the +# project-Pages-site prefix (e.g., '/MyRepo/versions/v1.0.0/') from URLs in +# versions.json before mapping them to filesystem paths under gh-pages. +# On a user/org root Pages site there is no prefix; for project Pages sites +# the prefix is '//'. Either way, after stripping the prefix the URL +# should map directly to a folder on gh-pages. +# +# Use shell parameter expansion rather than sed regex — BSD/macOS sed +# doesn't support the lazy quantifier '+?' and ERE flag spellings differ +# across implementations. Parameter expansion is POSIX and portable. +REPO_NAME="" +REPO_URL=$(git remote get-url origin 2>/dev/null || true) +if [ -n "$REPO_URL" ]; then + REPO_URL=${REPO_URL%.git} # strip optional trailing .git + REPO_NAME=${REPO_URL##*/} # take everything after the last '/' +fi + +if [ "$STEP3_OK" -ne 1 ]; then + echo " ⏭️ Skipped — versions.json failed validation in step 3" +elif [ -f "$WORK_DIR/versions.json" ]; then + FOLDER_CHECK_RESULT=0 + python3 - "$WORK_DIR" "$REPO_NAME" <<'PYEOF' || FOLDER_CHECK_RESULT=1 +import json, os, sys + +work_dir = sys.argv[1] +repo_name = sys.argv[2] if len(sys.argv) > 2 else "" + +# Sentinel returned when a URL cannot be mapped to a safe folder name. +UNSAFE = object() + +def url_to_folder(url, repo_name): + """Map a versions.json URL to a folder path relative to the gh-pages root. + + Returns None for root-level aliases (no separate folder), UNSAFE for + URLs that would escape the gh-pages root, or a relative folder string.""" + if not url or url == "/": + return None # Root-level alias — no separate folder to check + # Strip the project-Pages prefix '//' if present. + if repo_name and url.startswith(f"/{repo_name}/"): + url = url[len(f"/{repo_name}/"):] + folder = url.strip("/") + if not folder: + return None + # Reject anything that could escape the gh-pages root via parent-dir + # traversal, backslash injection, or absolute paths. This is defense + # against a malformed (or hostile) versions.json on the deployed site. + parts = folder.split("/") + if any(p in ("", "..", ".") or "\\" in p for p in parts): + return UNSAFE + return folder + +with open(os.path.join(work_dir, "versions.json")) as f: + versions = json.load(f) + +missing = [] +for v in versions: + ver = v["version"] + url = v["url"] + folder_name = url_to_folder(url, repo_name) + if folder_name is None: + # Entry points at the site root (typically 'latest' on a user/org Pages + # site). The root index.html is already validated in step 2. + continue + if folder_name is UNSAFE: + missing.append(f"{ver} (url {url!r} would escape gh-pages root — rejected)") + continue + folder = os.path.join(work_dir, folder_name) + # Belt-and-suspenders: verify the resolved real path is still under + # work_dir (catches symlink shenanigans or anything the segment check missed). + real_folder = os.path.realpath(folder) + real_root = os.path.realpath(work_dir) + if os.path.commonpath([real_folder, real_root]) != real_root: + missing.append(f"{ver} (resolved path '{real_folder}' is outside gh-pages root — rejected)") + continue + if not os.path.isdir(folder): + missing.append(f"{ver} (folder '{folder_name}/' not found)") + elif not os.path.isfile(os.path.join(folder, "index.html")): + missing.append(f"{ver} (index.html missing in '{folder_name}/')") + +if missing: + for m in missing: + print(f" ❌ {m}") + sys.exit(1) +else: + print(f" ✅ All versioned folders exist and contain index.html") +PYEOF + + if [ "$FOLDER_CHECK_RESULT" -eq 0 ]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + fi +fi + +# ------------------------------------------------------------------ +# 5. Check for known stale DocFX root artifacts +# ------------------------------------------------------------------ +echo "" +echo "5. Checking for stale DocFX root artifacts..." + +# The 'public/' directory is a known DocFX build artifact that should never +# appear at the gh-pages root; its presence indicates a previous deploy did +# not clean up properly. +STALE_PATTERNS=("public") +found_stale=false + +for p in "${STALE_PATTERNS[@]}"; do + if [ -e "$WORK_DIR/$p" ]; then + check_warn "Potentially stale artifact found at root: '$p'" + found_stale=true + fi +done + +if [ "$found_stale" = "false" ]; then + check_pass "No known stale DocFX artifacts found at root" +fi + +# ------------------------------------------------------------------ +# Summary +# ------------------------------------------------------------------ +echo "" +echo "────────────────────────────────────────────────────────" +echo " Results: $PASS passed, $FAIL failed" +echo "────────────────────────────────────────────────────────" +echo "" + +if [ "$FAIL" -gt 0 ]; then + echo "❌ Validation FAILED – review the issues listed above." + exit 1 +else + echo "✅ Validation PASSED" + exit 0 +fi diff --git a/scripts/format.ps1 b/scripts/format.ps1 index 0a296dc5..aa9bf8f8 100644 --- a/scripts/format.ps1 +++ b/scripts/format.ps1 @@ -11,11 +11,11 @@ If specified, only checks formatting without making changes (like CI does). .EXAMPLE - pwsh ./scripts/format.ps1 + .\format.ps1 Formats all code in the repository. .EXAMPLE - pwsh ./scripts/format.ps1 -Check + .\format.ps1 -Check Checks formatting without making changes. #> @@ -38,9 +38,11 @@ if ($LASTEXITCODE -ne 0) Write-Host "❌ dotnet format is not available!" -ForegroundColor Red Write-Host "" Write-Host "The 'dotnet format' command is built into the .NET SDK starting with .NET 6." -ForegroundColor Yellow - Write-Host "This project requires .NET 8.0 SDK or later." -ForegroundColor Yellow + Write-Host "You need an SDK new enough to load this repo's target frameworks — see" -ForegroundColor Yellow + Write-Host ".github/workflows/pr.yaml (and global.json if present) for the SDK" -ForegroundColor Yellow + Write-Host "versions CI uses. The latest stable .NET SDK is generally a safe choice." -ForegroundColor Yellow Write-Host "" - Write-Host "Please install the .NET 8.0 SDK or later from:" -ForegroundColor Yellow + Write-Host "Install the .NET SDK from:" -ForegroundColor Yellow Write-Host "https://dotnet.microsoft.com/download" -ForegroundColor Cyan Write-Host "" exit 1 diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 37a218fd..975c7c73 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -1016,7 +1016,14 @@ function Start-Setup { Write-Host "" Write-Host "1. Configure branch protection (see REPO-INSTRUCTIONS.md if kept)" -ForegroundColor Yellow Write-Host "" - Write-Host "2. Start developing!" -ForegroundColor Yellow + Write-Host "2. Provision custom labels (includes the Maintenance framework labels)" -ForegroundColor Yellow + Write-Host " pwsh ./scripts/Setup-Labels.ps1" -ForegroundColor Gray + Write-Host "" + Write-Host "3. Create the parent Maintenance issue for this repo" -ForegroundColor Yellow + Write-Host " pwsh ./scripts/Setup-Maintenance.ps1 -MaintenanceProjectUrl ''" -ForegroundColor Gray + Write-Host " # The cross-repo Maintenance project URL — ask the repo owner if you don't have it" -ForegroundColor DarkGray + Write-Host "" + Write-Host "4. Start developing!" -ForegroundColor Yellow if ($solutionName) { Write-Host " # Solution file created: $solutionName.slnx" -ForegroundColor Gray Write-Host " # Add your projects to src/ and tests/" -ForegroundColor Gray diff --git a/scripts/templates/maintenance-parent-body.md b/scripts/templates/maintenance-parent-body.md new file mode 100644 index 00000000..4ced8861 --- /dev/null +++ b/scripts/templates/maintenance-parent-body.md @@ -0,0 +1,52 @@ +This issue is the living **improvement menu** for this repo. It is intentionally evergreen — the parent stays open forever. Sub-issues are spawned from the categories below as work begins, and they get closed when complete. The parent is never closed. + +## How this works + +- **This issue (`maintenance` label)** is the per-repo reference. Read it to see candidate work for this repo. +- **Sub-issues (`maintenance-task` + `maintenance - ` labels)** are the actual tracked work. +- All `maintenance-task` issues across all repos roll up into the Maintenance project board: {{MAINTENANCE_PROJECT_URL}} +- To create a sub-issue, use the **"Maintenance task"** issue template (`.github/ISSUE_TEMPLATE/maintenance-task.yaml`). It pre-fills the `maintenance-task` label and prompts for category, scope, acceptance criteria, and links. After creation, **manually add the matching `maintenance - ` label** — issue forms can't apply labels dynamically based on dropdown selections yet. + +## Candidate tasks by category + +### Security (`maintenance - security`) +- Run SAST / analyzer scan +- Audit dependencies for CVEs / outdated packages +- Fix findings from scans + +### Performance (`maintenance - performance`) +- Profile hot paths +- Add benchmarks for identified hotspots +- Optimize bottlenecks found via profiling / benchmarks +- Validate performance gains via benchmark deltas + +### Testing (`maintenance - testing`) +- Achieve / maintain code coverage ≥ 90 % +- Add integration test suite +- Add mutation tests (Stryker) +- Refactor test fixtures +- Add CI test-step improvements (e.g. coverage collectors, gates) + +### Cleanup (`maintenance - cleanup`) +- Refactor for reuse / quality / efficiency ("simplify pass") +- Remove dead code + +### Docs (`maintenance - docs`) +- XML doc coverage on all public API +- Refresh README and CHANGELOG +- Add usage samples + +### API (`maintenance - API`) +- Audit public vs internal surface +- Breaking-change vigilance / API review + +### CI/CD (`maintenance - CI/CD`) +- Refactor CI workflows +- Set up Docker build (if applicable) +- Improve packaging / publish pipeline + +## Notes + +- Not every category is relevant to every repo at every time. **Spawn sub-issues only when there is actionable work** — don't pre-fill the categories with placeholder tasks. +- Repo-specific decisions that don't fit the fleet-wide pattern (e.g., dropping a TFM, a one-off bug fix, a feature request) are tracked as **regular issues without the `maintenance - ` prefix**. They stay out of the Project board. +- This issue should not be closed. If everything is "done", that just means there's no actionable work right now — but the categories remain a reference for the next cycle. From f912867873961dc2a1cf8d21948afd3027662979 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 11:29:53 -0400 Subject: [PATCH 02/99] 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 ETL-Abstractions — the protected half. 5 files that trip the pr.yaml guard, isolated for admin-bypass merge. Part of #155. Co-Authored-By: Claude Opus 4.7 (1M context) --- .editorconfig | 13 ++- .github/workflows/codeql.yaml | 20 ++++ .github/workflows/docfx.yaml | 75 +++----------- .github/workflows/pr.yaml | 180 +++++----------------------------- BannedSymbols.txt | 3 +- 5 files changed, 65 insertions(+), 226 deletions(-) diff --git a/.editorconfig b/.editorconfig index 3fa1ddd2..8b3044ec 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,13 +25,12 @@ indent_size = 2 [*.{yml,yaml}] indent_size = 2 -# PowerShell files inherit LF + UTF-8 (no BOM) + 4-space indent from -# the global [*] section above — no [*.ps1] override needed. The -# `charset = utf-8` setting is what prevents editors from writing a -# BOM, which together with the LF requirement keeps the -# `#!/usr/bin/env pwsh` shebang at the top of every script in scripts/ -# working on Linux/macOS (CR breaks the kernel's exec lookup, and a -# leading BOM prevents shebang recognition entirely). +# 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 c3dfa777..0165e391 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 02aecc6b..3a362574 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -170,8 +170,13 @@ jobs: $maxLen = [Math]::Max($aIds.Length, $bIds.Length) for ($i = 0; $i -lt $maxLen; $i++) { - if ($i -ge $aIds.Length) { return -1 } # a has fewer identifiers -> lower precedence - if ($i -ge $bIds.Length) { return 1 } # b has fewer identifiers -> lower precedence + # SemVer §11.4.4: fewer prerelease identifiers = lower precedence. + # In *descending* order (newest first), the lower-precedence + # value sorts AFTER the higher-precedence one, so the comparator + # must return positive when 'a' is the shorter (lower-precedence) + # one and negative when 'b' is. + if ($i -ge $aIds.Length) { return 1 } # a has fewer identifiers -> lower precedence -> sorts later + if ($i -ge $bIds.Length) { return -1 } # b has fewer identifiers -> lower precedence -> sorts later $aId = $aIds[$i] $bId = $bIds[$i] @@ -220,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 @@ -377,9 +322,11 @@ jobs: git -C $WORK_DIR remote add origin "https://github.com/$($env:GITHUB_REPOSITORY).git" } - # Remove stale root files; preserve versions/, .git, .nojekyll, CNAME + # 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') + $_.Name -notin @('.git', 'CNAME', '.nojekyll', 'versions', 'dev') } | Remove-Item -Recurse -Force # Ensure .nojekyll exists so GitHub Pages does not run Jekyll diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 6c4781f4..b83c6480 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -274,55 +274,6 @@ jobs: echo "" echo "✅ Configuration files secured - using versions from main branch" - - 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")" - git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" - fi - done - else - # Check if file exists in main branch - if git cat-file -e "main-branch:$config_file" 2>/dev/null; then - echo " ✓ Copying $config_file from main branch" - git show "main-branch:$config_file" > "$config_file" - else - echo " ℹ️ $config_file not found in main branch, skipping" - fi - fi - done - - echo "" - echo "✅ Configuration files secured - using versions from main branch" - # Fix for .NET 5.0 on Ubuntu 22.04+ - install libssl1.1 from the focal-security # repository so APT verifies the package via GPG instead of a plain wget download. - name: Install OpenSSL 1.1 for .NET 5.0 @@ -350,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)..." @@ -615,14 +572,17 @@ jobs: persist-credentials: false - name: Fetch trusted configuration files from main branch - shell: pwsh + # Skip for Dependabot — its package-version bumps to protected files (e.g. + # Directory.Build.props) are legitimate and should not be overwritten by main's + # older versions. Dependabot's identity is GitHub-controlled and not spoofable. if: github.event.pull_request.user.login != 'dependabot[bot]' + shell: pwsh run: | Write-Host "Fetching configuration files from main branch to prevent malicious overrides..." - + # Fetch the main branch git fetch origin main:main-branch - + # List of configuration files that should come from trusted main branch $configFiles = @( ".editorconfig", @@ -630,7 +590,7 @@ jobs: "Directory.Build.targets", "BannedSymbols.txt" ) - + # Copy each configuration file from main branch if it exists foreach ($configFile in $configFiles) { # Check if file exists in main branch @@ -642,7 +602,7 @@ jobs: Write-Host " ℹ️ $configFile not found in main branch, skipping" } } - + # Handle glob patterns for .globalconfig, .ruleset, and workflow files $globPatterns = @("*.globalconfig", "*.ruleset", ".github/workflows/*.yml", ".github/workflows/*.yaml") foreach ($pattern in $globPatterns) { @@ -656,7 +616,7 @@ jobs: } } } - + Write-Host "" Write-Host "✅ Configuration files secured - using versions from main branch" @@ -672,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 @@ -910,55 +876,6 @@ jobs: echo "" echo "✅ Configuration files secured - using versions from main branch" - - 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")" - git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" - fi - done - else - # Check if file exists in main branch - if git cat-file -e "main-branch:$config_file" 2>/dev/null; then - echo " ✓ Copying $config_file from main branch" - git show "main-branch:$config_file" > "$config_file" - else - echo " ℹ️ $config_file not found in main branch, skipping" - fi - fi - done - - echo "" - echo "✅ Configuration files secured - using versions from main branch" - - name: Setup .NET uses: actions/setup-dotnet@v5 with: @@ -969,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)..." @@ -1293,55 +1216,6 @@ jobs: echo "" echo "✅ Configuration files secured - using versions from main branch" - - 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")" - git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" - fi - done - else - # Check if file exists in main branch - if git cat-file -e "main-branch:$config_file" 2>/dev/null; then - echo " ✓ Copying $config_file from main branch" - git show "main-branch:$config_file" > "$config_file" - else - echo " ℹ️ $config_file not found in main branch, skipping" - fi - fi - done - - echo "" - echo "✅ Configuration files secured - using versions from main branch" - - name: Install DevSkim CLI run: dotnet tool install --global Microsoft.CST.DevSkim.CLI diff --git a/BannedSymbols.txt b/BannedSymbols.txt index 0b80aad5..804798ab 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -1,4 +1,4 @@ -# BannedSymbols.txt - Async-First Enforcement for Wolfgang.Etl.Abstractions +# 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 @@ -79,4 +79,3 @@ M:System.Console.ReadLine(); Blocking - avoid in async code paths M:System.Console.Read(); Blocking - avoid in async code paths M:System.Console.ReadKey(); Blocking - avoid in async code paths M:System.Console.ReadKey(System.Boolean); Blocking - avoid in async code paths - From aaeb816f3b8f13b3e2f3bc3b58fa39e7dae9f5fd Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 12:27:08 -0400 Subject: [PATCH 03/99] Fill {{PROJECT_NAME}} placeholder in CONTRIBUTING.md (Wolfgang.Etl.Abstractions) The C1 drift re-sync brought the canonical CONTRIBUTING.md but left the template's {{PROJECT_NAME}} placeholder literal. Replace it with this repo's project name so the synced CONTRIBUTING.md is correct, not a raw template. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ec5c98e..5e21083b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to {{PROJECT_NAME}} +# Contributing to Wolfgang.Etl.Abstractions -Thank you for your interest in contributing to **{{PROJECT_NAME}}**! We welcome contributions to help improve this project. +Thank you for your interest in contributing to **Wolfgang.Etl.Abstractions**! We welcome contributions to help improve this project. ## How Can You Contribute? From 43a8694b69e649a81594e986aff0692107547f32 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 12:52:48 -0400 Subject: [PATCH 04/99] Propagate setup.ps1 UTF-8/newline fix from canonical Fold-down of the repo-template fix (PR #389): Replace-Placeholders now writes with -Encoding utf8NoBOM -NoNewline. Rolled to every repo so the campaign stays consistent with the updated canonical. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/setup.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 975c7c73..30fbc07e 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -208,7 +208,11 @@ function Replace-Placeholders { } if ($modified) { - Set-Content -Path $FilePath -Value $content + # Explicit utf8NoBOM: never write a BOM (the pr.yaml protected-config + # guard and shebang scripts depend on BOM-free files). -NoNewline: + # $content came from Get-Content -Raw and already ends with the file's + # original terminator, so Set-Content must not append another. + Set-Content -Path $FilePath -Value $content -Encoding utf8NoBOM -NoNewline Write-Success "Updated: $FilePath" } } From 0f8802797f5cf03927a9b0e3af50e498015c61f0 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 13:26:53 -0400 Subject: [PATCH 05/99] Add canonical test/benchmark project config (Phase-0 files) Propagates the three files folded into repo-template during drift Phase 0: tests/Directory.Build.props + benchmarks/Directory.Build.props (TreatWarningsAsErrors=false) and tests/.editorconfig (test-project analyzer relaxations). Excluded from the initial C1 sync; rolled out now so every repo matches the canonical template. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/Directory.Build.props | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 benchmarks/Directory.Build.props diff --git a/benchmarks/Directory.Build.props b/benchmarks/Directory.Build.props new file mode 100644 index 00000000..a53b408b --- /dev/null +++ b/benchmarks/Directory.Build.props @@ -0,0 +1,6 @@ + + + + false + + From 3951424b09ebf4288d07ad0e6e455c8f0684bd63 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 13:50:27 -0400 Subject: [PATCH 06/99] =?UTF-8?q?Drop=20tests/Directory.Build.props=20?= =?UTF-8?q?=E2=80=94=20hold=20test=20projects=20to=20the=20warning=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the TreatWarningsAsErrors=false override on test projects. The genuinely test-inappropriate analyzer rules are already silenced per-rule in tests/.editorconfig; test code now inherits the root Directory.Build.props (TreatWarningsAsErrors in Release, like src/examples). benchmarks remain exempt for now. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Directory.Build.props | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 tests/Directory.Build.props diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props deleted file mode 100644 index a53b408b..00000000 --- a/tests/Directory.Build.props +++ /dev/null @@ -1,6 +0,0 @@ - - - - false - - From b515f962effca6ac59ff2e3830a2c1b7d3fa3978 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 15:28:16 -0400 Subject: [PATCH 07/99] 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 + examples/Net4.8/Example1-BasicETL/Example1-BasicETL.csproj | 1 - .../Example2-WithCancellationToken.csproj | 1 - .../Example3-WithGracefulCancellation.csproj | 1 - .../Example4a-WithExtractorProgress.csproj | 1 - .../Example4b-WithTransformerProgress.csproj | 1 - .../Example4c-WithLoaderProgress.csproj | 1 - .../Example5a-ExtractorWithProgressAndCancellation.csproj | 1 - .../Example6-ReducingDuplicateCode.csproj | 1 - examples/Net8.0/Example1-BasicETL/Example1-BasicETL.csproj | 1 - .../Example2-WithCancellationToken.csproj | 1 - .../Example3-WithGracefulCancellation.csproj | 1 - .../Example4a-WithExtractorProgress.csproj | 1 - .../Example4b-WithTransformerProgress.csproj | 1 - .../Example4c-WithLoaderProgress.csproj | 1 - .../Example5a-ExtractorWithProgressAndCancellation.csproj | 1 - .../Example6-ReducingDuplicateCode.csproj | 1 - src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj | 1 - .../Wolfgang.Etl.Abstractions.Tests.Unit.csproj | 1 - 19 files changed, 1 insertion(+), 18 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index b20a30e1..5ff5b390 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,7 @@ latest + enable true diff --git a/examples/Net4.8/Example1-BasicETL/Example1-BasicETL.csproj b/examples/Net4.8/Example1-BasicETL/Example1-BasicETL.csproj index 9a2feb28..d7459919 100644 --- a/examples/Net4.8/Example1-BasicETL/Example1-BasicETL.csproj +++ b/examples/Net4.8/Example1-BasicETL/Example1-BasicETL.csproj @@ -8,7 +8,6 @@ 8 true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj b/examples/Net4.8/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj index 4316f4ff..273b2973 100644 --- a/examples/Net4.8/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj +++ b/examples/Net4.8/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj @@ -8,7 +8,6 @@ 8 true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj b/examples/Net4.8/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj index 232b5775..826b920c 100644 --- a/examples/Net4.8/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj +++ b/examples/Net4.8/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj @@ -8,7 +8,6 @@ 8 true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj b/examples/Net4.8/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj index 28450714..e34ec595 100644 --- a/examples/Net4.8/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj +++ b/examples/Net4.8/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj @@ -8,7 +8,6 @@ latest true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj b/examples/Net4.8/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj index a0a34ffa..60140d49 100644 --- a/examples/Net4.8/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj +++ b/examples/Net4.8/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj @@ -8,7 +8,6 @@ latest true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj b/examples/Net4.8/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj index 45b3f05e..9da90757 100644 --- a/examples/Net4.8/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj +++ b/examples/Net4.8/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj @@ -8,7 +8,6 @@ latest true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj b/examples/Net4.8/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj index 53cb685b..50216572 100644 --- a/examples/Net4.8/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj +++ b/examples/Net4.8/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj @@ -8,7 +8,6 @@ latest true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj b/examples/Net4.8/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj index 5fdc3fb2..18135746 100644 --- a/examples/Net4.8/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj +++ b/examples/Net4.8/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj @@ -8,7 +8,6 @@ latest true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net8.0/Example1-BasicETL/Example1-BasicETL.csproj b/examples/Net8.0/Example1-BasicETL/Example1-BasicETL.csproj index 9a9aa817..780a4107 100644 --- a/examples/Net8.0/Example1-BasicETL/Example1-BasicETL.csproj +++ b/examples/Net8.0/Example1-BasicETL/Example1-BasicETL.csproj @@ -5,7 +5,6 @@ net8.0 Example1_BasicETL enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj b/examples/Net8.0/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj index cc13f612..d3f1d69e 100644 --- a/examples/Net8.0/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj +++ b/examples/Net8.0/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj @@ -5,7 +5,6 @@ net8.0 Example2_WithCancellationToken enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj b/examples/Net8.0/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj index 9ea861bc..886608c1 100644 --- a/examples/Net8.0/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj +++ b/examples/Net8.0/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj @@ -5,7 +5,6 @@ net8.0 Example3_WithGracefulCancellation enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj b/examples/Net8.0/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj index 5e9a9366..7e1297bd 100644 --- a/examples/Net8.0/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj +++ b/examples/Net8.0/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj @@ -5,7 +5,6 @@ net8.0 Example4a_WithExtractorProgress enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj b/examples/Net8.0/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj index f544645a..96156244 100644 --- a/examples/Net8.0/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj +++ b/examples/Net8.0/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj @@ -5,7 +5,6 @@ net8.0 Example4b_WithTransformerProgress enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj b/examples/Net8.0/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj index 12b6b994..874fc7e9 100644 --- a/examples/Net8.0/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj +++ b/examples/Net8.0/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj @@ -5,7 +5,6 @@ net8.0 Example4c_WithLoaderProgress enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj b/examples/Net8.0/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj index d1d651a2..d678396a 100644 --- a/examples/Net8.0/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj +++ b/examples/Net8.0/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj @@ -5,7 +5,6 @@ net8.0 Example5a_ExtractorWithProgressAndCancellation enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj b/examples/Net8.0/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj index 889e0a50..a759717b 100644 --- a/examples/Net8.0/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj +++ b/examples/Net8.0/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj @@ -5,7 +5,6 @@ net8.0 Example6_ReducingDuplicateCode enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj index 74f956b0..d65837fd 100644 --- a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj +++ b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj @@ -2,7 +2,6 @@ net462;net472;net48;net481;netstandard2.0;net5.0;net6.0;net7.0;net8.0;net9.0;net10.0 latest - enable 0.13.0 False $(AssemblyName) diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj index b3e112c4..88c03cf7 100644 --- a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj @@ -4,7 +4,6 @@ 0.13.0 latest enable - enable false true Copyright 2025 Chris Wolfgang From 8b3a375ee55b16001a944b8a7286034ee26c2d58 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 15:55:45 -0400 Subject: [PATCH 08/99] 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 5ff5b390..10c9d407 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ latest - enable + enable true From 0078d666f722dfb58119651d99a8535780be9402 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 21:06:56 -0400 Subject: [PATCH 09/99] 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 0165e391..ae5f1e33 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 c1287118fb9824b73d72f05ad9a0e3071dc915ed Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 21:11:13 -0400 Subject: [PATCH 10/99] 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 00000000..5309e9fa --- /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 a45db91884ded086669a6a6e8ca554d136c6e5b6 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 21:19:11 -0400 Subject: [PATCH 11/99] 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 9a2ae637..5a265138 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 c0ed898ee628d3b8f830b616d21a54bd49983bff Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 22 May 2026 21:39:21 -0400 Subject: [PATCH 12/99] Add github-actions ecosystem to Dependabot (CI2) Append a github-actions package-ecosystem to .github/dependabot.yml so Dependabot opens weekly PRs to bump pinned action versions in the repo's GitHub workflows. Grouped under a single PR per week. Existing nuget ecosystems are left unchanged. Initiative CI2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/dependabot.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4e6aa953..bc792950 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,3 +12,16 @@ updates: dotnet-dependencies: patterns: - "*" + + # Keep third-party GitHub Actions pinned in workflows up to date. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + groups: + github-actions: + patterns: + - "*" From f79c0a187d8dab4c74105356ed1b6aec580ca9bd Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 13:23:00 -0400 Subject: [PATCH 13/99] Add CHANGELOG.md (D3) Keep-a-Changelog skeleton with an [Unreleased] section so release notes can accumulate here between releases instead of being lost. Follows the canonical format used across the fleet. Initiative D3. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..10652f17 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security From 121eff1885e944fff2a3308f049da825da9e16f4 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 13:26:26 -0400 Subject: [PATCH 14/99] 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 3a362574..4abc4cd2 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 5e8fce7ff8f584376a17f6065e4a1974acada412 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 14:43:02 -0400 Subject: [PATCH 15/99] 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 10c9d407..a6d59c93 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -57,4 +57,24 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + From 24571bec5321b1b5d1b06c63899a80125d8e537b Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 15:20:02 -0400 Subject: [PATCH 16/99] 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 a6d59c93..aa1fde8b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -77,4 +77,29 @@ + + + + Chris Wolfgang + Chris Wolfgang + Copyright (c) Chris Wolfgang + git + true + true + true + snupkg + true + + + + + + all + + + From 5891f7f60f9e89e497fa9766c07d015e425136c5 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 15:22:55 -0400 Subject: [PATCH 17/99] 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 4abc4cd2..67442aa9 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 ab59451094422346acb88a65d6e4d414ef4be7a9 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 17:55:52 -0400 Subject: [PATCH 18/99] 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 67442aa9..9ef37305 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 804798ab..f047625c 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.Etl.Abstractions # Format: ; # T: = Type, M: = Method, P: = Property, F: = Field # Task.Wait() - All overloads - Absolutely NOT allowed in async code From a017ed48b2037e37db52d9ed7869c29f96ea1cd7 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 17:58:26 -0400 Subject: [PATCH 19/99] Address PR review round 2 (canonical-unprotected) - scripts/Setup-BranchRuleset.ps1: jq filter wrapped in '[ .[] | select() ]' so the output is always valid JSON even with multiple matches; gh stderr redirected to a temp file so it can't poison stdout. - scripts/Fix-BranchRuleset.ps1: same stderr-isolation fix on the rulesets fetch (no more '2>&1' merge). - scripts/Validate-DocsDeploy.sh: distinguish 'versions.json never created' from 'versions.json failed validation' in step 4 so the skip message reflects reality. - .github/dependabot.yml: drop the stale 'dotnet' label (no longer in the Setup-Labels.ps1 taxonomy). - REPO-INSTRUCTIONS.md: replace the stale label list (dependabot-*, dotnet) with the current Maintenance-framework labels Setup-Labels.ps1 actually creates. IAsyncEnumerable-Extensions also gets README.md / CONTRIBUTING.md updated to point at docs/README-FORMATTING.md since the root copy was removed by the earlier D7 cleanup. Fan-out of the round-2 Copilot fixes verified against DateTime-Extensions (#178 pilot). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/dependabot.yml | 1 - REPO-INSTRUCTIONS.md | 114 +++++++++----------------------- scripts/Fix-BranchRuleset.ps1 | 23 +++++-- scripts/Setup-BranchRuleset.ps1 | 24 +++++-- scripts/Validate-DocsDeploy.sh | 6 +- 5 files changed, 70 insertions(+), 98 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bc792950..13d90d91 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,6 @@ updates: open-pull-requests-limit: 10 labels: - "dependencies" - - "dotnet" groups: dotnet-dependencies: patterns: diff --git a/REPO-INSTRUCTIONS.md b/REPO-INSTRUCTIONS.md index 3632a85b..a816825d 100644 --- a/REPO-INSTRUCTIONS.md +++ b/REPO-INSTRUCTIONS.md @@ -1,38 +1,6 @@ # Setting Up Your Repository -## Automated Setup (Recommended) - -**NEW:** This template now includes automated setup scripts that handle configuration for you! - -### Quick Setup - -```powershell -pwsh ./scripts/setup.ps1 -``` - -**Note:** There are multiple scripts in this template: -- `scripts/setup.ps1` - Main repository setup (replaces placeholders, configures license) -- `scripts/Setup-BranchRuleset.ps1` - Branch protection configuration (run after setup) -- `scripts/Setup-GitHubPages.ps1` - GitHub Pages and DocFX documentation setup (optional) - -The main setup script will: -1. ✅ Prompt for all required information (with examples and defaults) -2. ✅ Auto-detect git repository information where possible -3. ✅ Replace placeholders in core template files (see TEMPLATE-PLACEHOLDERS.md for details and any manual steps, including DocFX docs) -4. ✅ Delete the template README.md -5. ✅ Rename README-TEMPLATE.md to README.md -6. ✅ Set up your chosen LICENSE (MIT, Apache 2.0, or MPL 2.0) -7. ✅ Remove unused license templates -8. ✅ **Optionally create a default .slnx solution file** with proper folder structure (requires Visual Studio 2022 17.10+) -9. ✅ Validate all replacements -10. ✅ Optionally clean up template-specific files - -**For detailed placeholder documentation, see [TEMPLATE-PLACEHOLDERS.md](TEMPLATE-PLACEHOLDERS.md)** -**For license selection guidance, see [LICENSE-SELECTION.md](LICENSE-SELECTION.md)** - ---- - -## Manual Setup Instructions +## Setup Instructions After you create your repo from the template you will still need to configure some settings. Below is a list of what needs to be done. Once you have completed the checklist below you can delete this file @@ -50,13 +18,7 @@ Below is a list of what needs to be done. Once you have completed the checklist ## Add Branch Protection Rules -> **Note:** Branch protection is now configured using a local PowerShell script. After setting up your repository, run the script to configure branch protection: -> ```powershell -> pwsh ./scripts/Setup-BranchRuleset.ps1 -> ``` -> The script includes interactive prompts that allow you to choose between **single developer** or **multi-developer** repository settings during execution. Simply run the script and select option [1] for single-developer mode (no approvals required) or option [2] for multi-developer mode (requires 1+ approval and code owner review). - -If you need to manually configure branch protection instead: +Configure branch protection rules for the `main` branch: 1. Go to your repository’s Settings → Branches. 2. Under “Branch protection rules,” click `Add branch ruleset` @@ -72,17 +34,15 @@ If you need to manually configure branch protection instead: Prevent Merging When Checks Fail These settings require that all checks in the pr.yaml file succeed before you can merge a branch into main -> **Note for Single-Developer Repositories:** This template is configured for single-developer use. The branch protection script (`scripts/Setup-BranchRuleset.ps1`) includes interactive prompts that allow you to choose between single-developer or multi-developer settings during execution. Simply run the script and select option [1] for single-developer mode (no PR approvals required) or option [2] for multi-developer mode (requires 1+ approval and code owner review). **Note:** The pr.yaml workflow uses `pull_request_target` to always run from the trusted main branch, even for PRs from feature branches. This prevents malicious workflow modifications in untrusted PR branches while still testing the PR's code. -> **Branch protection is now configured via local script!** Run `pwsh ./scripts/Setup-BranchRuleset.ps1` to automatically configure all required settings. Manual configuration below is only needed if you prefer not to use the automated script. - 1. Go to your repository’s Settings → Branches. 2. Under “Branch protection rules,” edit the rule for main. 3. Check “Require status checks to pass before merging.” 4. In the "Status checks that are required" list, select the status check contexts produced by your PR workflow jobs. These options appear after the workflow has run at least once on `main`. For example: - "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" - - "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" + - "Stage 2a: Windows Tests (.NET 5.0-10.0)" + - "Stage 2b: Windows .NET Framework Tests (4.6.2-4.8.1)" - "Stage 3: macOS Tests (.NET 6.0-10.0)" - "Security Scan (DevSkim)" @@ -103,29 +63,28 @@ Run the label setup script once after creating your repository: pwsh -File ./scripts/Setup-Labels.ps1 ``` -This creates the following labels used by Dependabot and workflows: +This creates the labels used by Dependabot and the Maintenance framework. +The canonical list lives in `scripts/Setup-Labels.ps1`; today it is: -1. `dependabot - security` -2. `dependabot-dependencies` -3. `dependencies` -4. `dotnet` +- `dependencies` — applied automatically by Dependabot to every update PR. +- `maintenance` — kind label for the per-repo parent Maintenance issue. +- `maintenance-task` — kind label for every Maintenance sub-issue. +- `maintenance - security` — scans, finding fixes, dependency vulnerability audit. +- `maintenance - performance` — profile, benchmark, optimize, validate. +- `maintenance - testing` — coverage, integration / smoke / mutation tests. +- `maintenance - cleanup` — refactor for reuse / quality / efficiency. +- `maintenance - docs` — XML doc coverage, README, CHANGELOG, samples. +- `maintenance - API` — public/internal surface audit, breaking-change vigilance. +- `maintenance - CI/CD` — Docker, CI workflow, build / publish pipeline. Requires the [GitHub CLI](https://cli.github.com/) to be installed and authenticated (`gh auth login`). ## Creating the project -### Automated Solution Creation (Recommended) - -If you used the automated setup script (`pwsh ./scripts/setup.ps1`), you had the option to create a default solution file automatically. The script creates a `.slnx` format solution (requires Visual Studio 2022 version 17.10+) with the following structure: -- Empty solution folders for `/benchmarks/`, `/examples/`, `/src/`, and `/tests/` -- A `/.root/` folder containing all repository configuration files (preserves directory structure) - -If you chose to create a solution during setup, skip to step 2 below. - -### Manual Solution Creation +### Creating a Solution -If you didn't create a solution during setup or prefer the traditional `.sln` format: +To create a solution: 1. Create a blank solution and save it in the root folder ```bash @@ -169,7 +128,7 @@ If you plan to publish NuGet packages using the automated release workflow, you - Set expiration date (recommended: 1 year) 5. Click **"Add secret"** -**Note:** The release workflow automatically publishes packages to NuGet.org when you publish a GitHub Release (typically associated with a version tag like `v1.0.0`). See [RELEASE-WORKFLOW-SETUP.md](docs/RELEASE-WORKFLOW-SETUP.md) for detailed information about the release workflow, testing, and troubleshooting. +**Note:** The release workflow automatically publishes packages to NuGet.org when you push a version tag (e.g., `v1.0.0`). ## Update Template Files @@ -185,7 +144,7 @@ After creating your repository from the template, update the following files wit ### Update CONTRIBUTING.md 1. Open `CONTRIBUTING.md` -2. Ensure any project name placeholders (for example, `Wolfgang.Etl.Abstractions`) have been replaced with your actual project name (the automated setup scripts should normally do this for you) +2. Ensure any project name placeholders (for example, `Wolfgang.Extensions.DateTime`) have been replaced with your actual project name 3. Review and adjust contribution guidelines as needed for your project ### Update CODEOWNERS @@ -200,23 +159,14 @@ After creating your repository from the template, update the following files wit If you want to publish your DocFX documentation to GitHub Pages automatically when you publish a GitHub Release: -1. Run the GitHub Pages setup script: - ```powershell - pwsh ./scripts/Setup-GitHubPages.ps1 - ``` - - The script will: - - **Prompt if you want to set up GitHub Pages** for documentation - - **Auto-detect repository information** (name, description, URLs) - - **Prompt for project details** needed for DocFX configuration - - **Replace placeholders** in DocFX files (Wolfgang.Etl.Abstractions, https://Chris-Wolfgang.github.io/ETL-Abstractions/, etc.) - - Create a `gh-pages` branch if it doesn't exist - - Configure GitHub Pages to serve from the `gh-pages` branch - - Verify that the DocFX workflow is reachable via `workflow_call` from `release.yaml` - - **Note:** If you've already run `scripts/setup.ps1`, the DocFX placeholders are already configured, and this script will skip the configuration step. +1. Set up GitHub Pages manually: + - Go to your repository's **Settings → Pages** + - Under "Build and deployment," select **Deploy from a branch** + - Select the `gh-pages` branch (create it if it doesn't exist: `git checkout --orphan gh-pages && git push origin gh-pages`) + - Save the settings + - Update the DocFX configuration files in `docfx_project/` to replace placeholders (e.g., `Wolfgang.D20-Dice`, `https://Chris-Wolfgang.github.io/D20-Dice/`) with your project's values -2. After setup, documentation will be automatically published when you publish a GitHub Release: +2. Documentation will be automatically published when you publish a GitHub Release: 1. Go to your repository's **Releases** page 2. Click **"Draft a new release"** 3. Choose or create a version tag (e.g., `v1.0.0`) @@ -228,13 +178,13 @@ If you want to publish your DocFX documentation to GitHub Pages automatically wh - **`workflow_call`**: Called automatically by `release.yaml` after a GitHub Release is published (passes the release tag as the version) - **`workflow_dispatch`**: Manual trigger for ad-hoc builds or dry-runs (available from the Actions tab) -**Alternative Approach:** If you prefer to configure DocFX placeholders separately from GitHub Pages setup, you can run `scripts/setup.ps1` first (which handles all template placeholders including DocFX), then run `scripts/Setup-GitHubPages.ps1` just to set up the gh-pages branch and GitHub Pages settings. ### Update Documentation (Optional) If you're using DocFX for documentation: -1. Review and customize the generated table of contents in `docfx_project/docs/toc.yml` as needed (the setup scripts already point this to your repository) +1. Review and customize the table of contents in `docfx_project/docs/toc.yml` and update repository-specific values (e.g., links and project names) 2. Customize the rest of the documentation content in `docfx_project/` + ### Multi-Version DocFX Documentation This repository is configured for versioned documentation using DocFX. The setup consists of: @@ -242,8 +192,9 @@ This repository is configured for versioned documentation using DocFX. The setup #### Key Files | File | Purpose | |------|---------| -| `docfx_project/docfx.json` | Per-build DocFX configuration included in this template and used by CI workflows to build docs. Uses `default` + `modern` templates with dark mode enabled (`colorMode: dark`). | -| `docfx_project/logo.svg` | Default repository logo used by DocFX. You can optionally copy this to the repo root as `logo.svg` if you want a root-level logo as well. | +| `docfx.json` | Optional root-level DocFX configuration for local/single-version documentation builds or previews. **Not used by CI workflows** for version discovery or multi-version wiring (handled via git tags). | +| `docfx_project/docfx.json` | Per-build DocFX configuration used by CI workflows to build docs. Uses `default` + `modern` templates with dark mode enabled (`colorMode: dark`). | +| `logo.svg` | Repository logo at the root; also present in `docfx_project/`. | #### How Versioning Works - CI workflows discover documentation versions **dynamically at runtime** by querying git tags that match the SemVer pattern `v*.*.*` (e.g. `v1.0.0`, `v0.3.0`). No manual version list is maintained in any config file. @@ -262,4 +213,3 @@ When you publish a new release (e.g. `v1.0.0`): The DocFX modern template is configured to default to dark mode. This is controlled by: - `"colorMode": "dark"` in `docfx_project/docfx.json` → `build.globalMetadata` - `"_enableDarkMode": true` enables the light/dark toggle so visitors can switch themes - diff --git a/scripts/Fix-BranchRuleset.ps1 b/scripts/Fix-BranchRuleset.ps1 index d9cdb881..f1dbad9e 100644 --- a/scripts/Fix-BranchRuleset.ps1 +++ b/scripts/Fix-BranchRuleset.ps1 @@ -88,14 +88,25 @@ if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { Write-Host "`nFetching existing rulesets..." -ForegroundColor Cyan try { - $rulesetsJson = gh api ` - -H "Accept: application/vnd.github+json" ` - -H "X-GitHub-Api-Version: 2022-11-28" ` - "/repos/$Repository/rulesets" ` - --paginate 2>&1 + # Capture stderr to a temp file so gh's progress/warnings can't poison + # the JSON stream on stdout (mixing them via 2>&1 can break ConvertFrom-Json + # even on a successful API call). + $rulesetsErr = [System.IO.Path]::GetTempFileName() + try { + $rulesetsJson = gh api ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/rulesets" ` + --paginate 2> $rulesetsErr + } finally { + if (Test-Path -LiteralPath $rulesetsErr) { + $errText = (Get-Content -LiteralPath $rulesetsErr -Raw -ErrorAction SilentlyContinue) + Remove-Item -LiteralPath $rulesetsErr -Force + } + } if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to fetch rulesets: $rulesetsJson" + Write-Error "Failed to fetch rulesets (exit code $LASTEXITCODE). gh stderr: $errText" exit 1 } diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1 index c5e8ab91..2d66a59e 100644 --- a/scripts/Setup-BranchRuleset.ps1 +++ b/scripts/Setup-BranchRuleset.ps1 @@ -101,18 +101,28 @@ Write-Host "📌 Protected branch: $BranchName`n" -ForegroundColor Cyan # Check if ruleset already exists Write-Host "🔍 Checking for existing rulesets..." -ForegroundColor Yellow try { - $rulesetOutput = gh api ` - -H "Accept: application/vnd.github+json" ` - -H "X-GitHub-Api-Version: 2022-11-28" ` - "/repos/$Repository/rulesets" ` - --paginate ` - --jq '.[] | select(.name == "Protect main branch")' 2>&1 + # Use a jq array wrapper ('[ .[] | select(...) ]') so the output is always + # a single valid JSON value (an array) even when multiple rulesets match — + # bare '.[] | select(...)' emits one JSON object per match, which is not + # valid JSON and breaks ConvertFrom-Json. Redirect stderr to a temp file + # so gh's progress/warnings can't poison the JSON stream on stdout. + $rulesetErr = [System.IO.Path]::GetTempFileName() + try { + $rulesetOutput = gh api ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/rulesets" ` + --paginate ` + --jq '[ .[] | select(.name == "Protect main branch") ]' 2> $rulesetErr + } finally { + if (Test-Path -LiteralPath $rulesetErr) { Remove-Item -LiteralPath $rulesetErr -Force } + } if ($LASTEXITCODE -ne 0) { Write-Warning "⚠️ Could not check for existing rulesets (API returned exit code $LASTEXITCODE). Continuing..." } elseif ($rulesetOutput) { $matchingRulesets = $rulesetOutput | ConvertFrom-Json - $existingRuleset = $matchingRulesets | Select-Object -First 1 + $existingRuleset = @($matchingRulesets) | Select-Object -First 1 if ($existingRuleset) { Write-Host "✅ Ruleset 'Protect main branch' already exists!" -ForegroundColor Green diff --git a/scripts/Validate-DocsDeploy.sh b/scripts/Validate-DocsDeploy.sh index 1f89fea1..2a62cb47 100644 --- a/scripts/Validate-DocsDeploy.sh +++ b/scripts/Validate-DocsDeploy.sh @@ -162,9 +162,11 @@ if [ -n "$REPO_URL" ]; then REPO_NAME=${REPO_URL##*/} # take everything after the last '/' fi -if [ "$STEP3_OK" -ne 1 ]; then +if [ ! -f "$WORK_DIR/versions.json" ]; then + echo " ⏭️ Skipped — no versions.json present (step 3 did not run)" +elif [ "$STEP3_OK" -ne 1 ]; then echo " ⏭️ Skipped — versions.json failed validation in step 3" -elif [ -f "$WORK_DIR/versions.json" ]; then +else FOLDER_CHECK_RESULT=0 python3 - "$WORK_DIR" "$REPO_NAME" <<'PYEOF' || FOLDER_CHECK_RESULT=1 import json, os, sys From b76e7105142d263eeb2f90feab7fb43e9b3c6a1b Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 18:00:23 -0400 Subject: [PATCH 20/99] 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 5309e9fa..62ab03f3 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 396c75d80b852c8c9f97455f7ac2d2552d41930c Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 23 May 2026 21:07:31 -0400 Subject: [PATCH 21/99] Drop vestigial one-time setup scripts (post-bootstrap cleanup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These scripts were run once when this repo was bootstrapped from repo-template and are not needed afterward: - scripts/setup.ps1 (orchestrator for the others) - scripts/Setup-Maintenance.ps1 (created the Maintenance issues) - scripts/Setup-BranchRuleset.ps1 (created the main branch ruleset) - scripts/Setup-GitHubPages.ps1 (bootstrapped gh-pages branch) - scripts/templates/maintenance-parent-body.md (template used by Setup-Maintenance) repo-template keeps these files — they remain available there so any new repo created from the template can still run them at bootstrap. Recurring utilities are intentionally kept: Setup-Labels.ps1 (idempotent), Fix-BranchRuleset.ps1, format.ps1, Validate-DocsDeploy.sh, build-pr.ps1. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/Setup-BranchRuleset.ps1 | 339 ------ scripts/Setup-GitHubPages.ps1 | 719 ------------ scripts/Setup-Maintenance.ps1 | 159 --- scripts/setup.ps1 | 1053 ------------------ scripts/templates/maintenance-parent-body.md | 52 - 5 files changed, 2322 deletions(-) delete mode 100644 scripts/Setup-BranchRuleset.ps1 delete mode 100644 scripts/Setup-GitHubPages.ps1 delete mode 100644 scripts/Setup-Maintenance.ps1 delete mode 100644 scripts/setup.ps1 delete mode 100644 scripts/templates/maintenance-parent-body.md diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1 deleted file mode 100644 index 2d66a59e..00000000 --- a/scripts/Setup-BranchRuleset.ps1 +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS - Creates a branch protection ruleset for the main branch in the current repository. - -.DESCRIPTION - This script uses the GitHub CLI (gh) to create a repository ruleset that protects - the main branch with pull request requirements, required status checks, security - scanning rules, and automatic Copilot code review. - Run this locally after creating a new repo from the template. - - The script will prompt you to choose between single-developer or multi-developer - repository settings: - - Single Developer: No PR approvals required (you can merge your own PRs) - - Multi-Developer: Requires 1+ approval and code owner review - - The ruleset includes: - - Pull request reviews with configurable approval requirements - - Required status checks (tests, security scans) - - Force push and deletion protection - -.PARAMETER Repository - The repository in owner/repo format. If not provided, uses the current repository. - -.PARAMETER BranchName - The branch to protect. Default is "main". - -.EXAMPLE - .\Setup-BranchRuleset.ps1 - Creates the ruleset for the current repository with interactive prompts - -.EXAMPLE - .\Setup-BranchRuleset.ps1 -Repository "Chris-Wolfgang/my-repo" - Creates the ruleset for a specific repository - -.NOTES - Requires: GitHub CLI (gh) authenticated with sufficient permissions - Install gh: https://cli.github.com/ - - Required Permissions: - - Admin access to the repository, OR - - Write access with "Administration" permission enabled - - These permissions are necessary to create and modify repository rulesets. -#> - -[CmdletBinding()] -param( - [Parameter()] - [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", - - [Parameter()] - [string]$BranchName = "main" -) - -# Check if gh CLI is installed -try { - $null = gh --version -} catch { - Write-Error "❌ GitHub CLI (gh) is not installed or not in PATH." - Write-Host "Install from: https://cli.github.com/" -ForegroundColor Yellow - exit 1 -} - -# Check if authenticated -try { - $null = gh auth status 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error "❌ Not authenticated with GitHub CLI." - Write-Host "Run: gh auth login" -ForegroundColor Yellow - exit 1 - } -} catch { - Write-Error "❌ Failed to check GitHub CLI authentication status." - exit 1 -} - -# Determine repository -if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { - # Placeholders not replaced or no repository specified - auto-detect - Write-Host "🔍 Detecting current repository..." -ForegroundColor Cyan - try { - $repoInfo = gh repo view --json nameWithOwner | ConvertFrom-Json - $Repository = $repoInfo.nameWithOwner - Write-Host "✅ Using repository: $Repository" -ForegroundColor Green - } catch { - if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { - Write-Error "❌ Could not detect repository. Please run the setup script (pwsh ./scripts/setup.ps1) first to replace placeholders, or specify -Repository parameter." - } else { - Write-Error "❌ Could not detect repository. Please run from within a git repository or specify -Repository parameter." - } - exit 1 - } -} else { - Write-Host "✅ Using specified repository: $Repository" -ForegroundColor Green -} - -Write-Host "`n🛡️ Setting up branch protection ruleset for: $Repository" -ForegroundColor Cyan -Write-Host "📌 Protected branch: $BranchName`n" -ForegroundColor Cyan - -# Check if ruleset already exists -Write-Host "🔍 Checking for existing rulesets..." -ForegroundColor Yellow -try { - # Use a jq array wrapper ('[ .[] | select(...) ]') so the output is always - # a single valid JSON value (an array) even when multiple rulesets match — - # bare '.[] | select(...)' emits one JSON object per match, which is not - # valid JSON and breaks ConvertFrom-Json. Redirect stderr to a temp file - # so gh's progress/warnings can't poison the JSON stream on stdout. - $rulesetErr = [System.IO.Path]::GetTempFileName() - try { - $rulesetOutput = gh api ` - -H "Accept: application/vnd.github+json" ` - -H "X-GitHub-Api-Version: 2022-11-28" ` - "/repos/$Repository/rulesets" ` - --paginate ` - --jq '[ .[] | select(.name == "Protect main branch") ]' 2> $rulesetErr - } finally { - if (Test-Path -LiteralPath $rulesetErr) { Remove-Item -LiteralPath $rulesetErr -Force } - } - - if ($LASTEXITCODE -ne 0) { - Write-Warning "⚠️ Could not check for existing rulesets (API returned exit code $LASTEXITCODE). Continuing..." - } elseif ($rulesetOutput) { - $matchingRulesets = $rulesetOutput | ConvertFrom-Json - $existingRuleset = @($matchingRulesets) | Select-Object -First 1 - - if ($existingRuleset) { - Write-Host "✅ Ruleset 'Protect main branch' already exists!" -ForegroundColor Green - Write-Host " View it at: https://github.com/$Repository/settings/rules" -ForegroundColor Cyan - $response = Read-Host "`nDo you want to continue anyway? This may fail. (y/N)" - if ($response -ne 'y' -and $response -ne 'Y') { - Write-Host "Exiting." -ForegroundColor Yellow - exit 0 - } - } - } else { - Write-Host "ℹ️ Ruleset 'Protect main branch' does not exist yet." -ForegroundColor Gray - } -} catch { - Write-Warning "⚠️ Could not check for existing rulesets: $($_.Exception.Message). Continuing..." -} - -# Prompt for repository type -Write-Host "`n👥 Repository Type Configuration" -ForegroundColor Cyan -Write-Host "" -Write-Host "Is this a single-developer or multi-developer repository?" -ForegroundColor Yellow -Write-Host "" -Write-Host " [1] Single Developer - No PR approvals required (you can merge your own PRs)" -ForegroundColor Gray -Write-Host " [2] Multi-Developer - Requires 1+ approval and code owner review" -ForegroundColor Gray -Write-Host "" -$repoTypeChoice = Read-Host "Enter your choice (1 or 2) [default: 1]" - -# Set defaults based on choice -$requireApprovals = 0 -$requireCodeOwnerReview = $false - -if ($repoTypeChoice -eq "2") { - $requireApprovals = 1 - $requireCodeOwnerReview = $true - Write-Host "✅ Configured for multi-developer repository (1 approval required)" -ForegroundColor Green -} else { - Write-Host "✅ Configured for single-developer repository (no approvals required)" -ForegroundColor Green -} - -# Create ruleset configuration -Write-Host "`n📝 Creating ruleset configuration..." -ForegroundColor Cyan - -$rulesetConfig = @{ - name = "Protect main branch" - target = "branch" - enforcement = "active" - conditions = @{ - ref_name = @{ - include = @("refs/heads/$BranchName") - exclude = @() - } - } - # No bypass actors allowed - all users (including admins) must follow branch protection rules - bypass_actors = @() - rules = @( - @{ - type = "pull_request" - parameters = @{ - required_approving_review_count = $requireApprovals - dismiss_stale_reviews_on_push = $true - require_code_owner_review = $requireCodeOwnerReview - require_last_push_approval = $false - required_review_thread_resolution = $true - } - }, - @{ - type = "required_status_checks" - parameters = @{ - strict_required_status_checks_policy = $true - # IMPORTANT: Workflows providing these required checks (specifically .github/workflows/pr.yaml) - # must NOT have path filters (paths/paths-ignore). If a workflow is path-filtered - # and doesn't run for a PR, GitHub will treat the required check as missing and - # block the merge. All required status checks must run on every PR. - required_status_checks = @( - @{ context = "Detect .NET Projects" }, - @{ context = "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" }, - @{ context = "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" }, - @{ context = "Stage 3: macOS Tests (.NET 6.0-10.0)" }, - @{ context = "Security Scan (DevSkim)" }, - @{ context = "Security Scan (CodeQL) (csharp)" }, - @{ context = "Secrets Scan (gitleaks)" } - ) - } - }, - @{ - type = "non_fast_forward" - }, - @{ - type = "deletion" - }, - # The CodeQL alerts-dashboard gate. Only blocks merges when the alerts - # threshold is exceeded; the underlying CodeQL workflow already runs as - # a required status check above, so this is the second-tier "results" - # gate. Activate it only AFTER the CodeQL workflow has completed at - # least one successful run — without prior analyses it blocks all PRs. - @{ - type = "code_scanning" - parameters = @{ - code_scanning_tools = @( - @{ - alerts_threshold = "errors" - security_alerts_threshold = "high_or_higher" - tool = "CodeQL" - } - ) - } - }, - # Auto-request a Copilot review on every PR, including drafts and on - # subsequent pushes. The rulesets API now supports this rule type - # (earlier versions of this script left the toggle to the UI). - @{ - type = "copilot_code_review" - parameters = @{ - review_draft_pull_requests = $true - review_on_push = $true - } - }, - # Block merges when the code-quality check (analyzer / formatter) emits - # errors. Severity matches the canonical libraries (errors only — warnings - # don't block, the build itself already promotes them in Release mode). - @{ - type = "code_quality" - parameters = @{ - severity = "errors" - } - } - ) -} - -# Convert to JSON -$jsonConfig = $rulesetConfig | ConvertTo-Json -Depth 10 - -# Save to temporary file -$tempFile = [System.IO.Path]::GetTempFileName() -$jsonConfig | Out-File -FilePath $tempFile -Encoding utf8NoBOM - -try { - Write-Host "🚀 Creating branch ruleset..." -ForegroundColor Cyan - - # Create the ruleset - $response = gh api ` - --method POST ` - -H "Accept: application/vnd.github+json" ` - -H "X-GitHub-Api-Version: 2022-11-28" ` - "/repos/$Repository/rulesets" ` - --input $tempFile 2>&1 - - if ($LASTEXITCODE -eq 0) { - Write-Host "`n✅ Successfully created branch ruleset 'Protect main branch'!" -ForegroundColor Green - Write-Host "`n🛡️ Protection Rules Enabled:" -ForegroundColor Cyan - Write-Host " ✅ Pull requests required before merging" -ForegroundColor Gray - if ($requireApprovals -gt 0) { - Write-Host " ✅ Required approvals: $requireApprovals" -ForegroundColor Gray - Write-Host " ✅ Code owner review required" -ForegroundColor Gray - } else { - Write-Host " ✅ No approvals required (single-developer mode)" -ForegroundColor Gray - } - Write-Host " ✅ Required status checks (must pass before merging):" -ForegroundColor Gray - Write-Host " - Detect .NET Projects" -ForegroundColor DarkGray - Write-Host " - Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" -ForegroundColor DarkGray - Write-Host " - Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" -ForegroundColor DarkGray - Write-Host " - Stage 3: macOS Tests (.NET 6.0-10.0)" -ForegroundColor DarkGray - Write-Host " - Security Scan (DevSkim)" -ForegroundColor DarkGray - Write-Host " - Security Scan (CodeQL) (csharp)" -ForegroundColor DarkGray - Write-Host " - Secrets Scan (gitleaks)" -ForegroundColor DarkGray - Write-Host " ✅ Branches must be up to date before merging" -ForegroundColor Gray - Write-Host " ✅ Conversation resolution required before merging" -ForegroundColor Gray - Write-Host " ✅ Stale reviews dismissed when new commits are pushed" -ForegroundColor Gray - Write-Host " ✅ Force pushes blocked on $BranchName branch" -ForegroundColor Gray - Write-Host " ✅ Branch deletion prevented for $BranchName" -ForegroundColor Gray - Write-Host " ✅ Code scanning: CodeQL alerts gate (errors / high+)" -ForegroundColor Gray - Write-Host " ✅ Copilot code review: auto-requested on every PR (incl. drafts, on push)" -ForegroundColor Gray - Write-Host " ✅ Code quality gate: blocks on analyzer / formatter errors" -ForegroundColor Gray - Write-Host " ✅ No bypass allowed - all users must follow these rules" -ForegroundColor Gray - - Write-Host "`n🔗 View ruleset at:" -ForegroundColor Cyan - Write-Host " https://github.com/$Repository/settings/rules" -ForegroundColor Blue - } else { - Write-Error "❌ Failed to create ruleset" - Write-Host $response -ForegroundColor Red - - if ($response -like "*403*" -or $response -like "*Resource not accessible*") { - Write-Host "`n💡 This error usually means:" -ForegroundColor Yellow - Write-Host " 1. You don't have admin access to this repository, OR" -ForegroundColor Yellow - Write-Host " 2. Your GitHub authentication doesn't have the required scopes" -ForegroundColor Yellow - Write-Host "`n🔧 Try re-authenticating with:" -ForegroundColor Cyan - Write-Host " gh auth login" -ForegroundColor Gray - Write-Host " For more information about required scopes, see: https://cli.github.com/manual/gh_auth_login" -ForegroundColor Gray - } - - if ($response -like "*422*" -or $response -like "*Validation Failed*") { - Write-Host "`n💡 This validation error usually means:" -ForegroundColor Yellow - Write-Host " 1. The repository doesn't meet the requirements for rulesets (e.g., needs to be a GitHub Pro/Team/Enterprise repo)" -ForegroundColor Yellow - Write-Host " 2. Some configuration in the ruleset is invalid for this repository type" -ForegroundColor Yellow - Write-Host " 3. Required workflows or status checks might not exist yet" -ForegroundColor Yellow - Write-Host "`n🔧 Possible solutions:" -ForegroundColor Cyan - Write-Host " - Verify this is a GitHub Pro, Team, or Enterprise repository" -ForegroundColor Gray - Write-Host " - Check that the required workflows exist in .github/workflows/" -ForegroundColor Gray - Write-Host " - Ensure you have admin permissions on the repository" -ForegroundColor Gray - } - - exit 1 - } -} catch { - Write-Error "❌ An error occurred: $_" - exit 1 -} finally { - # Clean up temp file - if (Test-Path $tempFile) { - Remove-Item $tempFile -Force - } -} - -Write-Host "`n🎉 Setup complete!" -ForegroundColor Green diff --git a/scripts/Setup-GitHubPages.ps1 b/scripts/Setup-GitHubPages.ps1 deleted file mode 100644 index 334266da..00000000 --- a/scripts/Setup-GitHubPages.ps1 +++ /dev/null @@ -1,719 +0,0 @@ -#!/usr/bin/env pwsh -#Requires -Version 7.0 - -<# -.SYNOPSIS - Sets up GitHub Pages with DocFX for automatic documentation publishing on GitHub Release. - -.DESCRIPTION - This script automates the setup of GitHub Pages for a .NET repository using DocFX. - It performs the following tasks: - 1. Prompts if you want to set up GitHub Pages for documentation - 2. Reads repository-specific information automatically where possible - 3. Prompts for any missing information needed for DocFX configuration - 4. Replaces placeholders in docfx.json and documentation markdown files - 5. Creates a gh-pages branch if it doesn't already exist - 6. Configures GitHub Pages settings to serve from the gh-pages branch - 7. Verifies the DocFX workflow is reachable via workflow_call from release.yaml - - Run this script locally after creating a new repository from the template. - -.PARAMETER Repository - The repository in owner/repo format. If not provided, uses the current repository. - -.PARAMETER EnablePages - If specified, automatically enables GitHub Pages without prompting. - -.PARAMETER SkipPrompt - If specified, skips the initial prompt asking if you want to set up GitHub Pages. - -.EXAMPLE - .\Setup-GitHubPages.ps1 - Sets up GitHub Pages for the current repository with interactive prompts - -.EXAMPLE - .\Setup-GitHubPages.ps1 -Repository "Chris-Wolfgang/my-repo" - Sets up GitHub Pages for a specific repository - -.EXAMPLE - .\Setup-GitHubPages.ps1 -EnablePages -SkipPrompt - Sets up GitHub Pages and automatically enables it without any prompts - -.NOTES - Requires: - - GitHub CLI (gh) authenticated with sufficient permissions - - Git installed and available in PATH - Install gh: https://cli.github.com/ -#> - -[CmdletBinding()] -param( - [Parameter()] - [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", - - [Parameter()] - [switch]$EnablePages, - - [Parameter()] - [switch]$SkipPrompt -) - -# Enable strict mode -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -# Color output functions -function Write-Success { - param([string]$Message) - Write-Host "✅ $Message" -ForegroundColor Green -} - -function Write-Info { - param([string]$Message) - Write-Host "ℹ️ $Message" -ForegroundColor Cyan -} - -function Write-Warning-Custom { - param([string]$Message) - Write-Host "⚠️ $Message" -ForegroundColor Yellow -} - -function Write-Error-Custom { - param([string]$Message) - Write-Host "❌ $Message" -ForegroundColor Red -} - -function Write-Step { - param([string]$Message) - Write-Host "`n🔧 $Message" -ForegroundColor Magenta -} - -# Helper function to read input with default value -function Read-Input { - param( - [Parameter(Mandatory)] - [string]$Prompt, - - [string]$Default = '', - - [string]$Example = '', - - [switch]$Required - ) - - $displayPrompt = $Prompt - if ($Default) { - $displayPrompt += " [$Default]" - } - if ($Example -and $Example -ne $Default) { - $displayPrompt += " (e.g., $Example)" - } - $displayPrompt += ": " - - do { - Write-Host $displayPrompt -NoNewline -ForegroundColor Yellow - $input = Read-Host - - if ([string]::IsNullOrWhiteSpace($input)) { - if ($Default) { - return $Default - } - if ($Required) { - Write-Warning-Custom "This field is required. Please enter a value." - continue - } - return '' - } - - return $input.Trim() - } while ($true) -} - -# Banner -Write-Host @" - -╔═══════════════════════════════════════════════════════════════════╗ -║ ║ -║ GitHub Pages Setup - DocFX Documentation Publishing ║ -║ ║ -╚═══════════════════════════════════════════════════════════════════╝ - -"@ -ForegroundColor Cyan - -# Initial prompt to confirm setup -if (-not $SkipPrompt) { - Write-Host "`n📚 This script will set up GitHub Pages for your repository documentation." -ForegroundColor Cyan - Write-Host "" - Write-Host "The setup process will:" -ForegroundColor Gray - Write-Host " • Configure DocFX documentation files with your project information" -ForegroundColor Gray - Write-Host " • Create a gh-pages branch for hosting documentation" -ForegroundColor Gray - Write-Host " • Enable GitHub Pages in repository settings" -ForegroundColor Gray - Write-Host " • Verify the DocFX workflow configuration" -ForegroundColor Gray - Write-Host "" - - $response = Read-Host "Do you want to set up GitHub Pages for documentation? (y/N)" - if ($response -ne 'y' -and $response -ne 'Y') { - Write-Info "Setup cancelled. You can run this script again anytime." - exit 0 - } - - Write-Host "" -} - -# Check if gh CLI is installed -Write-Step "Checking prerequisites..." -try { - $null = gh --version - Write-Success "GitHub CLI (gh) is installed" -} catch { - Write-Error-Custom "GitHub CLI (gh) is not installed or not in PATH." - Write-Host "Install from: https://cli.github.com/" -ForegroundColor Yellow - exit 1 -} - -# Check if git is installed -try { - $null = git --version - Write-Success "Git is installed" -} catch { - Write-Error-Custom "Git is not installed or not in PATH." - Write-Host "Install from: https://git-scm.com/" -ForegroundColor Yellow - exit 1 -} - -# Check if we're in a git repository -try { - $null = git rev-parse --git-dir 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error-Custom "Not in a git repository." - Write-Host "Please run this script from within a git repository." -ForegroundColor Yellow - exit 1 - } - Write-Success "Running in a git repository" -} catch { - Write-Error-Custom "Not in a git repository." - Write-Host "Please run this script from within a git repository." -ForegroundColor Yellow - exit 1 -} - -# Check if authenticated -try { - $null = gh auth status 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error-Custom "Not authenticated with GitHub CLI." - Write-Host "Run: gh auth login" -ForegroundColor Yellow - exit 1 - } - Write-Success "Authenticated with GitHub CLI" -} catch { - Write-Error-Custom "Failed to check GitHub CLI authentication status." - exit 1 -} - -# Determine repository -if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { - # Placeholders not replaced or no repository specified - auto-detect - Write-Info "Detecting current repository..." - try { - $repoInfo = gh repo view --json nameWithOwner | ConvertFrom-Json - $Repository = $repoInfo.nameWithOwner - Write-Success "Using repository: $Repository" - } catch { - if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { - Write-Error-Custom "Could not detect repository. Please run the setup script (scripts/setup.ps1 or scripts/setup.sh) first to replace placeholders, or specify -Repository parameter." - } else { - Write-Error-Custom "Could not detect repository. Please run from within a git repository or specify -Repository parameter." - } - exit 1 - } -} else { - Write-Success "Using specified repository: $Repository" -} - -Write-Host "`n📚 Setting up GitHub Pages for: $Repository" -ForegroundColor Cyan - -# Configure DocFX files -Write-Step "Configuring DocFX documentation files..." - -# Check if docfx.json has placeholders that need to be replaced -$docfxJsonPath = "docfx_project/docfx.json" -$needsDocFxConfig = $false - -if (Test-Path $docfxJsonPath) { - $docfxContent = Get-Content $docfxJsonPath -Raw - if ($docfxContent -match '{{[^}]+}}') { - $needsDocFxConfig = $true - Write-Info "DocFX configuration files contain placeholders that need to be replaced" - } else { - Write-Success "DocFX configuration files are already configured" - } -} else { - Write-Warning-Custom "docfx.json not found at $docfxJsonPath" - Write-Info "Skipping DocFX configuration" -} - -if ($needsDocFxConfig) { - Write-Host "" - Write-Host "📝 Gathering project information for DocFX configuration..." -ForegroundColor Cyan - Write-Host "" - - # Parse repository information - $repoOwner = $Repository -split '/' | Select-Object -First 1 - $repoName = $Repository -split '/' | Select-Object -Last 1 - $githubRepoUrl = "https://github.com/$Repository" - - # Try to get repository description from GitHub - try { - $repoFullInfo = gh repo view --json description,nameWithOwner | ConvertFrom-Json - $autoDescription = $repoFullInfo.description - if ([string]::IsNullOrWhiteSpace($autoDescription)) { - $autoDescription = "A .NET library/application" - } - } catch { - $autoDescription = "A .NET library/application" - } - - # Calculate default documentation URL - $defaultDocsUrl = "https://$repoOwner.github.io/$repoName/" - - # Prompt for project information - $projectName = Read-Input ` - -Prompt "Project Name" ` - -Default $repoName ` - -Example $repoName ` - -Required - - $projectDescription = Read-Input ` - -Prompt "Project Description" ` - -Default $autoDescription ` - -Example $autoDescription - - $packageName = Read-Input ` - -Prompt "NuGet Package Name (if publishing to NuGet)" ` - -Default $projectName ` - -Example $projectName - - $docsUrl = Read-Input ` - -Prompt "Documentation URL (GitHub Pages)" ` - -Default $defaultDocsUrl ` - -Example $defaultDocsUrl - - # Ensure docsUrl ends with / - if (-not $docsUrl.EndsWith('/')) { - $docsUrl += '/' - } - - # Summary - Write-Host "" - Write-Host "Configuration Summary:" -ForegroundColor Cyan - Write-Host " Project Name: $projectName" -ForegroundColor Gray - Write-Host " Description: $projectDescription" -ForegroundColor Gray - Write-Host " Package Name: $packageName" -ForegroundColor Gray - Write-Host " Repository URL: $githubRepoUrl" -ForegroundColor Gray - Write-Host " Documentation URL: $docsUrl" -ForegroundColor Gray - Write-Host "" - - $confirm = Read-Host "Proceed with this configuration? (Y/n)" - if ($confirm -and $confirm -ne 'Y' -and $confirm -ne 'y') { - Write-Warning-Custom "Configuration cancelled." - exit 0 - } - - # Create replacements hashtable - $replacements = @{ - '{{PROJECT_NAME}}' = $projectName - '{{PROJECT_DESCRIPTION}}' = $projectDescription - '{{PACKAGE_NAME}}' = $packageName - '{{GITHUB_REPO_URL}}' = $githubRepoUrl - '{{DOCS_URL}}' = $docsUrl - } - - # Files to update - $filesToUpdate = @( - 'docfx_project/docfx.json', - 'docfx_project/index.md', - 'docfx_project/api/index.md', - 'docfx_project/api/README.md', - 'docfx_project/docs/toc.yml', - 'docfx_project/docs/introduction.md', - 'docfx_project/docs/getting-started.md' - ) - - # Replace placeholders in files - Write-Host "" - Write-Info "Replacing placeholders in DocFX files..." - $filesUpdated = 0 - - foreach ($file in $filesToUpdate) { - if (Test-Path $file) { - $content = Get-Content $file -Raw -ErrorAction SilentlyContinue - if ($content) { - $originalContent = $content - - foreach ($placeholder in $replacements.Keys) { - $pattern = [regex]::Escape($placeholder) - $content = [regex]::Replace( - $content, - $pattern, - [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $replacements[$placeholder] } - ) - } - - if ($content -ne $originalContent) { - Set-Content -Path $file -Value $content -NoNewline -Encoding UTF8 - Write-Success " Updated: $file" - $filesUpdated++ - } - } - } - } - - if ($filesUpdated -gt 0) { - Write-Success "Successfully updated $filesUpdated DocFX file(s)" - } else { - Write-Info "No files needed updating" - } - - Write-Host "" -} - -# Check if gh-pages branch exists -Write-Step "Checking for gh-pages branch..." -try { - $branches = git ls-remote --heads origin gh-pages 2>&1 - - if ($LASTEXITCODE -ne 0) { - Write-Error-Custom "Error checking for gh-pages branch. Git exited with code $LASTEXITCODE.`nOutput:`n$branches" - exit 1 - } - - $ghPagesBranchExists = -not [string]::IsNullOrWhiteSpace($branches) - - if ($ghPagesBranchExists) { - Write-Success "gh-pages branch already exists" - } else { - Write-Info "gh-pages branch does not exist yet" - - # Check for uncommitted changes before creating gh-pages branch - $gitStatus = git status --porcelain 2>&1 - if (-not [string]::IsNullOrWhiteSpace($gitStatus)) { - Write-Warning-Custom "You have uncommitted changes in your working directory." - Write-Info "Please commit or stash your changes before proceeding." - Write-Info "Uncommitted changes:`n$gitStatus" - $response = Read-Host "Do you want to continue anyway? This may cause data loss. (y/N)" - if ($response -ne 'y' -and $response -ne 'Y') { - Write-Info "Aborting gh-pages branch creation." - exit 0 - } - } - - # Store the current branch name before switching - $originalBranch = git rev-parse --abbrev-ref HEAD 2>&1 - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($originalBranch) -or - $originalBranch -match '(fatal|error|warning|usage:)') { - Write-Warning-Custom "Could not determine current branch name. Will attempt to return to 'main' after creating gh-pages." - $originalBranch = "main" # Default fallback - } - - # Create gh-pages branch - Write-Step "Creating gh-pages branch..." - - # Create an orphan branch (no history) - $checkoutOutput = git checkout --orphan gh-pages 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error-Custom "Failed to create orphan gh-pages branch. Git output:`n$checkoutOutput" - throw "Git checkout --orphan failed" - } - - # Remove all files from staging - $rmOutput = git rm -rf . 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error-Custom "Failed to remove files from staging. Git output:`n$rmOutput" - throw "Git rm failed" - } - - # Create a placeholder index.html - $placeholderHtml = @" - - - - - Documentation - - -

Documentation Coming Soon

-

This site will contain the project documentation once it is generated.

-

Documentation is automatically published when you publish a GitHub Release.

- - -"@ - Set-Content -Path "index.html" -Value $placeholderHtml -Encoding UTF8 - - # Commit and push - $addOutput = git add index.html 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error-Custom "Failed to stage index.html. Git output:`n$addOutput" - throw "Git add failed" - } - - $commitOutput = git commit -m "Initialize gh-pages branch" 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error-Custom "Failed to commit gh-pages branch. Git output:`n$commitOutput" - throw "Git commit failed" - } - - $pushOutput = git push origin gh-pages 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error-Custom "Failed to push gh-pages branch. Git output:`n$pushOutput" - throw "Git push failed" - } - - # Switch back to original branch - try { - $checkoutBackOutput = git checkout $originalBranch 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Warning-Custom "Failed to switch back to original branch '$originalBranch'. Git output:`n$checkoutBackOutput" - # Try to detect the default branch as fallback - $defaultBranchOutput = git symbolic-ref refs/remotes/origin/HEAD 2>&1 - if ($LASTEXITCODE -eq 0 -and $defaultBranchOutput -and - $defaultBranchOutput -notmatch '(fatal|error|warning|usage:)') { - $defaultBranch = $defaultBranchOutput | ForEach-Object { $_ -replace '^refs/remotes/origin/', '' } - $checkoutDefaultOutput = git checkout $defaultBranch 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Warning-Custom "Failed to checkout default branch '$defaultBranch'. Git output:`n$checkoutDefaultOutput" - } - } else { - # Try main then master as last resort - $checkoutMainOutput = git checkout main 2>&1 - if ($LASTEXITCODE -ne 0) { - $checkoutMasterOutput = git checkout master 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Warning-Custom "Could not switch back to any default branch. You may need to manually switch branches." - } - } - } - } - } catch { - Write-Warning-Custom "Could not switch back to original branch. You may need to manually switch branches." - } - - Write-Success "Created and pushed gh-pages branch" - } -} catch { - Write-Error-Custom "Failed to check or create gh-pages branch: $_" - Write-Host "You may need to create the gh-pages branch manually." -ForegroundColor Yellow -} - -# Check and enable GitHub Pages -Write-Step "Configuring GitHub Pages settings..." -try { - # Get current Pages configuration - $pagesInfo = gh api "/repos/$Repository/pages" 2>&1 - - if ($LASTEXITCODE -eq 0) { - $pagesConfig = $pagesInfo | ConvertFrom-Json - Write-Success "GitHub Pages is already enabled" - Write-Info " Source: $($pagesConfig.source.branch)/$($pagesConfig.source.path)" - if ($pagesConfig.html_url) { - Write-Info " URL: $($pagesConfig.html_url)" - } - - # Check if it's configured to use gh-pages branch - if ($pagesConfig.source.branch -ne "gh-pages") { - Write-Warning-Custom "GitHub Pages is not configured to use the gh-pages branch" - if (-not $EnablePages) { - $response = Read-Host "Would you like to update it to use gh-pages branch? (y/N)" - if ($response -ne 'y' -and $response -ne 'Y') { - Write-Info "Skipping GitHub Pages branch update" - } else { - $EnablePages = $true - } - } - - if ($EnablePages) { - # Update Pages to use gh-pages branch - $pagesConfigUpdate = @{ - source = @{ - branch = "gh-pages" - path = "/" - } - } | ConvertTo-Json - - $tempFile = [System.IO.Path]::GetTempFileName() - $pagesConfigUpdate | Out-File -FilePath $tempFile -Encoding utf8NoBOM - - try { - $updateOutput = gh api --method PUT "/repos/$Repository/pages" --input $tempFile 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error-Custom "Failed to update GitHub Pages configuration. GitHub CLI output:`n$updateOutput" - } else { - Write-Success "Updated GitHub Pages to use gh-pages branch" - } - } catch { - Write-Error-Custom "Failed to update GitHub Pages configuration: $_" - } finally { - if (Test-Path $tempFile) { - Remove-Item $tempFile -Force - } - } - } - } - } else { - # Pages not enabled, try to enable it - Write-Info "GitHub Pages is not enabled yet" - - if (-not $EnablePages) { - $response = Read-Host "Would you like to enable GitHub Pages now? (y/N)" - if ($response -ne 'y' -and $response -ne 'Y') { - Write-Info "Skipping GitHub Pages setup" - Write-Info "You can enable it later in: Settings → Pages" - } else { - $EnablePages = $true - } - } - - if ($EnablePages) { - # Enable Pages with gh-pages branch - $pagesConfig = @{ - source = @{ - branch = "gh-pages" - path = "/" - } - } | ConvertTo-Json - - $tempFile = [System.IO.Path]::GetTempFileName() - $pagesConfig | Out-File -FilePath $tempFile -Encoding utf8NoBOM - - try { - $enableOutput = gh api --method POST "/repos/$Repository/pages" --input $tempFile 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error-Custom "Failed to enable GitHub Pages. GitHub CLI output:`n$enableOutput" - Write-Host "You may need to enable it manually in: Settings → Pages" -ForegroundColor Yellow - } else { - Write-Success "Enabled GitHub Pages with gh-pages branch" - - # Get the Pages URL - Start-Sleep -Seconds 2 - $pagesUrlInfo = gh api "/repos/$Repository/pages" 2>&1 - if ($LASTEXITCODE -eq 0) { - $pagesUrlData = $pagesUrlInfo | ConvertFrom-Json - if ($pagesUrlData.html_url) { - Write-Info " URL: $($pagesUrlData.html_url)" - } - } - } - } catch { - Write-Error-Custom "Failed to enable GitHub Pages: $_" - Write-Host "You may need to enable it manually in: Settings → Pages" -ForegroundColor Yellow - } finally { - if (Test-Path $tempFile) { - Remove-Item $tempFile -Force - } - } - } - } -} catch { - Write-Warning-Custom "Could not check GitHub Pages configuration" - Write-Info "You may need to enable GitHub Pages manually in: Settings → Pages" -} - -# Verify DocFX workflow configuration -Write-Step "Verifying DocFX workflow configuration..." -$workflowPath = ".github/workflows/docfx.yaml" - -if (Test-Path $workflowPath) { - $workflowContent = Get-Content $workflowPath -Raw - $normalizedWorkflowContent = $workflowContent -replace "`r`n", "`n" - - # Check if workflow is triggered via workflow_call (called by release.yaml) - $hasWorkflowCall = $normalizedWorkflowContent -match 'workflow_call:' - - if ($hasWorkflowCall) { - Write-Success "DocFX workflow is configured to be called via workflow_call from release.yaml" - } else { - Write-Warning-Custom "DocFX workflow does not appear to be configured for workflow_call" - Write-Info "The DocFX workflow should be invoked by release.yaml via workflow_call" - Write-Info " after a GitHub Release is published." - Write-Info "" - Write-Info "To enable automatic documentation publishing on GitHub Release:" - Write-Info " 1. Edit $workflowPath" - Write-Info " 2. Ensure the 'on:' section includes:" - Write-Info "" - Write-Host @" - on: - workflow_call: - inputs: - version: - description: 'Version tag for documentation (e.g., v1.0.0).' - required: false - default: '' - type: string - workflow_dispatch: -"@ -ForegroundColor DarkGray - Write-Info "" - Write-Info " 3. In release.yaml, add a job that calls docfx.yaml:" - Write-Info "" - Write-Host @" - trigger-docs: - needs: validate-release - permissions: - contents: write - uses: ./.github/workflows/docfx.yaml - with: - version: `${{ github.event.release.tag_name }} -"@ -ForegroundColor DarkGray - Write-Info "" - } -} else { - Write-Warning-Custom "DocFX workflow not found at $workflowPath" - Write-Info "Ensure you have a DocFX workflow configured" -} - -# Summary -Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan -Write-Host "📋 Setup Summary" -ForegroundColor Cyan -Write-Host ("=" * 70) -ForegroundColor Cyan - -Write-Host "`n✅ Completed Tasks:" -ForegroundColor Green -if ($needsDocFxConfig -and $filesUpdated -gt 0) { - Write-Host " • Configured DocFX files with project information" -ForegroundColor Gray -} -Write-Host " • Verified/Created gh-pages branch" -ForegroundColor Gray -if ($EnablePages) { - Write-Host " • Configured GitHub Pages settings" -ForegroundColor Gray -} -Write-Host " • Verified DocFX workflow configuration" -ForegroundColor Gray - -Write-Host "`n📝 Next Steps:" -ForegroundColor Yellow -if ($needsDocFxConfig -and $filesUpdated -gt 0) { - Write-Host " 1. Review and customize the generated documentation in docfx_project/" -ForegroundColor Gray - Write-Host " 2. Publish a GitHub Release to trigger documentation deployment" -ForegroundColor Gray - Write-Host " 3. Check the Actions tab to see the documentation build" -ForegroundColor Gray - Write-Host " 4. Visit your documentation site once published" -ForegroundColor Gray -} else { - Write-Host " 1. Ensure docfx_project/docfx.json is configured for your project" -ForegroundColor Gray - Write-Host " 2. Ensure .github/workflows/docfx.yaml has workflow_call in its 'on:' triggers and is called by release.yaml" -ForegroundColor Gray - Write-Host " 3. Publish a GitHub Release to trigger documentation deployment" -ForegroundColor Gray - Write-Host " 4. Check the Actions tab to see the documentation build" -ForegroundColor Gray -} - -Write-Host "`n🔗 Useful Links:" -ForegroundColor Cyan -Write-Host " • Repository: https://github.com/$Repository" -ForegroundColor Blue -Write-Host " • Actions: https://github.com/$Repository/actions" -ForegroundColor Blue -Write-Host " • Settings → Pages: https://github.com/$Repository/settings/pages" -ForegroundColor Blue - -# Get Pages URL if available -try { - $pagesUrlOutput = gh api "/repos/$Repository/pages" 2>&1 - if ($LASTEXITCODE -eq 0) { - $pagesUrlInfo = $pagesUrlOutput | ConvertFrom-Json - if ($pagesUrlInfo.html_url) { - Write-Host " • Documentation: $($pagesUrlInfo.html_url)" -ForegroundColor Blue - } - } -} catch { - # Silently ignore if we can't get the URL -} - -Write-Host "`n🎉 GitHub Pages setup complete!" -ForegroundColor Green -Write-Host "" diff --git a/scripts/Setup-Maintenance.ps1 b/scripts/Setup-Maintenance.ps1 deleted file mode 100644 index ae884871..00000000 --- a/scripts/Setup-Maintenance.ps1 +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS - Creates the per-repo parent "Maintenance" issue that anchors the Maintenance framework. - -.DESCRIPTION - The Maintenance framework tracks ongoing improvement work (security, performance, testing, - cleanup, docs, API, CI/CD) across all Chris-Wolfgang .NET code repos. Each repo has - one parent Maintenance issue (this script creates it) that documents candidate work by - category. Actual tracked work lives in sub-issues labeled `maintenance-task` plus a - `maintenance - ` label, and rolls up into a cross-repo GitHub Projects v2 board. - - This script is idempotent — if a `Maintenance: ` issue with the `maintenance` label - already exists in the target repository, the script reports it and exits 0. - - Requires that the labels `maintenance` and `maintenance-task` (plus the 7 category labels) - already exist in the target repo. Run Setup-Labels.ps1 first. - -.PARAMETER Repository - The repository in owner/repo format. If not provided, uses the current repository. - -.PARAMETER MaintenanceProjectUrl - The URL of the cross-repo Maintenance Projects v2 board (e.g. - https://github.com/users/Chris-Wolfgang/projects/N). Substituted into the issue body. - -.EXAMPLE - .\Setup-Maintenance.ps1 -MaintenanceProjectUrl 'https://github.com/users/Chris-Wolfgang/projects/5' - Creates the parent Maintenance issue for the current repository. - -.EXAMPLE - .\Setup-Maintenance.ps1 -Repository 'Chris-Wolfgang/my-repo' -MaintenanceProjectUrl 'https://github.com/users/Chris-Wolfgang/projects/5' - Creates the parent Maintenance issue for a specific repository. - -.NOTES - Requires: GitHub CLI (gh) authenticated with sufficient permissions. - Install gh: https://cli.github.com/ -#> - -[CmdletBinding()] -param( - [Parameter()] - [string]$Repository, - - [Parameter(Mandatory = $true)] - [string]$MaintenanceProjectUrl -) - -# Check gh CLI -try { - $null = gh --version -} catch { - Write-Error "❌ GitHub CLI (gh) is not installed or not in PATH." - Write-Host "Install from: https://cli.github.com/" -ForegroundColor Yellow - exit 1 -} - -try { - $null = gh auth status 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error "❌ Not authenticated with GitHub CLI." - Write-Host "Run: gh auth login" -ForegroundColor Yellow - exit 1 - } -} catch { - Write-Error "❌ Failed to check GitHub CLI authentication status." - exit 1 -} - -# Determine repository -if (-not $Repository) { - Write-Host "🔍 Detecting current repository..." -ForegroundColor Cyan - try { - $repoInfo = gh repo view --json nameWithOwner | ConvertFrom-Json - $Repository = $repoInfo.nameWithOwner - Write-Host "✅ Using repository: $Repository" -ForegroundColor Green - } catch { - Write-Error "❌ Could not detect repository. Please run from within a git repository or specify -Repository parameter." - exit 1 - } -} - -# Repository's bare name (after the /) -$repoName = ($Repository -split '/')[-1] -$issueTitle = "Maintenance: $repoName" - -# Idempotency: check if a parent Maintenance issue already exists. -# Limit is large (1000) so accidental over-use of the `maintenance` label can't cause -# the check to miss the actual parent and create a duplicate. After fetching, -# filter to exact title match. -Write-Host "`n🔍 Checking for existing parent Maintenance issue..." -ForegroundColor Cyan -# Capture stdout and stderr separately so JSON parsing isn't corrupted by -# any warnings gh emits to stderr. Only stdout is fed to ConvertFrom-Json. -$stderrFile = Join-Path ([IO.Path]::GetTempPath()) "setup-maintenance-stderr-$([guid]::NewGuid()).txt" -try { - $existing = gh issue list ` - --repo $Repository ` - --label 'maintenance' ` - --state all ` - --json number,title,state ` - --limit 1000 2> $stderrFile - - if ($LASTEXITCODE -ne 0) { - Write-Error "❌ Failed to query existing issues. Verify the 'maintenance' label exists in $Repository (run Setup-Labels.ps1 first)." - if (Test-Path $stderrFile) { Write-Host (Get-Content $stderrFile -Raw) -ForegroundColor Red } - exit 1 - } -} finally { - Remove-Item -Path $stderrFile -ErrorAction SilentlyContinue -} - -$matches = $existing | ConvertFrom-Json | Where-Object { $_.title -eq $issueTitle } -if ($matches) { - $match = $matches | Select-Object -First 1 - Write-Host "⏭️ Parent Maintenance issue already exists: #$($match.number) [$($match.state)]" -ForegroundColor Gray - Write-Host " https://github.com/$Repository/issues/$($match.number)" -ForegroundColor Gray - exit 0 -} - -# Read canonical body template -$scriptDir = Split-Path -Parent $PSCommandPath -$templatePath = Join-Path $scriptDir 'templates/maintenance-parent-body.md' - -if (-not (Test-Path $templatePath)) { - Write-Error "❌ Canonical body template not found at: $templatePath" - Write-Host "Expected the file at scripts/templates/maintenance-parent-body.md relative to this script." -ForegroundColor Yellow - exit 1 -} - -$body = Get-Content -Path $templatePath -Raw - -# Literal string replacement (.Replace) rather than -replace, since -# -replace's right-hand-side honors regex tokens like '$' and we don't want -# to alter the URL the caller passed in. -$body = $body.Replace('{{MAINTENANCE_PROJECT_URL}}', $MaintenanceProjectUrl) - -# Write body to a temp file to avoid command-line length / quoting issues. -# Use [IO.Path]::GetTempPath() so this works on Linux/macOS (where $env:TEMP -# can be unset) as well as Windows. -$bodyFile = Join-Path ([IO.Path]::GetTempPath()) "maintenance-parent-body-$([guid]::NewGuid()).md" -Set-Content -Path $bodyFile -Value $body -Encoding utf8NoBOM - -try { - Write-Host "`n📝 Creating parent Maintenance issue in $Repository..." -ForegroundColor Cyan - $createResult = gh issue create ` - --repo $Repository ` - --title $issueTitle ` - --body-file $bodyFile ` - --label 'maintenance' 2>&1 - - if ($LASTEXITCODE -eq 0) { - Write-Host "✅ Created: $createResult" -ForegroundColor Green - } else { - Write-Error "❌ Failed to create parent Maintenance issue." - Write-Host $createResult -ForegroundColor Red - exit 1 - } -} finally { - Remove-Item -Path $bodyFile -ErrorAction SilentlyContinue -} diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 deleted file mode 100644 index 30fbc07e..00000000 --- a/scripts/setup.ps1 +++ /dev/null @@ -1,1053 +0,0 @@ -#!/usr/bin/env pwsh -#Requires -Version 7.0 - -<# -.SYNOPSIS - Automated setup script for .NET repository template -.DESCRIPTION - This script automates the process of configuring a new repository created from this template. - It prompts for project information, replaces placeholders, sets up the license, and validates changes. - - The script automatically ensures it runs from the repository root directory: - - If run from the scripts/ directory, it will automatically change to the repository root - - If run from any other location, it will display an error and exit - -.EXAMPLE - # Recommended: Run from repository root - pwsh ./scripts/setup.ps1 - -.EXAMPLE - # Also works: Run from scripts directory (auto-corrects to root) - cd scripts - pwsh ./setup.ps1 - -.NOTES - Requires PowerShell Core 7.0 or later (cross-platform) -#> - -[CmdletBinding()] -param() - -# Enable strict mode -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -# Color output functions -function Write-Success { - param([string]$Message) - Write-Host "✅ $Message" -ForegroundColor Green -} - -function Write-Info { - param([string]$Message) - Write-Host "ℹ️ $Message" -ForegroundColor Cyan -} - -function Write-TemplateWarning { - param([string]$Message) - Write-Host "⚠️ $Message" -ForegroundColor Yellow -} - -function Write-TemplateError { - param([string]$Message) - Write-Host "❌ $Message" -ForegroundColor Red -} - -function Write-Step { - param([string]$Message) - Write-Host "`n🔧 $Message" -ForegroundColor Magenta -} - -# Banner -function Show-Banner { - Write-Host @" - -╔════════════════════════════════════════════════════════════════╗ -║ ║ -║ .NET Repository Template - Automated Setup ║ -║ ║ -╚════════════════════════════════════════════════════════════════╝ - -"@ -ForegroundColor Cyan -} - -# Ensure script is running from repository root -function Set-RepositoryRoot { - # Get the directory where the script is located - $scriptDir = Split-Path -Parent $PSCommandPath - - # If we're in the scripts directory, move up one level to the repository root - if ((Split-Path -Leaf $scriptDir) -eq 'scripts') { - $repoRoot = Split-Path -Parent $scriptDir - Set-Location $repoRoot - Write-Info "Changed working directory to repository root: $repoRoot" - } - - # Verify we're in the repository root by checking for key marker files - $markerFiles = @('README.md', '.gitignore', 'CONTRIBUTING.md') - $foundMarkers = @($markerFiles | Where-Object { Test-Path $_ }) - - if ($foundMarkers.Count -lt 2) { - Write-TemplateError "This script must be run from the repository root directory." - Write-Host "Expected to find key files like: $($markerFiles -join ', ')" -ForegroundColor Red - Write-Host "Current directory: $(Get-Location)" -ForegroundColor Yellow - Write-Host "" - Write-Host "Please run the script from the repository root:" -ForegroundColor Yellow - Write-Host " pwsh ./scripts/setup.ps1" -ForegroundColor Cyan - throw "Script not running from repository root" - } -} - -# Auto-detect git information -function Get-GitInfo { - $gitInfo = @{ - RemoteUrl = '' - RepoName = '' - Username = '' - UserEmail = '' - FullName = '' - } - - try { - # Get remote URL - $remoteUrl = git remote get-url origin 2>$null - if ($remoteUrl) { - $gitInfo.RemoteUrl = $remoteUrl -replace '\.git$', '' - - # Extract repo name - if ($remoteUrl -match '/([^/]+?)(?:\.git)?$') { - $gitInfo.RepoName = $matches[1] - } - - # Extract username (for GitHub URLs) - if ($remoteUrl -match 'github\.com[:/]([^/]+)/') { - $gitInfo.Username = "@$($matches[1])" - } - } - - # Get git user name - $userName = git config user.name 2>$null - if ($userName) { - $gitInfo.FullName = $userName - } - - # Get git user email - $userEmail = git config user.email 2>$null - if ($userEmail) { - $gitInfo.UserEmail = $userEmail - } - } - catch { - Write-Warning "Could not auto-detect git information" - } - - return $gitInfo -} - -# Prompt for input with default and example -function Read-Input { - param( - [string]$Prompt, - [string]$Default = '', - [string]$Example = '', - [switch]$Required - ) - - $message = $Prompt - if ($Example) { - $message += "`n Example: $Example" - } - if ($Default) { - $message += "`n Default: $Default" - } - $message += "`n > " - - do { - Write-Host $message -NoNewline -ForegroundColor Yellow - $userInput = Read-Host - - if ([string]::IsNullOrWhiteSpace($userInput) -and $Default) { - return $Default - } - - if ([string]::IsNullOrWhiteSpace($userInput) -and $Required) { - Write-TemplateError "This field is required. Please enter a value." - continue - } - - return $userInput - } while ($true) -} - -# Replace placeholders in a file -function Replace-Placeholders { - param( - [string]$FilePath, - [hashtable]$Replacements - ) - - if (-not (Test-Path $FilePath)) { - Write-Warning "File not found: $FilePath" - return - } - - $content = Get-Content $FilePath -Raw - $modified = $false - - foreach ($key in $Replacements.Keys) { - $placeholder = "{{$key}}" - if ($content -match [regex]::Escape($placeholder)) { - $pattern = [regex]::Escape($placeholder) - $content = [regex]::Replace( - $content, - $pattern, - [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $Replacements[$key] } - ) - $modified = $true - } - } - - if ($modified) { - # Explicit utf8NoBOM: never write a BOM (the pr.yaml protected-config - # guard and shebang scripts depend on BOM-free files). -NoNewline: - # $content came from Get-Content -Raw and already ends with the file's - # original terminator, so Set-Content must not append another. - Set-Content -Path $FilePath -Value $content -Encoding utf8NoBOM -NoNewline - Write-Success "Updated: $FilePath" - } -} - -# Main setup function -function Start-Setup { - Show-Banner - - # Ensure we're in the repository root - Set-RepositoryRoot - - Write-Info "This script will configure your new repository." - Write-Info "It will prompt you for project information and replace all placeholders." - Write-Host "" - - # Auto-detect git info - Write-Step "Auto-detecting git repository information..." - $gitInfo = Get-GitInfo - - if ($gitInfo.RemoteUrl) { - Write-Success "Detected repository: $($gitInfo.RemoteUrl)" - } - - # Collect project information - Write-Step "Collecting project information..." - Write-Host "" - - # Ask if creating NuGet package - Write-Host "Will this project be published as a NuGet package? (Y/n): " -NoNewline -ForegroundColor Yellow - $createNugetPackage = Read-Host - if ([string]::IsNullOrEmpty($createNugetPackage) -or $createNugetPackage -eq 'Y' -or $createNugetPackage -eq 'y') { - $isNugetPackage = $true - } - else { - $isNugetPackage = $false - } - Write-Host "" - - $projectName = Read-Input ` - -Prompt "Project Name (e.g., Wolfgang.Extensions.IAsyncEnumerable)" ` - -Example "MyCompany.MyLibrary" ` - -Required - - $projectDescription = Read-Input ` - -Prompt "Project Description (one-line description)" ` - -Example "High-performance extension methods for IAsyncEnumerable" ` - -Required - - if ($isNugetPackage) { - $packageName = Read-Input ` - -Prompt "NuGet Package Name" ` - -Default $projectName ` - -Example $projectName - } - else { - $packageName = $projectName - } - - $githubRepoUrl = Read-Input ` - -Prompt "GitHub Repository URL" ` - -Default $gitInfo.RemoteUrl ` - -Example "https://github.com/username/repo-name" ` - -Required - - # Extract repo name from URL if not already detected - $repoName = $gitInfo.RepoName - if ([string]::IsNullOrWhiteSpace($repoName) -and $githubRepoUrl -match '/([^/]+?)(?:\.git)?$') { - $repoName = $matches[1] - } - if ([string]::IsNullOrWhiteSpace($repoName)) { - $repoName = Read-Input ` - -Prompt "Repository Name" ` - -Example "my-repo-name" ` - -Required - } - - $githubUsername = Read-Input ` - -Prompt "GitHub Username (with @)" ` - -Default $gitInfo.Username ` - -Example "@YourUsername" ` - -Required - - # Ensure @ prefix - if ($githubUsername -notmatch '^@') { - $githubUsername = "@$githubUsername" - } - - # Normalize GitHub URL and generate docs URL - # Handle SSH URLs (git@github.com:org/repo.git) and HTTPS URLs - # Remove trailing .git and normalize to https://github.com// - $normalizedUrl = $githubRepoUrl - - # Convert SSH URL to HTTPS format - if ($normalizedUrl -match '^git@github\.com:(.+)$') { - $normalizedUrl = "https://github.com/$($matches[1])" - } - - # Remove trailing .git - $normalizedUrl = $normalizedUrl -replace '\.git$', '' - - # Extract owner and repo from normalized HTTPS URL - $docsUrl = $normalizedUrl -replace 'https://github\.com/([^/]+)/([^/]+).*', 'https://$1.github.io/$2/' - - $docsUrl = Read-Input ` - -Prompt "Documentation URL (GitHub Pages)" ` - -Default $docsUrl ` - -Example "https://username.github.io/repo-name/" - - # Get copyright holder - $copyrightHolder = Read-Input ` - -Prompt "Copyright Holder Name" ` - -Default $gitInfo.FullName ` - -Example "John Doe" ` - -Required - - $currentYear = (Get-Date).Year - $year = Read-Input ` - -Prompt "Copyright Year" ` - -Default $currentYear.ToString() ` - -Example $currentYear.ToString() - - if ($isNugetPackage) { - $nugetStatus = Read-Input ` - -Prompt "NuGet Package Status" ` - -Default "Coming soon to NuGet.org" ` - -Example "Available on NuGet.org" - } - else { - $nugetStatus = "Not applicable" - } - - # License selection - Write-Step "Selecting License..." - Write-Host "" - Write-Host "Available licenses:" -ForegroundColor Yellow - Write-Host " 1) MIT - Most permissive, simple, business-friendly" - Write-Host " 2) Apache-2.0 - Permissive with patent grant" - Write-Host " 3) MPL-2.0 - Weak copyleft, file-level" - Write-Host "" - Write-Host "For detailed comparison, see LICENSE-SELECTION.md" -ForegroundColor Cyan - Write-Host "" - - do { - Write-Host "Select license (1-3): " -NoNewline -ForegroundColor Yellow - $licenseChoice = Read-Host - - switch ($licenseChoice) { - '1' { - $licenseType = 'MIT' - $licenseFile = 'LICENSE-MIT.txt' - break - } - '2' { - $licenseType = 'Apache-2.0' - $licenseFile = 'LICENSE-APACHE-2.0.txt' - break - } - '3' { - $licenseType = 'MPL-2.0' - $licenseFile = 'LICENSE-MPL-2.0.txt' - break - } - default { - Write-TemplateError "Invalid choice. Please enter 1, 2, or 3." - continue - } - } - break - } while ($true) - - Write-Success "Selected: $licenseType License" - - # Template repository info (for REPO-INSTRUCTIONS.md) - $templateRepoOwner = Read-Input ` - -Prompt "Template Repository Owner (the GitHub user/org that owns the template you used)" ` - -Default "Chris-Wolfgang" ` - -Example "YourUsername" - - $templateRepoName = Read-Input ` - -Prompt "Template Repository Name (the name of the template repository you used)" ` - -Default "repo-template" ` - -Example "my-template" - - # Solution creation - Write-Step "Solution Creation" - Write-Host "" - Write-Host "Create a default solution? (y/N): " -NoNewline -ForegroundColor Yellow - $createSolution = Read-Host - - $solutionName = '' - if ($createSolution -eq 'y' -or $createSolution -eq 'Y') { - $isValidSolutionName = $false - while (-not $isValidSolutionName) { - $solutionName = Read-Input ` - -Prompt "Solution Name" ` - -Default $repoName ` - -Example $repoName ` - -Required - - $invalidFileNameChars = [System.IO.Path]::GetInvalidFileNameChars() - if ($solutionName.IndexOfAny($invalidFileNameChars) -ne -1) { - $invalidCharsDisplay = -join $invalidFileNameChars - Write-Error "Solution name contains invalid characters. Please avoid any of: $invalidCharsDisplay" -ErrorAction Continue - } - else { - $isValidSolutionName = $true - } - } - } - - # Summary - Write-Step "Configuration Summary" - Write-Host "" - Write-Host "Project Information:" -ForegroundColor Cyan - Write-Host " Project Name: $projectName" - Write-Host " Description: $projectDescription" - Write-Host " Package Name: $packageName" - Write-Host " Repository URL: $githubRepoUrl" - Write-Host " Repository Name: $repoName" - Write-Host " GitHub Username: $githubUsername" - Write-Host " Documentation URL: $docsUrl" - Write-Host " License: $licenseType" - Write-Host " Copyright Holder: $copyrightHolder" - Write-Host " Copyright Year: $year" - Write-Host " NuGet Status: $nugetStatus" - Write-Host " Template Owner: $templateRepoOwner" - Write-Host " Template Name: $templateRepoName" - if ($solutionName) { - Write-Host " Solution Name: $solutionName" - } - Write-Host "" - - Write-Host "Proceed with configuration? (Y/n): " -NoNewline -ForegroundColor Yellow - $confirm = Read-Host - if ($confirm -and $confirm -ne 'Y' -and $confirm -ne 'y') { - Write-Warning "Setup cancelled." - exit 0 - } - - # Create replacements hashtable - $replacements = @{ - 'PROJECT_NAME' = $projectName - 'PROJECT_DESCRIPTION' = $projectDescription - 'PACKAGE_NAME' = $packageName - 'GITHUB_REPO_URL' = $githubRepoUrl - 'REPO_NAME' = $repoName - 'GITHUB_USERNAME' = $githubUsername - 'DOCS_URL' = $docsUrl - 'LICENSE_TYPE' = $licenseType - 'YEAR' = $year - 'COPYRIGHT_HOLDER' = $copyrightHolder - 'NUGET_STATUS' = $nugetStatus - 'TEMPLATE_REPO_OWNER' = $templateRepoOwner - 'TEMPLATE_REPO_NAME' = $templateRepoName - } - - # Perform setup - Write-Step "Performing setup..." - Write-Host "" - - $totalSteps = if ($solutionName) { 5 } else { 4 } - - # Step 1: README swap - Write-Info "Step 1/${totalSteps}: Swapping README files..." - if (Test-Path 'README.md') { - Remove-Item 'README.md' -Force - Write-Success "Deleted template README.md" - } - - if (Test-Path 'README-TEMPLATE.md') { - Rename-Item 'README-TEMPLATE.md' 'README.md' - Write-Success "Renamed README-TEMPLATE.md → README.md" - } - else { - Write-Error "README-TEMPLATE.md not found!" - exit 1 - } - - # Step 2: Replace placeholders - Write-Info "Step 2/${totalSteps}: Replacing placeholders in files..." - - $filesToUpdate = @( - 'README.md', - 'CONTRIBUTING.md', - '.github/CODEOWNERS', - 'REPO-INSTRUCTIONS.md', - 'scripts/Setup-BranchRuleset.ps1', - 'docfx_project/docfx.json', - 'docfx_project/index.md', - 'docfx_project/api/index.md', - 'docfx_project/api/README.md', - 'docfx_project/docs/toc.yml', - 'docfx_project/docs/introduction.md', - 'docfx_project/docs/getting-started.md', - 'BannedSymbols.txt' - ) - - foreach ($file in $filesToUpdate) { - Replace-Placeholders -FilePath $file -Replacements $replacements - } - - # Step 3: Set up LICENSE - Write-Info "Step 3/${totalSteps}: Setting up LICENSE file..." - - if (Test-Path $licenseFile) { - # Read license template - $licenseContent = Get-Content $licenseFile -Raw - - # Replace placeholders using safe regex replacement with MatchEvaluator - $licenseContent = [regex]::Replace( - $licenseContent, - [regex]::Escape('{{YEAR}}'), - [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $year } - ) - $licenseContent = [regex]::Replace( - $licenseContent, - [regex]::Escape('{{COPYRIGHT_HOLDER}}'), - [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $copyrightHolder } - ) - - # Save as LICENSE - Set-Content -Path 'LICENSE' -Value $licenseContent -NoNewline - Write-Success "Created LICENSE file ($licenseType)" - - # Delete all license templates - Remove-Item 'LICENSE-MIT.txt' -Force -ErrorAction SilentlyContinue - Remove-Item 'LICENSE-APACHE-2.0.txt' -Force -ErrorAction SilentlyContinue - Remove-Item 'LICENSE-MPL-2.0.txt' -Force -ErrorAction SilentlyContinue - Write-Success "Removed license template files" - } - else { - Write-Error "License template file not found: $licenseFile" - exit 1 - } - - # Step 4: Create solution (if requested) - if ($solutionName) { - Write-Info "Step 4/${totalSteps}: Creating solution file..." - - # Create blank solution in .slnx format - # Note: .slnx format requires Visual Studio 2022 version 17.10 or later - $solutionFileName = "$solutionName.slnx" - - # Build the solution XML structure - $xmlBuilder = New-Object System.Text.StringBuilder - [void]$xmlBuilder.AppendLine('') - - # Build .root folder with all remaining files - # Exclude files and directories that have their own solution folders or are build artifacts - # Note: .git directory is excluded separately below - $excludePatterns = @( - 'obj', # Build output - 'bin', # Build output - 'TestResults', # Test artifacts - 'CoverageReport', # Coverage artifacts - 'node_modules', # Node dependencies - '*.user', # User-specific files - '*.suo', # Visual Studio user options - '*.sln', # Solution files (prevent including solution in itself) - '*.slnx', # Solution files (prevent including solution in itself) - '*.env', # Environment files (may contain secrets) - '*.key', # Key files (may contain secrets) - '*.pem', # Certificate files (may contain secrets) - 'secrets*', # Secret files - 'benchmarks', # Has its own solution folder - 'examples', # Has its own solution folder - 'src', # Has its own solution folder - 'tests', # Has its own solution folder - 'docfx_project' # Documentation source (built separately) - ) - - # Get current directory for relative path calculation - $currentDir = Get-Location - - # Helper function to get relative path safely - function Get-SafeRelativePath { - param($FullPath) - try { - # Use Resolve-Path with -Relative for safe relative path calculation - $rel = Resolve-Path -Path $FullPath -Relative -ErrorAction Stop - # Remove leading .\ or ./ prefix properly - if ($rel.StartsWith('.\')) { - $rel = $rel.Substring(2) - } - elseif ($rel.StartsWith('./')) { - $rel = $rel.Substring(2) - } - return $rel.Replace('\', '/') - } - catch { - # Fallback: manual calculation - $path = $FullPath - if ($path.StartsWith($currentDir.Path, [System.StringComparison]::OrdinalIgnoreCase)) { - $baseLength = $currentDir.Path.Length - # Ensure we only strip the base path when it's a complete directory component - if ($path.Length -eq $baseLength -or - ($path.Length -gt $baseLength -and - ($path[$baseLength] -eq [System.IO.Path]::DirectorySeparatorChar -or - $path[$baseLength] -eq [System.IO.Path]::AltDirectorySeparatorChar))) { - # Remove the base path and any leading separator - $path = $path.Substring($baseLength) - if ($path.StartsWith('\') -or $path.StartsWith('/')) { - $path = $path.Substring(1) - } - } - } - return $path.Replace('\', '/') - } - } - - # Get all files in the repository - $allFiles = Get-ChildItem -Recurse -File -Force | Where-Object { - # Get relative path safely - $relativePath = Get-SafeRelativePath $_.FullName - - # Exclude files under .git directory specifically (not .github) - if ($relativePath -like '.git/*') { - return $false - } - - # Exclude hidden files (starting with .) except those in .github directory - $fileName = [System.IO.Path]::GetFileName($relativePath) - $isInGitHubDir = $relativePath -like '.github/*' - if ($fileName.StartsWith('.') -and -not $isInGitHubDir) { - return $false - } - - # Exclude files matching patterns using precise matching - $shouldExclude = $false - $pathSegments = $relativePath -split '[\\/]+' - $fileExtension = [System.IO.Path]::GetExtension($relativePath) - - foreach ($pattern in $excludePatterns) { - # Handle extension patterns like '*.user' or '*.suo' - if ($pattern.StartsWith('*.')) { - $ext = $pattern.Substring(1) - if ($fileExtension -ieq $ext) { - $shouldExclude = $true - break - } - } - # Handle wildcard patterns like 'secrets*' - elseif ($pattern.Contains('*')) { - if ($relativePath -like $pattern) { - $shouldExclude = $true - break - } - } - # Treat as a path segment name and match against segments - else { - if ($pathSegments -contains $pattern) { - $shouldExclude = $true - break - } - } - } - - -not $shouldExclude - } - - # Group files by directory for .root structure - # Cache relative paths to avoid recalculating - $filesByDirectory = @{} - $relativePathCache = @{} - - foreach ($file in $allFiles) { - # Get relative path safely (use cached if available) - if (-not $relativePathCache.ContainsKey($file.FullName)) { - $relativePathCache[$file.FullName] = Get-SafeRelativePath $file.FullName - } - $relativePath = $relativePathCache[$file.FullName] - $directory = Split-Path $relativePath -Parent - if ([string]::IsNullOrEmpty($directory)) { - $directory = '.' - } - else { - $directory = $directory.Replace('\', '/') - } - - if (-not $filesByDirectory.ContainsKey($directory)) { - $filesByDirectory[$directory] = @() - } - $filesByDirectory[$directory] += $relativePath - } - - # Sort directories to ensure proper nesting order - $sortedDirectories = $filesByDirectory.Keys | Sort-Object - - # Build folder structure with XML escaping - foreach ($directory in $sortedDirectories) { - if ($directory -eq '.') { - # Root files - [void]$xmlBuilder.AppendLine(' ') - foreach ($filePath in ($filesByDirectory[$directory] | Sort-Object)) { - $escapedPath = [System.Security.SecurityElement]::Escape($filePath) - [void]$xmlBuilder.AppendLine(" ") - } - [void]$xmlBuilder.AppendLine(' ') - } - else { - # Subdirectory files - $folderName = "/.root/$directory/" - $escapedFolderName = [System.Security.SecurityElement]::Escape($folderName) - [void]$xmlBuilder.AppendLine(" ") - foreach ($filePath in ($filesByDirectory[$directory] | Sort-Object)) { - $escapedPath = [System.Security.SecurityElement]::Escape($filePath) - [void]$xmlBuilder.AppendLine(" ") - } - [void]$xmlBuilder.AppendLine(' ') - } - } - - # Add solution folders for benchmarks, examples, src, tests (only if directories exist) - # These are added after .root to prioritize configuration files in solution explorer - $solutionFolders = @('benchmarks', 'examples', 'src', 'tests') - foreach ($folder in $solutionFolders) { - if (Test-Path -Path $folder -PathType Container) { - [void]$xmlBuilder.AppendLine(" ") - } - } - - [void]$xmlBuilder.AppendLine('') - - # Write solution file with error handling - try { - Set-Content -Path $solutionFileName -Value $xmlBuilder.ToString() -ErrorAction Stop - Write-Success "Created solution file: $solutionFileName" - - # Show summary - $fileCount = $allFiles.Count - $folderCount = $filesByDirectory.Keys.Count - Write-Info "Added $fileCount files in $folderCount folders to .root/" - } - catch { - Write-TemplateWarning "Failed to create solution file '$solutionFileName'. Repository setup will continue." - Write-TemplateWarning "Error: $($_.Exception.Message)" - # Clear solutionFileName so Next Steps won't reference it - $solutionFileName = '' - } - } - - # Step 5: Validation - Write-Info "Step ${totalSteps}/${totalSteps}: Validating changes..." - - # Core placeholders that should have been replaced by the script - # Note: YEAR and COPYRIGHT_HOLDER are handled in LICENSE file generation, not in FILES_TO_UPDATE - $corePlaceholders = @( - 'PROJECT_NAME', 'PROJECT_DESCRIPTION', 'PACKAGE_NAME', - 'GITHUB_REPO_URL', 'REPO_NAME', 'GITHUB_USERNAME', - 'DOCS_URL', 'LICENSE_TYPE', - 'NUGET_STATUS', 'TEMPLATE_REPO_OWNER', 'TEMPLATE_REPO_NAME' - ) - - # Optional placeholders that users fill in manually as they develop - $optionalPlaceholderDescriptions = @{ - 'QUICK_START_EXAMPLE' = 'Code example showing basic usage' - 'FEATURES_TABLE' = 'Markdown table listing features' - 'FEATURE_EXAMPLES' = 'Code examples demonstrating features' - 'TARGET_FRAMEWORKS' = 'List of supported .NET frameworks' - 'ACKNOWLEDGMENTS' = 'Credits for libraries/tools used' - } - - # Collect placeholders grouped by placeholder name - $corePlaceholdersByName = @{} - $optionalPlaceholdersByName = @{} - - foreach ($file in $filesToUpdate) { - if (Test-Path $file) { - $content = Get-Content $file -Raw - $matches = [regex]::Matches($content, '\{\{([A-Z_]+)\}\}') - foreach ($match in $matches) { - $placeholderName = $match.Groups[1].Value - - # Categorize placeholder - if ($corePlaceholders -contains $placeholderName) { - if (-not $corePlaceholdersByName.ContainsKey($placeholderName)) { - $corePlaceholdersByName[$placeholderName] = @() - } - if ($corePlaceholdersByName[$placeholderName] -notcontains $file) { - $corePlaceholdersByName[$placeholderName] += $file - } - } - elseif ($optionalPlaceholderDescriptions.ContainsKey($placeholderName)) { - if (-not $optionalPlaceholdersByName.ContainsKey($placeholderName)) { - $optionalPlaceholdersByName[$placeholderName] = @() - } - if ($optionalPlaceholdersByName[$placeholderName] -notcontains $file) { - $optionalPlaceholdersByName[$placeholderName] += $file - } - } - } - } - } - - # Report core placeholders that weren't replaced (this is an error) - if ($corePlaceholdersByName.Count -gt 0) { - Write-TemplateError "Error: The following required placeholders were not replaced:" - Write-Host "" - foreach ($placeholderName in ($corePlaceholdersByName.Keys | Sort-Object)) { - Write-Host " {{$placeholderName}}" -ForegroundColor Red - Write-Host " Found in:" -ForegroundColor Gray - foreach ($file in $corePlaceholdersByName[$placeholderName]) { - Write-Host " - $file" -ForegroundColor Gray - } - Write-Host "" - } - Write-Warning "This indicates the script did not replace all required placeholders. Please review the files and replace these manually." - Write-Host "" - exit 1 - } - else { - Write-Success "All required placeholders replaced successfully!" - } - - # Report optional placeholders that need manual updates - if ($optionalPlaceholdersByName.Count -gt 0) { - Write-Host "" - Write-Info "Optional content placeholders to fill in as you develop your project:" - Write-Host "" - - foreach ($placeholderName in ($optionalPlaceholdersByName.Keys | Sort-Object)) { - $description = $optionalPlaceholderDescriptions[$placeholderName] - - Write-Host " {{$placeholderName}}" -ForegroundColor Yellow - Write-Host " Description: $description" -ForegroundColor Gray - Write-Host " Found in:" -ForegroundColor Gray - foreach ($file in $optionalPlaceholdersByName[$placeholderName]) { - Write-Host " - $file" -ForegroundColor Gray - } - Write-Host "" - } - Write-Info "See TEMPLATE-PLACEHOLDERS.md for details on each placeholder." - } - - # Optional cleanup - Write-Step "Cleanup" - Write-Host "" - Write-Host "Remove template-specific files? (y/N)" -ForegroundColor Yellow - Write-Host " Files to remove:" -ForegroundColor Gray - Write-Host " - scripts/setup.ps1 (this script)" -ForegroundColor Gray - Write-Host " - LICENSE-SELECTION.md" -ForegroundColor Gray - Write-Host "" - Write-Host " Note: TEMPLATE-PLACEHOLDERS.md will remain for your reference." -ForegroundColor Cyan - Write-Host " Delete it manually when you've reviewed it and no longer need it." -ForegroundColor Cyan - Write-Host "" - Write-Host "Remove template files? (y/N): " -NoNewline -ForegroundColor Yellow - $cleanup = Read-Host - - if ($cleanup -eq 'y' -or $cleanup -eq 'Y') { - $filesToRemove = @( - 'scripts/setup.ps1', - 'LICENSE-SELECTION.md' - ) - - foreach ($file in $filesToRemove) { - if (Test-Path $file) { - Remove-Item $file -Force - Write-Success "Removed: $file" - } - } - } - else { - Write-Info "Keeping template files. You can remove them manually later." - } - - # Success! - Write-Host "" - Write-Host "╔════════════════════════════════════════════════════════════════╗" -ForegroundColor Green - Write-Host "║ ║" -ForegroundColor Green - Write-Host "║ 🎉 Setup Complete! 🎉 ║" -ForegroundColor Green - Write-Host "║ ║" -ForegroundColor Green - Write-Host "╚════════════════════════════════════════════════════════════════╝" -ForegroundColor Green - Write-Host "" - - # Git operations - Write-Step "Git Operations" - Write-Host "" - - # Step 1: Create branch and commit changes - Write-Host "Create a branch and commit these changes? (Y/n): " -NoNewline -ForegroundColor Yellow - $commitChanges = Read-Host - if ([string]::IsNullOrEmpty($commitChanges) -or $commitChanges -eq 'Y' -or $commitChanges -eq 'y') { - # Generate branch name - $branchName = "setup/configure-from-template-$(Get-Date -Format 'yyyyMMdd-HHmmss')" - - Write-Info "Step 1/4: Creating branch '$branchName'..." - git checkout -b $branchName - if ($LASTEXITCODE -eq 0) { - Write-Success "Branch created successfully!" - Write-Host "" - - Write-Info "Step 2/4: Committing changes..." - git add . - if ($LASTEXITCODE -eq 0) { - git commit -m "Configure repository from template" - if ($LASTEXITCODE -eq 0) { - Write-Success "Changes committed successfully!" - Write-Host "" - - # Step 3: Push to GitHub - Write-Info "Step 3/4: Pushing branch to GitHub..." - git push -u origin $branchName - if ($LASTEXITCODE -eq 0) { - Write-Success "Branch pushed to GitHub successfully!" - Write-Host "" - - # Step 4: Create Pull Request - Write-Info "Step 4/4: Creating pull request..." - - # Check if gh command is available - try { - $null = Get-Command gh -ErrorAction Stop - - gh pr create --title "Configure repository from template" --body "This PR contains the initial repository configuration from the template setup script.`n`nPlease review the changes, make any necessary adjustments, and merge to main when ready." --base main --head $branchName - if ($LASTEXITCODE -eq 0) { - Write-Success "Pull request created successfully!" - Write-Host "" - - # Get PR URL (best-effort; fall back to generic instruction on failure) - $prUrl = gh pr view $branchName --json url --jq .url 2>$null - if ($LASTEXITCODE -eq 0 -and $prUrl) { - Write-Host "╔════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan - Write-Host "║ ║" -ForegroundColor Cyan - Write-Host "║ 📋 Review Required ║" -ForegroundColor Cyan - Write-Host "║ ║" -ForegroundColor Cyan - Write-Host "╚════════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan - Write-Host "" - Write-Host "Branch: $branchName" -ForegroundColor Yellow - Write-Host "Pull Request: $prUrl" -ForegroundColor Yellow - Write-Host "" - Write-Info "Please review the pull request, make any necessary changes, and merge it to main before continuing with development." - Write-Host "" - } - else { - Write-Host "╔════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan - Write-Host "║ ║" -ForegroundColor Cyan - Write-Host "║ 📋 Review Required ║" -ForegroundColor Cyan - Write-Host "║ ║" -ForegroundColor Cyan - Write-Host "╚════════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan - Write-Host "" - Write-Host "Branch: $branchName" -ForegroundColor Yellow - Write-Host "" - Write-Info "Please review the pull request, make any necessary changes, and merge it to main before continuing with development." - Write-Info "You can view the pull request with: gh pr view $branchName --web" - Write-Host "" - } - } - else { - Write-TemplateWarning "Failed to create pull request. You can create it manually with:" - Write-Host " gh pr create --title ""Configure repository from template"" --body ""Initial setup"" --base main --head $branchName" -ForegroundColor Gray - Write-Host "" - } - } - catch { - Write-TemplateWarning "GitHub CLI (gh) is not installed or not available in PATH." - Write-TemplateWarning "Please install it from https://cli.github.com/ to enable automatic PR creation." - Write-Host "" - Write-Info "You can create the pull request manually with:" - Write-Host " gh pr create --title ""Configure repository from template"" --body ""Initial setup"" --base main --head $branchName" -ForegroundColor Gray - Write-Host "" - } - } - else { - Write-TemplateWarning "Push failed. You can push manually later with:" - Write-Host " git push -u origin $branchName" -ForegroundColor Gray - Write-Host "" - } - } - else { - Write-TemplateWarning "Commit failed. You can commit manually later with:" - Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray - Write-Host " git push -u origin $branchName" -ForegroundColor Gray - Write-Host "" - } - } - else { - Write-TemplateWarning "Git add failed. You can commit manually later with:" - Write-Host " git add ." -ForegroundColor Gray - Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray - Write-Host " git push -u origin $branchName" -ForegroundColor Gray - Write-Host "" - } - } - else { - Write-TemplateWarning "Failed to create branch. You can create it manually with:" - Write-Host " git checkout -b $branchName" -ForegroundColor Gray - Write-Host " git add ." -ForegroundColor Gray - Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray - Write-Host " git push -u origin $branchName" -ForegroundColor Gray - Write-Host "" - } - } - else { - Write-Info "Skipping branch creation and commit. You can do this manually later with:" - Write-Host " git checkout -b setup/configure-from-template-" -ForegroundColor Gray - Write-Host " git add ." -ForegroundColor Gray - Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray - Write-Host " git push -u origin setup/configure-from-template-" -ForegroundColor Gray - Write-Host " gh pr create --title ""Configure repository from template"" --base main" -ForegroundColor Gray - Write-Host "" - } - - # Next steps - Write-Host "✅ Next Steps:" -ForegroundColor Cyan - Write-Host "" - Write-Host "1. Configure branch protection (see REPO-INSTRUCTIONS.md if kept)" -ForegroundColor Yellow - Write-Host "" - Write-Host "2. Provision custom labels (includes the Maintenance framework labels)" -ForegroundColor Yellow - Write-Host " pwsh ./scripts/Setup-Labels.ps1" -ForegroundColor Gray - Write-Host "" - Write-Host "3. Create the parent Maintenance issue for this repo" -ForegroundColor Yellow - Write-Host " pwsh ./scripts/Setup-Maintenance.ps1 -MaintenanceProjectUrl ''" -ForegroundColor Gray - Write-Host " # The cross-repo Maintenance project URL — ask the repo owner if you don't have it" -ForegroundColor DarkGray - Write-Host "" - Write-Host "4. Start developing!" -ForegroundColor Yellow - if ($solutionName) { - Write-Host " # Solution file created: $solutionName.slnx" -ForegroundColor Gray - Write-Host " # Add your projects to src/ and tests/" -ForegroundColor Gray - } - else { - Write-Host " dotnet new sln -n $projectName" -ForegroundColor Gray - Write-Host " # Add your projects to src/ and tests/" -ForegroundColor Gray - } - Write-Host "" - - Write-Info "Your repository is now configured and ready for development!" - Write-Host "" -} - -# Run setup -try { - Start-Setup -} -catch { - Write-Error "Setup failed: $_" - Write-Host $_.ScriptStackTrace -ForegroundColor Red - exit 1 -} diff --git a/scripts/templates/maintenance-parent-body.md b/scripts/templates/maintenance-parent-body.md deleted file mode 100644 index 4ced8861..00000000 --- a/scripts/templates/maintenance-parent-body.md +++ /dev/null @@ -1,52 +0,0 @@ -This issue is the living **improvement menu** for this repo. It is intentionally evergreen — the parent stays open forever. Sub-issues are spawned from the categories below as work begins, and they get closed when complete. The parent is never closed. - -## How this works - -- **This issue (`maintenance` label)** is the per-repo reference. Read it to see candidate work for this repo. -- **Sub-issues (`maintenance-task` + `maintenance - ` labels)** are the actual tracked work. -- All `maintenance-task` issues across all repos roll up into the Maintenance project board: {{MAINTENANCE_PROJECT_URL}} -- To create a sub-issue, use the **"Maintenance task"** issue template (`.github/ISSUE_TEMPLATE/maintenance-task.yaml`). It pre-fills the `maintenance-task` label and prompts for category, scope, acceptance criteria, and links. After creation, **manually add the matching `maintenance - ` label** — issue forms can't apply labels dynamically based on dropdown selections yet. - -## Candidate tasks by category - -### Security (`maintenance - security`) -- Run SAST / analyzer scan -- Audit dependencies for CVEs / outdated packages -- Fix findings from scans - -### Performance (`maintenance - performance`) -- Profile hot paths -- Add benchmarks for identified hotspots -- Optimize bottlenecks found via profiling / benchmarks -- Validate performance gains via benchmark deltas - -### Testing (`maintenance - testing`) -- Achieve / maintain code coverage ≥ 90 % -- Add integration test suite -- Add mutation tests (Stryker) -- Refactor test fixtures -- Add CI test-step improvements (e.g. coverage collectors, gates) - -### Cleanup (`maintenance - cleanup`) -- Refactor for reuse / quality / efficiency ("simplify pass") -- Remove dead code - -### Docs (`maintenance - docs`) -- XML doc coverage on all public API -- Refresh README and CHANGELOG -- Add usage samples - -### API (`maintenance - API`) -- Audit public vs internal surface -- Breaking-change vigilance / API review - -### CI/CD (`maintenance - CI/CD`) -- Refactor CI workflows -- Set up Docker build (if applicable) -- Improve packaging / publish pipeline - -## Notes - -- Not every category is relevant to every repo at every time. **Spawn sub-issues only when there is actionable work** — don't pre-fill the categories with placeholder tasks. -- Repo-specific decisions that don't fit the fleet-wide pattern (e.g., dropping a TFM, a one-off bug fix, a feature request) are tracked as **regular issues without the `maintenance - ` prefix**. They stay out of the Project board. -- This issue should not be closed. If everything is "done", that just means there's no actionable work right now — but the categories remain a reference for the next cycle. From 01adf6416857bf8eda1d46762e5e98a83c5f4fb0 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 10:14:44 -0400 Subject: [PATCH 22/99] 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 aa1fde8b..8ec4751d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ latest - enable + enable true From 6212a64c7021f29376e1b40a63ce38d03a0264e9 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 10:31:55 -0400 Subject: [PATCH 23/99] 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 8ec4751d..c0579999 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,13 @@ latest + enable From 62c4c267c390971f153a8b7e324e8cbe84850b46 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 10:48:22 -0400 Subject: [PATCH 24/99] 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 62ab03f3..ef29f503 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 002e929a3b27ddaa70cc884ce72aaacf40b003b9 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 10:54:19 -0400 Subject: [PATCH 25/99] 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 9ef37305..2681a34d 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 e9647303ff411b5e97f6e9188c536bef20b3328d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 10:58:34 -0400 Subject: [PATCH 26/99] 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 2681a34d..8feb098f 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 a89230d4815790408e759c77a6325caaa24065fd Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 21:47:36 -0400 Subject: [PATCH 27/99] 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 890b5ae2..21cb4796 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 30097577381a0c9825b8d6af6effbae9a39fba86 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 21:51:15 -0400 Subject: [PATCH 28/99] 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 21cb4796..c891c94b 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 136232901f349206c3ece5169bf87da3643f95f9 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 21:53:28 -0400 Subject: [PATCH 29/99] 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 8feb098f..8d6c14f2 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 940f80a9b9a1847dd8cfafae519143d7fd72d35a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:02:26 -0400 Subject: [PATCH 30/99] 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 ef29f503..932531ae 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 10a0a2bfd0eb923b5641568f42e0c22ad692bb8b Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:05:55 -0400 Subject: [PATCH 31/99] 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 932531ae..04abf24f 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 96f05a2c57e82cdd9148fe555dfa3eafe1d4106a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:10:52 -0400 Subject: [PATCH 32/99] 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 ae5f1e33..5bb7e9ae 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 b83c6480..a5dc22bd 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 e7ddde339f08017e6ef6629c5a0d4e79627704b2 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:14:22 -0400 Subject: [PATCH 33/99] 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 5a265138..36f3c65c 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 274c497f9df93170e6d51f85c6cd7dea4f65a1c1 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:18:39 -0400 Subject: [PATCH 34/99] 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 8d6c14f2..c456b68c 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 e08c79b38ccfb99378f55343c3631b9289dca318 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:21:58 -0400 Subject: [PATCH 35/99] 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 36f3c65c..c4cba1e4 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 e182e5f9320b1f191a353ab29e472a63f062bf1a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:25:13 -0400 Subject: [PATCH 36/99] 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 c456b68c..6908fa9a 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 c4517ff328ae9778fb27a2a995ba59d4b29391c6 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:31:03 -0400 Subject: [PATCH 37/99] 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 5bb7e9ae..d7d46626 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 a5dc22bd..eaa7566e 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 d495cf007471f148d66a642be1c043ca8098c811 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:37:11 -0400 Subject: [PATCH 38/99] 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 c4cba1e4..ac52bd95 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 f6c986486e7454915720bf977459d02335583342 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:42:23 -0400 Subject: [PATCH 39/99] 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 6908fa9a..c4de5835 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 e81a27f4039413b8dd87abfc3e2c2a13b4f4717d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:46:40 -0400 Subject: [PATCH 40/99] 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 c4de5835..8c7b8ceb 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 2e6c2aa84f717bfa6d75ff98828d86c55432c028 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:49:42 -0400 Subject: [PATCH 41/99] 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 eaa7566e..1cc05a9c 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 2440aaecfd420fbc1266e578f2f7678c2ba86fec Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:53:48 -0400 Subject: [PATCH 42/99] 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 1cc05a9c..ae5a742f 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 b0e1d6c9de103acdbae236bee78d94dc9b754249 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 22:58:17 -0400 Subject: [PATCH 43/99] 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 ae5a742f..cd126dd0 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 d16d3498c80d49216c99656bd86d18cac7641238 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:01:27 -0400 Subject: [PATCH 44/99] 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 cd126dd0..e80c2ffd 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 5c80a35e7a7ccc166cbd355ed8738197090b1bcd Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:06:31 -0400 Subject: [PATCH 45/99] 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 ac52bd95..9a803066 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 502d3d46e877891cb81c4d94f724b213a40d64b3 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:14:41 -0400 Subject: [PATCH 46/99] 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 e80c2ffd..33e3a2b9 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 18d6b051b30acdbbd5e5b48eef56fefe1b8b3be4 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:17:49 -0400 Subject: [PATCH 47/99] 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 33e3a2b9..82363a47 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 4a5dc5d329eb7fddc513cff2003567c35597e94a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:21:21 -0400 Subject: [PATCH 48/99] 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 82363a47..8fe0759f 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 6918c865aedb90e976d73ade3b17ebc801042466 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:24:55 -0400 Subject: [PATCH 49/99] 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 8fe0759f..ab0f4622 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 ba3039ed0f564c223771dd9731d72d445f820504 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:27:36 -0400 Subject: [PATCH 50/99] 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 04abf24f..a858d535 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 a6c683b9392e7c4dab1fffe18decd92322291686 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:31:28 -0400 Subject: [PATCH 51/99] 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) --- src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj index d65837fd..9e9d810c 100644 --- a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj +++ b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj @@ -5,7 +5,6 @@ 0.13.0 False $(AssemblyName) - Chris Wolfgang Contains interfaces and base classes used to build ETL applications Copyright 2025 Chris Wolfgang https://github.com/Chris-Wolfgang/ETL-Abstractions From e0ed138703bbc730e80b6496ed932f7fef725b46 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:34:08 -0400 Subject: [PATCH 52/99] 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 8c7b8ceb..4f63d5a4 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 969591a87f5bf4ce16b08978844a32607368b726 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 24 May 2026 23:35:54 -0400 Subject: [PATCH 53/99] 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 d7d46626..b0a09fcd 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 f9d798fcaf440555a970eec1033109138f5bd4dc Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 09:59:29 -0400 Subject: [PATCH 54/99] 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 4f63d5a4..bcf7a7e8 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 4d7d1d4bf9e3a1aa475487d4d7b30b63c846975e Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 10:20:13 -0400 Subject: [PATCH 55/99] 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 ab0f4622..9c391f4e 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 6f8e015ab6bdc728cfc07a52aee4cfff08b4014a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 10:28:45 -0400 Subject: [PATCH 56/99] 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 9c391f4e..e968c160 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 e30027712d4ba59db21604801fbfc4ed57508432 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 10:56:11 -0400 Subject: [PATCH 57/99] CONTRIBUTING.md: correct format.ps1 and README-FORMATTING.md paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The formatting section referenced `pwsh ./format.ps1` and linked to `README-FORMATTING.md` at the repo root, but the actual paths are `scripts/format.ps1` and `docs/README-FORMATTING.md`. Both are broken as written — contributors who follow the instructions get a "file not found" on the script and a 404 on the link. Correcting both references to match the canonical repo layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e21083b..2ed4ee96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -182,10 +182,10 @@ dotnet format dotnet format --verify-no-changes # PowerShell formatting script -pwsh ./format.ps1 +pwsh ./scripts/format.ps1 ``` -See [README-FORMATTING.md](README-FORMATTING.md) for detailed formatting rules. +See [docs/README-FORMATTING.md](docs/README-FORMATTING.md) for detailed formatting rules. --- From 5eb97cae318d584e72c3984b252044113554a8be Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 10:58:52 -0400 Subject: [PATCH 58/99] scripts/format.ps1: work from repo root + correct usage examples Two related problems: 1. EXAMPLE comments showed `.\format.ps1` (current directory), but the script lives at `scripts/format.ps1`. Contributors who copy- pasted the examples got "file not found". 2. The script used `Get-ChildItem -Path .` to find the solution, which only worked when invoked from the repo root. Invoking from anywhere else (including the scripts/ folder where the script lives) failed with "No solution file found". Now the script pins cwd to the repo root via Resolve-Path $PSScriptRoot/.. inside a Push-Location / try / finally Pop-Location block, so it works from any working directory inside the repo. The EXAMPLE comments and the Check-mode error message both reference the correct `.\scripts\format.ps1` path. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/format.ps1 | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/scripts/format.ps1 b/scripts/format.ps1 index aa9bf8f8..21cb4015 100644 --- a/scripts/format.ps1 +++ b/scripts/format.ps1 @@ -11,12 +11,17 @@ If specified, only checks formatting without making changes (like CI does). .EXAMPLE - .\format.ps1 - Formats all code in the repository. + .\scripts\format.ps1 + Formats all code in the repository (invoke from repo root). .EXAMPLE - .\format.ps1 -Check + .\scripts\format.ps1 -Check Checks formatting without making changes. + +.NOTES + The script resolves the solution from the repo root regardless of + the caller's current directory, so invoking it from any working + directory inside the repo works. #> param( @@ -25,6 +30,12 @@ param( $ErrorActionPreference = "Stop" +# Pin cwd to the repo root so the solution lookup below works regardless +# of where the caller invokes the script from (repo root, scripts/, etc). +$repoRoot = (Resolve-Path -Path (Join-Path $PSScriptRoot '..')).Path +Push-Location $repoRoot +try { + Write-Host "🎨 Code Formatting Script" -ForegroundColor Cyan Write-Host "" @@ -80,7 +91,7 @@ if ($Check) { Write-Host "" Write-Host "❌ Formatting issues detected!" -ForegroundColor Red - Write-Host "Run '.\format.ps1' (without -Check) to fix them automatically." -ForegroundColor Yellow + Write-Host "Run '.\scripts\format.ps1' (without -Check) to fix them automatically." -ForegroundColor Yellow exit 1 } } @@ -104,3 +115,6 @@ else exit 1 } } +} finally { + Pop-Location +} From 515570d1afcf149e6b40f7caa833ed3c8fba0f33 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 11:03:39 -0400 Subject: [PATCH 59/99] CONTRIBUTING.md: correct AsyncFixer description (ConfigureAwait is MA0004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AsyncFixer bullet listed "Ensures proper ConfigureAwait() usage" as one of its capabilities. That's inaccurate — ConfigureAwait enforcement in this fleet is handled by Meziantou's MA0004 (plus SonarAnalyzer's S3216 and Microsoft's CA2007), not by AsyncFixer. AsyncFixer covers other async anti-patterns: AsyncFixer01–05, cancellation-token propagation, fire-and-forget detection. Updated the bullet to describe what AsyncFixer actually does and added a note pointing readers at the right analyzer for ConfigureAwait enforcement. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ed4ee96..7046ab76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,9 +62,11 @@ All code is analyzed by these tools during build: - Advanced C# pattern detection 3. **AsyncFixer** - - Detects async/await anti-patterns - - Ensures proper `ConfigureAwait()` usage - - Prevents fire-and-forget async calls + - Detects common async/await anti-patterns (AsyncFixer01–05) + - Flags missing or incorrect cancellation-token propagation + - Prevents fire-and-forget async calls (`async void` outside event handlers) + - NOTE: `ConfigureAwait()` enforcement is handled by Meziantou's + MA0004 / SonarAnalyzer S3216 / CA2007, not by AsyncFixer. 4. **Microsoft.VisualStudio.Threading.Analyzers** - Thread safety enforcement From a9395015454d136dfe307872bbf22d3823b1d338 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 11:09:36 -0400 Subject: [PATCH 60/99] Fix-BranchRuleset.ps1: normalize Repository before gh api calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When setup.ps1 reads the repo from an SSH-style git remote (`git@github.com:owner/repo.git`), the placeholder replacement can leave a leading "@" and/or trailing ".git" in the Repository parameter of this script. Both break `gh api /repos//rulesets` calls — the "@" turns into a 404 and the ".git" suffix is rejected as an unknown repo. Added a normalization step right after the parameter declaration that strips both. Auto-detection via `gh repo view` already produces a clean nameWithOwner value, so this only affects the user-supplied or placeholder-substituted path. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/Fix-BranchRuleset.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/Fix-BranchRuleset.ps1 b/scripts/Fix-BranchRuleset.ps1 index f1dbad9e..99325345 100644 --- a/scripts/Fix-BranchRuleset.ps1 +++ b/scripts/Fix-BranchRuleset.ps1 @@ -65,6 +65,14 @@ try { exit 1 } +# Normalize Repository: strip leading "@" and trailing ".git" that can +# leak in from SSH remotes (e.g. git@github.com:owner/repo.git -> @owner/repo). +# Both prefixes make gh api /repos/... calls fail with 404. +if ($Repository) { + $Repository = $Repository.TrimStart('@') + if ($Repository.EndsWith('.git')) { $Repository = $Repository.Substring(0, $Repository.Length - 4) } +} + # Determine repository if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { Write-Host "Detecting current repository..." -ForegroundColor Cyan From d75681711435834c18d8778851d9088155b8f9b7 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:04:01 -0400 Subject: [PATCH 61/99] scripts/build-pr.ps1: align local checks with CI semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes so the local script behaves like the per-PR workflow instead of silently passing where CI would fail: 1. Linux/macOS gitleaks install used `curl ... | tar -C /usr/local/bin`, which requires sudo for normal users and fails out of the box on most local dev shells. Now installs to $HOME/.local/bin and adds that to PATH (only if not already there). Matches the user-writable pattern the CI workflows use. 2. When CoverageReport/Summary.txt was missing after a successful test run, the script printed "skipping threshold check" and reported success. pr.yaml fails the job in that situation. Align: emit a hard failure with a clear message about ReportGenerator and mark Coverage as failed. 3. "No test projects found in ./tests — skipping" let the script finish "All checks passed" even when the repo had src/ projects. Same rule as the pr.yaml tests-gate strict mode now: if ./src has projects, refuse the silent skip and fail. If neither has projects (template-pack / in-dev repos), graceful skip is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/build-pr.ps1 | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/build-pr.ps1 b/scripts/build-pr.ps1 index d7fd64c1..bf2b67f5 100644 --- a/scripts/build-pr.ps1 +++ b/scripts/build-pr.ps1 @@ -84,7 +84,17 @@ if (-not $SkipTests -and $failed.Count -eq 0) { $testProjects = @(Get-ChildItem -Path './tests' -Recurse -File -Include '*.csproj', '*.vbproj', '*.fsproj' -ErrorAction SilentlyContinue) if ($testProjects.Count -eq 0) { - Write-Host "No test projects found in ./tests — skipping" + # If ./src has projects, fail — silent skip would diverge from CI's + # strict gate. If neither ./src nor ./tests has projects (template-pack + # / in-dev repos), the skip is legitimate. + $srcHasProjects = @(Get-ChildItem -Path './src' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj' -ErrorAction SilentlyContinue).Count -gt 0 + if ($srcHasProjects) { + Write-Fail "./tests has no test projects but ./src contains projects — refusing to silently skip the coverage gate." + $failed += "Tests" + } + else { + Write-Host "No test projects found in ./tests and no ./src projects — skipping (template-pack / in-dev shape)." + } } else { foreach ($testProj in $testProjects) { @@ -202,7 +212,11 @@ if (-not $SkipTests -and -not $SkipCoverage -and $failed.Count -eq 0) { } } else { - Write-Host "Coverage report not generated — skipping threshold check" + # Diverged from pr.yaml behavior in the past — that would let a local + # "All checks passed" silently hide ReportGenerator failures while CI + # rejected the same situation. Fail loudly here too, so local matches CI. + Write-Fail "Coverage report not generated (CoverageReport/Summary.txt missing) — ReportGenerator likely failed." + $failed += "Coverage" } } } @@ -267,7 +281,15 @@ if (-not $SkipSecurity) { else { $archive = "gitleaks_${version}_linux_x64.tar.gz" $url = "https://github.com/gitleaks/gitleaks/releases/download/v${version}/$archive" - curl -sSfL $url | tar xz -C /usr/local/bin gitleaks + # Install to a user-writable location instead of /usr/local/bin + # (which would require sudo for most local dev shells). $HOME/.local/bin + # is on PATH by default on most Linux distros and macOS; if not, prepend it. + $localBin = Join-Path $HOME ".local/bin" + New-Item -ItemType Directory -Force -Path $localBin | Out-Null + curl -sSfL $url | tar xz -C $localBin gitleaks + if (-not ($env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -eq $localBin })) { + $env:PATH = "$localBin$([IO.Path]::PathSeparator)$env:PATH" + } } } From 6528c0d929eb7b6a2f72bfbe03b88c872073c7c4 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:05:37 -0400 Subject: [PATCH 62/99] CONTRIBUTING.md: bump prerequisite SDK to 10.0 to match repo TFMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prerequisites listed ".NET 8.0 SDK or later", but the repo targets net10.0 (and multi-targets older TFMs). SDK 8.0 cannot load a net10.0 csproj — contributors who follow the instructions would hit NETSDK1045 "does not recognize TargetFramework net10.0". Bumped to .NET 10.0 SDK with a short note about why older SDKs won't work. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7046ab76..55f7a0d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -147,7 +147,7 @@ var now = DateTimeOffset.UtcNow; ## Build and Test Instructions ### Prerequisites -- .NET 8.0 SDK or later +- .NET 10.0 SDK or later (required for the repo's net10.0 target; older SDKs cannot load the csproj) - PowerShell Core (optional, for formatting scripts) ### Build the Project From d29727d748839c1b422ea52713ed5da4e37c01ac Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:07:31 -0400 Subject: [PATCH 63/99] scripts/Validate-DocsDeploy.sh: rmdir mktemp path before worktree add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git worktree add` requires the target path NOT to exist, but the script created the path first via `mktemp -d` and then immediately called `git worktree add "$WORK_DIR" origin/gh-pages` — failing with "fatal: '' already exists" on every invocation. Fix: `rmdir` the directory right after mktemp reserves the unique name. mktemp still guarantees uniqueness, the trap still cleans up afterward, and worktree-add now succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/Validate-DocsDeploy.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/Validate-DocsDeploy.sh b/scripts/Validate-DocsDeploy.sh index 2a62cb47..b18f527b 100644 --- a/scripts/Validate-DocsDeploy.sh +++ b/scripts/Validate-DocsDeploy.sh @@ -51,7 +51,11 @@ check_pass "gh-pages branch exists on remote" # ------------------------------------------------------------------ # Use an explicit template so this works on BSD/macOS mktemp (which rejects # `mktemp -d` with no template), not only GNU coreutils. +# Reserve a unique path via mktemp -d (handles BSD/macOS too), then rmdir it +# so `git worktree add` can create it cleanly. The directory must NOT exist +# at the moment of worktree-add or git errors with "already exists". WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gh-pages-validate.XXXXXX") +rmdir "$WORK_DIR" cleanup() { git worktree remove "$WORK_DIR" --force 2>/dev/null || true rm -rf "$WORK_DIR" From 346d4b0308150b948fbd38ac694789d1e2c45ddc Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:10:09 -0400 Subject: [PATCH 64/99] Setup-Labels.ps1: restore early -Repository format validation The [ValidateScript] guard for -Repository was dropped in an earlier refactor, letting callers pass a full URL (https://github.com/owner/repo), a value with a leading "@" from an SSH remote, or other malformed inputs. gh api would later fail with a confusing 404 and the user wouldn't know the parameter format was the problem. Restored an early format check via [ValidatePattern]: - empty (auto-detect via `gh repo view` kicks in downstream) - the template {{GITHUB_USERNAME}}/{{REPO_NAME}} placeholder (also replaced downstream by setup.ps1) - strict `owner/repo` (no /, no @, no whitespace) Anything else fails at parameter binding with a clear error. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/Setup-Labels.ps1 | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/scripts/Setup-Labels.ps1 b/scripts/Setup-Labels.ps1 index 09da36d8..e61680f6 100644 --- a/scripts/Setup-Labels.ps1 +++ b/scripts/Setup-Labels.ps1 @@ -38,6 +38,104 @@ [CmdletBinding()] param( [Parameter()] + # Accept empty (auto-detect from `gh repo view`), the template placeholder + # (replaced by setup.ps1), or a strict owner/repo format. Rejecting URLs + # and malformed inputs here surfaces the problem at parameter binding + # instead of as a confusing 404 from gh api downstream. + [ValidatePattern('^$|^\{\{GITHUB_USERNAME\}\}/\{\{REPO_NAME\}\}$|^[^/@\s]+/[^/@\s]+ + +# Check if gh CLI is installed +try { + $null = gh --version +} catch { + Write-Error "❌ GitHub CLI (gh) is not installed or not in PATH." + Write-Host "Install from: https://cli.github.com/" -ForegroundColor Yellow + exit 1 +} + +# Check if authenticated +try { + $null = gh auth status 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ Not authenticated with GitHub CLI." + Write-Host "Run: gh auth login" -ForegroundColor Yellow + exit 1 + } +} catch { + Write-Error "❌ Failed to check GitHub CLI authentication status." + exit 1 +} + +# Determine repository +if (-not $Repository) { + Write-Host "🔍 Detecting current repository..." -ForegroundColor Cyan + try { + $repoInfo = gh repo view --json nameWithOwner | ConvertFrom-Json + $Repository = $repoInfo.nameWithOwner + Write-Host "✅ Using repository: $Repository" -ForegroundColor Green + } catch { + Write-Error "❌ Could not detect repository. Please run from within a git repository or specify -Repository parameter." + exit 1 + } +} else { + Write-Host "✅ Using specified repository: $Repository" -ForegroundColor Green +} + +Write-Host "`n🏷️ Creating labels for: $Repository`n" -ForegroundColor Cyan + +$labels = @( + # Dependabot — applies `dependencies` automatically per .github/dependabot.yml + @{ name = "dependencies"; color = "0366d6"; description = "Pull requests that update a dependency file" }, + + # Maintenance framework — kind labels (neutral steel: the meta is colorless) + @{ name = "maintenance"; color = "9aa7b3"; description = "Per-repo parent Maintenance issue (living improvement menu)" }, + @{ name = "maintenance-task"; color = "5a6c7d"; description = "A Maintenance sub-issue — actionable improvement work" }, + + # Maintenance framework — category labels (applied to sub-issues) + @{ name = "maintenance - security"; color = "c4161c"; description = "Maintenance: scans, finding fixes, dependency vulnerability audit" }, + @{ name = "maintenance - performance"; color = "2cbe4e"; description = "Maintenance: profile, benchmark, optimize, validate gains" }, + @{ name = "maintenance - testing"; color = "f9c513"; description = "Maintenance: coverage %, integration/smoke/mutation tests, fixtures" }, + @{ name = "maintenance - cleanup"; color = "a2845e"; description = "Maintenance: refactor for reuse, quality, efficiency" }, + @{ name = "maintenance - docs"; color = "0075ca"; description = "Maintenance: XML doc coverage, README, CHANGELOG, samples" }, + @{ name = "maintenance - API"; color = "ed7d31"; description = "Maintenance: public/internal surface audit, breaking-change vigilance" }, + @{ name = "maintenance - CI/CD"; color = "ec6cb9"; description = "Maintenance: Docker, CI workflow, build/publish pipeline" } +) + +$created = 0 +$skipped = 0 +$failed = 0 + +foreach ($label in $labels) { + $response = gh api ` + --method POST ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/labels" ` + -f "name=$($label.name)" ` + -f "color=$($label.color)" ` + -f "description=$($label.description)" 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Host " ✅ Created label: $($label.name)" -ForegroundColor Green + $created++ + } elseif ($response -like "*already_exists*") { + Write-Host " ⏭️ Label already exists, skipping: $($label.name)" -ForegroundColor Gray + $skipped++ + } else { + Write-Host " ❌ Failed to create label: $($label.name)" -ForegroundColor Red + Write-Host " $response" -ForegroundColor Red + $failed++ + } +} + +Write-Host "" +if ($failed -eq 0) { + Write-Host "🎉 Done! Created: $created, Skipped (already existed): $skipped" -ForegroundColor Green +} else { + Write-Host "⚠️ Done with errors. Created: $created, Skipped: $skipped, Failed: $failed" -ForegroundColor Yellow + exit 1 +} +)] [string]$Repository ) From ded3f4e1af6fc9d9993ab261ba423270a11d6b8d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:14:43 -0400 Subject: [PATCH 65/99] 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 c891c94b..b2b546cb 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 fde5e5efbd00b9f735f129bbe4f18c9b0f53a1e2 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:17:39 -0400 Subject: [PATCH 66/99] scripts/build-pr.ps1: drop -UseBasicParsing from Invoke-WebRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows gitleaks install path uses Invoke-WebRequest with -UseBasicParsing, which is a Windows-PowerShell-5.1-only flag. The script's shebang is pwsh, so PowerShell 7+ errors on the unsupported flag and gitleaks never installs locally. Same fix we applied to docfx.yaml's Invoke-WebRequest earlier — drop the flag (it's unnecessary in pwsh). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/build-pr.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-pr.ps1 b/scripts/build-pr.ps1 index bf2b67f5..48e66efb 100644 --- a/scripts/build-pr.ps1 +++ b/scripts/build-pr.ps1 @@ -273,7 +273,7 @@ if (-not $SkipSecurity) { $dest = Join-Path $env:LOCALAPPDATA "gitleaks" New-Item -ItemType Directory -Force -Path $dest | Out-Null $zip = Join-Path $env:TEMP $archive - Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing + Invoke-WebRequest -Uri $url -OutFile $zip Expand-Archive -Path $zip -DestinationPath $dest -Force Remove-Item $zip -ErrorAction SilentlyContinue $env:PATH = "$dest;$env:PATH" From 89eb640025ba005e76b7379443e08513205afffd Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:38:15 -0400 Subject: [PATCH 67/99] Remove subtree TreatWarningsAsErrors=false overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Policy: TreatWarningsAsErrors=true applies to ALL projects in Release (src + tests + examples + benchmarks). The subtree Directory.Build.props files in tests/, examples/ were overriding the root property back to false for their respective subtrees, contradicting the stated convention. Per-csproj is the right tool for stylistic / test-pattern / benchmark-specific suppressions — broad TWEA flips at the subtree level are too coarse and let real regressions slip through CI. (The .editorconfig [tests/**/*.cs] relaxations don't help either: the Windows runner doesn't honor them, so test-project warnings need per-csproj regardless.) Each removed file contained only the MSBuild Import + the TWEA override, nothing else worth preserving. After this commit, tests/, examples/ subtree(s) inherit root TWEA=true in Release. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/Directory.Build.props | 6 ------ tests/Directory.Build.props | 6 ------ 2 files changed, 12 deletions(-) delete mode 100644 examples/Directory.Build.props delete mode 100644 tests/Directory.Build.props diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props deleted file mode 100644 index a53b408b..00000000 --- a/examples/Directory.Build.props +++ /dev/null @@ -1,6 +0,0 @@ - - - - false - - diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props deleted file mode 100644 index a53b408b..00000000 --- a/tests/Directory.Build.props +++ /dev/null @@ -1,6 +0,0 @@ - - - - false - - From 4af1ba167947485a80dee234e13e24071e8b4cb4 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:38:18 -0400 Subject: [PATCH 68/99] Remove subtree TreatWarningsAsErrors=false overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Policy: TreatWarningsAsErrors=true applies to ALL projects in Release (src + tests + examples + benchmarks). The subtree Directory.Build.props files in examples/, benchmarks/ were overriding the root property back to false for their respective subtrees, contradicting the stated convention. Per-csproj is the right tool for stylistic / test-pattern / benchmark-specific suppressions — broad TWEA flips at the subtree level are too coarse and let real regressions slip through CI. (The .editorconfig [tests/**/*.cs] relaxations don't help either: the Windows runner doesn't honor them, so test-project warnings need per-csproj regardless.) Each removed file contained only the MSBuild Import + the TWEA override, nothing else worth preserving. After this commit, examples/, benchmarks/ subtree(s) inherit root TWEA=true in Release. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/Directory.Build.props | 6 ------ examples/Directory.Build.props | 6 ------ 2 files changed, 12 deletions(-) delete mode 100644 benchmarks/Directory.Build.props delete mode 100644 examples/Directory.Build.props diff --git a/benchmarks/Directory.Build.props b/benchmarks/Directory.Build.props deleted file mode 100644 index a53b408b..00000000 --- a/benchmarks/Directory.Build.props +++ /dev/null @@ -1,6 +0,0 @@ - - - - false - - diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props deleted file mode 100644 index a53b408b..00000000 --- a/examples/Directory.Build.props +++ /dev/null @@ -1,6 +0,0 @@ - - - - false - - From c367093c279f2e52410825909be2405e8ead5384 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:42:24 -0400 Subject: [PATCH 69/99] 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 e968c160..6f88d421 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 05d39a8bd3b2a90fcf719ff530a30fe3de42bf5e Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:44:54 -0400 Subject: [PATCH 70/99] 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 6f88d421..d832da6e 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 c7cb0af78d397ce648d26aadf0ee1530a4391fd0 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 13:52:48 -0400 Subject: [PATCH 71/99] 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 d832da6e..96824b1d 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 05bf7b9dadaa5a75e0a367e3280966057b566f13 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:10:00 -0400 Subject: [PATCH 72/99] 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) --- src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj index 9e9d810c..43bb897e 100644 --- a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj +++ b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj @@ -6,7 +6,6 @@ False $(AssemblyName) Contains interfaces and base classes used to build ETL applications - Copyright 2025 Chris Wolfgang https://github.com/Chris-Wolfgang/ETL-Abstractions README.md https://github.com/Chris-Wolfgang/ETL-Abstractions From cafb7346e6c85df7f02dffd5fcfe3ff8d702732e Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:13:26 -0400 Subject: [PATCH 73/99] Validate-DocsDeploy.sh: handle SSH remote URLs in REPO_NAME parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `REPO_NAME=${REPO_URL##*/}` worked for HTTPS remotes (`https://github.com/owner/repo[.git]` → "repo") but is brittle for SSH-style remotes (`git@github.com:owner/repo.git`). For example if someone has an SSH remote without the `.git` suffix, the parsing falls back to using parts of the host instead of the repo name. Changing the parameter expansion to strip after either `/` or `:` (`${REPO_URL##*[/:]}`) so both URL forms produce just "repo". Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/Validate-DocsDeploy.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/Validate-DocsDeploy.sh b/scripts/Validate-DocsDeploy.sh index b18f527b..ab6fd0d0 100644 --- a/scripts/Validate-DocsDeploy.sh +++ b/scripts/Validate-DocsDeploy.sh @@ -163,7 +163,11 @@ REPO_NAME="" REPO_URL=$(git remote get-url origin 2>/dev/null || true) if [ -n "$REPO_URL" ]; then REPO_URL=${REPO_URL%.git} # strip optional trailing .git - REPO_NAME=${REPO_URL##*/} # take everything after the last '/' + # Take everything after the last '/' or ':' — handles both + # HTTPS (https://github.com/owner/repo) and SSH + # (git@github.com:owner/repo) remotes. Without the colon + # split, SSH-style remotes without /repo after a / fail. + REPO_NAME=${REPO_URL##*[/:]} fi if [ ! -f "$WORK_DIR/versions.json" ]; then From d160f9559a5e7e34fb89160f51c24eaa4f037d36 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:14:49 -0400 Subject: [PATCH 74/99] build-pr.ps1: ensure .NET global-tools dir is on PATH after install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After installing dotnet-reportgenerator-globaltool, the script invokes `reportgenerator` immediately. On a shell where the .NET installer hasn't yet appended $HOME/.dotnet/tools (Linux/macOS) or $env:USERPROFILE\.dotnet\tools (Windows) to PATH — common for fresh terminals or pwsh sessions launched from a script — the call fails with "reportgenerator: command not found". Switched to update-or-install (idempotent for already-installed tools) and added an explicit PATH-prepend after the install so the subsequent reportgenerator call always finds the binary. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/build-pr.ps1 | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/build-pr.ps1 b/scripts/build-pr.ps1 index 48e66efb..cb0b8fa3 100644 --- a/scripts/build-pr.ps1 +++ b/scripts/build-pr.ps1 @@ -174,7 +174,23 @@ if (-not $SkipTests -and -not $SkipCoverage -and $failed.Count -eq 0) { $rgPath = Get-Command reportgenerator -ErrorAction SilentlyContinue if (-not $rgPath) { Write-Host "Installing ReportGenerator..." - dotnet tool install -g dotnet-reportgenerator-globaltool + dotnet tool update -g dotnet-reportgenerator-globaltool 2>$null + if ($LASTEXITCODE -ne 0) { dotnet tool install -g dotnet-reportgenerator-globaltool } + # Ensure global tools dir is on PATH for this session. The .NET + # installer normally adds it to the user's profile, but a fresh + # shell or a pwsh-invoked-from-script session may not have it yet. + $globalToolsDir = if ($IsWindows -or $env:OS -eq 'Windows_NT') { + Join-Path $env:USERPROFILE '.dotnet\tools' + } else { + Join-Path $HOME '.dotnet/tools' + } + if (Test-Path $globalToolsDir -PathType Container) { + $sep = [IO.Path]::PathSeparator + $pathSegments = $env:PATH -split [regex]::Escape($sep) + if ($pathSegments -notcontains $globalToolsDir) { + $env:PATH = "$globalToolsDir$sep$env:PATH" + } + } } reportgenerator ` From 52b279ecd3d478f2e84b50c5fa59cd6c4c0aece9 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:17:27 -0400 Subject: [PATCH 75/99] 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 bcf7a7e8..ba5aecf6 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 19bd726ae04a4a3f35cf5ca350d176b64039c3d6 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:23:40 -0400 Subject: [PATCH 76/99] 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 9a803066..c3d64fc7 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 afd407e30cfe7a6710a1d075cce854769c698233 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 14:32:31 -0400 Subject: [PATCH 77/99] 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 ba5aecf6..4a8b4380 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 747d5a725557e1b7229f05c2625d9365ce630b68 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:26:17 -0400 Subject: [PATCH 78/99] 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 a858d535..82f96d23 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 33b3f26cbe2a2bf5a5eca6698e6d8988a81b83bf Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:28:21 -0400 Subject: [PATCH 79/99] 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 96824b1d..37dcc677 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 8c950b704fe13afe3567d081d7ae89f3413a91f3 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:36:47 -0400 Subject: [PATCH 80/99] 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 37dcc677..a438d293 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 f8e32c7b1f3688cae5cdaf1aebb3862009e1077a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:39:11 -0400 Subject: [PATCH 81/99] 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 4a8b4380..f7c7ed55 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 61b476ce63f25918bae4c536bb80653cb957d500 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:41:15 -0400 Subject: [PATCH 82/99] 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 f7c7ed55..420140b0 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 6c04fd7e77335f7819d9a405f6d1a31b41c52985 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:43:22 -0400 Subject: [PATCH 83/99] 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 420140b0..c709d02f 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 be81b1e2e11953d99323388d1735684c424cb95d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:55:53 -0400 Subject: [PATCH 84/99] 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 c709d02f..7ce4e271 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 8d86f38d58d08e3032f32bf0f33dd7e0a136d2f5 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 15:57:33 -0400 Subject: [PATCH 85/99] 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 a438d293..db76554a 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 07729f4e0338e1a1dfcbabd3114d30299b9abebe Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 25 May 2026 21:37:44 -0400 Subject: [PATCH 86/99] scripts: repair Setup-Labels.ps1 param block + drop --paginate on rulesets Fixes two bugs in the canonical-unprotected scripts that were fanned out from repo-template: - Setup-Labels.ps1: the ValidatePattern attribute string was left unterminated and the [string]$Repository declaration + closing paren were dropped, leaving a duplicated script body that ran with $Repository unbound. Param block restored to a single well-formed declaration; duplicated body removed. - Fix-BranchRuleset.ps1: gh api --paginate concatenates multiple JSON array payloads when results span pages, which breaks ConvertFrom-Json. Switched to ?per_page=100 in a single call. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/Fix-BranchRuleset.ps1 | 8 ++- scripts/Setup-Labels.ps1 | 95 +---------------------------------- 2 files changed, 7 insertions(+), 96 deletions(-) diff --git a/scripts/Fix-BranchRuleset.ps1 b/scripts/Fix-BranchRuleset.ps1 index 99325345..31632679 100644 --- a/scripts/Fix-BranchRuleset.ps1 +++ b/scripts/Fix-BranchRuleset.ps1 @@ -101,11 +101,15 @@ try { # even on a successful API call). $rulesetsErr = [System.IO.Path]::GetTempFileName() try { + # Don't use --paginate here: it concatenates multiple JSON array + # payloads when results span pages, which breaks ConvertFrom-Json. + # Rulesets are typically few per repo; per_page=100 in a single + # call is enough and produces valid JSON. $rulesetsJson = gh api ` -H "Accept: application/vnd.github+json" ` -H "X-GitHub-Api-Version: 2022-11-28" ` - "/repos/$Repository/rulesets" ` - --paginate 2> $rulesetsErr + "/repos/$Repository/rulesets?per_page=100" ` + 2> $rulesetsErr } finally { if (Test-Path -LiteralPath $rulesetsErr) { $errText = (Get-Content -LiteralPath $rulesetsErr -Raw -ErrorAction SilentlyContinue) diff --git a/scripts/Setup-Labels.ps1 b/scripts/Setup-Labels.ps1 index e61680f6..e8b70f54 100644 --- a/scripts/Setup-Labels.ps1 +++ b/scripts/Setup-Labels.ps1 @@ -42,100 +42,7 @@ param( # (replaced by setup.ps1), or a strict owner/repo format. Rejecting URLs # and malformed inputs here surfaces the problem at parameter binding # instead of as a confusing 404 from gh api downstream. - [ValidatePattern('^$|^\{\{GITHUB_USERNAME\}\}/\{\{REPO_NAME\}\}$|^[^/@\s]+/[^/@\s]+ - -# Check if gh CLI is installed -try { - $null = gh --version -} catch { - Write-Error "❌ GitHub CLI (gh) is not installed or not in PATH." - Write-Host "Install from: https://cli.github.com/" -ForegroundColor Yellow - exit 1 -} - -# Check if authenticated -try { - $null = gh auth status 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error "❌ Not authenticated with GitHub CLI." - Write-Host "Run: gh auth login" -ForegroundColor Yellow - exit 1 - } -} catch { - Write-Error "❌ Failed to check GitHub CLI authentication status." - exit 1 -} - -# Determine repository -if (-not $Repository) { - Write-Host "🔍 Detecting current repository..." -ForegroundColor Cyan - try { - $repoInfo = gh repo view --json nameWithOwner | ConvertFrom-Json - $Repository = $repoInfo.nameWithOwner - Write-Host "✅ Using repository: $Repository" -ForegroundColor Green - } catch { - Write-Error "❌ Could not detect repository. Please run from within a git repository or specify -Repository parameter." - exit 1 - } -} else { - Write-Host "✅ Using specified repository: $Repository" -ForegroundColor Green -} - -Write-Host "`n🏷️ Creating labels for: $Repository`n" -ForegroundColor Cyan - -$labels = @( - # Dependabot — applies `dependencies` automatically per .github/dependabot.yml - @{ name = "dependencies"; color = "0366d6"; description = "Pull requests that update a dependency file" }, - - # Maintenance framework — kind labels (neutral steel: the meta is colorless) - @{ name = "maintenance"; color = "9aa7b3"; description = "Per-repo parent Maintenance issue (living improvement menu)" }, - @{ name = "maintenance-task"; color = "5a6c7d"; description = "A Maintenance sub-issue — actionable improvement work" }, - - # Maintenance framework — category labels (applied to sub-issues) - @{ name = "maintenance - security"; color = "c4161c"; description = "Maintenance: scans, finding fixes, dependency vulnerability audit" }, - @{ name = "maintenance - performance"; color = "2cbe4e"; description = "Maintenance: profile, benchmark, optimize, validate gains" }, - @{ name = "maintenance - testing"; color = "f9c513"; description = "Maintenance: coverage %, integration/smoke/mutation tests, fixtures" }, - @{ name = "maintenance - cleanup"; color = "a2845e"; description = "Maintenance: refactor for reuse, quality, efficiency" }, - @{ name = "maintenance - docs"; color = "0075ca"; description = "Maintenance: XML doc coverage, README, CHANGELOG, samples" }, - @{ name = "maintenance - API"; color = "ed7d31"; description = "Maintenance: public/internal surface audit, breaking-change vigilance" }, - @{ name = "maintenance - CI/CD"; color = "ec6cb9"; description = "Maintenance: Docker, CI workflow, build/publish pipeline" } -) - -$created = 0 -$skipped = 0 -$failed = 0 - -foreach ($label in $labels) { - $response = gh api ` - --method POST ` - -H "Accept: application/vnd.github+json" ` - -H "X-GitHub-Api-Version: 2022-11-28" ` - "/repos/$Repository/labels" ` - -f "name=$($label.name)" ` - -f "color=$($label.color)" ` - -f "description=$($label.description)" 2>&1 - - if ($LASTEXITCODE -eq 0) { - Write-Host " ✅ Created label: $($label.name)" -ForegroundColor Green - $created++ - } elseif ($response -like "*already_exists*") { - Write-Host " ⏭️ Label already exists, skipping: $($label.name)" -ForegroundColor Gray - $skipped++ - } else { - Write-Host " ❌ Failed to create label: $($label.name)" -ForegroundColor Red - Write-Host " $response" -ForegroundColor Red - $failed++ - } -} - -Write-Host "" -if ($failed -eq 0) { - Write-Host "🎉 Done! Created: $created, Skipped (already existed): $skipped" -ForegroundColor Green -} else { - Write-Host "⚠️ Done with errors. Created: $created, Skipped: $skipped, Failed: $failed" -ForegroundColor Yellow - exit 1 -} -)] + [ValidatePattern('^$|^\{\{GITHUB_USERNAME\}\}/\{\{REPO_NAME\}\}$|^[^/@\s]+/[^/@\s]+$')] [string]$Repository ) From 75a460d7ac9043c8939a9e8c4ec967c14b678276 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Tue, 26 May 2026 12:50:02 -0400 Subject: [PATCH 87/99] =?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 db76554a..b0e53ac3 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 d21302818a773ecfe9ed4ac21299116b34d2df92 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Tue, 26 May 2026 12:51:28 -0400 Subject: [PATCH 88/99] C4: drop stale from src csproj(s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every shippable src csproj across the fleet had `1.0.0` hardcoded — last touched at v1.0.0 and never bumped. Released NuGet packages from v1.1.0 onward have shipped with AssemblyVersion=1.0.0 even though the package bumped normally. Drop the explicit AssemblyVersion line. .NET SDK derives AssemblyVersion, FileVersion, and InformationalVersion from when they are not specified, so all four track every bump automatically going forward. They are not byte-identical — AssemblyVersion is normalized to a 4-part System.Version, InformationalVersion can carry a + under SourceLink — but they all reflect the current Version, which is the point. Mirrors DateTime-Extensions PR #184 (already merged to main). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj index 74f956b0..2dbeb455 100644 --- a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj +++ b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj @@ -12,7 +12,6 @@ https://github.com/Chris-Wolfgang/ETL-Abstractions README.md https://github.com/Chris-Wolfgang/ETL-Abstractions - 1.0.0 MIT True ETL-Abstractions.png From 59e0f3cbb32582c9a04c7db7e29c187dd7a105d1 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Thu, 28 May 2026 09:55:44 -0400 Subject: [PATCH 89/99] Restore 1.0.0.0 + (C4 fix) The C4 fanout dropped on the rationale that the hardcoded 1.0.0 was 'stale' relative to released package versions, but that staleness was the correct binding-stability behaviour for a library shipping a net462 TFM. SDK-deriving AssemblyVersion from changes the binding identity on every minor/patch bump, breaking .NET Framework consumers without binding redirects. --- .../Wolfgang.Etl.Abstractions.csproj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj index 2dbeb455..76472948 100644 --- a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj +++ b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj @@ -4,6 +4,13 @@ latest enable 0.13.0 + + 1.0.0.0 + $(Version).0 False $(AssemblyName) Chris Wolfgang From d7afc9b0230cbdb232c916831f19301e1f982924 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Thu, 28 May 2026 14:05:48 -0400 Subject: [PATCH 90/99] D8: add verify-docs-build job (canonical sync from repo-template#395) --- .github/workflows/release.yaml | 99 +++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9a2ae637..97710462 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -562,10 +562,107 @@ jobs: retention-days: 90 if-no-files-found: warn + # 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 + # 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 + 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 -Encoding utf8 + } else { + Write-Host "::notice::No docfx_project/docfx.json - skipping docs verification." + "found=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } + + - name: Setup .NET + 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 .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 + + - 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' + shell: pwsh + 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 to NuGet (only if validation passed) 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 d9a56be85b1f8f68d81d1be9f4f754b7af27c82a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Thu, 28 May 2026 14:22:41 -0400 Subject: [PATCH 91/99] Refine AssemblyVersion comment + use prerelease-safe FileVersion Addresses Copilot review feedback on the fix/restore-assemblyversion PR: - Reword comment so it does not imply AssemblyVersion tracks NuGet major (it is a fixed binding-stability baseline, not derived from Version). - Use regex-strip property function for FileVersion so prerelease values (e.g., 0.1.0-alpha) still produce a 4-part numeric AssemblyFileVersion (otherwise FileVersion fails the build). - Remove pre-existing literal 1.0.0 where present (ETL repos) since MSBuild last-write semantics would otherwise override the property-function value. --- .../Wolfgang.Etl.Abstractions.csproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj index 76472948..1ce91038 100644 --- a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj +++ b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj @@ -4,13 +4,13 @@ latest enable 0.13.0 - + 1.0.0.0 - $(Version).0 + $([System.Text.RegularExpressions.Regex]::Replace("$(Version)", "[-+].*$", "")).0 False $(AssemblyName) Chris Wolfgang From 2de08369b7e3249fce8bdd522775f5e706bc12e0 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sun, 31 May 2026 22:00:39 -0400 Subject: [PATCH 92/99] v0.13.1 release prep: dup-fix + missing docs file + version bump --- .github/workflows/release.yaml | 97 -------- docs/DOCFX-VERSION-PICKER.md | 210 ++++++++++++++++++ .../PublicAPI.Shipped.txt | 128 +++++++++++ .../PublicAPI.Unshipped.txt | 1 + .../Wolfgang.Etl.Abstractions.csproj | 2 +- 5 files changed, 340 insertions(+), 98 deletions(-) create mode 100644 docs/DOCFX-VERSION-PICKER.md create mode 100644 src/Wolfgang.Etl.Abstractions/PublicAPI.Shipped.txt create mode 100644 src/Wolfgang.Etl.Abstractions/PublicAPI.Unshipped.txt diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 66cb9b5c..336b732c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -660,103 +660,6 @@ jobs: Write-Host "Documentation built successfully - release may proceed" # 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 - # 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 - 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 -Encoding utf8 - } else { - Write-Host "::notice::No docfx_project/docfx.json - skipping docs verification." - "found=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - } - - - name: Setup .NET - 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 .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 - - - 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' - shell: pwsh - 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, verify-docs-build] diff --git a/docs/DOCFX-VERSION-PICKER.md b/docs/DOCFX-VERSION-PICKER.md new file mode 100644 index 00000000..a01679f9 --- /dev/null +++ b/docs/DOCFX-VERSION-PICKER.md @@ -0,0 +1,210 @@ +# DocFX Version Picker + +The docs site shows an in-page version picker (a `` between the app title and the theme toggle, populated from `versions.json` and pre-selecting whichever version the current URL is under. | +| Pick a different version in the dropdown | Browser navigates to `//versions//` | + +The "latest" alias is filtered out of the dropdown (redundant — the +highest-numbered `v*` row already represents latest); `versions.json` +still includes it so external links / scripts can resolve it. + +--- + +## The four moving parts + +### 1. `docfx_project/public/version-picker.js` + +Browser-side picker (~160 lines). On `DOMContentLoaded`: + +- Detects whether the host is `*.github.io` and computes the repo + prefix accordingly — same file works on github.io, on + `docfx build --serve` localhost, and on CNAME-served custom domains. +- Fetches `versions.json` from the site root (default browser cache + policy — the file changes infrequently). +- Builds a themed `