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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![.NET](https://img.shields.io/badge/.NET-Multi--Targeted-purple.svg)](https://dotnet.microsoft.com/) +[![GitHub](https://img.shields.io/badge/GitHub-Repository-181717?logo=github)](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