From 163a4fb643b222abaa465bacdf2d01e8808ab4ed Mon Sep 17 00:00:00 2001
From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
Date: Sun, 8 Mar 2026 22:33:58 -0400
Subject: [PATCH 01/13] Updated ISSUE_TEMPLATE
---
.github/ISSUE_TEMPLATE/bug_report.md | 32 -------------
.github/ISSUE_TEMPLATE/feature_request.md | 20 ---------
.github/ISSUE_TEMPLATE/feature_request.yaml | 50 +++++++++++++++++++++
3 files changed, 50 insertions(+), 52 deletions(-)
delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md
delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yaml
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 2fcf1c6..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,32 +0,0 @@
----
-name: Bug report
-about: Create a report to help us improve
-title: ''
-labels: ''
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-1. Go to '...'
-2. Click on '....'
-3. Scroll down to '....'
-4. See error
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Desktop (please complete the following information):**
- - OS: [e.g. iOS]
- - Browser [e.g. chrome, safari]
- - Version [e.g. 22]
-
-**Additional context**
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index bbcbbe7..0000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: Feature request
-about: Suggest an idea for this project
-title: ''
-labels: ''
-assignees: ''
-
----
-
-**Is your feature request related to a problem? Please describe.**
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
-
-**Describe the solution you'd like**
-A clear and concise description of what you want to happen.
-
-**Describe alternatives you've considered**
-A clear and concise description of any alternative solutions or features you've considered.
-
-**Additional context**
-Add any other context or screenshots about the feature request here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
new file mode 100644
index 0000000..559ded5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -0,0 +1,50 @@
+name: "š Feature request"
+description: "š” Suggest an idea for this project"
+title: "[Feature]
"
+labels: [enhancement, feature-request]
+assignees: []
+body:
+ - type: markdown
+ attributes:
+ value: |
+ ## Thanks for suggesting a feature!
+
+ Please use this form to propose a new feature or enhancement for this project. Providing as much detail as possible helps us understand your idea and evaluate it effectively.
+
+ When completing this form, please:
+ - Describe the problem this feature will address.
+ - Explain the solution you'd like to see.
+ - List any alternative approaches you've considered.
+ - Add any relevant context, examples, or screenshots.
+ - type: textarea
+ id: problem
+ attributes:
+ label: Is your feature request related to a problem? Please describe.
+ description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+ placeholder: Please describe the problem this feature will solve.
+ validations:
+ required: true
+ - type: textarea
+ id: solution
+ attributes:
+ label: Describe the solution you'd like
+ description: A clear and concise description of what you want to happen.
+ placeholder: What do you want to happen?
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Describe alternatives you've considered
+ description: A clear and concise description of any alternative solutions or features you've considered.
+ placeholder: List any alternative solutions or features you've tried or considered.
+ validations:
+ required: false
+ - type: textarea
+ id: context
+ attributes:
+ label: Additional context
+ description: Add any other context or screenshots about the feature request here.
+ placeholder: Any other context to explain your request?
+ validations:
+ required: false
From 8669982f8e853d409cc6957b60728662d4ba1146 Mon Sep 17 00:00:00 2001
From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
Date: Sun, 8 Mar 2026 22:34:21 -0400
Subject: [PATCH 02/13] Updated workflows
---
.github/dependabot.yml | 44 +-
.github/version-picker-template.html | 39 ++
.github/workflows/build-all-versions.yaml | 347 ++++++++++++
.github/workflows/codeql.yml | 182 ++++++
.github/workflows/create-labels.yaml | 86 ---
.github/workflows/docfx.yaml | 323 +++++++++--
.github/workflows/pr.yaml | 651 ++++++++++++++++++++--
.github/workflows/release.yaml | 633 +++++++++++++++++----
.github/workflows/security-scanning.yml | 36 --
9 files changed, 1979 insertions(+), 362 deletions(-)
create mode 100644 .github/version-picker-template.html
create mode 100644 .github/workflows/build-all-versions.yaml
create mode 100644 .github/workflows/codeql.yml
delete mode 100644 .github/workflows/create-labels.yaml
delete mode 100644 .github/workflows/security-scanning.yml
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 885018f..4e6aa95 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,46 +1,14 @@
version: 2
updates:
- package-ecosystem: "nuget"
- directory: "/" # Root - for solution-level dependencies
+ directory: "/"
schedule:
interval: "weekly"
- open-pull-requests-limit: 5
- labels:
- - "dependencies"
- - "dotnet"
-
- - package-ecosystem: "nuget"
- directory: "/src"
- schedule:
- interval: "weekly"
- open-pull-requests-limit: 5
- labels:
- - "dependencies"
- - "dotnet"
-
- - package-ecosystem: "nuget"
- directory: "/tests"
- schedule:
- interval: "weekly"
- open-pull-requests-limit: 5
- labels:
- - "dependencies"
- - "dotnet"
-
- - package-ecosystem: "nuget"
- directory: "/benchmarks"
- schedule:
- interval: "weekly"
- open-pull-requests-limit: 5
- labels:
- - "dependencies"
- - "dotnet"
-
- - package-ecosystem: "nuget"
- directory: "/examples"
- schedule:
- interval: "weekly"
- open-pull-requests-limit: 5
+ open-pull-requests-limit: 10
labels:
- "dependencies"
- "dotnet"
+ groups:
+ dotnet-dependencies:
+ patterns:
+ - "*"
diff --git a/.github/version-picker-template.html b/.github/version-picker-template.html
new file mode 100644
index 0000000..39a2594
--- /dev/null
+++ b/.github/version-picker-template.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+ {{TITLE}}
+
+
+
+ {{TITLE}}
+ Select a documentation version:
+
+
+
diff --git a/.github/workflows/build-all-versions.yaml b/.github/workflows/build-all-versions.yaml
new file mode 100644
index 0000000..a7430ad
--- /dev/null
+++ b/.github/workflows/build-all-versions.yaml
@@ -0,0 +1,347 @@
+name: Build All Versioned Docs
+
+# One-shot (or on-demand) workflow that builds DocFX documentation for every
+# v*.*.* git tag and deploys the output to gh-pages under versions//.
+# The version list is discovered automatically from git tags at runtime ā no
+# manual updates are required when new releases are published.
+#
+# Versions without a docfx_project at their tag (e.g. v0.1.x) use the
+# current docfx_project configuration so that the documentation structure
+# is consistent across all versions.
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read # Default to read-only; the build-and-deploy job overrides with write
+
+jobs:
+ build-and-deploy:
+ name: Build & Deploy All Versioned Docs
+ runs-on: windows-latest
+
+ permissions:
+ contents: write # Required to push to gh-pages branch
+
+ steps:
+ - name: Checkout repository (full history + all tags)
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Full history so all tags are reachable
+ persist-credentials: false
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 5.0.x
+ 6.0.x
+ 7.0.x
+ 8.0.x
+ 9.0.x
+ 10.0.x
+
+ - name: Install DocFX
+ run: |
+ dotnet tool update docfx --global || dotnet tool install docfx --global
+ shell: pwsh
+
+ - name: Build docs for each version
+ id: build
+ shell: pwsh
+ run: |
+ $outDir = Join-Path $env:RUNNER_TEMP 'all_version_docs'
+ New-Item -ItemType Directory -Force -Path $outDir | Out-Null
+
+ # Versions to build ā discovered automatically from all v*.*.* git tags so
+ # no manual list updates are needed when new releases are published.
+ # 'latest' is handled separately below.
+ $semverRe = '^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?[0-9A-Za-z.-]+))?$'
+ $versions = @(
+ git tag -l 'v*' |
+ Where-Object { $_ -match $semverRe } |
+ ForEach-Object {
+ $_ -match $semverRe | Out-Null
+ $stable = if ([string]::IsNullOrEmpty($Matches['prerelease'])) { 1 } else { 0 }
+ [PSCustomObject]@{
+ Tag = $_
+ Major = [int]$Matches['major']
+ Minor = [int]$Matches['minor']
+ Patch = [int]$Matches['patch']
+ Stable = $stable
+ }
+ } |
+ Sort-Object -Property Major, Minor, Patch, Stable -Descending |
+ Select-Object -ExpandProperty Tag
+ )
+ Write-Host "Discovered $($versions.Count) version tag(s): $($versions -join ', ')"
+
+ foreach ($version in $versions) {
+ Write-Host "========================================" -ForegroundColor Cyan
+ Write-Host "Building docs for $version" -ForegroundColor Cyan
+ Write-Host "========================================" -ForegroundColor Cyan
+
+ $workDir = Join-Path $env:RUNNER_TEMP "workdir-$version"
+
+ # Clean up any leftover worktree from a previous run
+ $removeOutput = git worktree remove $workDir --force 2>&1
+ if ($LASTEXITCODE -ne 0 -and (Test-Path $workDir)) {
+ Write-Warning "ā ļø Could not remove worktree for $version via git; attempting manual cleanup."
+ Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+
+ # Create a worktree checked out at the version tag
+ git worktree add $workDir $version
+ if ($LASTEXITCODE -ne 0) {
+ Write-Warning "ā ļø Failed to create worktree for $version ā skipping."
+ continue
+ }
+
+ # Overlay the current docfx_project config so that older tags
+ # (which may lack docfx_project/) still produce consistent docs.
+ if (Test-Path 'docfx_project') {
+ if (Test-Path "$workDir/docfx_project") {
+ Remove-Item "$workDir/docfx_project" -Recurse -Force
+ }
+ Copy-Item 'docfx_project' "$workDir/docfx_project" -Recurse -Force
+ }
+
+ # Copy build-support files that were added in later versions to help
+ # older code compile cleanly. These files contain global settings
+ # (analyzer rules, banned symbols) that are version-agnostic; copying
+ # them into older worktrees is safe and avoids restore/build warnings.
+ foreach ($f in @('Directory.Build.props', '.globalconfig', 'BannedSymbols.txt')) {
+ if ((Test-Path $f) -and -not (Test-Path "$workDir/$f")) {
+ Copy-Item $f "$workDir/$f" -Force -ErrorAction Stop
+ }
+ }
+ Push-Location $workDir
+ 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 |
+ Select-Object -First 1
+ if ($slnFile) {
+ Write-Host "Restoring $($slnFile.Name)..."
+ dotnet restore $slnFile.FullName 2>&1 | Write-Host
+ Write-Host "Building $($slnFile.Name)..."
+ dotnet build $slnFile.FullName --configuration Release --no-restore 2>&1 | Write-Host
+ }
+
+ Write-Host "Running docfx metadata..."
+ docfx metadata docfx_project/docfx.json 2>&1 | Write-Host
+
+ Write-Host "Running docfx build..."
+ docfx build docfx_project/docfx.json 2>&1 | Write-Host
+
+ if (Test-Path 'docfx_project/_site') {
+ $dest = Join-Path $outDir 'versions' $version
+ New-Item -ItemType Directory -Force -Path $dest | Out-Null
+ Copy-Item 'docfx_project/_site/*' $dest -Recurse -Force -ErrorAction Stop
+ Write-Host "ā
Docs built for $version" -ForegroundColor Green
+ } else {
+ Write-Warning "ā ļø No _site output produced for $version."
+ }
+ } finally {
+ Pop-Location
+ $removeOutput = git worktree remove $workDir --force 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ Write-Warning "ā ļø git worktree remove failed for $version (exit $LASTEXITCODE): $removeOutput"
+ }
+ }
+ }
+
+ # Build 'latest' from the most recent stable v*.*.* tag so that the
+ # 'latest' docs always match the last published release rather than an
+ # arbitrary HEAD commit.
+ Write-Host "========================================" -ForegroundColor Cyan
+ Write-Host "Building docs for latest" -ForegroundColor Cyan
+ Write-Host "========================================" -ForegroundColor Cyan
+
+ $semverRe = '^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)$'
+ $latestTag = git tag -l 'v*' |
+ Where-Object { $_ -match $semverRe } |
+ ForEach-Object {
+ $_ -match $semverRe | Out-Null
+ [PSCustomObject]@{
+ Tag = $_
+ Major = [int]$Matches['major']
+ Minor = [int]$Matches['minor']
+ Patch = [int]$Matches['patch']
+ }
+ } |
+ Sort-Object -Property Major, Minor, Patch -Descending |
+ Select-Object -First 1 -ExpandProperty Tag
+
+ if (-not $latestTag) {
+ Write-Error "ā No stable v*.*.* tag found ā cannot build 'latest'."
+ exit 1
+ }
+
+ Write-Host "Latest stable tag: $latestTag" -ForegroundColor Cyan
+
+ $latestWorkDir = Join-Path $env:RUNNER_TEMP 'workdir-latest'
+ $removeOutput = git worktree remove $latestWorkDir --force 2>&1
+ if ($LASTEXITCODE -ne 0 -and (Test-Path $latestWorkDir)) {
+ Remove-Item $latestWorkDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+
+ git worktree add $latestWorkDir $latestTag
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "ā Failed to create worktree for $latestTag (latest)."
+ exit 1
+ }
+
+ # Overlay current docfx_project and build-support files (same as versioned builds)
+ if (Test-Path 'docfx_project') {
+ if (Test-Path "$latestWorkDir/docfx_project") {
+ Remove-Item "$latestWorkDir/docfx_project" -Recurse -Force
+ }
+ Copy-Item 'docfx_project' "$latestWorkDir/docfx_project" -Recurse -Force
+ }
+ foreach ($f in @('Directory.Build.props', '.globalconfig', 'BannedSymbols.txt')) {
+ if ((Test-Path $f) -and -not (Test-Path "$latestWorkDir/$f")) {
+ Copy-Item $f "$latestWorkDir/$f" -Force -ErrorAction Stop
+ }
+ }
+
+ Push-Location $latestWorkDir
+ try {
+ $slnFile = Get-ChildItem -Filter '*.sln' -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if ($slnFile) {
+ Write-Host "Restoring $($slnFile.Name)..."
+ dotnet restore $slnFile.FullName
+ if ($LASTEXITCODE -ne 0) {
+ Write-Warning "ā ļø dotnet restore failed for latest ā continuing to docfx."
+ }
+ Write-Host "Building $($slnFile.Name)..."
+ dotnet build $slnFile.FullName --configuration Release --no-restore
+ if ($LASTEXITCODE -ne 0) {
+ Write-Warning "ā ļø dotnet build failed for latest ā continuing to docfx."
+ }
+ }
+
+ Write-Host "Running docfx metadata..."
+ docfx metadata docfx_project/docfx.json
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "ā docfx metadata failed for latest (exit $LASTEXITCODE)."
+ exit $LASTEXITCODE
+ }
+
+ Write-Host "Running docfx build..."
+ docfx build docfx_project/docfx.json
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "ā docfx build failed for latest (exit $LASTEXITCODE)."
+ exit $LASTEXITCODE
+ }
+
+ if (Test-Path 'docfx_project/_site') {
+ $dest = Join-Path $outDir 'versions' 'latest'
+ New-Item -ItemType Directory -Force -Path $dest | Out-Null
+ Copy-Item 'docfx_project/_site/*' $dest -Recurse -Force -ErrorAction Stop
+ Write-Host "ā
Docs built for latest ($latestTag)" -ForegroundColor Green
+ } else {
+ Write-Error "ā No _site output for latest build."
+ exit 1
+ }
+ } finally {
+ Pop-Location
+ $removeOutput = git worktree remove $latestWorkDir --force 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ Write-Warning "ā ļø git worktree remove failed for latest (exit $LASTEXITCODE): $removeOutput"
+ }
+ }
+
+ - name: Generate versions.json for every version
+ # Writes a versions.json (consumed by the version-switcher dropdown) into
+ # each version's output directory. All URLs are rooted under versions/.
+ shell: pwsh
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ run: |
+ $outDir = Join-Path $env:RUNNER_TEMP 'all_version_docs'
+
+ $repoName = ($env:GITHUB_REPOSITORY -split '/')[-1]
+ $base = if ($repoName) { "/$repoName/" } else { "/" }
+
+ $semverRe = '^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?[0-9A-Za-z.-]+))?$'
+ $tags = git tag -l 'v*' | Where-Object { $_ -ne '' }
+
+ $taggedVersions = foreach ($t in $tags) {
+ if ($t -match $semverRe) {
+ $stable = if ([string]::IsNullOrEmpty($Matches['prerelease'])) { 1 } else { 0 }
+ [PSCustomObject]@{
+ Tag = $t
+ Major = [int]$Matches['major']
+ Minor = [int]$Matches['minor']
+ Patch = [int]$Matches['patch']
+ Stable = $stable
+ }
+ }
+ }
+
+ $orderedTags = $taggedVersions |
+ Sort-Object -Property Major, Minor, Patch, Stable -Descending |
+ Select-Object -ExpandProperty Tag
+
+ [array]$versions = @([PSCustomObject]@{ version = 'latest'; url = "${base}versions/latest/" })
+ foreach ($t in $orderedTags) {
+ $versions += [PSCustomObject]@{ version = $t; url = "${base}versions/$t/" }
+ }
+
+ $versionsJson = ConvertTo-Json -InputObject $versions -Depth 3
+
+ Get-ChildItem -Path (Join-Path $outDir 'versions') -Directory | ForEach-Object {
+ $versionsJson | Set-Content -Path (Join-Path $_.FullName 'versions.json') -Encoding utf8NoBOM
+ Write-Host "Wrote versions.json to $($_.Name)/"
+ }
+
+ Write-Host "Generated versions.json with $($versions.Count) entries: $($versions | ForEach-Object { $_.version })"
+
+ - name: Generate root index.html
+ # Builds index.html from the shared template (.github/version-picker-template.html)
+ # so the page layout is maintained in one place and both this workflow and
+ # docfx.yaml produce identical markup.
+ shell: pwsh
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ run: |
+ $outDir = Join-Path $env:RUNNER_TEMP 'all_version_docs'
+
+ $repoName = ($env:GITHUB_REPOSITORY -split '/')[-1]
+ $title = if ($repoName) { "$repoName Documentation" } else { "Documentation" }
+
+ $versionsJsonPath = Join-Path $outDir 'versions' 'latest' 'versions.json'
+ $versions = Get-Content $versionsJsonPath -Raw | ConvertFrom-Json
+
+ $listItems = foreach ($v in $versions) {
+ $liClass = if ($v.version -eq 'latest') { ' class="latest"' } else { '' }
+ $label = if ($v.version -eq 'latest') { 'latest (stable)' } else { $v.version }
+ " $label "
+ }
+ $listHtml = $listItems -join "`n"
+
+ $templatePath = '.github/version-picker-template.html'
+ if (-not (Test-Path $templatePath)) {
+ Write-Error "Template file '$templatePath' not found. This file is required."
+ exit 1
+ }
+ $template = Get-Content $templatePath -Raw
+ $html = $template -replace '\{\{TITLE\}\}', $title `
+ -replace '\{\{VERSION_LIST\}\}', $listHtml
+
+ $html | Set-Content -Path (Join-Path $outDir 'index.html') -Encoding utf8NoBOM
+ Write-Host "Generated index.html with $($versions.Count) version link(s)."
+
+ - name: Deploy all versioned docs to gh-pages at root
+ # Publishes the entire all_version_docs/ output directory to gh-pages
+ # at the site root. keep_files: true preserves any other content
+ # already present on the branch (CNAME, root assets, etc.).
+ uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ${{ runner.temp }}/all_version_docs
+ destination_dir: .
+ keep_files: true
+ force_orphan: false
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..dc555dd
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,182 @@
+name: "CodeQL Security Analysis"
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ schedule:
+ - cron: '0 0 * * 0' # Weekly on Sunday at midnight UTC
+
+permissions:
+ contents: read # Default to read-only; the analyze job overrides where required
+
+jobs:
+ analyze:
+ name: "Security Scan (CodeQL)"
+ runs-on: windows-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'csharp' ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Check for C# source code
+ id: check-csharp
+ shell: pwsh
+ run: |
+ Write-Host "=== CodeQL C# Source Code Detection ===" -ForegroundColor Cyan
+ Write-Host "Searching for C# source files using git index..." -ForegroundColor Cyan
+
+ # Use git ls-files to efficiently find C# files (respects .gitignore and doesn't traverse excluded dirs)
+ $csharpFiles = @()
+ try {
+ $gitOutput = git ls-files '*.cs' '*.csproj' 2>$null
+ if ($LASTEXITCODE -eq 0) {
+ $csharpFiles = @($gitOutput | Where-Object { $_ })
+ } else {
+ Write-Warning "git ls-files failed with exit code $LASTEXITCODE. Falling back to filesystem search for C# files."
+ }
+ } catch {
+ Write-Warning "Exception while running git ls-files: $($_.Exception.Message). Falling back to filesystem search for C# files."
+ }
+
+ if (-not $csharpFiles -or $csharpFiles.Count -eq 0) {
+ Write-Host "git ls-files did not return any C# files. Scanning the filesystem for *.cs and *.csproj files..." -ForegroundColor Cyan
+ $csharpFiles = @(Get-ChildItem -Path . -Recurse -Include *.cs,*.csproj -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName)
+ }
+ $csharpFileCount = $csharpFiles.Count
+
+ Write-Host "" -ForegroundColor Cyan
+ if ($csharpFileCount -eq 0) {
+ Write-Host "ā ļø No C# source code found." -ForegroundColor Yellow
+ Write-Host " This appears to be an empty repository or a repository without projects." -ForegroundColor Yellow
+ Write-Host " CodeQL analysis will be SKIPPED, but the job will complete successfully." -ForegroundColor Yellow
+ Write-Host " This ensures branch protection requirements are met." -ForegroundColor Yellow
+ echo "has-csharp=false" >> $env:GITHUB_OUTPUT
+ } else {
+ Write-Host "ā
Found $csharpFileCount C# file(s)." -ForegroundColor Green
+ Write-Host " CodeQL analysis will PROCEED." -ForegroundColor Green
+ echo "has-csharp=true" >> $env:GITHUB_OUTPUT
+ }
+ Write-Host "========================================" -ForegroundColor Cyan
+
+ - name: Initialize CodeQL
+ if: steps.check-csharp.outputs.has-csharp == 'true'
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Setup .NET
+ if: steps.check-csharp.outputs.has-csharp == 'true'
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Build for CodeQL Analysis
+ id: build
+ if: steps.check-csharp.outputs.has-csharp == 'true'
+ shell: pwsh
+ run: |
+ Write-Host "Building solution for CodeQL analysis..."
+
+ # Find solution file (.sln or .slnx)
+ $solution = Get-ChildItem -Path . -Recurse -Depth 2 -Include "*.sln", "*.slnx" | Select-Object -First 1
+
+ if ($solution) {
+ Write-Host "Found solution: $($solution.FullName)"
+ dotnet restore $solution.FullName
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "dotnet restore failed with exit code $LASTEXITCODE"
+ exit $LASTEXITCODE
+ }
+ dotnet build $solution.FullName --configuration Release --no-restore
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "dotnet build failed with exit code $LASTEXITCODE"
+ exit $LASTEXITCODE
+ }
+ } else {
+ Write-Host "No solution file found, building all projects..."
+ dotnet restore
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "dotnet restore failed with exit code $LASTEXITCODE"
+ exit $LASTEXITCODE
+ }
+ dotnet build --configuration Release --no-restore
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "dotnet build failed with exit code $LASTEXITCODE"
+ exit $LASTEXITCODE
+ }
+ }
+
+ Write-Host "ā
Build completed for CodeQL analysis"
+
+ - name: Perform CodeQL Analysis
+ id: perform-codeql-analysis
+ if: steps.check-csharp.outputs.has-csharp == 'true'
+ uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:${{matrix.language}}"
+
+ - name: Complete Security Scan
+ if: always()
+ shell: pwsh
+ run: |
+ Write-Host "=== CodeQL Security Scan Complete ===" -ForegroundColor Cyan
+
+ # Check the outcome of previous steps
+ $checkCsharpOutcome = "${{ steps.check-csharp.outcome }}"
+ $hasCsharp = "${{ steps.check-csharp.outputs.has-csharp }}"
+ $buildOutcome = "${{ steps.build.outcome }}"
+ $codeqlOutcome = "${{ steps.perform-codeql-analysis.outcome }}"
+
+ # Determine overall status
+ $hasFailure = $false
+ $failureMessages = @()
+
+ # Check if check-csharp step failed
+ if ($checkCsharpOutcome -eq "failure") {
+ $hasFailure = $true
+ $failureMessages += "ā C# source code detection failed"
+ }
+
+ # Check if build step failed (only relevant if C# code exists)
+ if ($hasCsharp -eq "true" -and $buildOutcome -eq "failure") {
+ $hasFailure = $true
+ $failureMessages += "ā Build failed during CodeQL analysis"
+ }
+
+ # Check if CodeQL analysis step failed (only relevant if C# code exists)
+ if ($hasCsharp -eq "true" -and $codeqlOutcome -eq "failure") {
+ $hasFailure = $true
+ $failureMessages += "ā CodeQL analysis failed"
+ }
+
+ # Display results based on actual step outcomes
+ if ($hasFailure) {
+ Write-Host "ā Security scan completed with errors:" -ForegroundColor Red
+ foreach ($msg in $failureMessages) {
+ Write-Host " $msg" -ForegroundColor Red
+ }
+ exit 1
+ } elseif ($hasCsharp -eq "true") {
+ Write-Host "ā
CodeQL analysis completed successfully." -ForegroundColor Green
+ Write-Host " Results have been uploaded to GitHub Security." -ForegroundColor Green
+ } elseif ($hasCsharp -eq "false") {
+ Write-Host "ā
Security scan completed successfully (no C# code to analyze)." -ForegroundColor Green
+ Write-Host " This job ran successfully and reports a passing status to branch protection." -ForegroundColor Green
+ } else {
+ Write-Host "ā
Security scan job completed." -ForegroundColor Green
+ Write-Host " Job status reported to branch protection." -ForegroundColor Green
+ }
+ Write-Host "========================================" -ForegroundColor Cyan
diff --git a/.github/workflows/create-labels.yaml b/.github/workflows/create-labels.yaml
deleted file mode 100644
index ce661f5..0000000
--- a/.github/workflows/create-labels.yaml
+++ /dev/null
@@ -1,86 +0,0 @@
-name: Create Dependabot Security and Dependencies Labels
-on:
- workflow_dispatch:
-
-jobs:
- create-labels:
- permissions:
- issues: write
- runs-on: ubuntu-latest
- steps:
- - name: Create "dependabot - security" label
- uses: actions/github-script@v6
- with:
- script: |
- try {
- await github.rest.issues.createLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: "dependabot - security",
- color: "b60205"
- });
- } catch (error) {
- if (error.status === 422 && error.response?.data?.errors?.[0]?.code === 'already_exists') {
- console.log('Label "dependabot - security" already exists, skipping creation');
- } else {
- console.error('Failed to create label "dependabot - security":', error.message);
- throw error;
- }
- }
- - name: Create "dependabot-dependencies" label
- uses: actions/github-script@v6
- with:
- script: |
- try {
- await github.rest.issues.createLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: "dependabot-dependencies",
- color: "d93f0b"
- });
- } catch (error) {
- if (error.status === 422 && error.response?.data?.errors?.[0]?.code === 'already_exists') {
- console.log('Label "dependabot-dependencies" already exists, skipping creation');
- } else {
- console.error('Failed to create label "dependabot-dependencies":', error.message);
- throw error;
- }
- }
- - name: Create "dependencies" label
- uses: actions/github-script@v6
- with:
- script: |
- try {
- await github.rest.issues.createLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: "dependencies",
- color: "0366d6"
- });
- } catch (error) {
- if (error.status === 422 && error.response?.data?.errors?.[0]?.code === 'already_exists') {
- console.log('Label "dependencies" already exists, skipping creation');
- } else {
- console.error('Failed to create label "dependencies":', error.message);
- throw error;
- }
- }
- - name: Create "dotnet" label
- uses: actions/github-script@v6
- with:
- script: |
- try {
- await github.rest.issues.createLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: "dotnet",
- color: "512bd4"
- });
- } catch (error) {
- if (error.status === 422 && error.response?.data?.errors?.[0]?.code === 'already_exists') {
- console.log('Label "dotnet" already exists, skipping creation');
- } else {
- console.error('Failed to create label "dotnet":', error.message);
- throw error;
- }
- }
diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml
index 2c7b242..36fbebd 100644
--- a/.github/workflows/docfx.yaml
+++ b/.github/workflows/docfx.yaml
@@ -1,63 +1,314 @@
name: Deploy DocFX Pages
on:
- push:
- branches:
- - main # Your primary branch
+ # Called by release.yaml after a GitHub Release is published.
+ # Callers may pass an explicit 'version' string (e.g. v1.2.3); when omitted,
+ # the destination directory is derived from github.ref_name automatically.
+ workflow_call:
+ inputs:
+ version:
+ description: 'Version tag for documentation (e.g., v1.0.0). Defaults to the triggering ref name.'
+ required: false
+ default: ''
+ type: string
+ deploy_to_pages:
+ description: 'Deploy to GitHub Pages'
+ required: false
+ type: boolean
+ default: true
+ deploy_as_latest:
+ description: 'Also deploy to the site root (/) and versions/latest/ as the current latest version'
+ required: false
+ type: boolean
+ default: true
+ # Manual trigger for ad-hoc builds or dry-runs.
+ # Leave 'version' blank to use the selected branch or tag name as the destination.
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version tag for documentation (e.g., v1.0.0). Leave blank to use the ref name.'
+ required: false
+ default: ''
+ deploy_to_pages:
+ description: 'Deploy to GitHub Pages (uncheck for dry-run)'
+ type: boolean
+ default: true
+ deploy_as_latest:
+ description: 'Also deploy to the site root (/) and versions/latest/ (uncheck when rebuilding older versions)'
+ type: boolean
+ default: true
+
+permissions:
+ contents: read # Default to read-only; the build-and-deploy job overrides with write
jobs:
- build:
- runs-on: ubuntu-latest
+ build-and-deploy:
+ name: Build & Deploy Documentation
+ runs-on: windows-latest
permissions:
- contents: read # Allow read access for checkout
- pages: write # Allow write access for Pages deployment
- id-token: write # Allow writing of ID tokens for deployment
+ contents: write # Allow write access for gh-pages branch
steps:
- name: Checkout repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Full history needed to enumerate all v* tags
+ persist-credentials: false
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: '10.0.x'
+ dotnet-version: |
+ 5.0.x
+ 6.0.x
+ 7.0.x
+ 8.0.x
+ 9.0.x
+ 10.0.x
+
+ - name: Restore dependencies
+ run: dotnet restore
+ shell: pwsh
+
+ - name: Build solution
+ run: dotnet build --configuration Release --no-restore
+ shell: pwsh
- name: Install DocFX
- run: dotnet tool update docfx --global
+ run: dotnet tool update docfx --global || dotnet tool install docfx --global
+ shell: pwsh
- - name: Build DocFx Metadata
- run: docfx metadata
+ - name: Build DocFX Metadata
+ run: docfx metadata
working-directory: docfx_project
+ shell: pwsh
- name: Build Docs
- run: docfx build
+ run: docfx build
working-directory: docfx_project
+ shell: pwsh
- - name: Verify build output
+ - name: Verify build output
run: |
- if [ ! -d "docfx_project/_site" ]; then
- echo "Error: _site directory not found!"
- exit 1
- fi
- echo "Build successful. Contents of _site:"
- ls -la docfx_project/_site
+ if (-Not (Test-Path "docfx_project/_site")) {
+ Write-Host "Error: docfx_project/_site directory not found!"
+ exit 1
+ }
+ Write-Host "Build successful. Contents of _site:"
+ Get-ChildItem "docfx_project/_site"
+ Write-Host "API documentation:"
+ Get-ChildItem "docfx_project/_site/api"
+ shell: pwsh
+
+ - name: Generate versions.json
+ # Produces versions.json consumed by the DocFX version-switcher dropdown.
+ # Site layout:
+ # // ā version-picker index.html (deployed to root)
+ # //versions/latest/ ā latest docs (alias under versions/)
+ # //versions/v1.2.3/ ā versioned docs (under versions/)
+ # versions.json format expected by the dropdown:
+ # [{ "version": "latest", "url": "//versions/latest/" }, { "version": "v1.2.3", "url": "//versions/v1.2.3/" }]
+ # URLs must include the repo path segment because this is a GitHub Pages project site
+ # (https://.github.io//), not a root user/org site.
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ shell: pwsh
+ run: |
+ $tags = git tag -l 'v*' | Where-Object { $_ -ne '' }
+
+ # Strict SemVer pattern: vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-PRERELEASE
+ $semverRe = '^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?[0-9A-Za-z.-]+))?$'
+
+ $taggedVersions = foreach ($t in $tags) {
+ if ($t -match $semverRe) {
+ # Stable releases (no prerelease) have Stable=1; prerelease builds have Stable=0.
+ # Sorting descending by Stable places stable (1) before prerelease (0) of the same version.
+ $stable = if ([string]::IsNullOrEmpty($Matches['prerelease'])) { 1 } else { 0 }
+ [PSCustomObject]@{
+ Tag = $t
+ Major = [int]$Matches['major']
+ Minor = [int]$Matches['minor']
+ Patch = [int]$Matches['patch']
+ Stable = $stable
+ }
+ }
+ }
+
+ # Sort descending by SemVer components so newest stable version comes first
+ $orderedTags = $taggedVersions |
+ Sort-Object -Property Major, Minor, Patch, Stable -Descending |
+ Select-Object -ExpandProperty Tag
+
+ # Build the base path for this GitHub Pages project site: //
+ # GITHUB_REPOSITORY is "owner/repo"; we need just the repo name.
+ $repoName = ($env:GITHUB_REPOSITORY -split '/')[-1]
+ $base = if ($repoName) { "/$repoName/" } else { "/" }
+
+ # "latest" points to the versions/latest/ folder; each version points to versions//.
+ [array]$versions = @([PSCustomObject]@{ version = 'latest'; url = "${base}versions/latest/" })
+ foreach ($t in $orderedTags) {
+ $versions += [PSCustomObject]@{ version = $t; url = "${base}versions/$t/" }
+ }
+
+ ConvertTo-Json -InputObject $versions -Depth 3 |
+ 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
- - name: Upload artifact
- uses: actions/upload-pages-artifact@v3
+ # 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
+ # github.ref_name so the workflow works without callers passing a version.
+ # Sanitization steps (applied in order):
+ # 1. Replace forward slashes with hyphens (prevents nested paths).
+ # 2. Replace any character outside [A-Za-z0-9._-] with a hyphen.
+ # 3. Collapse consecutive hyphens into one.
+ # 4. Strip leading dots and hyphens (avoids hidden/awkward directory names).
+ # 5. Fall back to "latest" if the result is empty, ".", or "..".
+ id: dest
+ env:
+ INPUT_VERSION: ${{ inputs.version }}
+ REF_NAME: ${{ github.ref_name }}
+ shell: pwsh
+ run: |
+ $raw = if ($env:INPUT_VERSION -ne '') { $env:INPUT_VERSION } else { $env:REF_NAME }
+ $sanitized = $raw -replace '/', '-'
+ $sanitized = $sanitized -replace '[^A-Za-z0-9._\-]', '-'
+ $sanitized = $sanitized -replace '-{2,}', '-'
+ $sanitized = $sanitized -replace '^[.\-]+', ''
+ if ([string]::IsNullOrEmpty($sanitized) -or $sanitized -eq '.' -or $sanitized -eq '..') {
+ $sanitized = 'latest'
+ }
+ Add-Content -Path $env:GITHUB_OUTPUT -Value "dir=$sanitized"
+
+ - name: Deploy versioned docs to GitHub Pages
+ # Publishes this build into versions// on gh-pages, e.g. versions/v1.2.3/.
+ # keep_files: true preserves all other version folders already present in gh-pages.
+ if: inputs.deploy_to_pages != false
+ uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
with:
- path: docfx_project/_site # The path to the folder to upload
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./docfx_project/_site
+ destination_dir: versions/${{ steps.dest.outputs.dir }}
+ keep_files: true
+ force_orphan: false
- deploy:
- needs: build
- permissions:
- pages: write
- id-token: write
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v4
+ - name: Deploy latest docs to versions/latest/ folder
+ # Also publishes to versions/latest/ as a stable, bookmarkable URL alias for the
+ # latest version. The versions.json 'latest' entry points to versions/latest/.
+ # Skipped when deploy_as_latest is false (e.g. when rebuilding an older version).
+ if: inputs.deploy_to_pages != false && inputs.deploy_as_latest != false
+ uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./docfx_project/_site
+ destination_dir: versions/latest
+ keep_files: true
+ force_orphan: false
+
+ - name: Generate root index.html
+ # Generates the version-picker page and writes it into _site/index.html AFTER
+ # the versioned-docs deploys have already run, so versions// and
+ # versions/latest/ each keep the real DocFX landing page. Only the subsequent
+ # root deploy (below) receives the picker as its index.html.
+ if: inputs.deploy_to_pages != false && inputs.deploy_as_latest != false
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ shell: pwsh
+ run: |
+ $repoName = ($env:GITHUB_REPOSITORY -split '/')[-1]
+ $title = if ($repoName) { "$repoName Documentation" } else { "Documentation" }
+
+ $versions = Get-Content 'docfx_project/_site/versions.json' -Raw | ConvertFrom-Json
+
+ $listItems = foreach ($v in $versions) {
+ $liClass = if ($v.version -eq 'latest') { ' class="latest"' } else { '' }
+ $label = if ($v.version -eq 'latest') { 'latest (stable)' } else { $v.version }
+ " $label "
+ }
+ $listHtml = $listItems -join "`n"
+
+ if (Test-Path '.github/version-picker-template.html') {
+ $template = Get-Content '.github/version-picker-template.html' -Raw
+ } else {
+ Write-Host "Error: .github/version-picker-template.html not found; cannot generate root index.html. Add the template file or disable the version picker step."
+ exit 1
+ }
+
+ $html = $template -replace '\{\{TITLE\}\}', $title `
+ -replace '\{\{VERSION_LIST\}\}', $listHtml
+
+ $html | Set-Content -Path 'docfx_project/_site/index.html' -Encoding utf8NoBOM
+ Write-Host "Generated index.html with $($versions.Count) version link(s)."
+
+ - name: Deploy version picker to GitHub Pages root
+ # Publishes the version-picker index.html (generated above) to the site root ('/').
+ # keep_files: true preserves the versions/ folder and all its sub-folders.
+ # Skipped when deploy_as_latest is false (e.g. when rebuilding an older version).
+ if: inputs.deploy_to_pages != false && inputs.deploy_as_latest != false
+ uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./docfx_project/_site
+ keep_files: true
+ force_orphan: false
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index 458a8c5..16d9c77 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -1,31 +1,86 @@
-# This workflow runs on pull requests to validate code quality, run tests, and perform security scans
-# before merging into main or other protected branches
+# Sequential PR validation workflow with coverage gating
+# Stage 1: Linux tests with 90% coverage requirement
+# Stage 2: Windows .NET (5.0-10.0) and .NET Framework (4.6.2-4.8.1) tests (only if Linux passes)
+# Stage 3: macOS tests (only if Stage 2 passes)
+#
+# SECURITY NOTE:
+# - Uses pull_request_target to run workflow from the trusted main branch, not from the PR branch
+# - This prevents malicious workflow YAML changes in untrusted PR branches from taking effect
+# - All checkout steps use PR refs (refs/pull/*/head) to check out PR code from the base repo
+# - persist-credentials: false prevents the checkout token from being written to git config for subsequent git commands
+# (it does NOT, by itself, prevent steps from accessing github.token / GITHUB_TOKEN if you explicitly expose it)
+# - Default GITHUB_TOKEN permissions are restricted to read-only repository contents to limit impact if exposed
-name: PR Checks
+name: PR Checks v3 (Gated)
permissions:
contents: read
+
+env:
+ CODECOV_MINIMUM: 90
on:
- pull_request:
+ pull_request_target: # Runs from the main branch, not from PR branch
branches:
- # List any other branches here that you want this workflow to run on
- main
-
jobs:
- build-and-test:
- # Specifies the OS to run this workflow on. You can specify more than one. For a complete and current list, review the [documentation] (https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/choose-the-runner-for-a-job)
+ # ============================================================================
+ # DETECTION: Check if .csproj files exist
+ # ============================================================================
+ detect-projects:
+ name: "Detect .NET Projects"
runs-on: ubuntu-latest
- if: github.repository != 'Chris-Wolfgang/repo-template' # Only run in child repos otherwise this will fail because the template does not have any projects
+ if: github.repository != 'Chris-Wolfgang/repo-template'
+ outputs:
+ has-projects: ${{ steps.check-projects.outputs.has-projects }}
+
steps:
- name: Checkout code
uses: actions/checkout@v4
+ with:
+ ref: refs/pull/${{ github.event.pull_request.number }}/head
+ persist-credentials: false
+
+ - name: Check for .NET project files
+ id: check-projects
+ run: |
+ if git ls-files '*.csproj' '*.vbproj' '*.fsproj' | grep -q .; then
+ echo "has-projects=true" >> $GITHUB_OUTPUT
+ echo "ā
Found .NET project files - .NET build and test jobs will run"
+ else
+ echo "has-projects=false" >> $GITHUB_OUTPUT
+ echo "ā¹ļø No .NET project files found - skipping .NET build and test jobs"
+ fi
+
+ # ============================================================================
+ # STAGE 1: Linux - .NET Core/5+ Tests with Coverage Gate
+ # ============================================================================
+ test-linux-core:
+ name: "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate"
+ runs-on: ubuntu-latest
+ needs: detect-projects
+ if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true'
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: refs/pull/${{ github.event.pull_request.number }}/head
+ persist-credentials: false
+
+ # Fix for .NET 5.0 on Ubuntu 22.04+ - install libssl1.1 from the focal-security
+ # repository so APT verifies the package via GPG instead of a plain wget download.
+ - name: Install OpenSSL 1.1 for .NET 5.0
+ run: |
+ echo "deb https://security.ubuntu.com/ubuntu focal-security main" | sudo tee /etc/apt/sources.list.d/focal-security.list
+ sudo apt-get update -q
+ sudo apt-get install --yes libssl1.1
+ sudo rm /etc/apt/sources.list.d/focal-security.list
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
- 3.1.x
5.0.x
6.0.x
7.0.x
@@ -33,91 +88,575 @@ jobs:
9.0.x
10.0.x
- - name: Restore dependencies
- run: dotnet restore
-
- - name: Build Solution (Release)
- # Specify any additional build options
- run: dotnet build --no-restore --configuration Release
+ - name: Restore and build (exclude .NET Framework-only projects)
+ run: |
+ echo "Finding .NET project files in repository (via find command)..."
+
+ # Filter out projects that ONLY target .NET Framework 4.x
+ # Multi-targeting projects (e.g., net8.0;net48) will be INCLUDED
+ projects=()
+ project_found=false
+
+ while IFS= read -r -d '' proj; do
+ project_found=true
+ # Check if project has any .NET 5+ target framework
+ # Look for: net5.0, net6.0, net7.0, net8.0, net9.0, net10.0, or netcoreapp, netstandard
+ # Normalize line endings to handle multi-line / elements
+ if tr -d '\n\r' < "$proj" | grep -qE '.*(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp|netstandard)'; then
+ projects+=("$proj")
+ echo "ā Including: $proj (has .NET 5+ or .NET Core target)"
+ else
+ echo "ā Excluding: $proj (Framework-only, incompatible with Linux)"
+ fi
+ done < <(find . -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0)
+
+ if [ "$project_found" = false ]; then
+ echo "ā No .NET projects found."
+ echo "This should not occur as detect-projects already verified project existence."
+ exit 1
+ fi
+
+ if [ ${#projects[@]} -eq 0 ]; then
+ echo "ā No compatible .NET projects found."
+ echo "All projects target only .NET Framework 4.x, which is incompatible with Linux."
+ exit 1
+ fi
+
+ echo ""
+ echo "=========================================="
+ echo "Projects to build:"
+ echo "=========================================="
+ printf '%s\n' "${projects[@]}"
+ echo ""
+
+ # Restore each project
+ echo "Restoring projects..."
+ for proj in "${projects[@]}"; do
+ echo "Restoring: $proj"
+ dotnet restore "$proj" || exit 1
+ done
+
+ echo ""
+ echo "Building projects..."
+ # Build each project, handling multi-targeting projects
+ # For multi-targeting projects, build only Linux-compatible frameworks (.NET 5.0+, .NET Core, .NET Standard)
+ for proj in "${projects[@]}"; do
+ echo "Building: $proj"
+
+ # Extract target frameworks from the project file
+ # Support both (single) and (multiple)
+ # Collapse newlines so multi-line values are handled correctly
+ frameworks=$(tr '\n' ' ' < "$proj" | grep -oP '\s*\K[^<]+' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp[0-9.]+|netstandard[0-9.]+)$' || true)
+
+ if [ -z "$frameworks" ]; then
+ echo "ā ļø No Linux-compatible frameworks found in $proj"
+ continue
+ fi
+
+ # Check if this is a multi-targeting project
+ framework_count=$(echo "$frameworks" | wc -l)
+
+ if [ "$framework_count" -eq 1 ]; then
+ # Single target framework - build normally
+ echo " Target framework: $frameworks"
+ 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 and Collect Coverage for Each Test Project (Release)
+ - name: Run tests with coverage (.NET Core 5.0 - 10.0)
run: |
- find ./tests -type f -name '*Test*.csproj' | while read proj; do
- echo "Testing $proj"
- dotnet test "$proj" --no-build --configuration Release --collect:"XPlat Code Coverage" --results-directory "./TestResults"
+ # Find all test projects (C#, VB.NET, F#)
+ mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0)
+
+ if [ ${#test_projects[@]} -eq 0 ]; then
+ echo "ā No test projects found in ./tests directory!"
+ exit 1
+ fi
+
+ echo "=========================================="
+ echo "Found test projects:"
+ echo "=========================================="
+ printf '%s\n' "${test_projects[@]}"
+ echo ""
+
+ for test_proj in "${test_projects[@]}"; do
+ echo "=========================================="
+ echo "Testing project: $test_proj"
+ echo "=========================================="
+
+ # Extract target frameworks from the project file
+ # Support both (single) and (multiple)
+ frameworks=$(grep -oP '\K[^<]+' "$test_proj" | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)$' || true)
+
+ if [ -z "$frameworks" ]; then
+ echo "ā Skipping: No compatible .NET 5.0-10.0 target frameworks found"
+ echo ""
+ continue
+ fi
+
+ echo "Target frameworks: $(echo "$frameworks" | tr '\n' ' ')"
+ echo ""
+
+ # Test each framework that the project actually targets
+ while IFS= read -r fw; do
+ [ -z "$fw" ] && continue
+ echo "Testing framework: $fw"
+
+ dotnet test "$test_proj" \
+ --configuration Release \
+ --framework "$fw" \
+ --collect:"XPlat Code Coverage" \
+ --results-directory "./TestResults" \
+ --logger "console;verbosity=minimal" || exit 1
+ done <<< "$frameworks"
+ echo ""
done
+ - name: Check for coverage files
+ id: check-coverage
+ run: |
+ if find TestResults -type f -name "coverage.cobertura.xml" 2>/dev/null | grep -q .; then
+ echo "has-coverage=true" >> $GITHUB_OUTPUT
+ echo "ā
Coverage files found"
+ else
+ echo "has-coverage=false" >> $GITHUB_OUTPUT
+ echo "ā¹ļø No coverage files found - skipping coverage report generation"
+ fi
+
- name: Install ReportGenerator
+ if: steps.check-coverage.outputs.has-coverage == 'true'
run: dotnet tool install -g dotnet-reportgenerator-globaltool
- - name: Generate Coverage Reports (HTML, TextSummary, GitHub Markdown, CSV)
+ - name: Generate coverage report
+ if: steps.check-coverage.outputs.has-coverage == 'true'
run: |
- reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"CoverageReport" -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary"
+ reportgenerator \
+ -reports:"TestResults/**/coverage.cobertura.xml" \
+ -targetdir:"CoverageReport" \
+ -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary"
- - name: Check Coverage Thresholds
+ - name: Enforce 90% coverage threshold
+ if: steps.check-coverage.outputs.has-coverage == 'true'
run: |
if [ ! -f CoverageReport/Summary.txt ]; then
- echo "CoverageReport/Summary.txt not found! Coverage report was not generated."
+ echo "ā Coverage report not generated!"
exit 1
fi
+ echo "Coverage Summary:"
+ cat CoverageReport/Summary.txt
+ echo ""
+
failed_projects=""
+ threshold=${CODECOV_MINIMUM:-90}
+
while read -r line; do
- module=$(echo "$line" | awk '{print $1}')
- percent=$(echo "$line" | awk '{print $NF}' | tr -d '%' | xargs)
- echo "Checking module: '$module', percent: '$percent'"
- if [[ "$percent" =~ ^[0-9]+$ ]]; then
- if [ "$percent" -lt 80 ]; then
- echo "FAIL: $module is below 80% ($percent%)"
- failed_projects="$failed_projects $module ($percent%)"
+ # Match lines with module names and percentages
+ if echo "$line" | grep -qE '^[^ ].*[0-9]+%$' && ! echo "$line" | grep -q '^Summary'; then
+ module=$(echo "$line" | awk '{print $1}')
+ percent=$(echo "$line" | awk '{print $NF}' | tr -d '%')
+
+ echo "Checking module: '$module' - Coverage: ${percent}%"
+
+ if [ "$percent" -lt "$threshold" ]; then
+ echo " ā FAIL: Below ${threshold}% threshold"
+ failed_projects="$failed_projects $module (${percent}%)"
else
- echo "PASS: $module meets coverage ($percent%)"
+ echo " ā
PASS: Meets ${threshold}% threshold"
fi
- else
- echo "WARNING: extracted percent value '$percent' is not a number!"
fi
- done < <(grep -E '^[^ ].*[0-9]+%$' CoverageReport/Summary.txt | grep -v '^Summary')
+ done < CoverageReport/Summary.txt
if [ -n "$failed_projects" ]; then
- echo "The following projects are below 80% line coverage:$failed_projects"
+ echo ""
+ echo "=========================================="
+ echo "ā COVERAGE GATE FAILED"
+ echo "=========================================="
+ echo "Projects below ${threshold}% coverage: $failed_projects"
+ echo ""
+ echo "Stage 1 failed. Windows, macOS, and .NET Framework tests will NOT run."
exit 1
+ else
+ echo ""
+ echo "=========================================="
+ echo "ā
COVERAGE GATE PASSED"
+ echo "=========================================="
+ echo "All projects meet ${threshold}% coverage threshold."
+ echo "Proceeding to Stage 2 (Windows and macOS tests)."
fi
- - name: Upload coverage results and reports
+ - name: Upload Linux coverage results
if: always()
uses: actions/upload-artifact@v4
with:
- name: coverage-results-and-report
+ name: coverage-linux
path: |
TestResults/
CoverageReport/
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-output
+ path: |
+ src/**/bin/Release
+ tests/**/bin/Release
+
+ # ============================================================================
+ # STAGE 2: Windows - All .NET Tests (Gated by Stage 1)
+ # ============================================================================
+ test-windows:
+ name: "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)"
+ runs-on: windows-latest
+ needs: [detect-projects, test-linux-core]
+ if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true'
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: refs/pull/${{ github.event.pull_request.number }}/head
+ persist-credentials: false
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 5.0.x
+ 6.0.x
+ 7.0.x
+ 8.0.x
+ 9.0.x
+ 10.0.x
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build solution
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Run all .NET tests (.NET 5.0-10.0 and Framework 4.6.2-4.8.1)
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $testProjects = @(Get-ChildItem -Path './tests/*' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj')
+
+ if (@($testProjects).Count -eq 0) {
+ Write-Error "ā No test projects found in ./tests directory!"
+ exit 1
+ }
+
+ Write-Host "==========================================" -ForegroundColor Cyan
+ Write-Host "Found test projects:" -ForegroundColor Cyan
+ Write-Host "==========================================" -ForegroundColor Cyan
+ $testProjects | ForEach-Object { Write-Host $_.FullName -ForegroundColor White }
+ Write-Host ""
+
+ foreach ($testProj in $testProjects) {
+ Write-Host "==========================================" -ForegroundColor Cyan
+ Write-Host "Testing project: $($testProj.FullName)" -ForegroundColor Cyan
+ Write-Host "==========================================" -ForegroundColor Cyan
+
+ # Extract target frameworks from the project file
+ # Support both (single) and (multiple)
+ $content = Get-Content $testProj.FullName -Raw
+ $tfmMatch = [regex]::Match($content, '([^<]+) ')
+
+ if (-not $tfmMatch.Success) {
+ Write-Host "ā Skipping: No target frameworks found" -ForegroundColor Yellow
+ Write-Host ""
+ continue
+ }
+
+ # Split by semicolon for multi-targeting projects
+ $frameworks = $tfmMatch.Groups[1].Value -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0|462|47|471|472|48|481)$' }
+
+ if ($frameworks.Count -eq 0) {
+ Write-Host "ā Skipping: No compatible .NET 5.0-10.0 or Framework 4.6.2-4.8.1 target frameworks found" -ForegroundColor Yellow
+ Write-Host ""
+ continue
+ }
+
+ Write-Host "Target frameworks: $($frameworks -join ', ')" -ForegroundColor White
+ Write-Host ""
+
+ # Test each framework that the project actually targets
+ foreach ($fw in $frameworks) {
+ Write-Host "Testing framework: $fw" -ForegroundColor Yellow
+
+ dotnet test $testProj.FullName `
+ --configuration Release `
+ --framework $fw `
+ --logger "console;verbosity=normal"
+
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "Tests failed for $fw in $($testProj.Name)"
+ exit 1
+ }
+ }
+ Write-Host ""
+ }
+
+ # ============================================================================
+ # STAGE 3: macOS Tests (Gated by Stage 2)
+ # ============================================================================
+ test-macos-core:
+ name: "Stage 3: macOS Tests (.NET 6.0-10.0)"
+ runs-on: macos-latest
+ needs: [detect-projects, test-windows]
+ if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true'
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: refs/pull/${{ github.event.pull_request.number }}/head
+ persist-credentials: false
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 6.0.x
+ 7.0.x
+ 8.0.x
+ 9.0.x
+ 10.0.x
+
+ - name: Restore and build (exclude .NET Framework-only projects)
+ run: |
+ echo "Enumerating tracked .NET project files (git ls-files)..."
+
+ # Filter out projects that ONLY target .NET Framework 4.x
+ # Multi-targeting projects (e.g., net8.0;net48) will be INCLUDED
+ projects=()
+ project_found=false
+
+ while IFS= read -r -d '' proj; do
+ project_found=true
+ # Check if project has any .NET 6+ target framework (macOS ARM64 compatible)
+ # Look for: net6.0, net7.0, net8.0, net9.0, net10.0
+ # Normalize newlines to spaces so multi-line 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 from the project file
+ # Support both (single) and (multiple)
+ # Trim whitespace from each framework before filtering
+ frameworks=$(tr -d '\n\r' < "$proj" | sed -n -E 's/.*[[:space:]]*>([^<]+)<\/TargetFrameworks?>.*/\1/p' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true)
+
+ if [ -z "$frameworks" ]; then
+ echo "ā ļø No macOS ARM64-compatible frameworks found in $proj"
+ continue
+ fi
+
+ # Check if this is a multi-targeting project
+ framework_count=$(echo "$frameworks" | wc -l)
+
+ if [ "$framework_count" -eq 1 ]; then
+ # Single target framework - build normally
+ echo " Target framework: $frameworks"
+ dotnet build "$proj" --no-restore --configuration Release || exit 1
+ else
+ # Multi-targeting project - build each compatible framework separately
+ echo " Target frameworks (multi-targeting): $(echo "$frameworks" | tr '\n' ' ')"
+ while IFS= read -r fw; do
+ [ -z "$fw" ] && continue
+ echo " Building framework: $fw"
+ dotnet build "$proj" --no-restore --configuration Release --framework "$fw" || exit 1
+ done <<< "$frameworks"
+ fi
+ done
+
+ echo ""
+ echo "ā
All compatible projects built successfully"
+
+ - name: Run tests (.NET 6.0 - 10.0 only - ARM64 compatible)
+ run: |
+ # Find all test projects (C#, VB.NET, F#)
+ test_projects=()
+ while IFS= read -r -d '' file; do
+ test_projects+=("$file")
+ done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0)
+
+ if [ ${#test_projects[@]} -eq 0 ]; then
+ echo "ā No test projects found in ./tests directory!"
+ exit 1
+ fi
+
+ echo "=========================================="
+ echo "Found test projects:"
+ echo "=========================================="
+ printf '%s\n' "${test_projects[@]}"
+ echo ""
+
+ for test_proj in "${test_projects[@]}"; do
+ echo "=========================================="
+ echo "Testing project: $test_proj"
+ echo "=========================================="
+
+ # Extract target frameworks from the project file
+ # Support both (single) and (multiple)
+ # Only include .NET 6.0+ (ARM64 compatible on macOS)
+ # Normalize line endings to handle multi-line / elements
+ frameworks=$(tr -d '\n\r' < "$test_proj" | grep -Eo '[^<]+' | sed -E 's///' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true)
+
+ if [ -z "$frameworks" ]; then
+ echo "ā Skipping: No compatible .NET 6.0-10.0 target frameworks found (ARM64 required)"
+ echo ""
+ continue
+ fi
+
+ echo "Target frameworks: $(echo "$frameworks" | tr '\n' ' ')"
+ echo ""
+
+ # Test each framework that the project actually targets
+ while IFS= read -r fw; do
+ [ -z "$fw" ] && continue
+ echo "Testing framework: $fw"
+
+ dotnet test "$test_proj" \
+ --configuration Release \
+ --framework "$fw" \
+ --logger "console;verbosity=normal" || exit 1
+ done <<< "$frameworks"
+ echo ""
+ done
+
+ - name: Display macOS architecture info
+ if: always()
+ run: |
+ echo ""
+ echo "=========================================="
+ echo "ā¹ļø macOS Testing Notes"
+ echo "=========================================="
+ echo "Architecture: $(uname -m)"
+ echo ""
+ echo "Skipped frameworks (no ARM64 support):"
+ echo " - .NET 5.0 ā"
+ echo ""
+ echo "Tested frameworks (ARM64 compatible):"
+ echo " - .NET 6.0 ā
"
+ echo " - .NET 7.0 ā
"
+ echo " - .NET 8.0 ā
"
+ echo " - .NET 9.0 ā
"
+ echo " - .NET 10.0 ā
"
+ echo ""
+ echo ".NET Core 5.0 are tested on Linux and Windows"
+ echo ""
+
+ - name: Summarize pipeline result
+ run: |
+ echo "=========================================="
+ echo "ā
ALL STAGES PASSED"
+ echo "=========================================="
+ echo "Stage 1: Linux tests + 90% coverage ā
"
+ echo "Stage 2: Windows .NET Core & .NET Framework tests ā
"
+ echo "Stage 3: macOS tests ā
"
+ echo ""
+ echo "PR is ready to merge! š"
+
+ # ============================================================================
+ # Security Scan (Runs in parallel, independently of .NET jobs)
+ # ============================================================================
+ security-scan:
+ name: "Security Scan (DevSkim)"
+ runs-on: ubuntu-latest
+ if: github.repository != 'Chris-Wolfgang/repo-template'
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: refs/pull/${{ github.event.pull_request.number }}/head
+ persist-credentials: false
+
- name: Install DevSkim CLI
run: dotnet tool install --global Microsoft.CST.DevSkim.CLI
- - name: Run DevSkim Security Scan (Save output)
- run: devskim analyze --source-code . --file-format text -E --ignore-rule-ids DS176209 --ignore-globs "**/api/**,**/CoverageReport/**" --output-file devskim-results.txt
+ - 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: Show DevSkim Results in Summary
- if: failure()
+ - name: Display security scan results
+ if: always()
run: |
- echo "### DevSkim Security Issues Found"
- cat devskim-results.txt
- shell: bash
+ 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 DevSkim Results as Artifact
+ - name: Upload security scan results
if: always()
uses: actions/upload-artifact@v4
with:
name: devskim-results
path: devskim-results.txt
-
- - name: Generate DocFX metadata
- if: runner.os == 'Windows'
- working-directory: docfx_project
- run: dotnet docfx metadata docfx.json
-
- - name: Build DocFX site
- if: runner.os == 'Windows'
- working-directory: docfx_project
- run: dotnet docfx build docfx.json
+ if-no-files-found: warn
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index d5ed106..63ae0f8 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -1,29 +1,32 @@
-name: Release on Version Tag
+name: Release on Published Release
+
on:
- push:
- tags:
- - 'v*.*.*'
+ release:
+ types: [published]
permissions:
- contents: read
+ contents: read # Default to read-only; individual jobs declare write where required
+
+env:
+ CODECOV_MINIMUM: 90
jobs:
- build-and-test:
- name: Build and Test on ${{ matrix.os }}
- runs-on: ${{ matrix.os }}
- strategy:
- matrix:
- os: [ubuntu-latest, windows-latest, macos-latest]
+ # Streamlined validation: All frameworks, Windows only
+ validate-release:
+ name: Validate Release Build
+ runs-on: windows-latest
+ if: github.repository != 'Chris-Wolfgang/repo-template'
steps:
- name: Checkout code
uses: actions/checkout@v4
+ with:
+ persist-credentials: false
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
- 3.1.x
5.0.x
6.0.x
7.0.x
@@ -34,82 +37,269 @@ jobs:
- name: Restore dependencies
run: dotnet restore
- # Linux/macOS: Build non-.NET Framework targets only
- - name: Build projects (Linux/macOS)
- if: runner.os != 'Windows'
- shell: bash
- run: |
- echo "Building main library for cross-platform targets..."
- for fw in netstandard2.0 net8.0 net10.0; do
- dotnet build src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj \
- --no-restore \
- --configuration Release \
- --framework "$fw"
- done
-
- echo "Building test project for cross-platform targets..."
- for fw in netcoreapp3.1 net50 net6.0 net7.0 net8.0 net9.0 net10.0; do
- dotnet build tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj \
- --no-restore \
- --configuration Release \
- --framework "$fw" || true
- done
-
- # Build .NET 8.0 examples only
- for proj in examples/Net8.0/*/*.csproj; do
- [ -f "$proj" ] && dotnet build "$proj" --no-restore --configuration Release
- done
-
- # Windows: Build all targets including .NET Framework
- - name: Build solution (Windows)
- if: runner.os == 'Windows'
+ - name: Build Solution (Release)
run: dotnet build --no-restore --configuration Release
- # Linux/macOS: Run tests on .NET 8.0 only
- - name: Run tests (Linux/macOS)
- if: runner.os != 'Windows'
- shell: bash
+ - name: Run multi-framework tests with coverage
+ shell: pwsh
run: |
- dotnet test tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj \
- --configuration Release \
- --framework net8.0 \
- --no-build \
- --logger "trx" \
- --results-directory "./TestResults"
-
- # Windows: Run tests on all frameworks
- - name: Run tests (Windows)
- if: runner.os == 'Windows'
+ $testProjects = Get-ChildItem -Path './tests' -Recurse -Filter '*Test*.csproj'
+
+ if ($testProjects.Count -eq 0) {
+ Write-Error "ā No test projects found - release requires tests to validate quality"
+ exit 1
+ }
+
+ foreach ($testProj in $testProjects) {
+ Write-Host "==========================================" -ForegroundColor Cyan
+ Write-Host "Testing project: $($testProj.Name)" -ForegroundColor Cyan
+ Write-Host "==========================================" -ForegroundColor Cyan
+
+ # Parse the project file to extract target frameworks
+ try {
+ [xml]$projectXml = Get-Content $testProj.FullName
+ } catch {
+ Write-Error "ā Failed to parse project file $($testProj.Name): $_"
+ exit 1
+ }
+
+ # Search all PropertyGroup elements for TargetFramework(s)
+ $targetFramework = $null
+ $targetFrameworks = $null
+ foreach ($propGroup in $projectXml.Project.PropertyGroup) {
+ if ($propGroup.TargetFrameworks) {
+ $targetFrameworks = $propGroup.TargetFrameworks
+ break
+ } elseif ($propGroup.TargetFramework) {
+ $targetFramework = $propGroup.TargetFramework
+ break
+ }
+ }
+
+ # Determine which frameworks this project targets
+ $frameworks = @()
+ if ($targetFrameworks) {
+ # Multiple frameworks (semicolon-separated)
+ $frameworks = $targetFrameworks -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
+ if ($frameworks.Count -eq 0) {
+ Write-Error "ā TargetFrameworks property in $($testProj.Name) is empty or malformed"
+ exit 1
+ }
+ } elseif ($targetFramework) {
+ # Single framework
+ $frameworks = @($targetFramework.Trim())
+ if (-not $frameworks[0]) {
+ Write-Error "ā TargetFramework property in $($testProj.Name) is empty"
+ exit 1
+ }
+ } else {
+ # If no TargetFramework/TargetFrameworks are defined directly in the project file,
+ # attempt to resolve them via MSBuild (to account for Directory.Build.props, imports, etc.).
+ Write-Host "No TargetFramework or TargetFrameworks found directly in $($testProj.Name); querying MSBuild..." -ForegroundColor Yellow
+
+ $msbuildOutput = @()
+ $msbuildExitCode = 0
+ foreach ($prop in @("TargetFrameworks", "TargetFramework")) {
+ $result = dotnet msbuild $testProj.FullName /nologo "-getProperty:$prop" 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ $msbuildExitCode = $LASTEXITCODE
+ }
+ if ($result) {
+ $msbuildOutput += $result
+ }
+ }
+
+ if ($msbuildExitCode -ne 0) {
+ # MSBuild query failed, fall back to running tests without explicit --framework
+ Write-Warning "MSBuild query failed for $($testProj.Name). Tests will run without explicit --framework."
+ $frameworks = @('')
+ } else {
+ # MSBuild succeeded, parse the output
+ $resolvedFrameworks = @()
+ foreach ($line in $msbuildOutput) {
+ if ([string]::IsNullOrWhiteSpace($line)) {
+ continue
+ }
+
+ # Expect lines like "TargetFrameworks=net7.0;net8.0" or "TargetFramework=net8.0"
+ # Support both '=' and ':' separators for different MSBuild output formats
+ if ($line -match '^\s*TargetFrameworks\s*[:=]\s*(.+)$') {
+ $propertyValue = $Matches[1].Trim()
+ $resolvedFrameworks = $propertyValue -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
+ break
+ } elseif ($line -match '^\s*TargetFramework\s*[:=]\s*(.+)$') {
+ $propertyValue = $Matches[1].Trim()
+ if ($propertyValue) {
+ $resolvedFrameworks = @($propertyValue)
+ }
+
+ if ($resolvedFrameworks.Count -gt 0) {
+ break
+ }
+ }
+ }
+
+ if ($resolvedFrameworks.Count -gt 0) {
+ $frameworks = $resolvedFrameworks
+ } else {
+ Write-Warning "MSBuild query returned no target frameworks for $($testProj.Name). Tests will run without explicit --framework."
+ $frameworks = @('')
+ }
+ }
+ }
+
+ Write-Host "Detected frameworks: $($frameworks -join ', ')" -ForegroundColor Cyan
+
+ foreach ($fw in $frameworks) {
+ if ([string]::IsNullOrWhiteSpace($fw)) {
+ Write-Host "Testing project $($testProj.Name) without explicit --framework (using SDK/MSBuild defaults)" -ForegroundColor Yellow
+
+ # When framework cannot be determined, run tests once without specifying --framework.
+ # Collect coverage in this case to avoid missing data due to unknown TFM.
+ dotnet test $testProj.FullName `
+ --configuration Release `
+ --no-build `
+ --no-restore `
+ --collect:"XPlat Code Coverage" `
+ --results-directory "./TestResults" `
+ --logger "console;verbosity=minimal"
+
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "ā Tests failed (no explicit TargetFramework) in $($testProj.Name)"
+ exit $LASTEXITCODE
+ }
+
+ continue
+ }
+
+ Write-Host "Testing framework: $fw" -ForegroundColor Yellow
+
+ # Collect coverage only for .NET 5.0+ TFMs; still run tests for all frameworks
+ if ($fw -match '^net([5-9]|[1-9][0-9]+)\.') {
+ dotnet test $testProj.FullName `
+ --configuration Release `
+ --framework $fw `
+ --no-build `
+ --no-restore `
+ --collect:"XPlat Code Coverage" `
+ --results-directory "./TestResults" `
+ --logger "console;verbosity=minimal"
+ } else {
+ # For older frameworks (e.g., netstandard, net4x, net3x, etc.), run tests without coverage
+ dotnet test $testProj.FullName `
+ --configuration Release `
+ --framework $fw `
+ --no-build `
+ --no-restore `
+ --results-directory "./TestResults" `
+ --logger "console;verbosity=minimal"
+ }
+
+ if ($LASTEXITCODE -ne 0) {
+ if ($fw) {
+ Write-Error "ā Tests failed for $fw in $($testProj.Name)"
+ } else {
+ Write-Error "ā Tests failed in $($testProj.Name)"
+ }
+ exit $LASTEXITCODE
+ }
+ }
+ Write-Host ""
+ }
+ Write-Host "ā
All framework tests passed" -ForegroundColor Green
+
+ - name: Verify coverage threshold
shell: pwsh
run: |
- dotnet test tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj `
- --configuration Release `
- --no-build `
- --logger "trx" `
- --results-directory "./TestResults"
+ # Check if coverage files exist
+ $coverageFiles = Get-ChildItem -Path "TestResults" -Recurse -Filter "coverage.cobertura.xml" -ErrorAction SilentlyContinue
+
+ if ($coverageFiles.Count -eq 0) {
+ Write-Error "ā No coverage files found - coverage data is required to enforce the 90% threshold"
+ exit 1
+ }
+
+ dotnet tool install -g dotnet-reportgenerator-globaltool
+
+ reportgenerator `
+ -reports:"TestResults/**/coverage.cobertura.xml" `
+ -targetdir:"CoverageReport" `
+ -reporttypes:"TextSummary;Html"
+
+ Write-Host "==========================================" -ForegroundColor Cyan
+ Write-Host "Coverage Summary:" -ForegroundColor Cyan
+ Write-Host "==========================================" -ForegroundColor Cyan
+ Get-Content CoverageReport/Summary.txt
+ Write-Host ""
+
+ # Parse coverage and enforce threshold per module (matching pr.yaml)
+ $summaryContent = Get-Content CoverageReport/Summary.txt
+ $threshold = if ($env:CODECOV_MINIMUM) { [int]$env:CODECOV_MINIMUM } else { 90 }
+ $failedModules = @()
+ $coverageFound = $false
+
+ foreach ($line in $summaryContent) {
+ # Match lines with module names and percentages (skip Summary line)
+ if ($line -match '^([^ ]+)\s+.*\s+(\d+(?:\.\d+)?)%$' -and $line -notmatch '^Summary') {
+ $coverageFound = $true
+ $module = $matches[1]
+ $coverage = [decimal]$matches[2]
+
+ Write-Host "Checking module: '$module' - Coverage: ${coverage}%" -ForegroundColor Cyan
+
+ if ($coverage -lt $threshold) {
+ Write-Host " ā FAIL: Below ${threshold}% threshold" -ForegroundColor Red
+ $failedModules += "$module (${coverage}%)"
+ } else {
+ Write-Host " ā
PASS: Meets ${threshold}% threshold" -ForegroundColor Green
+ }
+ }
+ }
+
+ # Ensure we found and parsed coverage data
+ if (-not $coverageFound) {
+ Write-Error "ā Failed to parse coverage data from Summary.txt - cannot enforce threshold"
+ exit 1
+ }
+
+ if ($failedModules.Count -gt 0) {
+ Write-Host ""
+ Write-Host "==========================================" -ForegroundColor Red
+ Write-Host "ā COVERAGE GATE FAILED" -ForegroundColor Red
+ Write-Host "==========================================" -ForegroundColor Red
+ Write-Host "Modules below ${threshold}% coverage: $($failedModules -join ', ')" -ForegroundColor Red
+ exit 1
+ }
+
+ Write-Host ""
+ Write-Host "==========================================" -ForegroundColor Green
+ Write-Host "ā
All modules meet ${threshold}% coverage threshold" -ForegroundColor Green
+ Write-Host "==========================================" -ForegroundColor Green
- - name: Upload test results
+ - name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
- name: test-results-${{ matrix.os }}
- path: 'TestResults/**/*.trx'
+ name: release-coverage
+ path: CoverageReport/
- publish:
- name: Pack and Publish NuGet
- needs: build-and-test
- runs-on: windows-latest # Changed from ubuntu-latest
- permissions:
- contents: write # Needed for creating release
+ # Pack and validate NuGet package
+ pack-and-validate:
+ name: Pack & Validate NuGet
+ needs: validate-release
+ runs-on: windows-latest
+ outputs:
+ has-packages: ${{ steps.check-packages.outputs.has-packages }}
steps:
- name: Checkout code
uses: actions/checkout@v4
+ with:
+ persist-credentials: false
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
- 3.1.x
5.0.x
6.0.x
7.0.x
@@ -117,63 +307,286 @@ jobs:
9.0.x
10.0.x
- - name: Restore dependencies
- run: dotnet restore
-
- - name: Build Solution (Release)
- run: dotnet build --no-restore --configuration Release
+ - name: Restore and build
+ run: |
+ dotnet restore
+ dotnet build --no-restore --configuration Release
- - name: Pack NuGet Package with Symbols
+ - name: Pack NuGet packages
+ id: check-packages
shell: pwsh
run: |
- dotnet pack src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj `
- --no-build `
- --configuration Release `
- --output ./nuget-packages `
- --include-symbols `
- --include-source `
- -p:SymbolPackageFormat=snupkg
-
- - name: List packages
+ # Create output directory for NuGet packages
+ $packagesPath = Join-Path $PWD 'nuget-packages'
+ New-Item -ItemType Directory -Force -Path $packagesPath | Out-Null
+
+ # Find all .csproj files in the src directory recursively
+ $projects = Get-ChildItem -Path 'src' -Recurse -Filter '*.csproj'
+
+ # Handle case when no projects are found (e.g., template repository)
+ if ($projects.Count -eq 0) {
+ Write-Warning "No projects found in src/ directory - skipping package creation"
+ Write-Warning "Downstream publish and release jobs will be skipped"
+ # Create empty directory for artifact upload
+ New-Item -ItemType File -Path (Join-Path $packagesPath '.placeholder') -Force | Out-Null
+ # Set output to indicate no packages were created
+ "has-packages=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
+ exit 0
+ }
+
+ # Iterate through each project and create NuGet package
+ foreach ($proj in $projects) {
+ Write-Host "š¦ Packing $($proj.Name)" -ForegroundColor Cyan
+ dotnet pack $proj.FullName --no-build --configuration Release --output $packagesPath
+
+ # Check if pack operation failed and exit with error
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "ā Pack failed for $($proj.Name)"
+ exit $LASTEXITCODE
+ }
+ }
+
+ # Check whether any .nupkg files were actually created
+ $packages = Get-ChildItem -Path $packagesPath -Filter '*.nupkg' -ErrorAction SilentlyContinue
+
+ if ($packages.Count -eq 0) {
+ Write-Warning "No .nupkg files were produced during packing - downstream publish and release jobs will be skipped"
+ "has-packages=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
+ exit 0
+ }
+
+ # At least one package was created successfully
+ Write-Host "ā
NuGet packages created successfully" -ForegroundColor Green
+ "has-packages=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
+
+ - name: Smoke test NuGet package installation
shell: pwsh
run: |
- Write-Host "Packages created:"
- Get-ChildItem ./nuget-packages -Recurse | Select-Object FullName, Length
+ $packages = Get-ChildItem -Path 'nuget-packages' -Filter '*.nupkg' -ErrorAction SilentlyContinue
+
+ if ($packages.Count -eq 0) {
+ Write-Warning "No .nupkg files found - skipping smoke test"
+ exit 0
+ }
+
+ # Helper to read package ID and version from the .nuspec inside a .nupkg
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ function Get-PackageMetadata {
+ param (
+ [Parameter(Mandatory = $true)]
+ [string] $NupkgPath
+ )
+
+ $zip = [System.IO.Compression.ZipFile]::OpenRead($NupkgPath)
+ try {
+ $nuspecEntry = $zip.Entries | Where-Object { $_.FullName -like '*.nuspec' } | Select-Object -First 1
+ if (-not $nuspecEntry) {
+ throw "No .nuspec file found in package '$NupkgPath'."
+ }
- - name: Publish NuGet Package
+ $stream = $nuspecEntry.Open()
+ try {
+ $reader = New-Object System.IO.StreamReader($stream)
+ $nuspecXml = [xml]$reader.ReadToEnd()
+ $id = $nuspecXml.package.metadata.id
+ $version = $nuspecXml.package.metadata.version
+
+ if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($version)) {
+ throw "Failed to read id/version from nuspec in '$NupkgPath'."
+ }
+
+ [PSCustomObject]@{
+ Id = $id
+ Version = $version
+ }
+ }
+ finally {
+ $stream.Dispose()
+ }
+ }
+ finally {
+ $zip.Dispose()
+ }
+ }
+
+ # Create temporary test project
+ $testDir = Join-Path $PWD 'package-smoke-test'
+ New-Item -ItemType Directory -Force -Path $testDir | Out-Null
+
+ # Restrict NuGet restores in this directory to the local package source only
+ $nugetConfigPath = Join-Path $testDir 'NuGet.config'
+ # Build NuGet.config content as array to avoid YAML parsing issues with here-strings
+ $nugetConfigContent = @(
+ ''
+ ''
+ ' '
+ ' '
+ ' '
+ ' '
+ ' '
+ ' '
+ ' '
+ ' '
+ ' '
+ ' '
+ )
+ $nugetConfigContent | Set-Content -Path $nugetConfigPath -Encoding UTF8
+
+ Push-Location $testDir
+ try {
+ dotnet new console -n SmokeTest -f net8.0
+
+ # Try to install the newly created package(s)
+ foreach ($package in $packages) {
+ Write-Host "š§Ŗ Smoke testing package: $($package.Name)" -ForegroundColor Yellow
+
+ $metadata = Get-PackageMetadata -NupkgPath $package.FullName
+ $packageId = $metadata.Id
+ $packageVersion = $metadata.Version
+
+ dotnet add SmokeTest/SmokeTest.csproj package $packageId --version $packageVersion --source '../nuget-packages'
+
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "ā Failed to install package $($package.Name)"
+ exit $LASTEXITCODE
+ }
+
+ Write-Host "ā
Package $($package.Name) installed successfully" -ForegroundColor Green
+ }
+
+ # Try to build the test project with the package
+ Write-Host "Building smoke test project..." -ForegroundColor Yellow
+ dotnet build SmokeTest/SmokeTest.csproj
+
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "ā Smoke test project failed to build with installed packages"
+ exit $LASTEXITCODE
+ }
+
+ Write-Host "ā
Smoke test passed - packages are installable and buildable" -ForegroundColor Green
+
+ } finally {
+ Pop-Location
+ }
+
+ - name: Upload NuGet packages
+ uses: actions/upload-artifact@v4
+ with:
+ name: nuget-packages
+ path: ./nuget-packages/
+ retention-days: 90
+ if-no-files-found: warn
+
+ # Publish to NuGet (only if validation passed)
+ publish-nuget:
+ name: Publish to NuGet.org
+ needs: pack-and-validate
+ if: needs.pack-and-validate.outputs.has-packages == 'true'
+ runs-on: windows-latest
+ steps:
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 5.0.x
+ 6.0.x
+ 7.0.x
+ 8.0.x
+ 9.0.x
+ 10.0.x
+
+ - name: Download packages
+ uses: actions/download-artifact@v4
+ with:
+ name: nuget-packages
+ path: ./packages
+
+ - name: Validate NuGet API key
+ shell: pwsh
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ run: |
+ if ([string]::IsNullOrEmpty($env:NUGET_API_KEY)) {
+ Write-Error "ā NUGET_API_KEY secret not configured!"
+ Write-Host "Please add it in: Repository Settings ā Secrets and variables ā Actions ā New repository secret"
+ exit 1
+ }
+ Write-Host "ā
NUGET_API_KEY is configured" -ForegroundColor Green
+
+ - name: Publish to NuGet
shell: pwsh
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
run: |
- $packages = Get-ChildItem ./nuget-packages/*.nupkg -Exclude *.symbols.nupkg
- foreach ($pkg in $packages) {
- Write-Host "Publishing: $($pkg.Name)"
- dotnet nuget push $pkg.FullName `
- --api-key "$env:NUGET_API_KEY" `
- --source https://api.nuget.org/v3/index.json `
- --skip-duplicate
+ $packages = Get-ChildItem -Path './packages' -Filter '*.nupkg' -ErrorAction SilentlyContinue
+
+ if ($packages.Count -eq 0) {
+ Write-Warning "No .nupkg files found - nothing to publish"
+ exit 0
}
- # Publish symbol packages
- $symbolPackages = Get-ChildItem ./nuget-packages/*.snupkg
- foreach ($pkg in $symbolPackages) {
- Write-Host "Publishing symbols: $($pkg.Name)"
- dotnet nuget push $pkg.FullName `
- --api-key "$env:NUGET_API_KEY" `
+ foreach ($package in $packages) {
+ Write-Host "š¤ Publishing $($package.Name) to NuGet.org" -ForegroundColor Cyan
+
+ dotnet nuget push $package.FullName `
+ --api-key $env:NUGET_API_KEY `
--source https://api.nuget.org/v3/index.json `
--skip-duplicate
+
+ # Exit code 0 = success, 409 would be duplicate (handled by --skip-duplicate flag)
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "ā Failed to publish $($package.Name)"
+ exit $LASTEXITCODE
+ }
+
+ Write-Host "ā
Successfully published $($package.Name)" -ForegroundColor Green
}
+
+ Write-Host ""
+ Write-Host "==========================================" -ForegroundColor Green
+ Write-Host "ā
All packages published to NuGet.org" -ForegroundColor Green
+ Write-Host "==========================================" -ForegroundColor Green
- - name: Upload NuGet artifacts
- uses: actions/upload-artifact@v4
+ # Build and deploy versioned documentation via the shared docfx workflow
+ # Note: reusable workflow jobs called via `uses:` do not require `runs-on` in the caller
+ trigger-docs:
+ name: Build & Deploy Documentation
+ needs: validate-release
+ permissions:
+ contents: write # Required by docfx.yaml to push to gh-pages branch
+ uses: ./.github/workflows/docfx.yaml
+ with:
+ version: ${{ github.event.release.tag_name }}
+
+ # Attach NuGet packages and coverage report to the GitHub Release page
+ update-release-artifacts:
+ name: Attach Artifacts to Release
+ needs: [validate-release, pack-and-validate, publish-nuget]
+ if: needs.pack-and-validate.outputs.has-packages == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # Required to upload assets to the GitHub Release
+ steps:
+ - name: Download NuGet packages artifact
+ uses: actions/download-artifact@v4
with:
name: nuget-packages
- path: ./nuget-packages/*
+ path: ./nuget-packages
- - name: Create GitHub Release
- uses: softprops/action-gh-release@v1
+ - name: Download coverage report artifact
+ uses: actions/download-artifact@v4
with:
- files: ./nuget-packages/*.nupkg
- generate_release_notes: true
- draft: false
- prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
+ name: release-coverage
+ path: ./release-coverage
+
+ - name: Zip coverage report
+ run: zip -r release-coverage.zip ./release-coverage
+
+ - name: Attach artifacts to release
+ uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
+ with:
+ tag_name: ${{ github.event.release.tag_name }}
+ files: |
+ ./nuget-packages/*.nupkg
+ release-coverage.zip
+
diff --git a/.github/workflows/security-scanning.yml b/.github/workflows/security-scanning.yml
deleted file mode 100644
index 4fe4e0d..0000000
--- a/.github/workflows/security-scanning.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-name: Security Scanning
-
-on:
- push:
- branches:
- - main
- schedule:
- - cron: '0 0 * * *' # Daily at midnight
-
-permissions:
- contents: read # Default for all jobs (least privilege)
-
-jobs:
- secret-scanning:
- name: Secret Scanning
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - name: Checkout code
- uses: actions/checkout@v3
-
- - name: Secret Scanning Placeholder
- run: echo "GitHub-native secret scanning is enabled"
-
- dependency-scanning:
- name: Dependency Scanning
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - name: Checkout code
- uses: actions/checkout@v3
-
- - name: Dependency Scan Placeholder
- run: echo "Dependabot alerts enabled in repository settings"
From 1f127ed0a7c50e4ee12c828a331519f0dff97f85 Mon Sep 17 00:00:00 2001
From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
Date: Sun, 8 Mar 2026 22:36:01 -0400
Subject: [PATCH 03/13] Establish async-first code quality and formatting
policy
- Add .editorconfig for strict C# style, async/await, and analyzer rules
- Add .gitattributes for cross-platform line ending consistency
- Expand .gitignore for modern build, IDE, and tool artifacts
- Add .globalconfig for global analyzer and Roslynator settings
- Add BannedSymbols.txt to block sync/obsolete APIs (e.g., Task.Wait, .Result)
- Update Directory.Build.props: latest C# version, analyzers, warnings as errors
- Enforce maintainable, modern, and secure async-first codebase
---
.editorconfig | 441 ++++++++++++++++++++++++++++++++++++++++++
.gitattributes | 51 +++++
.gitignore | 56 ++++--
.globalconfig | 10 +
BannedSymbols.txt | 82 ++++++++
Directory.Build.props | 58 +++++-
6 files changed, 678 insertions(+), 20 deletions(-)
create mode 100644 .editorconfig
create mode 100644 .gitattributes
create mode 100644 .globalconfig
create mode 100644 BannedSymbols.txt
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..7b0f7a6
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,441 @@
+root = true
+
+# All files
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+# XML project files
+[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
+indent_size = 2
+
+# XML config files
+[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
+indent_size = 2
+
+# JSON files
+[*.json]
+indent_size = 2
+
+# YAML files
+[*.{yml,yaml}]
+indent_size = 2
+
+# PowerShell files
+# PowerShell uses CRLF to maintain compatibility with Windows and PowerShell conventions
+# This overrides the global end_of_line = lf setting and aligns with .gitattributes line 14
+[*.ps1]
+indent_size = 4
+end_of_line = crlf
+charset = utf-8-bom
+
+# C# files
+[*.cs]
+
+# SA0001 - Disable XML documentation file requirement
+dotnet_diagnostic.SA0001.severity = none
+
+# .NET Code Analysis Rules
+# Enable .NET analyzers with conservative defaults
+dotnet_analyzer_diagnostic.severity = suggestion
+
+# IDE (Code Style) Rules
+dotnet_diagnostic.IDE0005.severity = suggestion # Remove unnecessary usings
+# Allow var usage - modern C# style
+dotnet_diagnostic.IDE0007.severity = none # Use var instead of explicit type
+dotnet_diagnostic.IDE0008.severity = none # Use explicit type instead of var
+
+# CA (Code Analysis) Rules - Set defaults
+dotnet_diagnostic.CA1000.severity = warning
+dotnet_diagnostic.CA1001.severity = warning
+dotnet_diagnostic.CA1010.severity = warning
+dotnet_diagnostic.CA1016.severity = warning
+dotnet_diagnostic.CA1063.severity = warning
+dotnet_diagnostic.CA1849.severity = warning # Call async methods when in async method
+
+# AsyncFixer Rules (all 5 rules explicitly configured)
+dotnet_diagnostic.AsyncFixer01.severity = error # Unnecessary async/await
+dotnet_diagnostic.AsyncFixer02.severity = error # Blocking synchronous operations inside async methods
+dotnet_diagnostic.AsyncFixer03.severity = warning # Fire-and-forget async void
+dotnet_diagnostic.AsyncFixer04.severity = error # Fire-and-forget async call inside using block
+dotnet_diagnostic.AsyncFixer05.severity = suggestion # Downcasting from Task to Task
+
+# VSTHRD (Visual Studio Threading) Rules - Common rules explicitly configured
+dotnet_diagnostic.VSTHRD100.severity = warning # Avoid async void methods
+dotnet_diagnostic.VSTHRD101.severity = warning # Avoid unsupported async delegates
+dotnet_diagnostic.VSTHRD102.severity = warning # Implement internal logic asynchronously
+dotnet_diagnostic.VSTHRD103.severity = warning # Call async methods when in async method
+dotnet_diagnostic.VSTHRD104.severity = warning # Offer async option
+dotnet_diagnostic.VSTHRD105.severity = warning # Avoid method overloads that assume TaskScheduler.Current
+dotnet_diagnostic.VSTHRD106.severity = warning # Use InvokeAsync to raise async events
+dotnet_diagnostic.VSTHRD107.severity = warning # Await Task within using expression
+dotnet_diagnostic.VSTHRD108.severity = warning # Assert thread affinity unconditionally
+dotnet_diagnostic.VSTHRD109.severity = warning # Switch instead of assert in async methods
+dotnet_diagnostic.VSTHRD110.severity = warning # Observe result of async calls
+dotnet_diagnostic.VSTHRD111.severity = none # ConfigureAwait - not needed in library code targeting modern .NET
+dotnet_diagnostic.VSTHRD112.severity = warning # Implement System.IAsyncDisposable
+dotnet_diagnostic.VSTHRD114.severity = warning # Avoid returning null from a Task-returning method
+dotnet_diagnostic.VSTHRD200.severity = suggestion # Use Async naming convention
+
+# Roslynator Rules - Common rules explicitly configured
+dotnet_diagnostic.RCS1001.severity = suggestion # Add braces
+dotnet_diagnostic.RCS1036.severity = none # Remove unnecessary blank line
+dotnet_diagnostic.RCS1037.severity = suggestion # Remove trailing white-space
+dotnet_diagnostic.RCS1138.severity = warning # Add summary to documentation comment
+dotnet_diagnostic.RCS1140.severity = warning # Add exception to documentation comment
+dotnet_diagnostic.RCS1141.severity = suggestion # Add parameter to documentation comment
+dotnet_diagnostic.RCS1163.severity = warning # Unused parameter
+dotnet_diagnostic.RCS1175.severity = suggestion # Unused this parameter
+dotnet_diagnostic.RCS1180.severity = suggestion # Inline lazy initialization
+dotnet_diagnostic.RCS1181.severity = suggestion # Convert comment to documentation comment
+dotnet_diagnostic.RCS1186.severity = suggestion # Use Regex instance instead of static method
+dotnet_diagnostic.RCS1197.severity = suggestion # Optimize StringBuilder.Append/AppendLine call
+dotnet_diagnostic.RCS1214.severity = suggestion # Unnecessary interpolated string
+dotnet_diagnostic.RCS1227.severity = suggestion # Validate arguments correctly
+
+# Meziantou Analyzer Rules
+dotnet_diagnostic.MA0001.severity = suggestion # StringComparison missing
+dotnet_diagnostic.MA0002.severity = suggestion # IEqualityComparer missing
+dotnet_diagnostic.MA0003.severity = warning # Add parameter name to improve readability
+dotnet_diagnostic.MA0004.severity = suggestion # Use Task.ConfigureAwait(false)
+dotnet_diagnostic.MA0006.severity = warning # Use String.Equals instead of equality operator
+dotnet_diagnostic.MA0007.severity = suggestion # Add comma after the last value
+dotnet_diagnostic.MA0011.severity = suggestion # IFormatProvider is missing
+dotnet_diagnostic.MA0016.severity = suggestion # Prefer returning collection abstraction instead of implementation
+dotnet_diagnostic.MA0025.severity = warning # Implement the functionality instead of throwing NotImplementedException
+dotnet_diagnostic.MA0026.severity = suggestion # Fix TODO comment
+dotnet_diagnostic.MA0028.severity = warning # Optimize StringBuilder usage
+dotnet_diagnostic.MA0029.severity = warning # Combine LINQ methods
+dotnet_diagnostic.MA0036.severity = suggestion # Make class static
+dotnet_diagnostic.MA0038.severity = suggestion # Make method static
+dotnet_diagnostic.MA0040.severity = warning # Flow the cancellation token
+dotnet_diagnostic.MA0048.severity = warning # File name must match type name
+dotnet_diagnostic.MA0051.severity = warning # Method is too long
+dotnet_diagnostic.MA0053.severity = suggestion # Make class sealed
+dotnet_diagnostic.MA0056.severity = suggestion # Do not call overridable members in constructor
+dotnet_diagnostic.MA0073.severity = suggestion # Avoid comparison with bool constant
+dotnet_diagnostic.MA0076.severity = suggestion # Do not use implicit culture-sensitive ToString in interpolated strings
+
+# SonarAnalyzer Rules
+dotnet_diagnostic.S1118.severity = suggestion # Utility classes should not have public constructors
+dotnet_diagnostic.S1135.severity = none # (Disabled: overlaps with MA0026 "Fix TODO comment")
+dotnet_diagnostic.S1199.severity = warning # Nested code blocks should not be used
+dotnet_diagnostic.S2223.severity = warning # Non-constant static fields should not be visible
+dotnet_diagnostic.S2259.severity = warning # Null pointers should not be dereferenced
+dotnet_diagnostic.S2583.severity = warning # Conditionally executed code should be reachable
+dotnet_diagnostic.S2589.severity = warning # Boolean expressions should not be gratuitous
+dotnet_diagnostic.S2696.severity = suggestion # Instance members should not write to "static" fields
+dotnet_diagnostic.S2933.severity = warning # Fields that are only assigned in the constructor should be "readonly"
+dotnet_diagnostic.S2934.severity = warning # Property assignments should not be made for "readonly" fields
+dotnet_diagnostic.S3215.severity = suggestion # "interface" instances should not be cast to concrete types
+dotnet_diagnostic.S3216.severity = suggestion # "ConfigureAwait(false)" should be used in library code (especially for .NET Framework 4.6.2 / .NET Standard 2.0 targets)
+dotnet_diagnostic.S3218.severity = suggestion # Inner class members should not shadow outer class "static" or type members
+dotnet_diagnostic.S3236.severity = warning # Caller information arguments should not be provided explicitly
+dotnet_diagnostic.S3242.severity = suggestion # Method parameters should be declared with base types
+dotnet_diagnostic.S3247.severity = warning # Duplicate casts should not be made
+dotnet_diagnostic.S3253.severity = suggestion # Constructor and destructor declarations should not be redundant
+dotnet_diagnostic.S3257.severity = warning # Declarations and initializations should be as concise as possible
+dotnet_diagnostic.S3358.severity = warning # Ternary operators should not be nested
+dotnet_diagnostic.S3400.severity = warning # Methods should not return constants
+dotnet_diagnostic.S3441.severity = warning # Redundant property names should be omitted in anonymous classes
+dotnet_diagnostic.S3442.severity = warning # "abstract" classes should not have "public" constructors
+dotnet_diagnostic.S3443.severity = warning # Type should not be examined on "System.Type" instances
+dotnet_diagnostic.S3449.severity = suggestion # Right operands of shift operators should be integers
+dotnet_diagnostic.S3451.severity = warning # Classes should not have only "private" constructors
+dotnet_diagnostic.S3604.severity = warning # Member initializer values should not be redundant
+dotnet_diagnostic.S3776.severity = suggestion # Cognitive Complexity of methods should not be too high
+dotnet_diagnostic.S3881.severity = warning # "IDisposable" should be implemented correctly
+dotnet_diagnostic.S3897.severity = suggestion # Classes that provide "Equals()" should implement "IEquatable"
+dotnet_diagnostic.S3898.severity = warning # Value types should implement "IEquatable"
+dotnet_diagnostic.S3902.severity = warning # "Assembly.GetExecutingAssembly" should not be called
+dotnet_diagnostic.S3903.severity = warning # Types should be defined in named namespaces
+dotnet_diagnostic.S3904.severity = warning # Assemblies should have version information
+dotnet_diagnostic.S3925.severity = warning # "ISerializable" should be implemented correctly
+dotnet_diagnostic.S3926.severity = warning # Deserialization methods should be provided for "OptionalField" members
+dotnet_diagnostic.S3927.severity = warning # Serialization event handlers should be implemented correctly
+dotnet_diagnostic.S4049.severity = suggestion # Properties should be preferred
+dotnet_diagnostic.S4056.severity = suggestion # Overloads with a "CultureInfo" or an "IFormatProvider" parameter should be used
+dotnet_diagnostic.S4136.severity = warning # Method overloads should be grouped together
+
+# SecurityCodeScan Rules
+dotnet_diagnostic.SCS0005.severity = warning # Weak random number generator
+dotnet_diagnostic.SCS0006.severity = warning # Weak hash algorithm
+dotnet_diagnostic.SCS0015.severity = warning # Hardcoded password
+dotnet_diagnostic.SCS0016.severity = warning # Controller method is vulnerable to CSRF
+dotnet_diagnostic.SCS0017.severity = warning # Request validation disabled
+dotnet_diagnostic.SCS0018.severity = warning # Path traversal
+dotnet_diagnostic.SCS0019.severity = warning # OutputCache conflict
+dotnet_diagnostic.SCS0020.severity = warning # SQL injection via EF raw query
+dotnet_diagnostic.SCS0026.severity = warning # SQL injection via EF FromSqlRaw
+dotnet_diagnostic.SCS0029.severity = warning # Cross-Site Scripting (XSS)
+dotnet_diagnostic.SCS0031.severity = warning # SQL injection via EF ExecuteSqlRaw
+
+# Performance-critical rules for library code
+dotnet_diagnostic.CA1062.severity = warning # Validate arguments of public methods
+dotnet_diagnostic.CA1508.severity = warning # Avoid dead conditional code
+dotnet_diagnostic.CA1510.severity = none # Disabled for multi-targeting: recommends ArgumentNullException.ThrowIfNull (not available on net462/netstandard2.0)
+dotnet_diagnostic.CA1810.severity = warning # Initialize static fields inline
+dotnet_diagnostic.CA1822.severity = suggestion # Mark members as static
+dotnet_diagnostic.CA1825.severity = warning # Avoid zero-length array allocations
+dotnet_diagnostic.CA1826.severity = warning # Use property instead of Linq Enumerable method
+dotnet_diagnostic.CA1827.severity = warning # Do not use Count/LongCount when Any can be used
+dotnet_diagnostic.CA1828.severity = warning # Do not use CountAsync/LongCountAsync when AnyAsync can be used
+dotnet_diagnostic.CA1829.severity = warning # Use Length/Count property instead of Enumerable.Count method
+dotnet_diagnostic.CA1851.severity = warning # Possible multiple enumerations of IEnumerable collection
+
+# Async/IAsyncEnumerable specific rules (CRITICAL for this library)
+dotnet_diagnostic.CA2007.severity = warning # ConfigureAwait - enforce usage in library async code
+dotnet_diagnostic.CA2012.severity = error # Use ValueTasks correctly
+dotnet_diagnostic.CA2016.severity = warning # Forward CancellationToken parameter
+
+# Banned API Analyzer (RS0030) - Enforce async-first best practices
+dotnet_diagnostic.RS0030.severity = error # Using banned API - treat as error
+
+# New line preferences
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation preferences
+csharp_indent_case_contents = true
+csharp_indent_switch_labels = true
+csharp_indent_labels = one_less_than_current
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents_when_block = false
+
+# Space preferences
+csharp_space_after_cast = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_between_parentheses = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_declaration_name_and_open_parenthesis = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_after_comma = true
+csharp_space_before_comma = false
+csharp_space_after_dot = false
+csharp_space_before_dot = false
+csharp_space_after_semicolon_in_for_statement = true
+csharp_space_before_semicolon_in_for_statement = false
+csharp_space_around_declaration_statements = false
+csharp_space_before_open_square_brackets = false
+csharp_space_between_empty_square_brackets = false
+csharp_space_between_square_brackets = false
+
+# Organize usings
+dotnet_sort_system_directives_first = true
+dotnet_separate_import_directive_groups = false
+
+# Code style rules
+dotnet_style_qualification_for_field = false:suggestion
+dotnet_style_qualification_for_property = false:suggestion
+dotnet_style_qualification_for_method = false:suggestion
+dotnet_style_qualification_for_event = false:suggestion
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+dotnet_style_predefined_type_for_member_access = true:suggestion
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
+dotnet_style_readonly_field = true:suggestion
+csharp_prefer_braces = true:suggestion
+csharp_prefer_simple_using_statement = true:suggestion
+csharp_style_namespace_declarations = file_scoped:suggestion
+
+# var preferences - prefer 'var' usage for modern C# style
+csharp_style_var_when_type_is_apparent = true:silent
+csharp_style_var_for_built_in_types = true:silent
+csharp_style_var_elsewhere = true:silent
+
+# Expression preferences
+dotnet_style_prefer_auto_properties = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:silent
+dotnet_style_prefer_conditional_expression_over_return = true:silent
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+csharp_prefer_simple_default_expression = true:suggestion
+
+# Pattern matching
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+
+# Null checking preferences
+csharp_style_throw_expression = true:suggestion
+csharp_style_conditional_delegate_call = true:suggestion
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+
+# Wrapping preferences
+# Preserve manual line breaks and allow flexible parameter formatting
+csharp_preserve_single_line_statements = false
+csharp_preserve_single_line_blocks = true
+
+# Line length guidance (not enforced by dotnet format, but used by some IDEs)
+csharp_max_line_length = 120
+
+# Naming conventions
+dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
+dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
+dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
+
+dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.types_should_be_pascal_case.symbols = types
+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
+
+# Symbol specifications
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+
+dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+
+# Naming styles
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+dotnet_naming_style.begins_with_i.required_prefix = I
+dotnet_naming_style.begins_with_i.capitalization = pascal_case
+
+# Disable file header requirements
+dotnet_diagnostic.SA1633.severity = none
+dotnet_diagnostic.SA1634.severity = none
+dotnet_diagnostic.SA1635.severity = none
+dotnet_diagnostic.SA1636.severity = none
+dotnet_diagnostic.SA1637.severity = none
+dotnet_diagnostic.SA1638.severity = none
+dotnet_diagnostic.SA1639.severity = none
+dotnet_diagnostic.SA1640.severity = none
+dotnet_diagnostic.SA1641.severity = none
+file_header_template = unset
+
+# Disable overly strict formatting rules globally
+dotnet_diagnostic.SA1505.severity = none
+dotnet_diagnostic.SA1508.severity = none
+dotnet_diagnostic.SA1110.severity = none
+dotnet_diagnostic.SA1009.severity = none
+dotnet_diagnostic.SA1111.severity = none
+dotnet_diagnostic.SA1500.severity = none
+dotnet_diagnostic.SA1101.severity = none
+
+# Naming - error by default (strict)
+dotnet_diagnostic.SA1300.severity = error
+dotnet_diagnostic.IDE1006.severity = error
+dotnet_diagnostic.CA1707.severity = error
+
+# Source code - strict rules
+[src/**/*.cs]
+# Documentation required
+dotnet_diagnostic.SA1600.severity = warning
+dotnet_diagnostic.SA1601.severity = warning
+dotnet_diagnostic.SA1602.severity = warning
+
+# Library code should preserve synchronization context by default
+# Consumers decide whether to use ConfigureAwait(false) when calling library methods
+dotnet_diagnostic.MA0004.severity = none # Meziantou: Use ConfigureAwait
+dotnet_diagnostic.S3216.severity = none # SonarAnalyzer: ConfigureAwait
+dotnet_diagnostic.CA2007.severity = none # .NET Analyzer: Use ConfigureAwait
+
+# Test projects - relaxed naming, no doc requirements
+[tests/**/*.cs]
+# Allow Test_Method_Names_With_Underscores
+dotnet_diagnostic.SA1300.severity = none
+dotnet_diagnostic.IDE1006.severity = none
+dotnet_diagnostic.CA1707.severity = none
+
+# Allow synchronous CancellationTokenSource.Cancel() in tests
+dotnet_diagnostic.CA1849.severity = none
+
+# Relax async/await analyzer rules for tests
+dotnet_diagnostic.AsyncFixer01.severity = none # Allow unnecessary async/await in tests
+dotnet_diagnostic.AsyncFixer02.severity = none # Allow synchronous blocking in tests
+dotnet_diagnostic.AsyncFixer05.severity = none # Allow downcasting in tests
+dotnet_diagnostic.IDE0058.severity = none # Allow unused expression values in tests
+dotnet_diagnostic.VSTHRD103.severity = none # Allow calling sync methods when async alternatives exist in tests
+dotnet_diagnostic.VSTHRD102.severity = none # Allow synchronous implementation in tests
+dotnet_diagnostic.VSTHRD104.severity = none # Allow missing async options in tests
+dotnet_diagnostic.VSTHRD107.severity = none # Allow Task in using without await in tests
+dotnet_diagnostic.VSTHRD114.severity = none # Allow returning null from Task methods in tests
+
+# Banned API Analyzer - Just warn in tests (allow for testing purposes)
+dotnet_diagnostic.RS0030.severity = warning # Using banned API - warn instead of error in tests
+
+# Meziantou - Relax in tests
+dotnet_diagnostic.MA0004.severity = none # ConfigureAwait not needed in tests
+dotnet_diagnostic.MA0011.severity = none # IFormatProvider not critical in tests
+dotnet_diagnostic.MA0026.severity = none # TODO comments OK in tests
+dotnet_diagnostic.MA0040.severity = none # CancellationToken flow not critical in tests
+dotnet_diagnostic.MA0048.severity = none # File name matching not critical in tests
+dotnet_diagnostic.MA0051.severity = none # Method length OK in tests
+
+# SonarAnalyzer - Relax in tests
+dotnet_diagnostic.S1118.severity = none # Utility class constructors OK in tests
+dotnet_diagnostic.S1135.severity = none # TODO tags OK in tests
+dotnet_diagnostic.S3216.severity = none # ConfigureAwait not needed in tests
+dotnet_diagnostic.S3776.severity = none # Complexity OK in tests
+dotnet_diagnostic.S4049.severity = none # Properties vs methods flexibility in tests
+
+# .NET Analyzer - Relax in tests
+dotnet_diagnostic.CA2007.severity = none # ConfigureAwait not needed in tests
+
+# SecurityCodeScan - Relax in tests (but keep serious ones)
+dotnet_diagnostic.SCS0005.severity = suggestion # Weak random OK for test data
+
+# No documentation required for tests
+dotnet_diagnostic.SA1600.severity = none
+dotnet_diagnostic.SA1601.severity = none
+dotnet_diagnostic.SA1602.severity = none
+
+# Benchmark projects - relaxed naming, no doc requirements
+[benchmarks/**/*.cs]
+# Allow Benchmark_Method_Names
+dotnet_diagnostic.SA1300.severity = none
+dotnet_diagnostic.IDE1006.severity = none
+dotnet_diagnostic.CA1707.severity = none
+
+# Relax async/await analyzer rules for benchmarks
+dotnet_diagnostic.AsyncFixer01.severity = none # Allow unnecessary async/await in benchmarks
+
+# ConfigureAwait not needed in benchmarks
+dotnet_diagnostic.MA0004.severity = none # Meziantou: Use ConfigureAwait
+dotnet_diagnostic.S3216.severity = none # SonarAnalyzer: ConfigureAwait
+dotnet_diagnostic.CA2007.severity = none # .NET Analyzer: Use ConfigureAwait
+
+# Banned API Analyzer - Just warn in benchmarks (allow for benchmarking purposes)
+dotnet_diagnostic.RS0030.severity = warning # Using banned API - warn instead of error in benchmarks
+
+# No documentation required for benchmarks
+dotnet_diagnostic.SA1600.severity = none
+dotnet_diagnostic.SA1601.severity = none
+dotnet_diagnostic.SA1602.severity = none
+
+# Example projects - relaxed naming, docs encouraged
+[examples/**/*.cs]
+# Allow Example_Method_Names
+dotnet_diagnostic.SA1300.severity = none
+dotnet_diagnostic.IDE1006.severity = none
+dotnet_diagnostic.CA1707.severity = none
+
+# Documentation helpful but not required
+dotnet_diagnostic.SA1600.severity = suggestion
+dotnet_diagnostic.SA1601.severity = suggestion
+dotnet_diagnostic.SA1602.severity = suggestion
+
+# Banned API Analyzer - Allow in examples for demonstration purposes
+dotnet_diagnostic.RS0030.severity = none # Allow banned APIs in examples for demonstration
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..85e6969
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,51 @@
+# Auto detect text files and perform LF normalization
+* text=auto eol=lf
+
+# Source code
+*.cs text eol=lf
+*.csx text eol=lf
+*.vb text eol=lf
+*.fs text eol=lf
+*.fsx text eol=lf
+
+# Scripts
+# PowerShell scripts: CRLF line endings (intentional override)
+# Both .gitattributes and .editorconfig consistently configure PowerShell files
+# to use CRLF (Windows-style) line endings for PowerShell convention compliance.
+# See .editorconfig [*.ps1] section for the matching configuration.
+*.ps1 text eol=crlf
+
+
+# Build and configuration files
+*.xml text eol=lf
+*.csproj text eol=lf
+*.vbproj text eol=lf
+*.fsproj text eol=lf
+*.sln text eol=lf
+*.slnx text eol=lf
+*.props text eol=lf
+*.targets text eol=lf
+*.ruleset text eol=lf
+*.config text eol=lf
+*.json text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+
+# Documentation
+*.md text eol=lf
+*.txt text eol=lf
+
+# SVG files (XML-based text)
+*.svg text eol=lf
+
+# Denote all files that are truly binary and should not be modified
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.pdf binary
+*.dll binary
+*.exe binary
+*.nupkg binary
+*.snupkg binary
diff --git a/.gitignore b/.gitignore
index f291313..6c90e3a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
*.user
*.userosscache
*.sln.docstates
+*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
@@ -28,12 +29,17 @@ x86/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
-[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
+# Build results on 'Bin' directories
+**/[Bb]in/*
+# Uncomment if you have tasks that rely on *.refresh files to move binaries
+# (https://github.com/github/gitignore/pull/3736)
+#!**/[Bb]in/*.refresh
+
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
@@ -45,12 +51,16 @@ Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
+*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
+# Approval Tests result files
+*.received.*
+
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
@@ -77,6 +87,7 @@ StyleCopReport.xml
*.ilk
*.meta
*.obj
+*.idb
*.iobj
*.pch
*.pdb
@@ -210,7 +221,6 @@ PublishScripts/
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
-.nuget/
# Microsoft Azure Build Output
csx/
@@ -320,22 +330,22 @@ node_modules/
_Pvt_Extensions
# Paket dependency manager
-.paket/paket.exe
+**/.paket/paket.exe
paket-files/
# FAKE - F# Make
-.fake/
+**/.fake/
# CodeRush personal settings
-.cr/personal
+**/.cr/personal
# Python Tools for Visual Studio (PTVS)
-__pycache__/
+**/__pycache__/
*.pyc
# Cake - Uncomment if you are using it
-# tools/**
-# !tools/packages.config
+#tools/**
+#!tools/packages.config
# Tabs Studio
*.tss
@@ -357,6 +367,7 @@ ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
+MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
@@ -365,10 +376,10 @@ ASALocalRun/
*.nvuser
# MFractors (Xamarin productivity tool) working folder
-.mfractor/
+**/.mfractor/
# Local History for Visual Studio
-.localhistory/
+**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
@@ -380,7 +391,7 @@ healthchecksdb
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
-.ionide/
+**/.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
@@ -391,11 +402,14 @@ FodyWeavers.xsd
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
-*.code-workspace
+!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
+# Built Visual Studio Code Extensions
+*.vsix
+
# Windows Installer files from build outputs
*.cab
*.msi
@@ -403,5 +417,19 @@ FodyWeavers.xsd
*.msm
*.msp
-# JetBrains Rider
-*.sln.iml
+# Test results and coverage
+TestResults/
+CoverageReport/
+
+# Release workflow temporary directories
+package-smoke-test/
+nuget-packages/
+
+# DocFX generated files
+docfx_project/_site/
+docfx_project/obj/
+
+# Generated documentation (built by CI/CD)
+docs/*
+!docs/.gitkeep
+!docs/RELEASE-WORKFLOW-SETUP.md
diff --git a/.globalconfig b/.globalconfig
new file mode 100644
index 0000000..32d8d91
--- /dev/null
+++ b/.globalconfig
@@ -0,0 +1,10 @@
+is_global = true
+
+# Global analyzer configuration
+global_level = 9999
+
+# Roslynator configuration
+roslynator_accessibility_modifiers = explicit
+roslynator_enum_has_flag_style = method
+roslynator_object_creation_type_style = implicit_when_type_is_obvious
+roslynator_use_anonymous_function_or_method_group = method_group
diff --git a/BannedSymbols.txt b/BannedSymbols.txt
new file mode 100644
index 0000000..62de93f
--- /dev/null
+++ b/BannedSymbols.txt
@@ -0,0 +1,82 @@
+# BannedSymbols.txt - Async-First Enforcement for Wolfgang.Etl.TestKit
+# Format: ;
+# T: = Type, M: = Method, P: = Property, F: = Field
+# Task.Wait() - All overloads - Absolutely NOT allowed in async code
+M:System.Threading.Tasks.Task.Wait(); Use 'await' instead - this blocks the calling thread!
+M:System.Threading.Tasks.Task.Wait(System.Int32); Use 'await' with CancellationToken timeout instead
+M:System.Threading.Tasks.Task.Wait(System.TimeSpan); Use 'await' with CancellationToken timeout instead
+M:System.Threading.Tasks.Task.Wait(System.Int32,System.Threading.CancellationToken); Use 'await' instead
+M:System.Threading.Tasks.Task.Wait(System.Threading.CancellationToken); Use 'await' instead
+# Task.WaitAll/WaitAny - Use async alternatives
+M:System.Threading.Tasks.Task.WaitAll(System.Threading.Tasks.Task[]); Use 'await Task.WhenAll()' instead
+M:System.Threading.Tasks.Task.WaitAll(System.Threading.Tasks.Task[],System.Int32); Use 'await Task.WhenAll()' with CancellationToken instead
+M:System.Threading.Tasks.Task.WaitAll(System.Threading.Tasks.Task[],System.TimeSpan); Use 'await Task.WhenAll()' with CancellationToken instead
+M:System.Threading.Tasks.Task.WaitAll(System.Threading.Tasks.Task[],System.Int32,System.Threading.CancellationToken); Use 'await Task.WhenAll()' instead
+M:System.Threading.Tasks.Task.WaitAll(System.Threading.Tasks.Task[],System.Threading.CancellationToken); Use 'await Task.WhenAll()' instead
+M:System.Threading.Tasks.Task.WaitAny(System.Threading.Tasks.Task[]); Use 'await Task.WhenAny()' instead
+M:System.Threading.Tasks.Task.WaitAny(System.Threading.Tasks.Task[],System.Int32); Use 'await Task.WhenAny()' with CancellationToken instead
+M:System.Threading.Tasks.Task.WaitAny(System.Threading.Tasks.Task[],System.TimeSpan); Use 'await Task.WhenAny()' with CancellationToken instead
+M:System.Threading.Tasks.Task.WaitAny(System.Threading.Tasks.Task[],System.Int32,System.Threading.CancellationToken); Use 'await Task.WhenAny()' instead
+M:System.Threading.Tasks.Task.WaitAny(System.Threading.Tasks.Task[],System.Threading.CancellationToken); Use 'await Task.WhenAny()' instead
+# Task.Result - Blocking property access
+P:System.Threading.Tasks.Task`1.Result; Blocking! Use 'await' instead to get the result asynchronously
+# GetAwaiter().GetResult() - Also blocking
+M:System.Runtime.CompilerServices.TaskAwaiter.GetResult(); Blocking! Use 'await' instead
+M:System.Runtime.CompilerServices.TaskAwaiter`1.GetResult(); Blocking! Use 'await' instead
+# Thread.Sleep - Use Task.Delay for async delays
+M:System.Threading.Thread.Sleep(System.Int32); Use 'await Task.Delay()' instead for async-friendly delays
+M:System.Threading.Thread.Sleep(System.TimeSpan); Use 'await Task.Delay()' instead for async-friendly delays
+# Obsolete/Deprecated Threading APIs
+M:System.Threading.Thread.Suspend(); Deprecated and dangerous
+M:System.Threading.Thread.Resume(); Deprecated and dangerous
+T:System.ComponentModel.BackgroundWorker; Use async/await patterns instead of BackgroundWorker
+# Synchronous File I/O - Use async versions
+M:System.IO.File.ReadAllText(System.String); Use 'File.ReadAllTextAsync()' instead
+M:System.IO.File.ReadAllText(System.String,System.Text.Encoding); Use 'File.ReadAllTextAsync()' instead
+M:System.IO.File.ReadAllLines(System.String); Use 'File.ReadAllLinesAsync()' instead
+M:System.IO.File.ReadAllLines(System.String,System.Text.Encoding); Use 'File.ReadAllLinesAsync()' instead
+M:System.IO.File.ReadAllBytes(System.String); Use 'File.ReadAllBytesAsync()' instead
+M:System.IO.File.WriteAllText(System.String,System.String); Use 'File.WriteAllTextAsync()' instead
+M:System.IO.File.WriteAllText(System.String,System.String,System.Text.Encoding); Use 'File.WriteAllTextAsync()' instead
+M:System.IO.File.WriteAllLines(System.String,System.Collections.Generic.IEnumerable{System.String}); Use 'File.WriteAllLinesAsync()' instead
+M:System.IO.File.WriteAllLines(System.String,System.Collections.Generic.IEnumerable{System.String},System.Text.Encoding); Use 'File.WriteAllLinesAsync()' instead
+M:System.IO.File.WriteAllLines(System.String,System.String[]); Use 'File.WriteAllLinesAsync()' instead
+M:System.IO.File.WriteAllLines(System.String,System.String[],System.Text.Encoding); Use 'File.WriteAllLinesAsync()' instead
+M:System.IO.File.WriteAllBytes(System.String,System.Byte[]); Use 'File.WriteAllBytesAsync()' instead
+M:System.IO.File.AppendAllText(System.String,System.String); Use 'File.AppendAllTextAsync()' instead
+M:System.IO.File.AppendAllText(System.String,System.String,System.Text.Encoding); Use 'File.AppendAllTextAsync()' instead
+M:System.IO.File.AppendAllLines(System.String,System.Collections.Generic.IEnumerable{System.String}); Use 'File.AppendAllLinesAsync()' instead
+M:System.IO.File.AppendAllLines(System.String,System.Collections.Generic.IEnumerable{System.String},System.Text.Encoding); Use 'File.AppendAllLinesAsync()' instead
+# Synchronous Stream operations - Use async versions for file I/O
+M:System.IO.Stream.Read(System.Byte[],System.Int32,System.Int32); Use 'ReadAsync()' instead
+M:System.IO.Stream.Write(System.Byte[],System.Int32,System.Int32); Use 'WriteAsync()' instead
+M:System.IO.Stream.CopyTo(System.IO.Stream); Use 'CopyToAsync()' instead
+M:System.IO.Stream.CopyTo(System.IO.Stream,System.Int32); Use 'CopyToAsync()' instead
+M:System.IO.Stream.Flush(); Use 'FlushAsync()' instead
+M:System.IO.FileStream.Read(System.Byte[],System.Int32,System.Int32); Use 'ReadAsync()' instead
+M:System.IO.FileStream.Write(System.Byte[],System.Int32,System.Int32); Use 'WriteAsync()' instead
+# Obsolete Network APIs - Use HttpClient
+T:System.Net.WebClient; Obsolete - use HttpClient instead
+T:System.Net.WebRequest; Obsolete - use HttpClient instead
+T:System.Net.HttpWebRequest; Obsolete - use HttpClient instead
+T:System.Net.HttpWebResponse; Obsolete - use HttpClient instead
+M:System.Net.WebClient.DownloadString(System.String); Use 'HttpClient.GetStringAsync()' instead
+M:System.Net.WebClient.DownloadData(System.String); Use 'HttpClient.GetByteArrayAsync()' instead
+M:System.Net.WebClient.UploadString(System.String,System.String); Use 'HttpClient.PostAsync()' instead
+# Obsolete/Insecure Serialization
+T:System.Runtime.Serialization.Formatters.Binary.BinaryFormatter; Insecure and deprecated - use System.Text.Json.JsonSerializer instead
+T:System.Runtime.Serialization.Formatters.Soap.SoapFormatter; Insecure and deprecated - use System.Text.Json.JsonSerializer instead
+# DateTime Anti-patterns - Prefer DateTimeOffset for timezone safety
+P:System.DateTime.Now; Use 'DateTimeOffset.UtcNow' or 'DateTimeOffset.Now' for timezone-aware operations
+# Synchronous Parallel operations - Use async alternatives
+M:System.Threading.Tasks.Parallel.For(System.Int32,System.Int32,System.Action{System.Int32}); Synchronous - prefer async concurrency patterns (e.g., 'Task.WhenAll()', dataflow). On .NET 6+ targets, 'Parallel.ForEachAsync()' is also available.
+M:System.Threading.Tasks.Parallel.For(System.Int32,System.Int32,System.Threading.Tasks.ParallelOptions,System.Action{System.Int32}); Synchronous - prefer async concurrency patterns (e.g., 'Task.WhenAll()', dataflow). On .NET 6+ targets, 'Parallel.ForEachAsync()' is also available.
+M:System.Threading.Tasks.Parallel.ForEach``1(System.Collections.Generic.IEnumerable{``0},System.Action{``0}); Synchronous - prefer async concurrency patterns (e.g., 'Task.WhenAll()', dataflow). On .NET 6+ targets, 'Parallel.ForEachAsync()' is also available.
+M:System.Threading.Tasks.Parallel.ForEach``1(System.Collections.Generic.IEnumerable{``0},System.Threading.Tasks.ParallelOptions,System.Action{``0}); Synchronous - prefer async concurrency patterns (e.g., 'Task.WhenAll()', dataflow). On .NET 6+ targets, 'Parallel.ForEachAsync()' is also available.
+M:System.Threading.Tasks.Parallel.Invoke(System.Action[]); Synchronous - use 'Task.WhenAll()' with async delegates instead
+# Console Blocking operations - Avoid in async code
+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
+
diff --git a/Directory.Build.props b/Directory.Build.props
index 177e3c8..ffb6edc 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,10 +1,56 @@
+
+ latest
+
+
+ true
+ latest
+ true
+
+
+ <_SkipUpgradeNetAnalyzersNuGetWarning>true
+
+
+ true
+
+
-
-
-
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
From fe4b82d921ff43c02ca3654bc19822d0cc4ccbbf Mon Sep 17 00:00:00 2001
From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
Date: Sun, 8 Mar 2026 22:49:32 -0400
Subject: [PATCH 04/13] Update BannedSymbols.txt
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
BannedSymbols.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/BannedSymbols.txt b/BannedSymbols.txt
index 62de93f..0b80aad 100644
--- a/BannedSymbols.txt
+++ b/BannedSymbols.txt
@@ -1,4 +1,4 @@
-# BannedSymbols.txt - Async-First Enforcement for Wolfgang.Etl.TestKit
+# 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 da1486d2d2cd88b56373faf34bf6b866d5c73ba8 Mon Sep 17 00:00:00 2001
From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
Date: Sun, 8 Mar 2026 23:07:01 -0400
Subject: [PATCH 05/13] Standardized repo from repo-template
---
.github/copilot-instructions.md | 3 +
CONTRIBUTING.md | 244 ++++++++-
Directory.Build.props | 29 +-
ETL-Abstractions.sln | 193 +++++++
README.md | 208 ++++++--
README.original.md | 31 ++
REPO-INSTRUCTIONS.md | 266 ++++++++++
docfx_project/api/README.md | 23 +
docfx_project/api/index.md | 18 +
docfx_project/docfx.json | 55 +-
docfx_project/docs/getting-started.md | 53 ++
docfx_project/docs/index.md | 9 +
docfx_project/docs/introduction.md | 26 +
docfx_project/docs/toc.yml | 9 +
docfx_project/index.md | 45 +-
docfx_project/logo.svg | 23 +
docfx_project/toc.yml | 9 +-
docs/README.md | 1 -
docs/RELEASE-WORKFLOW-SETUP.md | 221 ++++++++
scripts/Setup-BranchRuleset.ps1 | 334 ++++++++++++
scripts/Setup-GitHubPages.ps1 | 714 ++++++++++++++++++++++++++
scripts/Setup-Labels.ps1 | 116 +++++
scripts/format.ps1 | 104 ++++
23 files changed, 2626 insertions(+), 108 deletions(-)
create mode 100644 .github/copilot-instructions.md
create mode 100644 ETL-Abstractions.sln
create mode 100644 README.original.md
create mode 100644 REPO-INSTRUCTIONS.md
create mode 100644 docfx_project/api/README.md
create mode 100644 docfx_project/api/index.md
create mode 100644 docfx_project/docs/getting-started.md
create mode 100644 docfx_project/docs/index.md
create mode 100644 docfx_project/docs/introduction.md
create mode 100644 docfx_project/docs/toc.yml
create mode 100644 docfx_project/logo.svg
delete mode 100644 docs/README.md
create mode 100644 docs/RELEASE-WORKFLOW-SETUP.md
create mode 100644 scripts/Setup-BranchRuleset.ps1
create mode 100644 scripts/Setup-GitHubPages.ps1
create mode 100644 scripts/Setup-Labels.ps1
create mode 100644 scripts/format.ps1
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..aaa113a
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,3 @@
+# Copilot Coding Agent Instructions
+
+## Repository Summary
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9a1181e..7316c04 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,40 +1,240 @@
-# Contributing to ETL-Abstractions
+# Contributing to Wolfgang.Etl.Abstractions
-Thank you for your interest in contributing! Your help is appreciated. Please follow these guidelines to make your contributions easy to review and integrate.
+Thank you for your interest in contributing to **Wolfgang.Etl.Abstractions**! We welcome contributions to help improve this project.
+
+## How Can You Contribute?
+
+You can contribute in several ways:
+- Reporting bugs
+- Suggesting enhancements
+- Submitting pull requests for new features or bug fixes
+- Improving documentation
+- Writing or improving tests
+
+**Please note:** Before coding anything please check with me first by entering an issue and getting approval for it. PRs are more likely to get merged if I have agreed to the changes.
+
+---
## Getting Started
-1. **Fork the repository** and clone it to your local machine.
-2. **Create a new branch** for your changes:
-3. **Make your changes** following the existing code style and conventions. This repository used styles and conventions from [Jetbrains Resharper](https://www.jetbrains.com/resharper/)
+1. **Fork the repository** and clone it locally.
+2. **Create a new branch** for your feature or bug fix:
+ ```sh
+ git checkout -b your-feature-name
+ ```
+3. **Make your changes** and commit them with clear messages:
+ ```sh
+ git commit -m "Describe your changes"
+ ```
+4. **Push your branch** to your fork:
+ ```sh
+ git push origin your-feature-name
+ ```
+5. **Open a pull request** describing your changes.
-## Code Guidelines
+6. **PR Checks:**
+ Once you create a pull request (PR), several Continuous Integration (CI) steps will run automatically. These may include:
+ - Building the project
+ - Running automated tests
+ - Checking code style and linting
+ - Running static analysis with multiple static analyzers (see list below)
-- Write clear, concise, and well-documented code.
-- Include docstrings and comments where necessary.
-- Follow naming conventions used in the repository.
+ **It is important to make sure that all CI steps pass before your PR can be merged.**
+ - If any CI step fails, please review the error messages and update your PR as needed.
+ - Maintainers will review your PR once all checks have passed.
-## Pull Requests
+---
+
+## Code Quality Standards
+
+This project maintains **extremely high code quality standards** through multiple layers of static analysis and automated enforcement.
+
+### The 7 Analyzers
+
+All code is analyzed by these tools during build:
+
+1. **Microsoft.CodeAnalysis.NetAnalyzers** (Built-in .NET SDK)
+ - Correctness, performance, and security rules
+ - Latest analysis level enabled
+
+2. **Roslynator.Analyzers**
+ - 500+ refactoring and code quality rules
+ - Advanced C# pattern detection
+
+3. **AsyncFixer**
+ - Detects async/await anti-patterns
+ - Ensures proper `ConfigureAwait()` usage
+ - Prevents fire-and-forget async calls
+
+4. **Microsoft.VisualStudio.Threading.Analyzers**
+ - Thread safety enforcement
+ - Async method naming conventions
+ - Deadlock prevention
-1. **Ensure your branch is up to date** with the target branch (usually `main` or `develope`).
-2. **Test your code** before submitting.
-3. **Describe your changes** clearly in the pull request description.
-4. Link to any related issues.
+5. **Microsoft.CodeAnalysis.BannedApiAnalyzers**
+ - Blocks usage of APIs listed in `BannedSymbols.txt`
+ - Enforces async-first patterns (see below)
+
+6. **Meziantou.Analyzer**
+ - Comprehensive code quality checks
+ - Performance optimizations
+ - Best practice enforcement
+
+7. **SonarAnalyzer.CSharp**
+ - Industry-standard code analysis
+ - Security vulnerability detection
+ - Code smell identification
+
+### Async-First Enforcement
+
+This library **prohibits synchronous blocking calls** via `BannedSymbols.txt`. The following APIs are **banned**:
+
+#### ā Blocking Async Operations
+```csharp
+// Banned - blocks threads
+task.Wait();
+task.Result;
+Task.WaitAll(tasks);
+
+// Required - truly async
+await task;
+await Task.WhenAll(tasks);
+```
+
+#### ā Synchronous I/O
+```csharp
+// Banned
+File.ReadAllText(path);
+stream.Read(buffer, 0, count);
+streamReader.ReadLine();
+
+// Required
+await File.ReadAllTextAsync(path);
+await stream.ReadAsync(buffer, 0, count);
+await streamReader.ReadLineAsync();
+```
+
+#### ā Thread Blocking
+```csharp
+// Banned
+Thread.Sleep(1000);
+Console.ReadLine();
+
+// Required
+await Task.Delay(1000);
+// Avoid blocking console reads in async code
+```
+
+#### ā Obsolete/Insecure APIs
+```csharp
+// Banned
+var client = new WebClient();
+var formatter = new BinaryFormatter();
+var now = DateTime.Now; // Use DateTimeOffset
+
+// Required
+var client = new HttpClient();
+// Use System.Text.Json.JsonSerializer
+var now = DateTimeOffset.UtcNow;
+```
+
+**Why?** This ensures all code is **truly asynchronous** and **non-blocking**, providing optimal performance in async contexts.
+
+---
-## Need help or have ideas?
+## Build and Test Instructions
-- Check the open issues first.
- - If your problem is there, add a comment or up-vote.
- - If not there, create a new issue. Be as descriptive as possible.
+### Prerequisites
+- .NET 8.0 SDK or later
+- PowerShell Core (optional, for formatting scripts)
-## Reporting Issues
+### Build the Project
-If you find a bug or have a feature request, please [open an issue](https://github.com/Chris-Wolfgang/ETL-Abstractions/issues) with a clear description.
+```bash
+# Restore NuGet packages
+dotnet restore
+
+# Build in Release configuration (enforces all analyzers)
+dotnet build --configuration Release
+```
+
+**Note:** Release builds treat all analyzer warnings as errors (`true `). Debug builds allow warnings to facilitate development.
+
+### Run Tests
+
+```bash
+# Run all unit tests
+dotnet test --configuration Release
+
+# Run with coverage (if configured)
+dotnet test --collect:"XPlat Code Coverage"
+```
+
+### Code Formatting
+
+This project uses `.editorconfig` for consistent code style:
+
+```bash
+# Format all code
+dotnet format
+
+# Check formatting without changes (CI mode)
+dotnet format --verify-no-changes
+
+# PowerShell formatting script
+pwsh ./format.ps1
+```
+
+See [README-FORMATTING.md](README-FORMATTING.md) for detailed formatting rules.
+
+---
+
+## .editorconfig Rules
+
+Key style rules enforced:
+
+- **Indentation:** 4 spaces (C#), 2 spaces (XML/JSON)
+- **Line endings:** LF (Unix-style)
+- **Charset:** UTF-8
+- **Trim trailing whitespace:** Yes
+- **Final newline:** Yes
+- **Braces:** New line style (Allman)
+- **Naming:** PascalCase for public members, camelCase for parameters/locals
+- **File-scoped namespaces:** Required in C# 10+
+- **`var` preferences:** Use for built-in types and when type is obvious
+- **Null checks:** Prefer pattern matching (`is null`, `is not null`)
+
+View the complete configuration in [.editorconfig](.editorconfig).
+
+---
+
+## Guidelines
+
+- Follow the coding style used in the project.
+- Write clear, concise commit messages.
+- Add relevant tests for new features or bug fixes.
+- Document any public APIs with XML documentation comments.
+- Ensure all analyzer warnings are addressed (they're treated as errors in Release builds).
+- Use async/await patterns - no blocking calls allowed.
+- Include `CancellationToken` parameters in async methods where appropriate.
+
+---
+
+## Pull Requests
+
+- Ensure your pull request passes all tests and analyzer checks.
+- Respond to review feedback in a timely manner.
+- Reference related issues in your pull request description.
+- Keep changes focused and atomic - one feature/fix per PR.
+- Update documentation if you change public APIs.
+
+---
## Code of Conduct
-Be respectful and considerate of others. See the [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for details.
+Please be respectful and considerate in all interactions. See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for our community guidelines.
---
-Thank you for helping make ETL-Abstractions better!
+Thank you for contributing! š
+
diff --git a/Directory.Build.props b/Directory.Build.props
index ffb6edc..02e6b59 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -6,33 +6,44 @@
true
latest
true
-
+
<_SkipUpgradeNetAnalyzersNuGetWarning>true
-
+
true
+
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
all
@@ -44,13 +55,5 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
diff --git a/ETL-Abstractions.sln b/ETL-Abstractions.sln
new file mode 100644
index 0000000..a80b831
--- /dev/null
+++ b/ETL-Abstractions.sln
@@ -0,0 +1,193 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 18
+VisualStudioVersion = 18.0.11222.15
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wolfgang.Etl.Abstractions", "src\Wolfgang.Etl.Abstractions\Wolfgang.Etl.Abstractions.csproj", "{C4987BAD-4513-955F-B3C1-7563D0C1A7A3}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
+ ProjectSection(SolutionItems) = preProject
+ README.md = README.md
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{8220BC33-6632-4D4C-9A50-B7978141A4E3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wolfgang.Etl.Abstractions.Tests.Unit", "tests\Wolfgang.Etl.Abstractions.Tests.Unit\Wolfgang.Etl.Abstractions.Tests.Unit.csproj", "{B8558C7F-934B-3DC1-AAED-D668CF964C8E}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{207971F1-432C-4AB4-9DC4-16A9E5ACF812}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example1-BasicETL", "examples\Net8.0\Example1-BasicETL\Example1-BasicETL.csproj", "{F1BA1AF5-9A71-421B-963C-E277610FBD40}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Net80", "Net80", "{3C48157B-5E90-489E-9444-E01F51D59F86}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Net48", "Net48", "{336D72A1-8E5E-49DE-83D9-DF6BE458BA24}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example1-BasicETL", "examples\Net4.8\Example1-BasicETL\Example1-BasicETL.csproj", "{B715D0C5-3F1A-485C-92D1-FFB87A801AC3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example2-WithCancellationToken", "examples\Net8.0\Example2-WithCancellationToken\Example2-WithCancellationToken.csproj", "{F4A2F47C-9687-0ABE-FEAE-D07873E0133A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example3-WithGracefulCancellation", "examples\Net8.0\Example3-WithGracefulCancellation\Example3-WithGracefulCancellation.csproj", "{733AD8E6-D170-789A-6F61-13C041011037}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example2-WithCancellationToken", "examples\Net4.8\Example2-WithCancellationToken\Example2-WithCancellationToken.csproj", "{2DC16382-F59F-4024-B560-D400E09EF91F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example3-WithGracefulCancellation", "examples\Net4.8\Example3-WithGracefulCancellation\Example3-WithGracefulCancellation.csproj", "{5A7CFA9D-DCE7-43FD-AF89-7418C36AAE14}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example4a-WithExtractorProgress", "examples\Net8.0\Example4a-WithExtractorProgress\Example4a-WithExtractorProgress.csproj", "{483AE567-071E-4797-B13B-84B142D4ED44}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example4b-WithTransformerProgress", "examples\Net8.0\Example4b-WithTransformerProgress\Example4b-WithTransformerProgress.csproj", "{6C19204D-7CC8-48D2-A475-49CD98931105}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example4c-WithLoaderProgress", "examples\Net8.0\Example4c-WithLoaderProgress\Example4c-WithLoaderProgress.csproj", "{E858F22E-01D7-4BE9-B8DE-0E4AFBA5B33A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example4a-WithExtractorProgress", "examples\Net4.8\Example4a-WithExtractorProgress\Example4a-WithExtractorProgress.csproj", "{8068B622-46A9-4ABA-BDA3-6AB259D1682C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example4c-WithLoaderProgress", "examples\Net4.8\Example4c-WithLoaderProgress\Example4c-WithLoaderProgress.csproj", "{6DA63E99-692F-461C-983A-270E5CBE8D45}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example4b-WithTransformerProgress", "examples\Net4.8\Example4b-WithTransformerProgress\Example4b-WithTransformerProgress.csproj", "{6BD7828E-55B4-4213-8DF9-7BFBA891B8EE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example5a-ExtractorWithProgressAndCancellation", "examples\Net8.0\Example5a-ExtractorWithProgressAndCancellation\Example5a-ExtractorWithProgressAndCancellation.csproj", "{2D14E222-4E44-40AB-82EF-1E8ABCED0476}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example5a-ExtractorWithProgressAndCancellation", "examples\Net4.8\Example5a-ExtractorWithProgressAndCancellation\Example5a-ExtractorWithProgressAndCancellation.csproj", "{861EA36D-970E-4CFE-9E72-D3D12F0BBB60}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example6-ReducingDuplicateCode", "examples\Net8.0\Example6-ReducingDuplicateCode\Example6-ReducingDuplicateCode.csproj", "{80E49C71-1073-4208-B48F-E0F399946B3B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example6-ReducingDuplicateCode", "examples\Net4.8\Example6-ReducingDuplicateCode\Example6-ReducingDuplicateCode.csproj", "{85A15A15-D528-4542-A546-63BE0EAED986}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
+ ProjectSection(SolutionItems) = preProject
+ docs\readme.md = docs\readme.md
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{9B9A162C-C5B8-495C-A6D0-8C3135E283B9}"
+ ProjectSection(SolutionItems) = preProject
+ .github\CODEOWNERS = .github\CODEOWNERS
+ .github\dependabot.yml = .github\dependabot.yml
+ .github\pull_request_template.md = .github\pull_request_template.md
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEMPLATE", "{AF971B90-A335-49AF-8AB6-F387CAED12E4}"
+ ProjectSection(SolutionItems) = preProject
+ .github\ISSUE_TEMPLATE\bug_report.md = .github\ISSUE_TEMPLATE\bug_report.md
+ .github\ISSUE_TEMPLATE\BUG_REPORT.yaml = .github\ISSUE_TEMPLATE\BUG_REPORT.yaml
+ .github\ISSUE_TEMPLATE\feature_request.md = .github\ISSUE_TEMPLATE\feature_request.md
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{2D19706F-4199-46BD-B047-C4ED3AEDD90A}"
+ ProjectSection(SolutionItems) = preProject
+ .github\workflows\create-labels.yaml = .github\workflows\create-labels.yaml
+ .github\workflows\docfx.yaml = .github\workflows\docfx.yaml
+ .github\workflows\pr.yaml = .github\workflows\pr.yaml
+ .github\workflows\release.yaml = .github\workflows\release.yaml
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{AE206253-B766-4B6A-8C08-9E70605A2B27}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {C4987BAD-4513-955F-B3C1-7563D0C1A7A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C4987BAD-4513-955F-B3C1-7563D0C1A7A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C4987BAD-4513-955F-B3C1-7563D0C1A7A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C4987BAD-4513-955F-B3C1-7563D0C1A7A3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B8558C7F-934B-3DC1-AAED-D668CF964C8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B8558C7F-934B-3DC1-AAED-D668CF964C8E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B8558C7F-934B-3DC1-AAED-D668CF964C8E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B8558C7F-934B-3DC1-AAED-D668CF964C8E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F1BA1AF5-9A71-421B-963C-E277610FBD40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F1BA1AF5-9A71-421B-963C-E277610FBD40}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F1BA1AF5-9A71-421B-963C-E277610FBD40}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F1BA1AF5-9A71-421B-963C-E277610FBD40}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B715D0C5-3F1A-485C-92D1-FFB87A801AC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B715D0C5-3F1A-485C-92D1-FFB87A801AC3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B715D0C5-3F1A-485C-92D1-FFB87A801AC3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B715D0C5-3F1A-485C-92D1-FFB87A801AC3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F4A2F47C-9687-0ABE-FEAE-D07873E0133A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F4A2F47C-9687-0ABE-FEAE-D07873E0133A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F4A2F47C-9687-0ABE-FEAE-D07873E0133A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F4A2F47C-9687-0ABE-FEAE-D07873E0133A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {733AD8E6-D170-789A-6F61-13C041011037}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {733AD8E6-D170-789A-6F61-13C041011037}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {733AD8E6-D170-789A-6F61-13C041011037}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {733AD8E6-D170-789A-6F61-13C041011037}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2DC16382-F59F-4024-B560-D400E09EF91F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2DC16382-F59F-4024-B560-D400E09EF91F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2DC16382-F59F-4024-B560-D400E09EF91F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2DC16382-F59F-4024-B560-D400E09EF91F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5A7CFA9D-DCE7-43FD-AF89-7418C36AAE14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5A7CFA9D-DCE7-43FD-AF89-7418C36AAE14}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5A7CFA9D-DCE7-43FD-AF89-7418C36AAE14}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5A7CFA9D-DCE7-43FD-AF89-7418C36AAE14}.Release|Any CPU.Build.0 = Release|Any CPU
+ {483AE567-071E-4797-B13B-84B142D4ED44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {483AE567-071E-4797-B13B-84B142D4ED44}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {483AE567-071E-4797-B13B-84B142D4ED44}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {483AE567-071E-4797-B13B-84B142D4ED44}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6C19204D-7CC8-48D2-A475-49CD98931105}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6C19204D-7CC8-48D2-A475-49CD98931105}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6C19204D-7CC8-48D2-A475-49CD98931105}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6C19204D-7CC8-48D2-A475-49CD98931105}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E858F22E-01D7-4BE9-B8DE-0E4AFBA5B33A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E858F22E-01D7-4BE9-B8DE-0E4AFBA5B33A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E858F22E-01D7-4BE9-B8DE-0E4AFBA5B33A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E858F22E-01D7-4BE9-B8DE-0E4AFBA5B33A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8068B622-46A9-4ABA-BDA3-6AB259D1682C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8068B622-46A9-4ABA-BDA3-6AB259D1682C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8068B622-46A9-4ABA-BDA3-6AB259D1682C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8068B622-46A9-4ABA-BDA3-6AB259D1682C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6DA63E99-692F-461C-983A-270E5CBE8D45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6DA63E99-692F-461C-983A-270E5CBE8D45}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6DA63E99-692F-461C-983A-270E5CBE8D45}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6DA63E99-692F-461C-983A-270E5CBE8D45}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6BD7828E-55B4-4213-8DF9-7BFBA891B8EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6BD7828E-55B4-4213-8DF9-7BFBA891B8EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6BD7828E-55B4-4213-8DF9-7BFBA891B8EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6BD7828E-55B4-4213-8DF9-7BFBA891B8EE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2D14E222-4E44-40AB-82EF-1E8ABCED0476}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2D14E222-4E44-40AB-82EF-1E8ABCED0476}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2D14E222-4E44-40AB-82EF-1E8ABCED0476}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2D14E222-4E44-40AB-82EF-1E8ABCED0476}.Release|Any CPU.Build.0 = Release|Any CPU
+ {861EA36D-970E-4CFE-9E72-D3D12F0BBB60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {861EA36D-970E-4CFE-9E72-D3D12F0BBB60}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {861EA36D-970E-4CFE-9E72-D3D12F0BBB60}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {861EA36D-970E-4CFE-9E72-D3D12F0BBB60}.Release|Any CPU.Build.0 = Release|Any CPU
+ {80E49C71-1073-4208-B48F-E0F399946B3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {80E49C71-1073-4208-B48F-E0F399946B3B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {80E49C71-1073-4208-B48F-E0F399946B3B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {80E49C71-1073-4208-B48F-E0F399946B3B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {85A15A15-D528-4542-A546-63BE0EAED986}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {85A15A15-D528-4542-A546-63BE0EAED986}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {85A15A15-D528-4542-A546-63BE0EAED986}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {85A15A15-D528-4542-A546-63BE0EAED986}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {C4987BAD-4513-955F-B3C1-7563D0C1A7A3} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {B8558C7F-934B-3DC1-AAED-D668CF964C8E} = {8220BC33-6632-4D4C-9A50-B7978141A4E3}
+ {F1BA1AF5-9A71-421B-963C-E277610FBD40} = {3C48157B-5E90-489E-9444-E01F51D59F86}
+ {3C48157B-5E90-489E-9444-E01F51D59F86} = {207971F1-432C-4AB4-9DC4-16A9E5ACF812}
+ {336D72A1-8E5E-49DE-83D9-DF6BE458BA24} = {207971F1-432C-4AB4-9DC4-16A9E5ACF812}
+ {B715D0C5-3F1A-485C-92D1-FFB87A801AC3} = {336D72A1-8E5E-49DE-83D9-DF6BE458BA24}
+ {F4A2F47C-9687-0ABE-FEAE-D07873E0133A} = {3C48157B-5E90-489E-9444-E01F51D59F86}
+ {733AD8E6-D170-789A-6F61-13C041011037} = {3C48157B-5E90-489E-9444-E01F51D59F86}
+ {2DC16382-F59F-4024-B560-D400E09EF91F} = {336D72A1-8E5E-49DE-83D9-DF6BE458BA24}
+ {5A7CFA9D-DCE7-43FD-AF89-7418C36AAE14} = {336D72A1-8E5E-49DE-83D9-DF6BE458BA24}
+ {483AE567-071E-4797-B13B-84B142D4ED44} = {3C48157B-5E90-489E-9444-E01F51D59F86}
+ {6C19204D-7CC8-48D2-A475-49CD98931105} = {3C48157B-5E90-489E-9444-E01F51D59F86}
+ {E858F22E-01D7-4BE9-B8DE-0E4AFBA5B33A} = {3C48157B-5E90-489E-9444-E01F51D59F86}
+ {8068B622-46A9-4ABA-BDA3-6AB259D1682C} = {336D72A1-8E5E-49DE-83D9-DF6BE458BA24}
+ {6DA63E99-692F-461C-983A-270E5CBE8D45} = {336D72A1-8E5E-49DE-83D9-DF6BE458BA24}
+ {6BD7828E-55B4-4213-8DF9-7BFBA891B8EE} = {336D72A1-8E5E-49DE-83D9-DF6BE458BA24}
+ {2D14E222-4E44-40AB-82EF-1E8ABCED0476} = {3C48157B-5E90-489E-9444-E01F51D59F86}
+ {861EA36D-970E-4CFE-9E72-D3D12F0BBB60} = {336D72A1-8E5E-49DE-83D9-DF6BE458BA24}
+ {80E49C71-1073-4208-B48F-E0F399946B3B} = {3C48157B-5E90-489E-9444-E01F51D59F86}
+ {85A15A15-D528-4542-A546-63BE0EAED986} = {336D72A1-8E5E-49DE-83D9-DF6BE458BA24}
+ {AF971B90-A335-49AF-8AB6-F387CAED12E4} = {9B9A162C-C5B8-495C-A6D0-8C3135E283B9}
+ {2D19706F-4199-46BD-B047-C4ED3AEDD90A} = {9B9A162C-C5B8-495C-A6D0-8C3135E283B9}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {F673635D-58CE-48A5-9AE4-31F4484BED9E}
+ EndGlobalSection
+EndGlobal
diff --git a/README.md b/README.md
index 3c9bfcd..e290284 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,181 @@
# Wolfgang.Etl.Abstractions
-This package contains interfaces and base classes for building ETLs using a specific design pattern
-
-The ETL design pattern is a common approach in data processing that involves three main stages:
-- **Extract**: Retrieving data from various sources.
-- **Transform**: Processing and transforming the extracted data into a desired format.
-- **Load**: Storing the transformed data into a target system.
-
-The abstractions in this package provide a way to define and implement these stages
-in a flexible and reusable manner. Each stage can be implemented with or without
-support for cancellation and progress reporting, allowing for greater control
-over the ETL process.
-
-To build an ETL using this package, you would typically need to create 5 classes:
-- One class for each of the three stages: Extract, Transform, and Load.
-- One class representing the source data.
-- One class representing the target data.
-- One class that acts as the ETL orchestrator, coordinating the execution of the three stages.
-
-The design uses lazy loading and lazy evaluation to ensure that data is processed only when needed.
-This allows for efficient memory usage and can handle large datasets without loading everything into memory at once.
-
-The process uses a pull method rather than a push method to move data through the pipeline.
-The process starts when the ETL orchestrator calls the `LoadAsyc` method of the `Loader` class.
-The loader will start enumerating through the list of items passed into its `LoadAsync` method.
-This will intern trigger the `TransformAsync` method of the `Transformer` class, which will process each item
-and yield the transformed results. The process of transformation will also trigger the `ExtractAsync` method of the `Extractor` class,
-which will retrieve the necessary data from the source.
-
-For more information check out the [documentation](https://github.com/Chris-Wolfgang/ETL-Abstractions/wiki)
\ No newline at end of file
+Interface and base classes for building ETLs
+
+[](LICENSE)
+[](https://dotnet.microsoft.com/)
+[](https://github.com/Chris-Wolfgang/ETL-Abstractions)
+
+---
+
+## š¦ Installation
+
+```bash
+dotnet add package Wolfgang.Etl.Abstractions
+```
+
+**NuGet Package:** Available on NuGet.org
+
+---
+
+## š License
+
+This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details.
+
+---
+
+## š Documentation
+
+- **GitHub Repository:** [https://github.com/Chris-Wolfgang/ETL-Abstractions](https://github.com/Chris-Wolfgang/ETL-Abstractions)
+- **API Documentation:** https://Chris-Wolfgang.github.io/ETL-Abstractions/
+- **Formatting Guide:** [README-FORMATTING.md](README-FORMATTING.md)
+- **Contributing Guide:** [CONTRIBUTING.md](CONTRIBUTING.md)
+
+---
+
+## š Quick Start
+
+{{QUICK_START_EXAMPLE}}
+
+---
+
+## ⨠Features
+
+{{FEATURES_TABLE}}
+
+**Examples:**
+{{FEATURE_EXAMPLES}}
+
+---
+
+## šÆ Target Frameworks
+
+| Framework | Versions |
+|-----------|----------|
+| .Net Framework | .net 4.6.2, .net 4.7.0, .net 4.7.1, .net 4.7.2, .net 4.8, .net 4.8.1 |
+| .Net Core | |
+| .Net | .net 5.0, .net 6.0, .net 7.0, .net 8.0, .net 9.0, .net 10.0 |
+
+---
+
+## š Code Quality & Static Analysis
+
+This project enforces **strict code quality standards** through **7 specialized analyzers** and custom async-first rules:
+
+### Analyzers in Use
+
+1. **Microsoft.CodeAnalysis.NetAnalyzers** - Built-in .NET analyzers for correctness and performance
+2. **Roslynator.Analyzers** - Advanced refactoring and code quality rules
+3. **AsyncFixer** - Async/await best practices and anti-pattern detection
+4. **Microsoft.VisualStudio.Threading.Analyzers** - Thread safety and async patterns
+5. **Microsoft.CodeAnalysis.BannedApiAnalyzers** - Prevents usage of banned synchronous APIs
+6. **Meziantou.Analyzer** - Comprehensive code quality rules
+7. **SonarAnalyzer.CSharp** - Industry-standard code analysis
+
+### Async-First Enforcement
+
+This library uses **`BannedSymbols.txt`** to prohibit synchronous APIs and enforce async-first patterns:
+
+**Blocked APIs Include:**
+- ā `Task.Wait()`, `Task.Result` - Use `await` instead
+- ā `Thread.Sleep()` - Use `await Task.Delay()` instead
+- ā Synchronous file I/O (`File.ReadAllText`) - Use async versions
+- ā Synchronous stream operations - Use `ReadAsync()`, `WriteAsync()`
+- ā `Parallel.For/ForEach` - Use `Task.WhenAll()` or `Parallel.ForEachAsync()`
+- ā Obsolete APIs (`WebClient`, `BinaryFormatter`)
+
+**Why?** To ensure all code is **truly async** and **non-blocking** for optimal performance in async contexts.
+
+---
+
+## š ļø Building from Source
+
+### Prerequisites
+- [.NET 8.0 SDK](https://dotnet.microsoft.com/download) or later
+- Optional: [PowerShell Core](https://github.com/PowerShell/PowerShell) for formatting scripts
+
+### Build Steps
+
+```bash
+# Clone the repository
+git clone https://github.com/Chris-Wolfgang/ETL-Abstractions.git
+cd ETL-Abstractions
+
+# Restore dependencies
+dotnet restore
+
+# Build the solution
+dotnet build --configuration Release
+
+# Run tests
+dotnet test --configuration Release
+
+# Run code formatting (PowerShell Core)
+pwsh ./format.ps1
+```
+
+### Code Formatting
+
+This project uses `.editorconfig` and `dotnet format`:
+
+```bash
+# Format code
+dotnet format
+
+# Verify formatting (as CI does)
+dotnet format --verify-no-changes
+```
+
+See [README-FORMATTING.md](README-FORMATTING.md) for detailed formatting guidelines.
+
+### Building Documentation
+
+This project uses [DocFX](https://dotnet.github.io/docfx/) to generate API documentation:
+
+```bash
+# Install DocFX (one-time setup)
+dotnet tool install -g docfx
+
+# Generate API metadata and build documentation
+cd docfx_project
+docfx metadata # Extract API metadata from source code
+docfx build # Build HTML documentation
+
+# Documentation is generated in the docs/ folder at the repository root
+```
+
+The documentation is automatically built and deployed to GitHub Pages when changes are pushed to the `main` branch.
+
+**Local Preview:**
+```bash
+# Serve documentation locally (with live reload)
+cd docfx_project
+docfx build --serve
+
+# Open http://localhost:8080 in your browser
+```
+
+**Documentation Structure:**
+- `docfx_project/` - DocFX configuration and source files
+- `docs/` - Generated HTML documentation (published to GitHub Pages)
+- `docfx_project/index.md` - Main landing page content
+- `docfx_project/docs/` - Additional documentation articles
+- `docfx_project/api/` - Auto-generated API reference YAML files
+
+---
+
+## š¤ Contributing
+
+Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for:
+- Code quality standards
+- Build and test instructions
+- Pull request guidelines
+- Analyzer configuration details
+
+---
+
+
+## š Acknowledgments
+
+{{ACKNOWLEDGMENTS}}
+
diff --git a/README.original.md b/README.original.md
new file mode 100644
index 0000000..3c9bfcd
--- /dev/null
+++ b/README.original.md
@@ -0,0 +1,31 @@
+# Wolfgang.Etl.Abstractions
+
+This package contains interfaces and base classes for building ETLs using a specific design pattern
+
+The ETL design pattern is a common approach in data processing that involves three main stages:
+- **Extract**: Retrieving data from various sources.
+- **Transform**: Processing and transforming the extracted data into a desired format.
+- **Load**: Storing the transformed data into a target system.
+
+The abstractions in this package provide a way to define and implement these stages
+in a flexible and reusable manner. Each stage can be implemented with or without
+support for cancellation and progress reporting, allowing for greater control
+over the ETL process.
+
+To build an ETL using this package, you would typically need to create 5 classes:
+- One class for each of the three stages: Extract, Transform, and Load.
+- One class representing the source data.
+- One class representing the target data.
+- One class that acts as the ETL orchestrator, coordinating the execution of the three stages.
+
+The design uses lazy loading and lazy evaluation to ensure that data is processed only when needed.
+This allows for efficient memory usage and can handle large datasets without loading everything into memory at once.
+
+The process uses a pull method rather than a push method to move data through the pipeline.
+The process starts when the ETL orchestrator calls the `LoadAsyc` method of the `Loader` class.
+The loader will start enumerating through the list of items passed into its `LoadAsync` method.
+This will intern trigger the `TransformAsync` method of the `Transformer` class, which will process each item
+and yield the transformed results. The process of transformation will also trigger the `ExtractAsync` method of the `Extractor` class,
+which will retrieve the necessary data from the source.
+
+For more information check out the [documentation](https://github.com/Chris-Wolfgang/ETL-Abstractions/wiki)
\ No newline at end of file
diff --git a/REPO-INSTRUCTIONS.md b/REPO-INSTRUCTIONS.md
new file mode 100644
index 0000000..1cdb78c
--- /dev/null
+++ b/REPO-INSTRUCTIONS.md
@@ -0,0 +1,266 @@
+# 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
+
+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
+
+## Creating Your Repository
+
+1. On the `Repositories` page click `New`
+1. On the `Create a new repository` page enter
+ 1. `Repository name`
+ 2. `Description`
+ 3. Select `Public` or `Private`
+1. `Start with a template` select `Chris-Wolfgang/repo-template`
+1. `Include all branches` set `On` - this will include the `develop` branch. If you don't want the `develop` branch or if there are other branches you don't want you can leave this `off` and create the `develop` branch in your new repository
+
+
+## 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:
+
+1. Go to your repositoryās Settings ā Branches.
+2. Under āBranch protection rules,ā click `Add branch ruleset`
+3. `Ruleset Name` enter `main`
+4. `Target branches` click `Add target`
+5. Select `Include by pattern`
+6. `Branch naming pattern` enter `main`
+7. Click `Add Inclusion pattern`
+
+
+## Security Settings
+
+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 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)"
+
+5. Enable āRequire branches to be up to date before merging.ā
+6. Check `Restrict deletions`
+7. Check `Require a pull request before merging`
+ 1. Check `Dismiss stale pull request approvals when new commits are pushed`
+ 3. **For multi-developer repos:** Check `Require review from Code Owners` and set required approvals to 1 or more
+8. Check `Block force pushes`
+9. Check `Require code scanning`
+
+
+## Add Custom Labels
+
+Run the label setup script once after creating your repository:
+
+```powershell
+pwsh -File ./scripts/Setup-Labels.ps1
+```
+
+This creates the following labels used by Dependabot and workflows:
+
+1. `dependabot - security`
+2. `dependabot-dependencies`
+3. `dependencies`
+4. `dotnet`
+
+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
+
+If you didn't create a solution during setup or prefer the traditional `.sln` format:
+
+1. Create a blank solution and save it in the root folder
+ ```bash
+ dotnet new sln -n YourSolutionName
+ ```
+2. Add new projects to the solution. Each application project will be in its own folder in the /src folder
+3. Add one or more test projects each in its own folder in the /tests folder
+4. If the solution will have benchmark project add each project in its own folder under /benchmarks
+
+```
+root
+āāā MySolution.sln
+āāā src
+ā āāā MyApp
+ā ā āāā MyApp.csproj
+ā āāā MyLib
+ā āāā MyLib.csproj
+āāā tests
+ā āāā MyApp.Tests
+ā ā āāā MyApp.Tests.csproj
+ā āāā MyLib.Tests
+ā āāā MyLib.Tests.csproj
+āāā benchmarks
+ āāā MyApp.Benchmarks
+ āāā MyApp.Benchmarks.csproj
+```
+
+
+## Configure Release Workflow (Optional)
+
+If you plan to publish NuGet packages using the automated release workflow, you need to configure the following:
+
+### Add NuGet API Key Secret
+
+1. Go to your repository's Settings ā Secrets and variables ā Actions
+2. Click **"New repository secret"**
+3. **Name:** `NUGET_API_KEY`
+4. **Value:** Your NuGet.org API key
+ - Get your key from [NuGet.org Account ā API Keys](https://www.nuget.org/account/apikeys)
+ - Recommended scopes: **Push new packages and package versions**
+ - 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.
+
+
+## Update Template Files
+
+After creating your repository from the template, update the following files with your project-specific information:
+
+### Update README.md
+
+1. Open `README.md` in the root folder
+2. Replace the template content with your project's description
+3. Add installation instructions, usage examples, and other relevant information
+
+### 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)
+3. Review and adjust contribution guidelines as needed for your project
+
+### Update CODEOWNERS
+
+1. Open `.github/CODEOWNERS`
+2. Replace `@Chris-Wolfgang` with your GitHub username or team names
+3. Uncomment and customize the example rules if you want different owners for specific directories
+
+**Note:** The CODEOWNERS file determines who is automatically requested for review when someone opens a pull request.
+
+### Setup GitHub Pages for Documentation (Optional)
+
+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.
+
+2. After setup, 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`)
+ 4. Click **"Publish release"**
+
+3. The documentation will be available at: `https://[username].github.io/[repo-name]/`
+
+**Note:** The DocFX workflow (`.github/workflows/docfx.yaml`) is configured to trigger via:
+- **`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)
+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:
+
+#### 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. |
+
+#### 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.
+- The `.github/workflows/build-all-versions.yaml` workflow enumerates all matching tags and builds documentation for each ā no file updates are required when a new release is published.
+- Each release triggers `.github/workflows/release.yaml` (on a published GitHub Release), which calls `.github/workflows/docfx.yaml` via `workflow_call` to build docs and deploy them to the `gh-pages` branch under `versions//`. You can also run `docfx.yaml` directly via `workflow_dispatch` from the Actions tab for ad-hoc builds.
+- After every versioned deploy, a `versions.json` is generated and written to `gh-pages`, powering the version-switcher dropdown.
+- `versions/latest/` always mirrors the most recent stable release; the site root (`/`) hosts the version-picker landing page that links to the latest and all other available documentation versions.
+
+#### Adding a New Version
+When you publish a new release (e.g. `v1.0.0`):
+1. Create and push a version tag (e.g. `v1.0.0`) to the repository.
+2. Publish a GitHub Release for that tag ā this triggers `release.yaml`, which calls `docfx.yaml` via `workflow_call` to automatically build and publish the docs. You can also run `docfx.yaml` directly via `workflow_dispatch` for ad-hoc or dry-run builds.
+3. To backfill all historical versions at once, run the **Build All Versioned Docs** workflow manually from the Actions tab.
+
+#### Dark Theme
+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/docfx_project/api/README.md b/docfx_project/api/README.md
new file mode 100644
index 0000000..03b453b
--- /dev/null
+++ b/docfx_project/api/README.md
@@ -0,0 +1,23 @@
+# API Documentation Directory
+
+This directory is auto-generated by DocFX during the build process.
+
+## How It Works
+
+When you run `docfx docfx_project/docfx.json` from the repository root, DocFX will:
+1. Scan the C# projects specified in `docfx.json` (configured to look for `src/**/*.csproj`)
+2. Extract XML documentation comments from your code
+3. Generate API reference documentation in this directory
+4. Create a `toc.yml` file that organizes the API documentation
+
+## Important Notes
+
+- **Do not manually edit generated DocFX output files in this folder** (such as `*.yml` and `toc.yml`) ā they will be overwritten each time you run the DocFX build
+- Hand-authored files like `index.md` and this `README.md` are intentionally maintained by hand and will be preserved across DocFX runs
+- The actual API reference metadata files (`*.yml` files) will be generated automatically
+
+## Template Placeholders
+
+The `index.md` file uses the following template placeholder:
+- `Wolfgang.Etl.Abstractions` - Will be replaced with your project name
+
diff --git a/docfx_project/api/index.md b/docfx_project/api/index.md
new file mode 100644
index 0000000..a43d915
--- /dev/null
+++ b/docfx_project/api/index.md
@@ -0,0 +1,18 @@
+# API Reference
+
+Welcome to the Wolfgang.Etl.Abstractions API documentation.
+
+This section contains the complete API reference, automatically generated from XML documentation comments in the source code.
+
+Browse the navigation menu to explore available namespaces and types.
+
+## Conventions
+
+- **Public APIs** are stable and follow semantic versioning
+- **Internal APIs** may change between minor versions
+- **Obsolete APIs** are marked with deprecation warnings
+
+## Getting Started
+
+For usage examples and guides, see the [Documentation](../docs/getting-started.md) section.
+
diff --git a/docfx_project/docfx.json b/docfx_project/docfx.json
index 08e1cf1..3a50cc9 100644
--- a/docfx_project/docfx.json
+++ b/docfx_project/docfx.json
@@ -1,60 +1,61 @@
{
+ "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json",
"metadata": [
{
"src": [
{
"files": [
- "src/Wolfgang.Etl.Abstractions/Wolfgang. Etl.Abstractions.csproj"
+ "src/**/*.csproj"
],
- "src": ".."
+ "src": "../"
}
],
- "dest": "api",
- "includePrivateMembers": false,
- "disableGitFeatures": false,
- "disableDefaultFilter": false,
- "noRestore": false,
- "namespaceLayout": "flattened",
- "memberLayout": "samePage",
- "allowCompilationErrors": false,
+ "dest": "api",
"properties": {
- "TargetFramework": "net10. 0"
- }
+ "TargetFramework": "net8.0"
+ },
+ "disableGitFeatures": false,
+ "disableDefaultFilter": false
}
],
"build": {
"content": [
{
"files": [
- "api/**.yml",
- "api/index.md"
- ]
- },
- {
- "files": [
- "articles/**.md",
- "articles/**/toc.yml",
- "toc.yml",
- "*.md"
+ "**/*.{md,yml}"
+ ],
+ "exclude": [
+ "_site/**"
]
}
],
"resource": [
{
"files": [
+ "logo.svg",
"images/**"
]
}
],
+
"output": "_site",
- "globalMetadataFiles": [],
- "fileMetadataFiles": [],
"template": [
"default",
"modern"
],
- "postProcessors": [],
- "keepFileLink": false,
- "disableGitFeatures": false
+ "globalMetadata": {
+ "_appName": "Wolfgang.Etl.Abstractions",
+ "_appTitle": "Wolfgang.Etl.Abstractions Documentation",
+ "_appLogoPath": "logo.svg",
+ "_enableSearch": true,
+ "_appFooter": "Made with DocFX",
+ "_disableSidebar": false,
+ "_disableTocFilter": false,
+ "_enableDarkMode": true,
+ "colorMode": "dark",
+ "_baseUrl": "https://Chris-Wolfgang.github.io/ETL-Abstractions/",
+ "pdf": true
+ }
}
}
+
diff --git a/docfx_project/docs/getting-started.md b/docfx_project/docs/getting-started.md
new file mode 100644
index 0000000..dab4357
--- /dev/null
+++ b/docfx_project/docs/getting-started.md
@@ -0,0 +1,53 @@
+# Getting Started
+
+This guide will help you quickly get up and running with Wolfgang.Etl.Abstractions.
+
+## Prerequisites
+
+
+
+## Installation
+
+### Via NuGet Package Manager
+
+```bash
+dotnet add package Wolfgang.Etl.Abstractions
+```
+
+### Via Package Manager Console
+
+```powershell
+Install-Package Wolfgang.Etl.Abstractions
+```
+
+## Quick Start
+
+
+
+```csharp
+// Add your quick start code example here
+// This should show the simplest way to use your library
+
+using Wolfgang.Etl.Abstractions;
+
+// Example usage
+```
+
+## Next Steps
+
+- Explore the [API Reference](../api/index.md) for detailed documentation
+- Read the [Introduction](introduction.md) to learn more about Wolfgang.Etl.Abstractions
+- Check out example projects in the [GitHub repository](https://github.com/Chris-Wolfgang/ETL-Abstractions)
+
+## Common Issues
+
+
+
+## Additional Resources
+
+- [GitHub Repository](https://github.com/Chris-Wolfgang/ETL-Abstractions)
+- [Contributing Guidelines](https://github.com/Chris-Wolfgang/ETL-Abstractions/blob/main/CONTRIBUTING.md)
+- [Report an Issue](https://github.com/Chris-Wolfgang/ETL-Abstractions/issues)
diff --git a/docfx_project/docs/index.md b/docfx_project/docs/index.md
new file mode 100644
index 0000000..a1aac43
--- /dev/null
+++ b/docfx_project/docs/index.md
@@ -0,0 +1,9 @@
+# Documentation
+
+Welcome to the documentation section. Browse the topics in the navigation menu to get started.
+
+## Available Documentation
+
+- [Introduction](introduction.md) - Overview and introduction
+- [Getting Started](getting-started.md) - Quick start guide
+
diff --git a/docfx_project/docs/introduction.md b/docfx_project/docs/introduction.md
new file mode 100644
index 0000000..ad1909f
--- /dev/null
+++ b/docfx_project/docs/introduction.md
@@ -0,0 +1,26 @@
+# Introduction
+
+Welcome to Wolfgang.Etl.Abstractions!
+
+## Overview
+
+Interface and base classes for building ETLs
+
+
+
+## Key Features
+
+
+
+## Getting Help
+
+If you need help with Wolfgang.Etl.Abstractions, please:
+
+- Check the [Getting Started](getting-started.md) guide
+- Review the [API Reference](../api/index.md)
+- Visit the [GitHub repository](https://github.com/Chris-Wolfgang/ETL-Abstractions)
+- Open an issue on [GitHub Issues](https://github.com/Chris-Wolfgang/ETL-Abstractions/issues)
diff --git a/docfx_project/docs/toc.yml b/docfx_project/docs/toc.yml
new file mode 100644
index 0000000..9834b7f
--- /dev/null
+++ b/docfx_project/docs/toc.yml
@@ -0,0 +1,9 @@
+- name: Index
+ href: index.md
+- name: Introduction
+ href: introduction.md
+- name: Getting Started
+ href: getting-started.md
+- name: Project website
+ href: 'https://github.com/Chris-Wolfgang/ETL-Abstractions'
+
diff --git a/docfx_project/index.md b/docfx_project/index.md
index 0264778..e586144 100644
--- a/docfx_project/index.md
+++ b/docfx_project/index.md
@@ -1,21 +1,42 @@
+---
+_layout: landing
+---
+
# Wolfgang.Etl.Abstractions Documentation
-Welcome to the Wolfgang.Etl.Abstractions API documentation.
+Welcome to the Wolfgang.Etl.Abstractions documentation. This site contains comprehensive guides, API reference, and examples to help you get started.
+
+## Quick Links
+
+- [Getting Started](docs/getting-started.md) - Learn the basics
+- [API Reference](xref:Wolfgang.Etl.Abstractions) - Complete API documentation
+- [GitHub Repository](https://github.com/Chris-Wolfgang/ETL-Abstractions) - View source code
+
+## About Wolfgang.Etl.Abstractions
+
+Interface and base classes for building ETLs
+
+## Installation
+
+```bash
+dotnet add package Wolfgang.Etl.Abstractions
+```
+
+## Documentation Sections
-## Overview
+### š [Documentation](docs/getting-started.md)
+Step-by-step guides and tutorials to help you use Wolfgang.Etl.Abstractions effectively.
-This package contains interfaces and base classes for building ETLs using a specific design pattern.
+### š [API Reference](xref:Wolfgang.Etl.Abstractions)
+Complete API documentation automatically generated from source code XML comments.
-The ETL design pattern is a common approach in data processing that involves three main stages:
-- **Extract**: Retrieving data from various sources.
-- **Transform**: Processing and transforming the extracted data into a desired format.
-- **Load**: Storing the transformed data into a target system.
+## Additional Resources
-## Getting Started
+- [Contributing Guidelines](https://github.com/Chris-Wolfgang/ETL-Abstractions/blob/main/CONTRIBUTING.md)
+- [Code of Conduct](https://github.com/Chris-Wolfgang/ETL-Abstractions/blob/main/CODE_OF_CONDUCT.md)
+- [License](https://github.com/Chris-Wolfgang/ETL-Abstractions/blob/main/LICENSE)
-See the [API Documentation](api/index.md) for detailed information about the available interfaces and classes.
+---
-## Links
+*Documentation built with [DocFX](https://dotnet.github.io/docfx/)*
-- [GitHub Repository](https://github.com/Chris-Wolfgang/ETL-Abstractions)
-- [NuGet Package](https://www.nuget.org/packages/Wolfgang.Etl.Abstractions)
\ No newline at end of file
diff --git a/docfx_project/logo.svg b/docfx_project/logo.svg
new file mode 100644
index 0000000..8de55ae
--- /dev/null
+++ b/docfx_project/logo.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+ W
+
\ No newline at end of file
diff --git a/docfx_project/toc.yml b/docfx_project/toc.yml
index 88c75a4..8e62d99 100644
--- a/docfx_project/toc.yml
+++ b/docfx_project/toc.yml
@@ -1,4 +1,5 @@
-- name: Home
- href: index.md
-- name: API Documentation
- href: api/
+- name: Documentation
+ href: docs/toc.yml
+- name: API Reference
+ href: api/toc.yml
+ homepage: api/index.md
diff --git a/docs/README.md b/docs/README.md
deleted file mode 100644
index 8b13789..0000000
--- a/docs/README.md
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/docs/RELEASE-WORKFLOW-SETUP.md b/docs/RELEASE-WORKFLOW-SETUP.md
new file mode 100644
index 0000000..91fe07d
--- /dev/null
+++ b/docs/RELEASE-WORKFLOW-SETUP.md
@@ -0,0 +1,221 @@
+# Release Workflow Setup Guide
+
+This guide explains how to configure the repository after merging the updated `release.yaml` workflow.
+
+## Overview
+
+The release workflow triggers when you **publish a GitHub Release** and implements a comprehensive validation and automatic deployment process that:
+- ā
Tests all target frameworks per test project on Windows
+- ā
Enforces 90% code coverage threshold
+- ā
Validates NuGet package integrity with smoke tests
+- ā
Automatically publishes to NuGet.org after validation passes
+- ā
Eliminates duplicate build work for faster releases
+
+## Required Post-Merge Configuration
+
+After merging this PR, complete the following setup step:
+
+### Add NuGet API Key Secret
+
+**Location:** Settings ā Secrets and variables ā Actions ā New repository secret
+
+1. Click **"New repository secret"**
+2. **Name:** `NUGET_API_KEY`
+3. **Value:** Your NuGet.org API key
+ - Get your key from [NuGet.org Account ā API Keys](https://www.nuget.org/account/apikeys)
+ - Recommended scopes: **Push new packages and package versions**
+ - Set expiration date (recommended: 1 year)
+4. Click **"Add secret"**
+
+**What this does:** Allows the workflow to authenticate with NuGet.org and publish packages. The workflow validates this secret exists before attempting to publish.
+
+### Verify Branch Protection Rules
+
+**Location:** Settings ā Branches ā main
+
+> **Note:** By default, the template is configured for single developer repositories. The branch protection setup 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 (0 approvals) or option [2] for multi-developer mode (1+ approvals and code owner review required).
+
+Ensure the following settings are enabled:
+
+- ā
**Require a pull request before merging**
+ - **Single developer repos:** 0 approvals (default)
+ - **Multi-developer repos:** 1+ approvals (recommended)
+- ā
**Require status checks to pass before merging**
+ - Required checks should include the following status check contexts:
+ - "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 3: macOS Tests (.NET 6.0-10.0)"
+ - "Security Scan (DevSkim)"
+ - "Security Scan (CodeQL)"
+- ā
**Require branches to be up to date before merging**
+- ā
**Require conversation resolution before merging**
+- ā
**Do not allow bypassing the above settings** (recommended, even for admins)
+- ā
**Restrict deletions**
+- ā
**Require linear history** (optional but recommended)
+
+**What this does:** Ensures all code merged to `main` has passed comprehensive validation, preventing broken releases.
+
+## Testing the Release Workflow
+
+After completing the setup, test the workflow by creating a GitHub Release:
+
+1. Go to your repository's **Releases** page
+2. Click **"Draft a new release"**
+3. Choose or create a tag (e.g., `v0.0.1-test`)
+4. Add a title and description (optional for a test)
+5. Check **"Set as a pre-release"** for test releases
+6. Click **"Publish release"**
+
+The workflow triggers automatically when the release is published.
+
+### Expected Workflow Behavior
+
+1. **Job 1: validate-release** (3-10 minutes)
+ - Runs all framework tests with coverage
+ - Enforces 90% coverage threshold
+ - Uploads coverage report
+ - ā
Auto-passes if tests succeed
+
+2. **Job 2: pack-and-validate** (2-5 minutes)
+ - Packs NuGet packages
+ - Performs smoke test installation
+ - Uploads packages as artifacts
+ - ā
Auto-passes if packages are valid
+
+3. **Job 3: publish-nuget** (1-2 minutes)
+ - Validates NUGET_API_KEY secret
+ - Publishes packages to NuGet.org automatically
+ - ā
Auto-completes if secret is valid
+
+### Monitoring the Workflow
+
+- **Actions Tab:** Shows workflow progress in real-time
+- **Artifacts:** Each job uploads artifacts (coverage reports, packages)
+- **Releases:** Check the Releases page after successful completion
+
+## Troubleshooting
+
+### "NUGET_API_KEY secret not configured" Error
+
+**Problem:** The `publish-nuget` job fails with secret validation error.
+
+**Solution:**
+1. Verify the secret name is exactly `NUGET_API_KEY` (case-sensitive)
+2. Re-add the secret in Settings ā Secrets ā Actions
+3. Re-run the workflow from the Actions tab (do not re-publish the release)
+
+### Tests Fail on Specific Framework
+
+**Problem:** Tests pass on some frameworks but fail on others (e.g., net462).
+
+**Solution:**
+1. Check the test logs for framework-specific issues
+2. Fix compatibility issues in your code
+3. Test locally: `dotnet test --framework net462`
+4. Push fix, then re-publish the release (or re-run the workflow from the Actions tab)
+
+### Coverage Below 90% Threshold
+
+**Problem:** Workflow fails at coverage validation step.
+
+**Solution:**
+1. Review `CoverageReport/Summary.txt` artifact
+2. Add tests for uncovered code paths
+3. Ensure tests run on all frameworks
+4. Push fix, then re-publish the release (or re-run the workflow from the Actions tab)
+
+### Smoke Test Fails to Install Package
+
+**Problem:** Package packs successfully but fails smoke test installation.
+
+**Solution:**
+1. Check package dependencies in `.csproj`
+2. Verify framework compatibility in ``
+3. Test locally: `dotnet pack` then try installing in a test project
+4. Fix packaging issues and re-publish the release (or re-run the workflow from the Actions tab)
+
+## Production Release Checklist
+
+Before creating a production GitHub Release (e.g., `v1.0.0`):
+
+- [ ] All tests pass on all platforms (pr.yaml workflow)
+- [ ] Code coverage meets 90% threshold
+- [ ] Security scan shows no critical issues
+- [ ] Version numbers updated in `.csproj` files
+- [ ] `CHANGELOG.md` updated with release notes (if applicable)
+- [ ] All PRs merged to `main` branch
+- [ ] Local build succeeds: `dotnet build --configuration Release`
+- [ ] Local tests pass: `dotnet test --configuration Release`
+
+**Create a production release:**
+1. Go to your repository's **Releases** page
+2. Click **"Draft a new release"**
+3. Choose or create the version tag (e.g., `v1.0.0`) targeting `main`
+4. Add a title and release notes
+5. Click **"Publish release"**
+
+**After workflow completes:**
+- [ ] Verify packages appear on NuGet.org
+- [ ] Test installing package from NuGet.org in a clean project
+- [ ] Announce release (if applicable)
+
+## Workflow Architecture
+
+```
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā Trigger: Published GitHub Release ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ ā
+ ā¼
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā Job 1: validate-release (Windows) ā
+ā ⢠Restore & Build ā
+ā ⢠Test all frameworks (net5.0-10.0, net462-481) ā
+ā ⢠Collect coverage ā
+ā ⢠Enforce 90% threshold ā
+ā ⢠Upload coverage artifacts ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ ā
+ ā¼ (only if tests pass)
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā Job 2: pack-and-validate (Windows) ā
+ā ⢠Restore & Build (fresh) ā
+ā ⢠Pack NuGet packages ā
+ā ⢠Smoke test installation ā
+ā ⢠Upload package artifacts ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ ā
+ ā¼ (only if packing succeeds)
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+ā Job 3: publish-nuget (Windows) ā
+ā ⢠Download packages ā
+ā ⢠Validate NUGET_API_KEY ā
+ā ⢠Publish to NuGet.org automatically ā
+āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+```
+
+## Key Improvements Over Previous Workflow
+
+| Issue | Before | After |
+|-------|--------|-------|
+| **Framework Coverage** | Default framework only | All frameworks (net5.0-10.0, net462-481) |
+| **Code Coverage** | Not enforced | 90% threshold enforced |
+| **Package Validation** | None | Smoke test installation |
+| **Deployment** | Incomplete publish script | Automatic publishing after validation |
+| **Secret Validation** | None | Validates before publishing |
+| **GitHub Releases** | Not used as trigger | Workflow triggered by published release |
+| **Build Efficiency** | Duplicate builds in each job | Build once per job with dependencies |
+| **Test Logging** | No logger parameter | Console logging with verbosity |
+| **Permissions** | Read-only | Write access for releases |
+
+## Support
+
+If you encounter issues not covered in this guide:
+
+1. Check the [Actions tab](../../actions) 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:
+ - Workflow run URL
+ - Error message and logs
+ - Steps to reproduce
diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1
new file mode 100644
index 0000000..5110464
--- /dev/null
+++ b/scripts/Setup-BranchRuleset.ps1
@@ -0,0 +1,334 @@
+<#
+.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)
+ - CodeQL code scanning enforcement (High+ severity)
+ - Automatic Copilot code review for pull requests
+ - Copilot review of new pushes and draft PRs
+ - CodeQL standard queries integration with Copilot reviews
+ - 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.
+
+ Note: The copilot_code_review ruleset type requires GitHub Copilot access
+ and may require GitHub Enterprise or specific subscription plans. Verify your organization has the
+ necessary subscriptions before running this script.
+#>
+
+[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 {
+ $matchingRulesets = 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")' | 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. 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.
+ # This also applies to the CodeQL workflow (codeql.yml) which provides the code_scanning
+ # rule below - see that section for details on how CodeQL handles graceful skipping.
+ required_status_checks = @(
+ @{ 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)" }
+ )
+ }
+ },
+ @{
+ type = "code_scanning"
+ parameters = @{
+ # NOTE: CodeQL uses the 'code_scanning' ruleset type instead of 'required_status_checks'
+ # because it has built-in intelligence to handle cases where scans don't run
+ # The workflow (.github/workflows/codeql.yml) has no path filters to ensure
+ # GitHub can properly evaluate this rule. The workflow runs on all PRs and gracefully
+ # skips analysis when there's no C# code, preventing false merge blocks while still
+ # enforcing security scanning when needed.
+ code_scanning_tools = @(
+ @{
+ tool = "CodeQL"
+ security_alerts_threshold = "high_or_higher"
+ alerts_threshold = "errors"
+ }
+ )
+ }
+ },
+ @{
+ type = "copilot_code_review"
+ # Not yet supported through API, must be set via UI
+ # <-- parameters = @{
+ # Automatically request Copilot code review for new pull requests
+ # if the author has Copilot access and hasn't reached their review request limit
+ # <-- auto_request_copilot_review = $true
+ # Review new pushes to the pull request automatically
+ # <-- review_new_pushes = $true
+ # Review draft pull requests before they are marked as ready
+ # <-- review_draft_pull_requests = $true
+ # Static analysis tools to include in Copilot code review
+ # <-- static_analysis_tools = @("CodeQL")
+ # Query suite for CodeQL
+ # <-- codeql_query_suite = "standard"
+ # }
+ },
+ @{
+ type = "non_fast_forward"
+ },
+ @{
+ type = "deletion"
+ },
+ @{
+ type = "update"
+ }
+ )
+}
+
+# Convert to JSON
+$jsonConfig = $rulesetConfig | ConvertTo-Json -Depth 10
+
+# Save to temporary file
+$tempFile = [System.IO.Path]::GetTempFileName()
+$jsonConfig | Out-File -FilePath $tempFile -Encoding UTF8
+
+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 " - 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)" -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 " ā
CodeQL code scanning enforcement (blocks on High+ severity findings)" -ForegroundColor Gray
+ Write-Host " ā
Automatic Copilot code review enabled:" -ForegroundColor Gray
+ Write-Host " - Auto-request for new pull requests" -ForegroundColor DarkGray
+ Write-Host " - Review new pushes automatically" -ForegroundColor DarkGray
+ Write-Host " - Review draft pull requests" -ForegroundColor DarkGray
+ Write-Host " - Static analysis tools: CodeQL (standard queries)" -ForegroundColor DarkGray
+ Write-Host " ā
Force pushes blocked on $BranchName branch" -ForegroundColor Gray
+ Write-Host " ā
Branch deletion prevented for $BranchName" -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
new file mode 100644
index 0000000..40b192e
--- /dev/null
+++ b/scripts/Setup-GitHubPages.ps1
@@ -0,0 +1,714 @@
+#!/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) {
+ $content = $content -replace [regex]::Escape($placeholder), $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 UTF8
+
+ 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-Labels.ps1 b/scripts/Setup-Labels.ps1
new file mode 100644
index 0000000..bb3a641
--- /dev/null
+++ b/scripts/Setup-Labels.ps1
@@ -0,0 +1,116 @@
+<#
+.SYNOPSIS
+ Creates custom GitHub labels for the repository.
+
+.DESCRIPTION
+ This script uses the GitHub CLI (gh) to create labels used by Dependabot and
+ 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)
+
+.PARAMETER Repository
+ The repository in owner/repo format. If not provided, uses the current repository.
+
+.EXAMPLE
+ .\Setup-Labels.ps1
+ Creates the labels for the current repository
+
+.EXAMPLE
+ .\Setup-Labels.ps1 -Repository "Chris-Wolfgang/my-repo"
+ Creates the labels for a specific repository
+
+.NOTES
+ Requires: GitHub CLI (gh) authenticated with sufficient permissions
+ Install gh: https://cli.github.com/
+#>
+
+[CmdletBinding()]
+param(
+ [Parameter()]
+ [string]$Repository
+)
+
+# 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 = @(
+ @{ name = "dependabot - security"; color = "b60205"; description = "Security update from Dependabot" },
+ @{ name = "dependabot-dependencies"; color = "d93f0b"; description = "Dependency update from Dependabot" },
+ @{ name = "dependencies"; color = "0366d6"; description = "Pull requests that update a dependency file" },
+ @{ name = "dotnet"; color = "512bd4"; description = ".NET related changes" }
+)
+
+$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
+}
diff --git a/scripts/format.ps1 b/scripts/format.ps1
new file mode 100644
index 0000000..5a65ff0
--- /dev/null
+++ b/scripts/format.ps1
@@ -0,0 +1,104 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Formats all C# code in the repository using dotnet format.
+
+.DESCRIPTION
+ This script runs 'dotnet format' on the solution to ensure consistent code formatting.
+ Run this before committing to ensure your code passes the formatting checks in CI.
+
+.PARAMETER Check
+ If specified, only checks formatting without making changes (like CI does).
+
+.EXAMPLE
+ .\format.ps1
+ Formats all code in the repository.
+
+.EXAMPLE
+ .\format.ps1 -Check
+ Checks formatting without making changes.
+#>
+
+param(
+ [switch]$Check
+)
+
+$ErrorActionPreference = "Stop"
+
+Write-Host "šØ Code Formatting Script" -ForegroundColor Cyan
+Write-Host ""
+
+# Verify dotnet format is available (built into .NET 6+ SDK)
+Write-Host "š Checking for dotnet format..." -ForegroundColor Yellow
+dotnet format --version | Out-Null
+
+if ($LASTEXITCODE -ne 0)
+{
+ Write-Host ""
+ 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 ""
+ Write-Host "Please install the .NET 8.0 SDK or later from:" -ForegroundColor Yellow
+ Write-Host "https://dotnet.microsoft.com/download" -ForegroundColor Cyan
+ Write-Host ""
+ exit 1
+}
+
+Write-Host "ā
dotnet format is available" -ForegroundColor Green
+Write-Host ""
+
+# Find solution file
+$solution = Get-ChildItem -Path . -File | Where-Object { $_.Extension -eq '.sln' -or $_.Extension -eq '.slnx' } | Select-Object -First 1
+
+if (-not $solution)
+{
+ Write-Host "ā No solution file found!" -ForegroundColor Red
+ exit 1
+}
+
+$solutionFile = $solution.FullName
+Write-Host "š Found solution: $($solution.Name)" -ForegroundColor Green
+Write-Host ""
+
+if ($Check)
+{
+ Write-Host "š Checking code formatting (read-only mode)..." -ForegroundColor Yellow
+ Write-Host ""
+
+ dotnet format $solutionFile --verify-no-changes --verbosity diagnostic
+
+ if ($LASTEXITCODE -eq 0)
+ {
+ Write-Host ""
+ Write-Host "ā
All files are properly formatted!" -ForegroundColor Green
+ }
+ else
+ {
+ Write-Host ""
+ Write-Host "ā Formatting issues detected!" -ForegroundColor Red
+ Write-Host "Run '.\format.ps1' (without -Check) to fix them automatically." -ForegroundColor Yellow
+ exit 1
+ }
+}
+else
+{
+ Write-Host "āļø Formatting code..." -ForegroundColor Yellow
+ Write-Host ""
+
+ dotnet format $solutionFile --verbosity diagnostic
+
+ if ($LASTEXITCODE -eq 0)
+ {
+ Write-Host ""
+ Write-Host "ā
Code formatting complete!" -ForegroundColor Green
+ Write-Host "Review changes and commit them." -ForegroundColor Cyan
+ }
+ else
+ {
+ Write-Host ""
+ Write-Host "ā Formatting failed!" -ForegroundColor Red
+ exit 1
+ }
+}
From 2a267584bad92ea567a328b4e989a1fbc184f691 Mon Sep 17 00:00:00 2001
From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
Date: Sun, 8 Mar 2026 23:10:36 -0400
Subject: [PATCH 06/13] Update .github/workflows/pr.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.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 16d9c77..377e9de 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -196,8 +196,8 @@ jobs:
echo "=========================================="
# Extract target frameworks from the project file
- # Support both (single) and (multiple)
- frameworks=$(grep -oP '\K[^<]+' "$test_proj" | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)$' || true)
+ # Support both (single) and (multiple), including multi-line values
+ frameworks=$(tr '\n' ' ' < "$test_proj" | grep -oP '\K[^<]+' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)$' || true)
if [ -z "$frameworks" ]; then
echo "ā Skipping: No compatible .NET 5.0-10.0 target frameworks found"
From bb652b48b23b7d943dd7fa2b490882142f723684 Mon Sep 17 00:00:00 2001
From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
Date: Sun, 8 Mar 2026 23:11:28 -0400
Subject: [PATCH 07/13] Update .github/workflows/pr.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.github/workflows/pr.yaml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index 377e9de..31c8a4f 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -333,6 +333,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
+ 3.1.x
5.0.x
6.0.x
7.0.x
From e0c4c3b661982ea6e645d47c5b332e13c10db779 Mon Sep 17 00:00:00 2001
From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
Date: Sun, 8 Mar 2026 23:12:16 -0400
Subject: [PATCH 08/13] Update .github/workflows/pr.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.github/workflows/pr.yaml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index 31c8a4f..1dcc9d9 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -333,6 +333,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
+ 3.1.x
3.1.x
5.0.x
6.0.x
From fdabf9a36c2301910999a01098348dbf39f280dc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 03:11:22 +0000
Subject: [PATCH 09/13] Scope analyzer PackageReferences to SDK-style projects
only
Co-authored-by: Chris-Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
---
Directory.Build.props | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Directory.Build.props b/Directory.Build.props
index 02e6b59..b9600a5 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,12 +14,12 @@
true
-
+
-
+
all
From eebde66fd574e91d8d87fbb2a5741b63ec3f460a Mon Sep 17 00:00:00 2001
From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
Date: Sun, 8 Mar 2026 23:13:35 -0400
Subject: [PATCH 10/13] Update .github/workflows/pr.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.github/workflows/pr.yaml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index 1dcc9d9..69ac209 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -333,6 +333,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
+ 3.1.x
3.1.x
3.1.x
5.0.x
From cbcb92b4908fa3c1295de77d5992de96161251cf Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 03:14:20 +0000
Subject: [PATCH 11/13] Add .NET 3.1 SDK to release.yaml setup-dotnet steps
that run build
Co-authored-by: Chris-Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
---
.github/workflows/release.yaml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 63ae0f8..4a5ab00 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -27,6 +27,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
+ 3.1.x
5.0.x
6.0.x
7.0.x
@@ -300,6 +301,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
+ 3.1.x
5.0.x
6.0.x
7.0.x
From 560aab352b1895f788ed1e625008323f5f50fa6f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 03:24:00 +0000
Subject: [PATCH 12/13] Initial plan
From 4022f1661b6fee6fba69d6b8be118b5c19457225 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Mar 2026 03:27:19 +0000
Subject: [PATCH 13/13] Fix pr.yaml: add 3.1.x to Linux setup-dotnet and remove
duplicate 3.1.x in Windows setup-dotnet
Co-authored-by: Chris-Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com>
---
.github/workflows/pr.yaml | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index 69ac209..dfe526c 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -81,6 +81,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
+ 3.1.x
5.0.x
6.0.x
7.0.x
@@ -333,8 +334,6 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
- 3.1.x
- 3.1.x
3.1.x
5.0.x
6.0.x