From 0bf5d13dd2b5631eb86cc58d7fb7e89593ab85ed Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:56:22 -0400 Subject: [PATCH 01/18] Add Setup-BranchRuleset.ps1 from repo-template Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/Setup-BranchRuleset.ps1 | 294 ++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 scripts/Setup-BranchRuleset.ps1 diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1 new file mode 100644 index 0000000..f8ef40e --- /dev/null +++ b/scripts/Setup-BranchRuleset.ps1 @@ -0,0 +1,294 @@ +<# +.SYNOPSIS + Creates a branch protection ruleset for the main branch in the current repository. + +.DESCRIPTION + This script uses the GitHub CLI (gh) to create a repository ruleset that protects + the main branch with pull request requirements, required status checks, security + scanning rules, and automatic Copilot code review. + Run this locally after creating a new repo from the template. + + The script will prompt you to choose between single-developer or multi-developer + repository settings: + - Single Developer: No PR approvals required (you can merge your own PRs) + - Multi-Developer: Requires 1+ approval and code owner review + + The ruleset includes: + - Pull request reviews with configurable approval requirements + - Required status checks (tests, security scans) + - Force push and deletion protection + +.PARAMETER Repository + The repository in owner/repo format. If not provided, uses the current repository. + +.PARAMETER BranchName + The branch to protect. Default is "main". + +.EXAMPLE + .\Setup-BranchRuleset.ps1 + Creates the ruleset for the current repository with interactive prompts + +.EXAMPLE + .\Setup-BranchRuleset.ps1 -Repository "Chris-Wolfgang/my-repo" + Creates the ruleset for a specific repository + +.NOTES + Requires: GitHub CLI (gh) authenticated with sufficient permissions + Install gh: https://cli.github.com/ + + Required Permissions: + - Admin access to the repository, OR + - Write access with "Administration" permission enabled + + These permissions are necessary to create and modify repository rulesets. + + Note: Copilot code review is not supported through the rulesets API and must be + enabled manually in the GitHub repository UI after 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 { + $rulesetOutput = gh api ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/rulesets" ` + --paginate ` + --jq '.[] | select(.name == "Protect main branch")' 2>&1 + + if ($LASTEXITCODE -ne 0) { + Write-Warning "āš ļø Could not check for existing rulesets (API returned exit code $LASTEXITCODE). Continuing..." + } elseif ($rulesetOutput) { + $matchingRulesets = $rulesetOutput | ConvertFrom-Json + $existingRuleset = $matchingRulesets | Select-Object -First 1 + + if ($existingRuleset) { + Write-Host "āœ… Ruleset 'Protect main branch' already exists!" -ForegroundColor Green + Write-Host " View it at: https://github.com/$Repository/settings/rules" -ForegroundColor Cyan + $response = Read-Host "`nDo you want to continue anyway? This may fail. (y/N)" + if ($response -ne 'y' -and $response -ne 'Y') { + Write-Host "Exiting." -ForegroundColor Yellow + exit 0 + } + } + } else { + Write-Host "ā„¹ļø Ruleset 'Protect main branch' does not exist yet." -ForegroundColor Gray + } +} catch { + Write-Warning "āš ļø Could not check for existing rulesets: $($_.Exception.Message). Continuing..." +} + +# Prompt for repository type +Write-Host "`nšŸ‘„ Repository Type Configuration" -ForegroundColor Cyan +Write-Host "" +Write-Host "Is this a single-developer or multi-developer repository?" -ForegroundColor Yellow +Write-Host "" +Write-Host " [1] Single Developer - No PR approvals required (you can merge your own PRs)" -ForegroundColor Gray +Write-Host " [2] Multi-Developer - Requires 1+ approval and code owner review" -ForegroundColor Gray +Write-Host "" +$repoTypeChoice = Read-Host "Enter your choice (1 or 2) [default: 1]" + +# Set defaults based on choice +$requireApprovals = 0 +$requireCodeOwnerReview = $false + +if ($repoTypeChoice -eq "2") { + $requireApprovals = 1 + $requireCodeOwnerReview = $true + Write-Host "āœ… Configured for multi-developer repository (1 approval required)" -ForegroundColor Green +} else { + Write-Host "āœ… Configured for single-developer repository (no approvals required)" -ForegroundColor Green +} + +# Create ruleset configuration +Write-Host "`nšŸ“ Creating ruleset configuration..." -ForegroundColor Cyan + +$rulesetConfig = @{ + name = "Protect main branch" + target = "branch" + enforcement = "active" + conditions = @{ + ref_name = @{ + include = @("refs/heads/$BranchName") + exclude = @() + } + } + # No bypass actors allowed - all users (including admins) must follow branch protection rules + bypass_actors = @() + rules = @( + @{ + type = "pull_request" + parameters = @{ + required_approving_review_count = $requireApprovals + dismiss_stale_reviews_on_push = $true + require_code_owner_review = $requireCodeOwnerReview + require_last_push_approval = $false + required_review_thread_resolution = $true + } + }, + @{ + type = "required_status_checks" + parameters = @{ + strict_required_status_checks_policy = $true + # IMPORTANT: Workflows providing these required checks (specifically .github/workflows/pr.yaml) + # must NOT have path filters (paths/paths-ignore). If a workflow is path-filtered + # and doesn't run for a PR, GitHub will treat the required check as missing and + # block the merge. All required status checks must run on every PR. + required_status_checks = @( + @{ context = "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)" } + ) + } + }, + # NOTE: code_scanning (CodeQL) is not included in this API-created ruleset because + # it requires a CodeQL workflow to be present and have run on the repo. Without prior + # analyses, the rule blocks all PRs. Add CodeQL integration separately if needed. + # NOTE: Copilot code review is not included in this API-created payload because + # it is not currently supported through the rulesets API. After the ruleset is + # created, enable Copilot code review settings manually in the GitHub repository UI. + @{ + type = "non_fast_forward" + }, + @{ + type = "deletion" + } + ) +} + +# Convert to JSON +$jsonConfig = $rulesetConfig | ConvertTo-Json -Depth 10 + +# Save to temporary file +$tempFile = [System.IO.Path]::GetTempFileName() +$jsonConfig | Out-File -FilePath $tempFile -Encoding utf8NoBOM + +try { + Write-Host "šŸš€ Creating branch ruleset..." -ForegroundColor Cyan + + # Create the ruleset + $response = gh api ` + --method POST ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/rulesets" ` + --input $tempFile 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Host "`nāœ… Successfully created branch ruleset 'Protect main branch'!" -ForegroundColor Green + Write-Host "`nšŸ›”ļø Protection Rules Enabled:" -ForegroundColor Cyan + Write-Host " āœ… Pull requests required before merging" -ForegroundColor Gray + if ($requireApprovals -gt 0) { + Write-Host " āœ… Required approvals: $requireApprovals" -ForegroundColor Gray + Write-Host " āœ… Code owner review required" -ForegroundColor Gray + } else { + Write-Host " āœ… No approvals required (single-developer mode)" -ForegroundColor Gray + } + Write-Host " āœ… Required status checks (must pass before merging):" -ForegroundColor Gray + Write-Host " - 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 " āœ… 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 " āš ļø Copilot code review: enable manually in repository settings" -ForegroundColor Yellow + Write-Host " (Not yet supported through the rulesets API)" -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 From 7c761975739567bb56fa63f127d6173b9e9424df Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:14:31 -0400 Subject: [PATCH 02/18] Replace template placeholders with actual repo name Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/Setup-BranchRuleset.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1 index f8ef40e..c9dc7ae 100644 --- a/scripts/Setup-BranchRuleset.ps1 +++ b/scripts/Setup-BranchRuleset.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", + [string]$Repository = "Chris-Wolfgang/In-memory-Logger", [Parameter()] [string]$BranchName = "main" @@ -78,7 +78,7 @@ try { } # Determine repository -if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { +if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { # Placeholders not replaced or no repository specified - auto-detect Write-Host "šŸ” Detecting current repository..." -ForegroundColor Cyan try { @@ -86,7 +86,7 @@ if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { $Repository = $repoInfo.nameWithOwner Write-Host "āœ… Using repository: $Repository" -ForegroundColor Green } catch { - if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { + if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { 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." From 8070807f860648ba147c8ab7ca6edc21a549fdd8 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:30:39 -0400 Subject: [PATCH 03/18] Upgrade pr.yaml to v3 (Gated) Sync with repo-template: multi-stage gated CI with security hardening, trusted config fetch from main, and 90% coverage gate. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr.yaml | 1124 +++++++++++++++++++++++++++++++------ 1 file changed, 961 insertions(+), 163 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index c777c61..fe0ef48 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -1,44 +1,305 @@ # Sequential PR validation workflow with coverage gating # Stage 1: Linux tests with 90% coverage requirement -# Stage 2: Windows and macOS tests (only if Linux passes) -# Stage 3: .NET Framework 4.x tests on Windows (only if Stage 2 passes) +# Stage 2: Windows .NET (5.0-10.0) and .NET Framework (4.6.2-4.8.1) tests (only if Linux passes) +# Stage 3: macOS tests (only if Stage 2 passes) +# +# SECURITY NOTE: +# - Uses pull_request_target to run workflow from the trusted main branch, not from the PR branch +# - This prevents malicious workflow YAML changes in untrusted PR branches from taking effect +# - All checkout steps use PR refs (refs/pull/*/head) to check out PR code from the base repo +# - After checkout, configuration files (.editorconfig, BannedSymbols.txt, etc.) are fetched from +# the main branch to prevent malicious PRs from disabling analyzers or bypassing code quality checks +# - If a PR changes any of these protected configuration files, CI explicitly fails with instructions +# for a maintainer to manually review and verify the changes before merging +# - persist-credentials: false prevents the checkout token from being written to git config for subsequent git commands +# (it does NOT, by itself, prevent steps from accessing github.token / GITHUB_TOKEN if you explicitly expose it) +# - After checkout, configuration files (.editorconfig, BannedSymbols.txt, etc.) are fetched from +# the main branch to prevent malicious PRs from disabling analyzers or bypassing code quality checks +# - Default GITHUB_TOKEN permissions are restricted to read-only repository contents to limit impact if exposed -name: PR Checks v2 (Gated) +name: PR Checks v3 (Gated) permissions: contents: read + +env: + CODECOV_MINIMUM: 90 on: - pull_request: - branches: + pull_request_target: # Runs from the main branch, not from PR branch + branches: - main - - develop - paths-ignore: - - '**.md' - - 'docs/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: + # ============================================================================ + # SECRETS SCAN: Detect leaked credentials before merge + # ============================================================================ + secrets-scan: + name: "Secrets Scan (gitleaks)" + runs-on: ubuntu-latest + if: github.repository != 'Chris-Wolfgang/repo-template' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/head + persist-credentials: false + fetch-depth: 0 + + - name: Run gitleaks + # gitleaks-action@v2 does not support pull_request_target, so invoke the CLI directly + run: | + GITLEAKS_VERSION="8.24.0" + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar xz -C /usr/local/bin gitleaks + gitleaks detect --source . --verbose --redact + shell: bash + + # ============================================================================ + # DETECTION: Check if .csproj files exist + # ============================================================================ + detect-projects: + name: "Detect .NET Projects" + runs-on: ubuntu-latest + if: github.repository != 'Chris-Wolfgang/repo-template' + outputs: + has-projects: ${{ steps.check-projects.outputs.has-projects }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/head + persist-credentials: false + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " āœ“ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " āš ļø Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " āœ“ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ā„¹ļø $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "āœ… Configuration files secured - using versions from main branch" + + - name: Detect protected configuration file changes + run: | + echo "Checking for changes to protected configuration files in this PR..." + + # Verify main-branch ref is available (it was fetched in the previous step) + if ! git cat-file -e main-branch 2>/dev/null; then + echo "āŒ main-branch ref not found - cannot detect configuration file changes" + exit 1 + fi + + changed_files=() + + # Check exact file matches against main branch git objects + # 2>/dev/null suppresses output when a file doesn't exist in one ref (new/deleted file), + # which git diff handles correctly via its exit code + exact_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + ) + + for config_file in "${exact_files[@]}"; do + if ! git diff --quiet main-branch HEAD -- "$config_file" 2>/dev/null; then + changed_files+=("$config_file") + fi + done + + # Check .globalconfig and .ruleset files using the same git diff approach + # --diff-filter=AMRC: Added, Modified, Renamed, Copied (excludes Deleted) + while IFS= read -r file; do + changed_files+=("$file") + done < <(git diff --name-only --diff-filter=AMRC main-branch HEAD 2>/dev/null | grep -E '\.(globalconfig|ruleset)$' || true) + + if [ ${#changed_files[@]} -gt 0 ]; then + echo "" + echo "āš ļø PROTECTED CONFIGURATION FILES CHANGED IN THIS PR:" + for file in "${changed_files[@]}"; do + echo " - $file" + done + echo "" + echo "āŒ CI uses the main branch version of these files to prevent security bypasses." + echo " The PR's changes to these files were NOT tested by CI." + echo " A maintainer must manually review and verify these changes before merging." + echo "" + echo "To proceed, a maintainer should:" + echo " 1. Review the configuration changes in this PR carefully" + echo " 2. Test the changes locally to confirm they work correctly" + echo " 3. Merge with awareness that CI did not validate these configuration changes" + exit 1 + else + echo "āœ… No protected configuration files changed - CI fully validates this PR" + fi + + - name: Check for .NET project files + id: check-projects + run: | + if git ls-files '*.csproj' '*.vbproj' '*.fsproj' | grep -q .; then + echo "has-projects=true" >> $GITHUB_OUTPUT + echo "āœ… Found .NET project files - .NET build and test jobs will run" + else + echo "has-projects=false" >> $GITHUB_OUTPUT + echo "ā„¹ļø No .NET project files found - skipping .NET build and test jobs" + fi + # ============================================================================ # 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 - if: github.repository != 'Chris-Wolfgang/repo-template' + 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 + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " āœ“ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " āš ļø Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " āœ“ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ā„¹ļø $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "āœ… Configuration files secured - using versions from main branch" + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " āœ“ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " āš ļø Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " āœ“ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ā„¹ļø $config_file not found in main branch, skipping" + fi + fi + done - # Fix for .NET 5.0 on Ubuntu 22.04+ - install libssl1.1 + echo "" + echo "āœ… Configuration files secured - using versions from main branch" + + # Fix for .NET 5.0 on Ubuntu 22.04+ - install libssl1.1 from the focal-security + # repository so APT verifies the package via GPG instead of a plain wget download. - name: Install OpenSSL 1.1 for .NET 5.0 run: | - wget http://archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2_amd64.deb - sudo dpkg -i libssl1.1_1.1.1f-1ubuntu2_amd64.deb + 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 @@ -46,44 +307,87 @@ jobs: 9.0.x 10.0.x - - name: Restore and build (exclude .NET Framework 4.x projects) + - name: Restore and build (exclude .NET Framework-only projects) run: | - echo "Finding all projects in solution..." + echo "Finding .NET project files in repository (via find command)..." - # Get list of all projects from solution file - if [ -f "*.sln" ]; then - sln_file=$(ls *.sln | head -n 1) - echo "Using solution file: $sln_file" - fi + # 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 - # Find all .csproj, .vbproj, and .fsproj files - # Exclude those with 'dotnet4' or 'DotNet4' in the path (case-insensitive) - projects=$(find . -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) | grep -iv "dotnet4") + 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 [ -z "$projects" ]; then - echo "No projects found!" + 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 (excluding .NET Framework 4.x):" + echo "Projects to build:" echo "==========================================" - echo "$projects" + printf '%s\n' "${projects[@]}" echo "" # Restore each project echo "Restoring projects..." - for proj in $projects; do + for proj in "${projects[@]}"; do echo "Restoring: $proj" dotnet restore "$proj" || exit 1 done echo "" echo "Building projects..." - # Build each project - for proj in $projects; do + # 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" - dotnet build "$proj" --no-restore --configuration Release || exit 1 + + # 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 "" @@ -91,10 +395,10 @@ jobs: - name: Run tests with coverage (.NET Core 5.0 - 10.0) run: | - # Find all test projects - test_projects=$(find ./tests -type f -name "*.csproj") + # 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 [ -z "$test_projects" ]; then + if [ ${#test_projects[@]} -eq 0 ]; then echo "āŒ No test projects found in ./tests directory!" exit 1 fi @@ -102,18 +406,30 @@ jobs: echo "==========================================" echo "Found test projects:" echo "==========================================" - echo "$test_projects" + printf '%s\n' "${test_projects[@]}" echo "" - # Test each framework individually to ensure all are tested - frameworks=(net5.0 net6.0 net7.0 net8.0 net9.0 net10.0) - - for test_proj in $test_projects; do + for test_proj in "${test_projects[@]}"; do echo "==========================================" echo "Testing project: $test_proj" echo "==========================================" - for fw in "${frameworks[@]}"; do + # Extract target frameworks from the project file + # Support both (single) and (multiple) + frameworks=$(grep -oP '\K[^<]+' "$test_proj" | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp3\.1)$' || true) + + 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" \ @@ -122,14 +438,27 @@ jobs: --collect:"XPlat Code Coverage" \ --results-directory "./TestResults" \ --logger "console;verbosity=minimal" || exit 1 - done + done <<< "$frameworks" echo "" done + - name: Check for coverage files + id: check-coverage + run: | + if find TestResults -type f -name "coverage.cobertura.xml" 2>/dev/null | grep -q .; then + echo "has-coverage=true" >> $GITHUB_OUTPUT + echo "āœ… Coverage files found" + else + echo "has-coverage=false" >> $GITHUB_OUTPUT + echo "ā„¹ļø No coverage files found - skipping coverage report generation" + fi + - name: Install ReportGenerator + if: steps.check-coverage.outputs.has-coverage == 'true' run: dotnet tool install -g dotnet-reportgenerator-globaltool - name: Generate coverage report + if: steps.check-coverage.outputs.has-coverage == 'true' run: | reportgenerator \ -reports:"TestResults/**/coverage.cobertura.xml" \ @@ -137,6 +466,7 @@ jobs: -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" - name: Enforce 90% coverage threshold + if: steps.check-coverage.outputs.has-coverage == 'true' run: | if [ ! -f CoverageReport/Summary.txt ]; then echo "āŒ Coverage report not generated!" @@ -148,7 +478,7 @@ jobs: echo "" failed_projects="" - threshold=90 + threshold=${CODECOV_MINIMUM:-90} while read -r line; do # Match lines with module names and percentages @@ -203,22 +533,116 @@ jobs: tests/**/bin/Release # ============================================================================ - # STAGE 2: Windows & macOS - .NET Core/5+ Tests (Gated by Stage 1) + # STAGE 2: Windows - All .NET Tests (Gated by Stage 1) # ============================================================================ - test-windows-core: - name: "Stage 2a: Windows Tests (.NET 5.0-10.0)" + test-windows: + name: "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" runs-on: windows-latest - needs: test-linux-core - if: github.repository != 'Chris-Wolfgang/repo-template' + 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: Fetch trusted configuration files from main branch + shell: pwsh + run: | + Write-Host "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + $configFiles = @( + ".editorconfig", + "Directory.Build.props", + "Directory.Build.targets", + "BannedSymbols.txt" + ) + + # Copy each configuration file from main branch if it exists + foreach ($configFile in $configFiles) { + # Check if file exists in main branch + $exists = git cat-file -e "main-branch:$configFile" 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host " āœ“ Copying $configFile from main branch" + git show "main-branch:$configFile" | Out-File -FilePath $configFile -Encoding UTF8 -NoNewline + } else { + Write-Host " ā„¹ļø $configFile not found in main branch, skipping" + } + } + + # Handle glob patterns for .globalconfig and .ruleset files + $globPatterns = @("*.globalconfig", "*.ruleset") + foreach ($pattern in $globPatterns) { + $files = git ls-tree -r --name-only main-branch | Select-String -Pattern $pattern.Replace("*", ".*") + foreach ($file in $files) { + if ($file) { + Write-Host " āœ“ Copying $file from main branch" + $dir = Split-Path -Parent $file + if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + git show "main-branch:$file" | Out-File -FilePath $file -Encoding UTF8 -NoNewline + } + } + } + + Write-Host "" + Write-Host "āœ… Configuration files secured - using versions from main branch" + + - name: Fetch trusted configuration files from main branch + shell: pwsh + run: | + Write-Host "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + $configFiles = @( + ".editorconfig", + "Directory.Build.props", + "Directory.Build.targets", + "BannedSymbols.txt" + ) + + # Copy each configuration file from main branch if it exists + foreach ($configFile in $configFiles) { + # Check if file exists in main branch + $exists = git cat-file -e "main-branch:$configFile" 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host " āœ“ Copying $configFile from main branch" + git show "main-branch:$configFile" | Out-File -FilePath $configFile -Encoding UTF8 -NoNewline + } else { + Write-Host " ā„¹ļø $configFile not found in main branch, skipping" + } + } + + # Handle glob patterns for .globalconfig and .ruleset files + $globPatterns = @("*.globalconfig", "*.ruleset") + foreach ($pattern in $globPatterns) { + $files = git ls-tree -r --name-only main-branch | Select-String -Pattern $pattern.Replace("*", ".*") + foreach ($file in $files) { + if ($file) { + Write-Host " āœ“ Copying $file from main branch" + $dir = Split-Path -Parent $file + if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + git show "main-branch:$file" | Out-File -FilePath $file -Encoding UTF8 -NoNewline + } + } + } + + Write-Host "" + Write-Host "āœ… Configuration files secured - using versions from main branch" - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: | + 3.1.x 5.0.x 6.0.x 7.0.x @@ -232,12 +656,14 @@ jobs: - name: Build solution run: dotnet build --no-restore --configuration Release - - name: Run tests (.NET Core 5.0 - 10.0) + - name: Run all .NET tests (.NET 5.0-10.0 and Framework 4.6.2-4.8.1) shell: pwsh run: | - $testProjects = Get-ChildItem -Path './tests' -Recurse -Filter '*.csproj' + $ErrorActionPreference = 'Stop' + + $testProjects = @(Get-ChildItem -Path './tests/*' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj') - if ($testProjects.Count -eq 0) { + if (@($testProjects).Count -eq 0) { Write-Error "āŒ No test projects found in ./tests directory!" exit 1 } @@ -248,21 +674,55 @@ jobs: $testProjects | ForEach-Object { Write-Host $_.FullName -ForegroundColor White } Write-Host "" - $frameworks = @('net5.0', 'net6.0', 'net7.0', 'net8.0', 'net9.0', 'net10.0') - 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|coreapp3\.1)$' } + + if ($frameworks.Count -eq 0) { + Write-Host "⊘ Skipping: No compatible .NET 5.0-10.0 or Framework 4.6.2-4.8.1 target frameworks found" -ForegroundColor Yellow + Write-Host "" + continue + } + + Write-Host "Target frameworks: $($frameworks -join ', ')" -ForegroundColor White + Write-Host "" + + # Test each framework; collect coverage only for .NET 5.0+ TFMs. + # netcoreapp3.1 and net4x are tested but excluded from coverage: + # netcoreapp3.1 has no matching test TFM on Linux (Stage 1) so its numbers + # would not be comparable; net4x cannot use the XPlat collector on Windows. foreach ($fw in $frameworks) { - Write-Host "Testing framework: $fw" -ForegroundColor Yellow - - dotnet test $testProj.FullName ` - --configuration Release ` - --framework $fw ` - --logger "console;verbosity=normal" - + Write-Host "Testing framework: $fw" -ForegroundColor Yellow + + if ($fw -match '^net([5-9]|[1-9][0-9]+)\.') { + dotnet test $testProj.FullName ` + --configuration Release ` + --framework $fw ` + --collect:"XPlat Code Coverage" ` + --results-directory "./TestResults" ` + --logger "console;verbosity=normal" + } else { + 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 @@ -271,15 +731,191 @@ jobs: Write-Host "" } - test-macos-core: - name: "Stage 2b: macOS Tests (.NET 6.0-10.0)" + - name: Check for coverage files + id: check-coverage + run: | + if (Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue) { + echo "has-coverage=true" >> $env:GITHUB_OUTPUT + Write-Host "āœ… Coverage files found" + } else { + echo "has-coverage=false" >> $env:GITHUB_OUTPUT + Write-Host "ā„¹ļø No coverage files found - skipping coverage report generation" + } + shell: pwsh + + - name: Install ReportGenerator + if: steps.check-coverage.outputs.has-coverage == 'true' + run: dotnet tool install -g dotnet-reportgenerator-globaltool + + - name: Generate coverage report + if: steps.check-coverage.outputs.has-coverage == 'true' + shell: pwsh + run: | + reportgenerator ` + -reports:"TestResults/**/coverage.cobertura.xml" ` + -targetdir:"CoverageReport" ` + -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" + + - name: Enforce 90% coverage threshold + if: steps.check-coverage.outputs.has-coverage == 'true' + shell: pwsh + run: | + if (-not (Test-Path "CoverageReport/Summary.txt")) { + Write-Error "āŒ Coverage report not generated!" + exit 1 + } + + Write-Host "Coverage Summary:" + Get-Content "CoverageReport/Summary.txt" + Write-Host "" + + $threshold = if ($env:CODECOV_MINIMUM) { [int]$env:CODECOV_MINIMUM } else { 90 } + $failedProjects = @() + + foreach ($line in (Get-Content "CoverageReport/Summary.txt")) { + if ($line -match '^\s*(\S+)\s+(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^\s*Summary') { + $module = $Matches[1] + $percent = [int][math]::Floor([double]$Matches[2]) + + Write-Host "Checking module: '$module' - Coverage: ${percent}%" + + if ($percent -lt $threshold) { + Write-Host " āŒ FAIL: Below ${threshold}% threshold" -ForegroundColor Red + $failedProjects += "$module (${percent}%)" + } else { + Write-Host " āœ… PASS: Meets ${threshold}% threshold" -ForegroundColor Green + } + } + } + + if ($failedProjects.Count -gt 0) { + Write-Host "" + Write-Host "==========================================" -ForegroundColor Red + Write-Host "āŒ COVERAGE GATE FAILED" -ForegroundColor Red + Write-Host "==========================================" -ForegroundColor Red + Write-Host "Projects below ${threshold}% coverage: $($failedProjects -join ', ')" -ForegroundColor Red + Write-Host "" + Write-Host "Stage 2 failed. macOS tests will NOT run." + exit 1 + } + + Write-Host "" + Write-Host "==========================================" -ForegroundColor Green + Write-Host "āœ… COVERAGE GATE PASSED" -ForegroundColor Green + Write-Host "==========================================" -ForegroundColor Green + Write-Host "All projects meet ${threshold}% coverage threshold." + Write-Host "Proceeding to Stage 3 (macOS tests)." + + - name: Upload Windows coverage results + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-windows + path: | + TestResults/ + CoverageReport/ + + # ============================================================================ + # STAGE 3: macOS Tests (Gated by Stage 2) + # ============================================================================ + test-macos-core: + name: "Stage 3: macOS Tests (.NET 6.0-10.0)" runs-on: macos-latest - needs: test-linux-core - if: github.repository != 'Chris-Wolfgang/repo-template' + needs: [detect-projects, test-windows] + if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true' steps: - - name: Checkout code + - name: Checkout code uses: actions/checkout@v4 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/head + persist-credentials: false + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " āœ“ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " āš ļø Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " āœ“ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ā„¹ļø $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "āœ… Configuration files secured - using versions from main branch" + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " āœ“ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " āš ļø Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " āœ“ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ā„¹ļø $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "āœ… Configuration files secured - using versions from main branch" - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -291,38 +927,87 @@ jobs: 9.0.x 10.0.x - - name: Restore and build (exclude .NET Framework 4.x projects) + - name: Restore and build (exclude .NET Framework-only projects) run: | - echo "Finding all projects in solution..." + 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 - # Find all .csproj, .vbproj, and .fsproj files - # Exclude those with 'dotnet4' or 'DotNet4' in the path (case-insensitive) - projects=$(find . -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) | grep -iv "dotnet4") + 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 [ -z "$projects" ]; then - echo "No projects found!" + 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 4.x):" + echo "Projects to build (excluding .NET Framework-only projects):" echo "==========================================" - echo "$projects" + printf '%s\n' "${projects[@]}" echo "" # Restore each project echo "Restoring projects..." - for proj in $projects; do + for proj in "${projects[@]}"; do echo "Restoring: $proj" dotnet restore "$proj" || exit 1 done echo "" echo "Building projects..." - # Build each project - for proj in $projects; do + # 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" - dotnet build "$proj" --no-restore --configuration Release || exit 1 + + # 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 "" @@ -330,10 +1015,13 @@ jobs: - name: Run tests (.NET 6.0 - 10.0 only - ARM64 compatible) run: | - # Find all test projects - test_projects=$(find ./tests -type f -name "*.csproj") + # 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 [ -z "$test_projects" ]; then + if [ ${#test_projects[@]} -eq 0 ]; then echo "āŒ No test projects found in ./tests directory!" exit 1 fi @@ -341,28 +1029,112 @@ jobs: echo "==========================================" echo "Found test projects:" echo "==========================================" - echo "$test_projects" + printf '%s\n' "${test_projects[@]}" echo "" - # Skip .NET Core 5.0 and .NET 5.0 - no ARM64 support on macOS - frameworks=(net6.0 net7.0 net8.0 net9.0 net10.0) - - for test_proj in $test_projects; do + for test_proj in "${test_projects[@]}"; do echo "==========================================" echo "Testing project: $test_proj" echo "==========================================" - for fw in "${frameworks[@]}"; do + # 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 + # All frameworks here are net6.0+ so all get coverage + while IFS= read -r fw; do + [ -z "$fw" ] && continue echo "Testing framework: $fw" - + dotnet test "$test_proj" \ --configuration Release \ --framework "$fw" \ + --collect:"XPlat Code Coverage" \ + --results-directory "./TestResults" \ --logger "console;verbosity=normal" || exit 1 - done + done <<< "$frameworks" echo "" done + - name: Install ReportGenerator + run: dotnet tool install -g dotnet-reportgenerator-globaltool + + - name: Generate coverage report + run: | + if find ./TestResults -name "coverage.cobertura.xml" -print -quit 2>/dev/null | grep -q .; then + reportgenerator \ + -reports:"TestResults/**/coverage.cobertura.xml" \ + -targetdir:"CoverageReport" \ + -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" + else + echo "ā„¹ļø No coverage files found - skipping report generation" + fi + + - name: Enforce 90% coverage threshold + run: | + if [ ! -f "CoverageReport/Summary.txt" ]; then + echo "āŒ Coverage report not generated!" + exit 1 + fi + + echo "Coverage Summary:" + cat CoverageReport/Summary.txt + echo "" + + THRESHOLD=${CODECOV_MINIMUM:-90} + FAILED=0 + + while IFS= read -r line; do + if echo "$line" | grep -qE '^[^ ]+.*[0-9]+%$' && ! echo "$line" | grep -q '^Summary'; then + MODULE=$(echo "$line" | awk '{print $1}') + PERCENT=$(echo "$line" | grep -oE '[0-9]+(\.[0-9]+)?%' | tail -1 | grep -oE '^[0-9]+') + echo "Checking module: '$MODULE' - Coverage: ${PERCENT}%" + if [ "$PERCENT" -lt "$THRESHOLD" ]; then + echo " āŒ FAIL: Below ${THRESHOLD}% threshold" + FAILED=1 + else + echo " āœ… PASS: Meets ${THRESHOLD}% threshold" + fi + fi + done < CoverageReport/Summary.txt + + if [ "$FAILED" -ne 0 ]; then + echo "" + echo "==========================================" + echo "āŒ COVERAGE GATE FAILED" + echo "==========================================" + echo "One or more modules are below ${THRESHOLD}% coverage." + echo "Stage 3 failed." + exit 1 + fi + + echo "" + echo "==========================================" + echo "āœ… COVERAGE GATE PASSED" + echo "==========================================" + echo "All modules meet ${THRESHOLD}% coverage threshold." + + - name: Upload macOS coverage results + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-macos + path: | + TestResults/ + CoverageReport/ + - name: Display macOS architecture info if: always() run: | @@ -383,104 +1155,129 @@ jobs: 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! šŸŽ‰" # ============================================================================ - # STAGE 3: Windows - .NET Framework 4.x Tests (Gated by Stage 2) + # Security Scan (Runs in parallel, independently of .NET jobs) # ============================================================================ - test-windows-framework: - name: "Stage 3: Windows .NET Framework Tests (4.6.2-4.8.1)" - runs-on: windows-latest - needs: [test-windows-core, test-macos-core] + security-scan: + name: "Security Scan (DevSkim)" + runs-on: ubuntu-latest if: github.repository != 'Chris-Wolfgang/repo-template' - steps: + steps: - name: Checkout code uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Build solution - run: dotnet build --no-restore --configuration Release - - - name: Run .NET Framework tests (4.6.2 - 4.8.1) - shell: pwsh + ref: refs/pull/${{ github.event.pull_request.number }}/head + persist-credentials: false + + - name: Fetch trusted configuration files from main branch run: | - $testProjects = Get-ChildItem -Path './tests' -Recurse -Filter '*.csproj' + echo "Fetching configuration files from main branch to prevent malicious overrides..." - if ($testProjects.Count -eq 0) { - Write-Error "āŒ No test projects found in ./tests directory!" - exit 1 - } + # Fetch the main branch + git fetch origin main:main-branch - 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 "" + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) - $frameworks = @('net462', 'net472', 'net48', 'net481') - - foreach ($testProj in $testProjects) { - Write-Host "==========================================" -ForegroundColor Cyan - Write-Host "Testing project: $($testProj.FullName)" -ForegroundColor Cyan - Write-Host "==========================================" -ForegroundColor Cyan - - 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 "" - } + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " āœ“ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " āš ļø Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " āœ“ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ā„¹ļø $config_file not found in main branch, skipping" + fi + fi + done - Write-Host "" - Write-Host "==========================================" -ForegroundColor Green - Write-Host "āœ… ALL STAGES PASSED" -ForegroundColor Green - Write-Host "==========================================" -ForegroundColor Green - Write-Host "Stage 1: Linux tests + 90% coverage āœ…" -ForegroundColor Green - Write-Host "Stage 2: Windows & macOS tests āœ…" -ForegroundColor Green - Write-Host "Stage 3: .NET Framework 4.x tests āœ…" -ForegroundColor Green - Write-Host "" - Write-Host "PR is ready to merge! šŸŽ‰" -ForegroundColor Green + echo "" + echo "āœ… Configuration files secured - using versions from main branch" - # ============================================================================ - # Security Scan (Runs in parallel with tests) - # ============================================================================ - 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 + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " āœ“ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " āš ļø Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " āœ“ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ā„¹ļø $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "āœ… Configuration files secured - using versions from main branch" - name: Install DevSkim CLI run: dotnet tool install --global Microsoft.CST.DevSkim.CLI - name: Run DevSkim security scan - continue-on-error: true run: | devskim analyze \ --source-code . \ --file-format text \ --output-file devskim-results.txt \ - -E \ --ignore-rule-ids DS176209 \ --ignore-globs "**/api/**,**/CoverageReport/**,**/TestResults/**" @@ -495,7 +1292,8 @@ jobs: echo "" if grep -qi "error\|critical\|high" devskim-results.txt; then - echo "āš ļø Security issues detected - review required" + echo "āŒ Security issues detected - review required" + exit 1 else echo "āœ… No critical security issues found" fi From a55e520c7ac9dc033dd79d69b7a527e7d170af86 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:32:40 -0400 Subject: [PATCH 04/18] Sync missing template files from repo-template Added: .gitattributes .globalconfig BannedSymbols.txt SECURITY.md .gitleaks.toml Directory.Build.props build-pr.ps1 setup.ps1 Setup-GitHubPages.ps1 Setup-Labels.ps1 codeql.yaml build-all-versions.yaml version-picker Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitattributes | 51 + .github/version-picker-template.html | 39 + .github/workflows/build-all-versions.yaml | 347 +++++++ .github/workflows/codeql.yaml | 186 ++++ .gitleaks.toml | 14 + .globalconfig | 10 + BannedSymbols.txt | 81 ++ Directory.Build.props | 59 ++ SECURITY.md | 23 + scripts/Setup-GitHubPages.ps1 | 719 ++++++++++++++ scripts/Setup-Labels.ps1 | 116 +++ scripts/build-pr.ps1 | 299 ++++++ scripts/setup.ps1 | 1042 +++++++++++++++++++++ 13 files changed, 2986 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/version-picker-template.html create mode 100644 .github/workflows/build-all-versions.yaml create mode 100644 .github/workflows/codeql.yaml create mode 100644 .gitleaks.toml create mode 100644 .globalconfig create mode 100644 BannedSymbols.txt create mode 100644 Directory.Build.props create mode 100644 SECURITY.md create mode 100644 scripts/Setup-GitHubPages.ps1 create mode 100644 scripts/Setup-Labels.ps1 create mode 100644 scripts/build-pr.ps1 create mode 100644 scripts/setup.ps1 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/.github/version-picker-template.html b/.github/version-picker-template.html new file mode 100644 index 0000000..39a2594 --- /dev/null +++ b/.github/version-picker-template.html @@ -0,0 +1,39 @@ + + + + + + {{TITLE}} + + + +

{{TITLE}}

+

Select a documentation version:

+
    +{{VERSION_LIST}} +
+ + 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.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000..a0ccb96 --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,186 @@ +name: "CodeQL Security Analysis" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '0 0 * * 0' # Weekly on Sunday at midnight UTC + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +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/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..c96c92a --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,14 @@ +# gitleaks configuration +# https://github.com/gitleaks/gitleaks +# +# Add allowlist rules here to suppress false positives from test fixtures, +# example configs, or documentation. + +title = "gitleaks config" + +[allowlist] + description = "Global allowlist" + paths = [ + '''(^|/)tests?/.*fixtures?/''', + '''(^|/)tests?/.*testdata/''', + ] 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..804798a --- /dev/null +++ b/BannedSymbols.txt @@ -0,0 +1,81 @@ +# BannedSymbols.txt - Async-First Enforcement for {{PROJECT_NAME}} +# Format: ; +# T: = Type, M: = Method, P: = Property, F: = Field +# Task.Wait() - All overloads - Absolutely NOT allowed in async code +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 new file mode 100644 index 0000000..2ada451 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,59 @@ + + + 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 + + + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e2bfe41 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability, please follow these steps: + +1. **Do not** create a public issue on this repository. +2. In the top navigation of this repository, click the **Security** tab. +3. In the top right, click the **Report a vulnerability** button. +4. Fill out the provided form with: + - A description of the vulnerability + - Steps to reproduce the issue + - Potential impact + - Suggested fix (if you have one) + +## Response Timeline + +We will acknowledge your report within 48 hours and provide an estimated timeline for a fix. + +## Thank You + +Your help is greatly appreciated! +Responsible disclosure of security vulnerabilities helps protect our entire community. diff --git a/scripts/Setup-GitHubPages.ps1 b/scripts/Setup-GitHubPages.ps1 new file mode 100644 index 0000000..e7a7488 --- /dev/null +++ b/scripts/Setup-GitHubPages.ps1 @@ -0,0 +1,719 @@ +#!/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 = "Chris-Wolfgang/In-memory-Logger", + + [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 "Chris-Wolfgang/In-memory-Logger" -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 "Chris-Wolfgang/In-memory-Logger") { + Write-Error-Custom "Could not detect repository. Please run the setup script (scripts/setup.ps1 or scripts/setup.sh) first to replace placeholders, or specify -Repository parameter." + } else { + Write-Error-Custom "Could not detect repository. Please run from within a git repository or specify -Repository parameter." + } + exit 1 + } +} else { + Write-Success "Using specified repository: $Repository" +} + +Write-Host "`nšŸ“š Setting up GitHub Pages for: $Repository" -ForegroundColor Cyan + +# Configure DocFX files +Write-Step "Configuring DocFX documentation files..." + +# Check if docfx.json has placeholders that need to be replaced +$docfxJsonPath = "docfx_project/docfx.json" +$needsDocFxConfig = $false + +if (Test-Path $docfxJsonPath) { + $docfxContent = Get-Content $docfxJsonPath -Raw + if ($docfxContent -match '{{[^}]+}}') { + $needsDocFxConfig = $true + Write-Info "DocFX configuration files contain placeholders that need to be replaced" + } else { + Write-Success "DocFX configuration files are already configured" + } +} else { + Write-Warning-Custom "docfx.json not found at $docfxJsonPath" + Write-Info "Skipping DocFX configuration" +} + +if ($needsDocFxConfig) { + Write-Host "" + Write-Host "šŸ“ Gathering project information for DocFX configuration..." -ForegroundColor Cyan + Write-Host "" + + # Parse repository information + $repoOwner = $Repository -split '/' | Select-Object -First 1 + $repoName = $Repository -split '/' | Select-Object -Last 1 + $githubRepoUrl = "https://github.com/$Repository" + + # Try to get repository description from GitHub + try { + $repoFullInfo = gh repo view --json description,nameWithOwner | ConvertFrom-Json + $autoDescription = $repoFullInfo.description + if ([string]::IsNullOrWhiteSpace($autoDescription)) { + $autoDescription = "A .NET library/application" + } + } catch { + $autoDescription = "A .NET library/application" + } + + # Calculate default documentation URL + $defaultDocsUrl = "https://$repoOwner.github.io/$repoName/" + + # Prompt for project information + $projectName = Read-Input ` + -Prompt "Project Name" ` + -Default $repoName ` + -Example $repoName ` + -Required + + $projectDescription = Read-Input ` + -Prompt "Project Description" ` + -Default $autoDescription ` + -Example $autoDescription + + $packageName = Read-Input ` + -Prompt "NuGet Package Name (if publishing to NuGet)" ` + -Default $projectName ` + -Example $projectName + + $docsUrl = Read-Input ` + -Prompt "Documentation URL (GitHub Pages)" ` + -Default $defaultDocsUrl ` + -Example $defaultDocsUrl + + # Ensure docsUrl ends with / + if (-not $docsUrl.EndsWith('/')) { + $docsUrl += '/' + } + + # Summary + Write-Host "" + Write-Host "Configuration Summary:" -ForegroundColor Cyan + Write-Host " Project Name: $projectName" -ForegroundColor Gray + Write-Host " Description: $projectDescription" -ForegroundColor Gray + Write-Host " Package Name: $packageName" -ForegroundColor Gray + Write-Host " Repository URL: $githubRepoUrl" -ForegroundColor Gray + Write-Host " Documentation URL: $docsUrl" -ForegroundColor Gray + Write-Host "" + + $confirm = Read-Host "Proceed with this configuration? (Y/n)" + if ($confirm -and $confirm -ne 'Y' -and $confirm -ne 'y') { + Write-Warning-Custom "Configuration cancelled." + exit 0 + } + + # Create replacements hashtable + $replacements = @{ + '{{PROJECT_NAME}}' = $projectName + '{{PROJECT_DESCRIPTION}}' = $projectDescription + '{{PACKAGE_NAME}}' = $packageName + '{{GITHUB_REPO_URL}}' = $githubRepoUrl + '{{DOCS_URL}}' = $docsUrl + } + + # Files to update + $filesToUpdate = @( + 'docfx_project/docfx.json', + 'docfx_project/index.md', + 'docfx_project/api/index.md', + 'docfx_project/api/README.md', + 'docfx_project/docs/toc.yml', + 'docfx_project/docs/introduction.md', + 'docfx_project/docs/getting-started.md' + ) + + # Replace placeholders in files + Write-Host "" + Write-Info "Replacing placeholders in DocFX files..." + $filesUpdated = 0 + + foreach ($file in $filesToUpdate) { + if (Test-Path $file) { + $content = Get-Content $file -Raw -ErrorAction SilentlyContinue + if ($content) { + $originalContent = $content + + foreach ($placeholder in $replacements.Keys) { + $pattern = [regex]::Escape($placeholder) + $content = [regex]::Replace( + $content, + $pattern, + [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $replacements[$placeholder] } + ) + } + + if ($content -ne $originalContent) { + Set-Content -Path $file -Value $content -NoNewline -Encoding UTF8 + Write-Success " Updated: $file" + $filesUpdated++ + } + } + } + } + + if ($filesUpdated -gt 0) { + Write-Success "Successfully updated $filesUpdated DocFX file(s)" + } else { + Write-Info "No files needed updating" + } + + Write-Host "" +} + +# Check if gh-pages branch exists +Write-Step "Checking for gh-pages branch..." +try { + $branches = git ls-remote --heads origin gh-pages 2>&1 + + if ($LASTEXITCODE -ne 0) { + Write-Error-Custom "Error checking for gh-pages branch. Git exited with code $LASTEXITCODE.`nOutput:`n$branches" + exit 1 + } + + $ghPagesBranchExists = -not [string]::IsNullOrWhiteSpace($branches) + + if ($ghPagesBranchExists) { + Write-Success "gh-pages branch already exists" + } else { + Write-Info "gh-pages branch does not exist yet" + + # Check for uncommitted changes before creating gh-pages branch + $gitStatus = git status --porcelain 2>&1 + if (-not [string]::IsNullOrWhiteSpace($gitStatus)) { + Write-Warning-Custom "You have uncommitted changes in your working directory." + Write-Info "Please commit or stash your changes before proceeding." + Write-Info "Uncommitted changes:`n$gitStatus" + $response = Read-Host "Do you want to continue anyway? This may cause data loss. (y/N)" + if ($response -ne 'y' -and $response -ne 'Y') { + Write-Info "Aborting gh-pages branch creation." + exit 0 + } + } + + # Store the current branch name before switching + $originalBranch = git rev-parse --abbrev-ref HEAD 2>&1 + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($originalBranch) -or + $originalBranch -match '(fatal|error|warning|usage:)') { + Write-Warning-Custom "Could not determine current branch name. Will attempt to return to 'main' after creating gh-pages." + $originalBranch = "main" # Default fallback + } + + # Create gh-pages branch + Write-Step "Creating gh-pages branch..." + + # Create an orphan branch (no history) + $checkoutOutput = git checkout --orphan gh-pages 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error-Custom "Failed to create orphan gh-pages branch. Git output:`n$checkoutOutput" + throw "Git checkout --orphan failed" + } + + # Remove all files from staging + $rmOutput = git rm -rf . 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error-Custom "Failed to remove files from staging. Git output:`n$rmOutput" + throw "Git rm failed" + } + + # Create a placeholder index.html + $placeholderHtml = @" + + + + + Documentation + + +

Documentation Coming Soon

+

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

+

Documentation is automatically published when you publish a GitHub Release.

+ + +"@ + Set-Content -Path "index.html" -Value $placeholderHtml -Encoding UTF8 + + # Commit and push + $addOutput = git add index.html 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error-Custom "Failed to stage index.html. Git output:`n$addOutput" + throw "Git add failed" + } + + $commitOutput = git commit -m "Initialize gh-pages branch" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error-Custom "Failed to commit gh-pages branch. Git output:`n$commitOutput" + throw "Git commit failed" + } + + $pushOutput = git push origin gh-pages 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error-Custom "Failed to push gh-pages branch. Git output:`n$pushOutput" + throw "Git push failed" + } + + # Switch back to original branch + try { + $checkoutBackOutput = git checkout $originalBranch 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning-Custom "Failed to switch back to original branch '$originalBranch'. Git output:`n$checkoutBackOutput" + # Try to detect the default branch as fallback + $defaultBranchOutput = git symbolic-ref refs/remotes/origin/HEAD 2>&1 + if ($LASTEXITCODE -eq 0 -and $defaultBranchOutput -and + $defaultBranchOutput -notmatch '(fatal|error|warning|usage:)') { + $defaultBranch = $defaultBranchOutput | ForEach-Object { $_ -replace '^refs/remotes/origin/', '' } + $checkoutDefaultOutput = git checkout $defaultBranch 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning-Custom "Failed to checkout default branch '$defaultBranch'. Git output:`n$checkoutDefaultOutput" + } + } else { + # Try main then master as last resort + $checkoutMainOutput = git checkout main 2>&1 + if ($LASTEXITCODE -ne 0) { + $checkoutMasterOutput = git checkout master 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning-Custom "Could not switch back to any default branch. You may need to manually switch branches." + } + } + } + } + } catch { + Write-Warning-Custom "Could not switch back to original branch. You may need to manually switch branches." + } + + Write-Success "Created and pushed gh-pages branch" + } +} catch { + Write-Error-Custom "Failed to check or create gh-pages branch: $_" + Write-Host "You may need to create the gh-pages branch manually." -ForegroundColor Yellow +} + +# Check and enable GitHub Pages +Write-Step "Configuring GitHub Pages settings..." +try { + # Get current Pages configuration + $pagesInfo = gh api "/repos/$Repository/pages" 2>&1 + + if ($LASTEXITCODE -eq 0) { + $pagesConfig = $pagesInfo | ConvertFrom-Json + Write-Success "GitHub Pages is already enabled" + Write-Info " Source: $($pagesConfig.source.branch)/$($pagesConfig.source.path)" + if ($pagesConfig.html_url) { + Write-Info " URL: $($pagesConfig.html_url)" + } + + # Check if it's configured to use gh-pages branch + if ($pagesConfig.source.branch -ne "gh-pages") { + Write-Warning-Custom "GitHub Pages is not configured to use the gh-pages branch" + if (-not $EnablePages) { + $response = Read-Host "Would you like to update it to use gh-pages branch? (y/N)" + if ($response -ne 'y' -and $response -ne 'Y') { + Write-Info "Skipping GitHub Pages branch update" + } else { + $EnablePages = $true + } + } + + if ($EnablePages) { + # Update Pages to use gh-pages branch + $pagesConfigUpdate = @{ + source = @{ + branch = "gh-pages" + path = "/" + } + } | ConvertTo-Json + + $tempFile = [System.IO.Path]::GetTempFileName() + $pagesConfigUpdate | Out-File -FilePath $tempFile -Encoding utf8NoBOM + + try { + $updateOutput = gh api --method PUT "/repos/$Repository/pages" --input $tempFile 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error-Custom "Failed to update GitHub Pages configuration. GitHub CLI output:`n$updateOutput" + } else { + Write-Success "Updated GitHub Pages to use gh-pages branch" + } + } catch { + Write-Error-Custom "Failed to update GitHub Pages configuration: $_" + } finally { + if (Test-Path $tempFile) { + Remove-Item $tempFile -Force + } + } + } + } + } else { + # Pages not enabled, try to enable it + Write-Info "GitHub Pages is not enabled yet" + + if (-not $EnablePages) { + $response = Read-Host "Would you like to enable GitHub Pages now? (y/N)" + if ($response -ne 'y' -and $response -ne 'Y') { + Write-Info "Skipping GitHub Pages setup" + Write-Info "You can enable it later in: Settings → Pages" + } else { + $EnablePages = $true + } + } + + if ($EnablePages) { + # Enable Pages with gh-pages branch + $pagesConfig = @{ + source = @{ + branch = "gh-pages" + path = "/" + } + } | ConvertTo-Json + + $tempFile = [System.IO.Path]::GetTempFileName() + $pagesConfig | Out-File -FilePath $tempFile -Encoding utf8NoBOM + + try { + $enableOutput = gh api --method POST "/repos/$Repository/pages" --input $tempFile 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error-Custom "Failed to enable GitHub Pages. GitHub CLI output:`n$enableOutput" + Write-Host "You may need to enable it manually in: Settings → Pages" -ForegroundColor Yellow + } else { + Write-Success "Enabled GitHub Pages with gh-pages branch" + + # Get the Pages URL + Start-Sleep -Seconds 2 + $pagesUrlInfo = gh api "/repos/$Repository/pages" 2>&1 + if ($LASTEXITCODE -eq 0) { + $pagesUrlData = $pagesUrlInfo | ConvertFrom-Json + if ($pagesUrlData.html_url) { + Write-Info " URL: $($pagesUrlData.html_url)" + } + } + } + } catch { + Write-Error-Custom "Failed to enable GitHub Pages: $_" + Write-Host "You may need to enable it manually in: Settings → Pages" -ForegroundColor Yellow + } finally { + if (Test-Path $tempFile) { + Remove-Item $tempFile -Force + } + } + } + } +} catch { + Write-Warning-Custom "Could not check GitHub Pages configuration" + Write-Info "You may need to enable GitHub Pages manually in: Settings → Pages" +} + +# Verify DocFX workflow configuration +Write-Step "Verifying DocFX workflow configuration..." +$workflowPath = ".github/workflows/docfx.yaml" + +if (Test-Path $workflowPath) { + $workflowContent = Get-Content $workflowPath -Raw + $normalizedWorkflowContent = $workflowContent -replace "`r`n", "`n" + + # Check if workflow is triggered via workflow_call (called by release.yaml) + $hasWorkflowCall = $normalizedWorkflowContent -match 'workflow_call:' + + if ($hasWorkflowCall) { + Write-Success "DocFX workflow is configured to be called via workflow_call from release.yaml" + } else { + Write-Warning-Custom "DocFX workflow does not appear to be configured for workflow_call" + Write-Info "The DocFX workflow should be invoked by release.yaml via workflow_call" + Write-Info " after a GitHub Release is published." + Write-Info "" + Write-Info "To enable automatic documentation publishing on GitHub Release:" + Write-Info " 1. Edit $workflowPath" + Write-Info " 2. Ensure the 'on:' section includes:" + Write-Info "" + Write-Host @" + on: + workflow_call: + inputs: + version: + description: 'Version tag for documentation (e.g., v1.0.0).' + required: false + default: '' + type: string + workflow_dispatch: +"@ -ForegroundColor DarkGray + Write-Info "" + Write-Info " 3. In release.yaml, add a job that calls docfx.yaml:" + Write-Info "" + Write-Host @" + trigger-docs: + needs: validate-release + permissions: + contents: write + uses: ./.github/workflows/docfx.yaml + with: + version: `${{ github.event.release.tag_name }} +"@ -ForegroundColor DarkGray + Write-Info "" + } +} else { + Write-Warning-Custom "DocFX workflow not found at $workflowPath" + Write-Info "Ensure you have a DocFX workflow configured" +} + +# Summary +Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan +Write-Host "šŸ“‹ Setup Summary" -ForegroundColor Cyan +Write-Host ("=" * 70) -ForegroundColor Cyan + +Write-Host "`nāœ… Completed Tasks:" -ForegroundColor Green +if ($needsDocFxConfig -and $filesUpdated -gt 0) { + Write-Host " • Configured DocFX files with project information" -ForegroundColor Gray +} +Write-Host " • Verified/Created gh-pages branch" -ForegroundColor Gray +if ($EnablePages) { + Write-Host " • Configured GitHub Pages settings" -ForegroundColor Gray +} +Write-Host " • Verified DocFX workflow configuration" -ForegroundColor Gray + +Write-Host "`nšŸ“ Next Steps:" -ForegroundColor Yellow +if ($needsDocFxConfig -and $filesUpdated -gt 0) { + Write-Host " 1. Review and customize the generated documentation in docfx_project/" -ForegroundColor Gray + Write-Host " 2. Publish a GitHub Release to trigger documentation deployment" -ForegroundColor Gray + Write-Host " 3. Check the Actions tab to see the documentation build" -ForegroundColor Gray + Write-Host " 4. Visit your documentation site once published" -ForegroundColor Gray +} else { + Write-Host " 1. Ensure docfx_project/docfx.json is configured for your project" -ForegroundColor Gray + Write-Host " 2. Ensure .github/workflows/docfx.yaml has workflow_call in its 'on:' triggers and is called by release.yaml" -ForegroundColor Gray + Write-Host " 3. Publish a GitHub Release to trigger documentation deployment" -ForegroundColor Gray + Write-Host " 4. Check the Actions tab to see the documentation build" -ForegroundColor Gray +} + +Write-Host "`nšŸ”— Useful Links:" -ForegroundColor Cyan +Write-Host " • Repository: https://github.com/$Repository" -ForegroundColor Blue +Write-Host " • Actions: https://github.com/$Repository/actions" -ForegroundColor Blue +Write-Host " • Settings → Pages: https://github.com/$Repository/settings/pages" -ForegroundColor Blue + +# Get Pages URL if available +try { + $pagesUrlOutput = gh api "/repos/$Repository/pages" 2>&1 + if ($LASTEXITCODE -eq 0) { + $pagesUrlInfo = $pagesUrlOutput | ConvertFrom-Json + if ($pagesUrlInfo.html_url) { + Write-Host " • Documentation: $($pagesUrlInfo.html_url)" -ForegroundColor Blue + } + } +} catch { + # Silently ignore if we can't get the URL +} + +Write-Host "`nšŸŽ‰ GitHub Pages setup complete!" -ForegroundColor Green +Write-Host "" diff --git a/scripts/Setup-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/build-pr.ps1 b/scripts/build-pr.ps1 new file mode 100644 index 0000000..d7fd64c --- /dev/null +++ b/scripts/build-pr.ps1 @@ -0,0 +1,299 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Runs the same checks as the Windows section of pr.yaml locally. + +.DESCRIPTION + Replicates the PR workflow's Windows stage locally so you can verify + your changes will pass before pushing. Runs in order: + 1. Restore and build (Release) + 2. Run all tests across all target frameworks + 3. Generate coverage report and enforce threshold + 4. Run DevSkim security scan + 5. Run gitleaks secrets scan + +.PARAMETER SkipTests + Skip test execution (build only). + +.PARAMETER SkipCoverage + Skip coverage report generation and threshold enforcement. + +.PARAMETER SkipSecurity + Skip DevSkim and gitleaks scans. + +.PARAMETER CoverageThreshold + Minimum coverage percentage required. Defaults to 90. + +.EXAMPLE + pwsh ./scripts/build-pr.ps1 + pwsh ./scripts/build-pr.ps1 -SkipSecurity + pwsh ./scripts/build-pr.ps1 -CoverageThreshold 80 +#> +param( + [switch]$SkipTests, + [switch]$SkipCoverage, + [switch]$SkipSecurity, + [int]$CoverageThreshold = 90 +) + +$ErrorActionPreference = 'Stop' +$failed = @() + +function Write-Step($message) { + Write-Host "" + Write-Host "==========================================" -ForegroundColor Cyan + Write-Host $message -ForegroundColor Cyan + Write-Host "==========================================" -ForegroundColor Cyan +} + +function Write-Pass($message) { + Write-Host $message -ForegroundColor Green +} + +function Write-Fail($message) { + Write-Host $message -ForegroundColor Red +} + +# ============================================================================ +# STEP 1: Restore and Build +# ============================================================================ +Write-Step "Step 1: Restore and Build (Release)" + +dotnet restore +if ($LASTEXITCODE -ne 0) { + Write-Fail "Restore failed" + $failed += "Restore" +} +else { + dotnet build --no-restore --configuration Release + if ($LASTEXITCODE -ne 0) { + Write-Fail "Build failed" + $failed += "Build" + } + else { + Write-Pass "Build succeeded" + } +} + +# ============================================================================ +# STEP 2: Run Tests +# ============================================================================ +if (-not $SkipTests -and $failed.Count -eq 0) { + Write-Step "Step 2: Run Tests (all target frameworks)" + + $testProjects = @(Get-ChildItem -Path './tests' -Recurse -File -Include '*.csproj', '*.vbproj', '*.fsproj' -ErrorAction SilentlyContinue) + + if ($testProjects.Count -eq 0) { + Write-Host "No test projects found in ./tests — skipping" + } + else { + foreach ($testProj in $testProjects) { + Write-Host "" + Write-Host "Testing: $($testProj.FullName)" -ForegroundColor White + + $content = Get-Content $testProj.FullName -Raw + $tfmMatch = [regex]::Match($content, '([^<]+)') + + if (-not $tfmMatch.Success) { + Write-Host " No target frameworks found — skipping" -ForegroundColor Yellow + continue + } + + $frameworks = $tfmMatch.Groups[1].Value -split ';' | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -match '^net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0|462|47|471|472|48|481|coreapp3\.1)$' } + + if ($frameworks.Count -eq 0) { + Write-Host " No compatible frameworks — skipping" -ForegroundColor Yellow + continue + } + + Write-Host " Frameworks: $($frameworks -join ', ')" + + foreach ($fw in $frameworks) { + Write-Host " Testing: $fw" -ForegroundColor Yellow + + $testArgs = @( + $testProj.FullName, + '--configuration', 'Release', + '--framework', $fw, + '--logger', 'console;verbosity=normal' + ) + + if ($fw -match '^net([5-9]|[1-9][0-9]+)\.') { + $testArgs += '--collect:XPlat Code Coverage' + $testArgs += '--results-directory' + $testArgs += './TestResults' + if (Test-Path 'coverlet.runsettings') { + $testArgs += '--settings' + $testArgs += 'coverlet.runsettings' + } + } + + dotnet test @testArgs + + if ($LASTEXITCODE -ne 0) { + Write-Fail " Tests failed for $fw" + $failed += "Tests ($fw)" + break + } + } + + if ($failed.Count -gt 0) { break } + } + + if ($failed.Count -eq 0) { + Write-Pass "All tests passed" + } + } +} + +# ============================================================================ +# STEP 3: Coverage Report and Threshold +# ============================================================================ +if (-not $SkipTests -and -not $SkipCoverage -and $failed.Count -eq 0) { + Write-Step "Step 3: Coverage Report (threshold: ${CoverageThreshold}%)" + + $coverageFiles = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue + + if (-not $coverageFiles) { + Write-Host "No coverage files found — skipping" + } + else { + # Install ReportGenerator if not present + $rgPath = Get-Command reportgenerator -ErrorAction SilentlyContinue + if (-not $rgPath) { + Write-Host "Installing ReportGenerator..." + dotnet tool install -g dotnet-reportgenerator-globaltool + } + + reportgenerator ` + -reports:"TestResults/**/coverage.cobertura.xml" ` + -targetdir:"CoverageReport" ` + -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" + + if (Test-Path "CoverageReport/Summary.txt") { + Write-Host "" + Get-Content "CoverageReport/Summary.txt" + Write-Host "" + + $failedProjects = @() + foreach ($line in (Get-Content "CoverageReport/Summary.txt")) { + if ($line -match '^\s*(\S+)\s+(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^\s*Summary') { + $module = $Matches[1] + $percent = [int][math]::Floor([double]$Matches[2]) + + if ($percent -lt $CoverageThreshold) { + Write-Fail " $module — ${percent}% (below ${CoverageThreshold}%)" + $failedProjects += "$module (${percent}%)" + } + else { + Write-Pass " $module — ${percent}%" + } + } + } + + if ($failedProjects.Count -gt 0) { + Write-Fail "Coverage gate FAILED: $($failedProjects -join ', ')" + $failed += "Coverage" + } + else { + Write-Pass "Coverage gate passed" + } + } + else { + Write-Host "Coverage report not generated — skipping threshold check" + } + } +} + +# ============================================================================ +# STEP 4: DevSkim Security Scan +# ============================================================================ +if (-not $SkipSecurity) { + Write-Step "Step 4: DevSkim Security Scan" + + $devskim = Get-Command devskim -ErrorAction SilentlyContinue + if (-not $devskim) { + Write-Host "Installing DevSkim CLI..." + dotnet tool install --global Microsoft.CST.DevSkim.CLI + } + + devskim analyze ` + --source-code . ` + --file-format text ` + --output-file devskim-results.txt ` + --ignore-rule-ids DS176209 ` + --ignore-globs "**/api/**,**/CoverageReport/**,**/TestResults/**" + + if (Test-Path "devskim-results.txt") { + $results = Get-Content "devskim-results.txt" -Raw + if ($results -and $results -match '(?i)(error|critical|high)') { + Write-Host $results + Write-Fail "DevSkim found security issues" + $failed += "DevSkim" + } + else { + Write-Pass "No critical security issues found" + } + Remove-Item "devskim-results.txt" -ErrorAction SilentlyContinue + } + else { + Write-Pass "No security issues found" + } +} + +# ============================================================================ +# STEP 5: Gitleaks Secrets Scan +# ============================================================================ +if (-not $SkipSecurity) { + Write-Step "Step 5: Gitleaks Secrets Scan" + + $gitleaks = Get-Command gitleaks -ErrorAction SilentlyContinue + if (-not $gitleaks) { + Write-Host "gitleaks not found — installing..." + $version = "8.24.0" + if ($IsWindows -or $env:OS -match 'Windows') { + $archive = "gitleaks_${version}_windows_x64.zip" + $url = "https://github.com/gitleaks/gitleaks/releases/download/v${version}/$archive" + $dest = Join-Path $env:LOCALAPPDATA "gitleaks" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + $zip = Join-Path $env:TEMP $archive + Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing + Expand-Archive -Path $zip -DestinationPath $dest -Force + Remove-Item $zip -ErrorAction SilentlyContinue + $env:PATH = "$dest;$env:PATH" + } + else { + $archive = "gitleaks_${version}_linux_x64.tar.gz" + $url = "https://github.com/gitleaks/gitleaks/releases/download/v${version}/$archive" + curl -sSfL $url | tar xz -C /usr/local/bin gitleaks + } + } + + gitleaks detect --source . --verbose --redact + if ($LASTEXITCODE -ne 0) { + Write-Fail "Gitleaks found secrets" + $failed += "Gitleaks" + } + else { + Write-Pass "No secrets detected" + } +} + +# ============================================================================ +# Summary +# ============================================================================ +Write-Host "" +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host "SUMMARY" -ForegroundColor Cyan +Write-Host "==========================================" -ForegroundColor Cyan + +if ($failed.Count -gt 0) { + Write-Fail "FAILED: $($failed -join ', ')" + exit 1 +} +else { + Write-Pass "All checks passed" + exit 0 +} diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 new file mode 100644 index 0000000..37a218f --- /dev/null +++ b/scripts/setup.ps1 @@ -0,0 +1,1042 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 + +<# +.SYNOPSIS + Automated setup script for .NET repository template +.DESCRIPTION + This script automates the process of configuring a new repository created from this template. + It prompts for project information, replaces placeholders, sets up the license, and validates changes. + + The script automatically ensures it runs from the repository root directory: + - If run from the scripts/ directory, it will automatically change to the repository root + - If run from any other location, it will display an error and exit + +.EXAMPLE + # Recommended: Run from repository root + pwsh ./scripts/setup.ps1 + +.EXAMPLE + # Also works: Run from scripts directory (auto-corrects to root) + cd scripts + pwsh ./setup.ps1 + +.NOTES + Requires PowerShell Core 7.0 or later (cross-platform) +#> + +[CmdletBinding()] +param() + +# Enable strict mode +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Color output functions +function Write-Success { + param([string]$Message) + Write-Host "āœ… $Message" -ForegroundColor Green +} + +function Write-Info { + param([string]$Message) + Write-Host "ā„¹ļø $Message" -ForegroundColor Cyan +} + +function Write-TemplateWarning { + param([string]$Message) + Write-Host "āš ļø $Message" -ForegroundColor Yellow +} + +function Write-TemplateError { + param([string]$Message) + Write-Host "āŒ $Message" -ForegroundColor Red +} + +function Write-Step { + param([string]$Message) + Write-Host "`nšŸ”§ $Message" -ForegroundColor Magenta +} + +# Banner +function Show-Banner { + Write-Host @" + +╔════════════════════════════════════════════════════════════════╗ +ā•‘ ā•‘ +ā•‘ .NET Repository Template - Automated Setup ā•‘ +ā•‘ ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + +"@ -ForegroundColor Cyan +} + +# Ensure script is running from repository root +function Set-RepositoryRoot { + # Get the directory where the script is located + $scriptDir = Split-Path -Parent $PSCommandPath + + # If we're in the scripts directory, move up one level to the repository root + if ((Split-Path -Leaf $scriptDir) -eq 'scripts') { + $repoRoot = Split-Path -Parent $scriptDir + Set-Location $repoRoot + Write-Info "Changed working directory to repository root: $repoRoot" + } + + # Verify we're in the repository root by checking for key marker files + $markerFiles = @('README.md', '.gitignore', 'CONTRIBUTING.md') + $foundMarkers = @($markerFiles | Where-Object { Test-Path $_ }) + + if ($foundMarkers.Count -lt 2) { + Write-TemplateError "This script must be run from the repository root directory." + Write-Host "Expected to find key files like: $($markerFiles -join ', ')" -ForegroundColor Red + Write-Host "Current directory: $(Get-Location)" -ForegroundColor Yellow + Write-Host "" + Write-Host "Please run the script from the repository root:" -ForegroundColor Yellow + Write-Host " pwsh ./scripts/setup.ps1" -ForegroundColor Cyan + throw "Script not running from repository root" + } +} + +# Auto-detect git information +function Get-GitInfo { + $gitInfo = @{ + RemoteUrl = '' + RepoName = '' + Username = '' + UserEmail = '' + FullName = '' + } + + try { + # Get remote URL + $remoteUrl = git remote get-url origin 2>$null + if ($remoteUrl) { + $gitInfo.RemoteUrl = $remoteUrl -replace '\.git$', '' + + # Extract repo name + if ($remoteUrl -match '/([^/]+?)(?:\.git)?$') { + $gitInfo.RepoName = $matches[1] + } + + # Extract username (for GitHub URLs) + if ($remoteUrl -match 'github\.com[:/]([^/]+)/') { + $gitInfo.Username = "@$($matches[1])" + } + } + + # Get git user name + $userName = git config user.name 2>$null + if ($userName) { + $gitInfo.FullName = $userName + } + + # Get git user email + $userEmail = git config user.email 2>$null + if ($userEmail) { + $gitInfo.UserEmail = $userEmail + } + } + catch { + Write-Warning "Could not auto-detect git information" + } + + return $gitInfo +} + +# Prompt for input with default and example +function Read-Input { + param( + [string]$Prompt, + [string]$Default = '', + [string]$Example = '', + [switch]$Required + ) + + $message = $Prompt + if ($Example) { + $message += "`n Example: $Example" + } + if ($Default) { + $message += "`n Default: $Default" + } + $message += "`n > " + + do { + Write-Host $message -NoNewline -ForegroundColor Yellow + $userInput = Read-Host + + if ([string]::IsNullOrWhiteSpace($userInput) -and $Default) { + return $Default + } + + if ([string]::IsNullOrWhiteSpace($userInput) -and $Required) { + Write-TemplateError "This field is required. Please enter a value." + continue + } + + return $userInput + } while ($true) +} + +# Replace placeholders in a file +function Replace-Placeholders { + param( + [string]$FilePath, + [hashtable]$Replacements + ) + + if (-not (Test-Path $FilePath)) { + Write-Warning "File not found: $FilePath" + return + } + + $content = Get-Content $FilePath -Raw + $modified = $false + + foreach ($key in $Replacements.Keys) { + $placeholder = "{{$key}}" + if ($content -match [regex]::Escape($placeholder)) { + $pattern = [regex]::Escape($placeholder) + $content = [regex]::Replace( + $content, + $pattern, + [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $Replacements[$key] } + ) + $modified = $true + } + } + + if ($modified) { + Set-Content -Path $FilePath -Value $content + Write-Success "Updated: $FilePath" + } +} + +# Main setup function +function Start-Setup { + Show-Banner + + # Ensure we're in the repository root + Set-RepositoryRoot + + Write-Info "This script will configure your new repository." + Write-Info "It will prompt you for project information and replace all placeholders." + Write-Host "" + + # Auto-detect git info + Write-Step "Auto-detecting git repository information..." + $gitInfo = Get-GitInfo + + if ($gitInfo.RemoteUrl) { + Write-Success "Detected repository: $($gitInfo.RemoteUrl)" + } + + # Collect project information + Write-Step "Collecting project information..." + Write-Host "" + + # Ask if creating NuGet package + Write-Host "Will this project be published as a NuGet package? (Y/n): " -NoNewline -ForegroundColor Yellow + $createNugetPackage = Read-Host + if ([string]::IsNullOrEmpty($createNugetPackage) -or $createNugetPackage -eq 'Y' -or $createNugetPackage -eq 'y') { + $isNugetPackage = $true + } + else { + $isNugetPackage = $false + } + Write-Host "" + + $projectName = Read-Input ` + -Prompt "Project Name (e.g., Wolfgang.Extensions.IAsyncEnumerable)" ` + -Example "MyCompany.MyLibrary" ` + -Required + + $projectDescription = Read-Input ` + -Prompt "Project Description (one-line description)" ` + -Example "High-performance extension methods for IAsyncEnumerable" ` + -Required + + if ($isNugetPackage) { + $packageName = Read-Input ` + -Prompt "NuGet Package Name" ` + -Default $projectName ` + -Example $projectName + } + else { + $packageName = $projectName + } + + $githubRepoUrl = Read-Input ` + -Prompt "GitHub Repository URL" ` + -Default $gitInfo.RemoteUrl ` + -Example "https://github.com/username/repo-name" ` + -Required + + # Extract repo name from URL if not already detected + $repoName = $gitInfo.RepoName + if ([string]::IsNullOrWhiteSpace($repoName) -and $githubRepoUrl -match '/([^/]+?)(?:\.git)?$') { + $repoName = $matches[1] + } + if ([string]::IsNullOrWhiteSpace($repoName)) { + $repoName = Read-Input ` + -Prompt "Repository Name" ` + -Example "my-repo-name" ` + -Required + } + + $githubUsername = Read-Input ` + -Prompt "GitHub Username (with @)" ` + -Default $gitInfo.Username ` + -Example "@YourUsername" ` + -Required + + # Ensure @ prefix + if ($githubUsername -notmatch '^@') { + $githubUsername = "@$githubUsername" + } + + # Normalize GitHub URL and generate docs URL + # Handle SSH URLs (git@github.com:org/repo.git) and HTTPS URLs + # Remove trailing .git and normalize to https://github.com// + $normalizedUrl = $githubRepoUrl + + # Convert SSH URL to HTTPS format + if ($normalizedUrl -match '^git@github\.com:(.+)$') { + $normalizedUrl = "https://github.com/$($matches[1])" + } + + # Remove trailing .git + $normalizedUrl = $normalizedUrl -replace '\.git$', '' + + # Extract owner and repo from normalized HTTPS URL + $docsUrl = $normalizedUrl -replace 'https://github\.com/([^/]+)/([^/]+).*', 'https://$1.github.io/$2/' + + $docsUrl = Read-Input ` + -Prompt "Documentation URL (GitHub Pages)" ` + -Default $docsUrl ` + -Example "https://username.github.io/repo-name/" + + # Get copyright holder + $copyrightHolder = Read-Input ` + -Prompt "Copyright Holder Name" ` + -Default $gitInfo.FullName ` + -Example "John Doe" ` + -Required + + $currentYear = (Get-Date).Year + $year = Read-Input ` + -Prompt "Copyright Year" ` + -Default $currentYear.ToString() ` + -Example $currentYear.ToString() + + if ($isNugetPackage) { + $nugetStatus = Read-Input ` + -Prompt "NuGet Package Status" ` + -Default "Coming soon to NuGet.org" ` + -Example "Available on NuGet.org" + } + else { + $nugetStatus = "Not applicable" + } + + # License selection + Write-Step "Selecting License..." + Write-Host "" + Write-Host "Available licenses:" -ForegroundColor Yellow + Write-Host " 1) MIT - Most permissive, simple, business-friendly" + Write-Host " 2) Apache-2.0 - Permissive with patent grant" + Write-Host " 3) MPL-2.0 - Weak copyleft, file-level" + Write-Host "" + Write-Host "For detailed comparison, see LICENSE-SELECTION.md" -ForegroundColor Cyan + Write-Host "" + + do { + Write-Host "Select license (1-3): " -NoNewline -ForegroundColor Yellow + $licenseChoice = Read-Host + + switch ($licenseChoice) { + '1' { + $licenseType = 'MIT' + $licenseFile = 'LICENSE-MIT.txt' + break + } + '2' { + $licenseType = 'Apache-2.0' + $licenseFile = 'LICENSE-APACHE-2.0.txt' + break + } + '3' { + $licenseType = 'MPL-2.0' + $licenseFile = 'LICENSE-MPL-2.0.txt' + break + } + default { + Write-TemplateError "Invalid choice. Please enter 1, 2, or 3." + continue + } + } + break + } while ($true) + + Write-Success "Selected: $licenseType License" + + # Template repository info (for REPO-INSTRUCTIONS.md) + $templateRepoOwner = Read-Input ` + -Prompt "Template Repository Owner (the GitHub user/org that owns the template you used)" ` + -Default "Chris-Wolfgang" ` + -Example "YourUsername" + + $templateRepoName = Read-Input ` + -Prompt "Template Repository Name (the name of the template repository you used)" ` + -Default "repo-template" ` + -Example "my-template" + + # Solution creation + Write-Step "Solution Creation" + Write-Host "" + Write-Host "Create a default solution? (y/N): " -NoNewline -ForegroundColor Yellow + $createSolution = Read-Host + + $solutionName = '' + if ($createSolution -eq 'y' -or $createSolution -eq 'Y') { + $isValidSolutionName = $false + while (-not $isValidSolutionName) { + $solutionName = Read-Input ` + -Prompt "Solution Name" ` + -Default $repoName ` + -Example $repoName ` + -Required + + $invalidFileNameChars = [System.IO.Path]::GetInvalidFileNameChars() + if ($solutionName.IndexOfAny($invalidFileNameChars) -ne -1) { + $invalidCharsDisplay = -join $invalidFileNameChars + Write-Error "Solution name contains invalid characters. Please avoid any of: $invalidCharsDisplay" -ErrorAction Continue + } + else { + $isValidSolutionName = $true + } + } + } + + # Summary + Write-Step "Configuration Summary" + Write-Host "" + Write-Host "Project Information:" -ForegroundColor Cyan + Write-Host " Project Name: $projectName" + Write-Host " Description: $projectDescription" + Write-Host " Package Name: $packageName" + Write-Host " Repository URL: $githubRepoUrl" + Write-Host " Repository Name: $repoName" + Write-Host " GitHub Username: $githubUsername" + Write-Host " Documentation URL: $docsUrl" + Write-Host " License: $licenseType" + Write-Host " Copyright Holder: $copyrightHolder" + Write-Host " Copyright Year: $year" + Write-Host " NuGet Status: $nugetStatus" + Write-Host " Template Owner: $templateRepoOwner" + Write-Host " Template Name: $templateRepoName" + if ($solutionName) { + Write-Host " Solution Name: $solutionName" + } + Write-Host "" + + Write-Host "Proceed with configuration? (Y/n): " -NoNewline -ForegroundColor Yellow + $confirm = Read-Host + if ($confirm -and $confirm -ne 'Y' -and $confirm -ne 'y') { + Write-Warning "Setup cancelled." + exit 0 + } + + # Create replacements hashtable + $replacements = @{ + 'PROJECT_NAME' = $projectName + 'PROJECT_DESCRIPTION' = $projectDescription + 'PACKAGE_NAME' = $packageName + 'GITHUB_REPO_URL' = $githubRepoUrl + 'REPO_NAME' = $repoName + 'GITHUB_USERNAME' = $githubUsername + 'DOCS_URL' = $docsUrl + 'LICENSE_TYPE' = $licenseType + 'YEAR' = $year + 'COPYRIGHT_HOLDER' = $copyrightHolder + 'NUGET_STATUS' = $nugetStatus + 'TEMPLATE_REPO_OWNER' = $templateRepoOwner + 'TEMPLATE_REPO_NAME' = $templateRepoName + } + + # Perform setup + Write-Step "Performing setup..." + Write-Host "" + + $totalSteps = if ($solutionName) { 5 } else { 4 } + + # Step 1: README swap + Write-Info "Step 1/${totalSteps}: Swapping README files..." + if (Test-Path 'README.md') { + Remove-Item 'README.md' -Force + Write-Success "Deleted template README.md" + } + + if (Test-Path 'README-TEMPLATE.md') { + Rename-Item 'README-TEMPLATE.md' 'README.md' + Write-Success "Renamed README-TEMPLATE.md → README.md" + } + else { + Write-Error "README-TEMPLATE.md not found!" + exit 1 + } + + # Step 2: Replace placeholders + Write-Info "Step 2/${totalSteps}: Replacing placeholders in files..." + + $filesToUpdate = @( + 'README.md', + 'CONTRIBUTING.md', + '.github/CODEOWNERS', + 'REPO-INSTRUCTIONS.md', + 'scripts/Setup-BranchRuleset.ps1', + 'docfx_project/docfx.json', + 'docfx_project/index.md', + 'docfx_project/api/index.md', + 'docfx_project/api/README.md', + 'docfx_project/docs/toc.yml', + 'docfx_project/docs/introduction.md', + 'docfx_project/docs/getting-started.md', + 'BannedSymbols.txt' + ) + + foreach ($file in $filesToUpdate) { + Replace-Placeholders -FilePath $file -Replacements $replacements + } + + # Step 3: Set up LICENSE + Write-Info "Step 3/${totalSteps}: Setting up LICENSE file..." + + if (Test-Path $licenseFile) { + # Read license template + $licenseContent = Get-Content $licenseFile -Raw + + # Replace placeholders using safe regex replacement with MatchEvaluator + $licenseContent = [regex]::Replace( + $licenseContent, + [regex]::Escape('{{YEAR}}'), + [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $year } + ) + $licenseContent = [regex]::Replace( + $licenseContent, + [regex]::Escape('{{COPYRIGHT_HOLDER}}'), + [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $copyrightHolder } + ) + + # Save as LICENSE + Set-Content -Path 'LICENSE' -Value $licenseContent -NoNewline + Write-Success "Created LICENSE file ($licenseType)" + + # Delete all license templates + Remove-Item 'LICENSE-MIT.txt' -Force -ErrorAction SilentlyContinue + Remove-Item 'LICENSE-APACHE-2.0.txt' -Force -ErrorAction SilentlyContinue + Remove-Item 'LICENSE-MPL-2.0.txt' -Force -ErrorAction SilentlyContinue + Write-Success "Removed license template files" + } + else { + Write-Error "License template file not found: $licenseFile" + exit 1 + } + + # Step 4: Create solution (if requested) + if ($solutionName) { + Write-Info "Step 4/${totalSteps}: Creating solution file..." + + # Create blank solution in .slnx format + # Note: .slnx format requires Visual Studio 2022 version 17.10 or later + $solutionFileName = "$solutionName.slnx" + + # Build the solution XML structure + $xmlBuilder = New-Object System.Text.StringBuilder + [void]$xmlBuilder.AppendLine('') + + # Build .root folder with all remaining files + # Exclude files and directories that have their own solution folders or are build artifacts + # Note: .git directory is excluded separately below + $excludePatterns = @( + 'obj', # Build output + 'bin', # Build output + 'TestResults', # Test artifacts + 'CoverageReport', # Coverage artifacts + 'node_modules', # Node dependencies + '*.user', # User-specific files + '*.suo', # Visual Studio user options + '*.sln', # Solution files (prevent including solution in itself) + '*.slnx', # Solution files (prevent including solution in itself) + '*.env', # Environment files (may contain secrets) + '*.key', # Key files (may contain secrets) + '*.pem', # Certificate files (may contain secrets) + 'secrets*', # Secret files + 'benchmarks', # Has its own solution folder + 'examples', # Has its own solution folder + 'src', # Has its own solution folder + 'tests', # Has its own solution folder + 'docfx_project' # Documentation source (built separately) + ) + + # Get current directory for relative path calculation + $currentDir = Get-Location + + # Helper function to get relative path safely + function Get-SafeRelativePath { + param($FullPath) + try { + # Use Resolve-Path with -Relative for safe relative path calculation + $rel = Resolve-Path -Path $FullPath -Relative -ErrorAction Stop + # Remove leading .\ or ./ prefix properly + if ($rel.StartsWith('.\')) { + $rel = $rel.Substring(2) + } + elseif ($rel.StartsWith('./')) { + $rel = $rel.Substring(2) + } + return $rel.Replace('\', '/') + } + catch { + # Fallback: manual calculation + $path = $FullPath + if ($path.StartsWith($currentDir.Path, [System.StringComparison]::OrdinalIgnoreCase)) { + $baseLength = $currentDir.Path.Length + # Ensure we only strip the base path when it's a complete directory component + if ($path.Length -eq $baseLength -or + ($path.Length -gt $baseLength -and + ($path[$baseLength] -eq [System.IO.Path]::DirectorySeparatorChar -or + $path[$baseLength] -eq [System.IO.Path]::AltDirectorySeparatorChar))) { + # Remove the base path and any leading separator + $path = $path.Substring($baseLength) + if ($path.StartsWith('\') -or $path.StartsWith('/')) { + $path = $path.Substring(1) + } + } + } + return $path.Replace('\', '/') + } + } + + # Get all files in the repository + $allFiles = Get-ChildItem -Recurse -File -Force | Where-Object { + # Get relative path safely + $relativePath = Get-SafeRelativePath $_.FullName + + # Exclude files under .git directory specifically (not .github) + if ($relativePath -like '.git/*') { + return $false + } + + # Exclude hidden files (starting with .) except those in .github directory + $fileName = [System.IO.Path]::GetFileName($relativePath) + $isInGitHubDir = $relativePath -like '.github/*' + if ($fileName.StartsWith('.') -and -not $isInGitHubDir) { + return $false + } + + # Exclude files matching patterns using precise matching + $shouldExclude = $false + $pathSegments = $relativePath -split '[\\/]+' + $fileExtension = [System.IO.Path]::GetExtension($relativePath) + + foreach ($pattern in $excludePatterns) { + # Handle extension patterns like '*.user' or '*.suo' + if ($pattern.StartsWith('*.')) { + $ext = $pattern.Substring(1) + if ($fileExtension -ieq $ext) { + $shouldExclude = $true + break + } + } + # Handle wildcard patterns like 'secrets*' + elseif ($pattern.Contains('*')) { + if ($relativePath -like $pattern) { + $shouldExclude = $true + break + } + } + # Treat as a path segment name and match against segments + else { + if ($pathSegments -contains $pattern) { + $shouldExclude = $true + break + } + } + } + + -not $shouldExclude + } + + # Group files by directory for .root structure + # Cache relative paths to avoid recalculating + $filesByDirectory = @{} + $relativePathCache = @{} + + foreach ($file in $allFiles) { + # Get relative path safely (use cached if available) + if (-not $relativePathCache.ContainsKey($file.FullName)) { + $relativePathCache[$file.FullName] = Get-SafeRelativePath $file.FullName + } + $relativePath = $relativePathCache[$file.FullName] + $directory = Split-Path $relativePath -Parent + if ([string]::IsNullOrEmpty($directory)) { + $directory = '.' + } + else { + $directory = $directory.Replace('\', '/') + } + + if (-not $filesByDirectory.ContainsKey($directory)) { + $filesByDirectory[$directory] = @() + } + $filesByDirectory[$directory] += $relativePath + } + + # Sort directories to ensure proper nesting order + $sortedDirectories = $filesByDirectory.Keys | Sort-Object + + # Build folder structure with XML escaping + foreach ($directory in $sortedDirectories) { + if ($directory -eq '.') { + # Root files + [void]$xmlBuilder.AppendLine(' ') + foreach ($filePath in ($filesByDirectory[$directory] | Sort-Object)) { + $escapedPath = [System.Security.SecurityElement]::Escape($filePath) + [void]$xmlBuilder.AppendLine(" ") + } + [void]$xmlBuilder.AppendLine(' ') + } + else { + # Subdirectory files + $folderName = "/.root/$directory/" + $escapedFolderName = [System.Security.SecurityElement]::Escape($folderName) + [void]$xmlBuilder.AppendLine(" ") + foreach ($filePath in ($filesByDirectory[$directory] | Sort-Object)) { + $escapedPath = [System.Security.SecurityElement]::Escape($filePath) + [void]$xmlBuilder.AppendLine(" ") + } + [void]$xmlBuilder.AppendLine(' ') + } + } + + # Add solution folders for benchmarks, examples, src, tests (only if directories exist) + # These are added after .root to prioritize configuration files in solution explorer + $solutionFolders = @('benchmarks', 'examples', 'src', 'tests') + foreach ($folder in $solutionFolders) { + if (Test-Path -Path $folder -PathType Container) { + [void]$xmlBuilder.AppendLine(" ") + } + } + + [void]$xmlBuilder.AppendLine('') + + # Write solution file with error handling + try { + Set-Content -Path $solutionFileName -Value $xmlBuilder.ToString() -ErrorAction Stop + Write-Success "Created solution file: $solutionFileName" + + # Show summary + $fileCount = $allFiles.Count + $folderCount = $filesByDirectory.Keys.Count + Write-Info "Added $fileCount files in $folderCount folders to .root/" + } + catch { + Write-TemplateWarning "Failed to create solution file '$solutionFileName'. Repository setup will continue." + Write-TemplateWarning "Error: $($_.Exception.Message)" + # Clear solutionFileName so Next Steps won't reference it + $solutionFileName = '' + } + } + + # Step 5: Validation + Write-Info "Step ${totalSteps}/${totalSteps}: Validating changes..." + + # Core placeholders that should have been replaced by the script + # Note: YEAR and COPYRIGHT_HOLDER are handled in LICENSE file generation, not in FILES_TO_UPDATE + $corePlaceholders = @( + 'PROJECT_NAME', 'PROJECT_DESCRIPTION', 'PACKAGE_NAME', + 'GITHUB_REPO_URL', 'REPO_NAME', 'GITHUB_USERNAME', + 'DOCS_URL', 'LICENSE_TYPE', + 'NUGET_STATUS', 'TEMPLATE_REPO_OWNER', 'TEMPLATE_REPO_NAME' + ) + + # Optional placeholders that users fill in manually as they develop + $optionalPlaceholderDescriptions = @{ + 'QUICK_START_EXAMPLE' = 'Code example showing basic usage' + 'FEATURES_TABLE' = 'Markdown table listing features' + 'FEATURE_EXAMPLES' = 'Code examples demonstrating features' + 'TARGET_FRAMEWORKS' = 'List of supported .NET frameworks' + 'ACKNOWLEDGMENTS' = 'Credits for libraries/tools used' + } + + # Collect placeholders grouped by placeholder name + $corePlaceholdersByName = @{} + $optionalPlaceholdersByName = @{} + + foreach ($file in $filesToUpdate) { + if (Test-Path $file) { + $content = Get-Content $file -Raw + $matches = [regex]::Matches($content, '\{\{([A-Z_]+)\}\}') + foreach ($match in $matches) { + $placeholderName = $match.Groups[1].Value + + # Categorize placeholder + if ($corePlaceholders -contains $placeholderName) { + if (-not $corePlaceholdersByName.ContainsKey($placeholderName)) { + $corePlaceholdersByName[$placeholderName] = @() + } + if ($corePlaceholdersByName[$placeholderName] -notcontains $file) { + $corePlaceholdersByName[$placeholderName] += $file + } + } + elseif ($optionalPlaceholderDescriptions.ContainsKey($placeholderName)) { + if (-not $optionalPlaceholdersByName.ContainsKey($placeholderName)) { + $optionalPlaceholdersByName[$placeholderName] = @() + } + if ($optionalPlaceholdersByName[$placeholderName] -notcontains $file) { + $optionalPlaceholdersByName[$placeholderName] += $file + } + } + } + } + } + + # Report core placeholders that weren't replaced (this is an error) + if ($corePlaceholdersByName.Count -gt 0) { + Write-TemplateError "Error: The following required placeholders were not replaced:" + Write-Host "" + foreach ($placeholderName in ($corePlaceholdersByName.Keys | Sort-Object)) { + Write-Host " {{$placeholderName}}" -ForegroundColor Red + Write-Host " Found in:" -ForegroundColor Gray + foreach ($file in $corePlaceholdersByName[$placeholderName]) { + Write-Host " - $file" -ForegroundColor Gray + } + Write-Host "" + } + Write-Warning "This indicates the script did not replace all required placeholders. Please review the files and replace these manually." + Write-Host "" + exit 1 + } + else { + Write-Success "All required placeholders replaced successfully!" + } + + # Report optional placeholders that need manual updates + if ($optionalPlaceholdersByName.Count -gt 0) { + Write-Host "" + Write-Info "Optional content placeholders to fill in as you develop your project:" + Write-Host "" + + foreach ($placeholderName in ($optionalPlaceholdersByName.Keys | Sort-Object)) { + $description = $optionalPlaceholderDescriptions[$placeholderName] + + Write-Host " {{$placeholderName}}" -ForegroundColor Yellow + Write-Host " Description: $description" -ForegroundColor Gray + Write-Host " Found in:" -ForegroundColor Gray + foreach ($file in $optionalPlaceholdersByName[$placeholderName]) { + Write-Host " - $file" -ForegroundColor Gray + } + Write-Host "" + } + Write-Info "See TEMPLATE-PLACEHOLDERS.md for details on each placeholder." + } + + # Optional cleanup + Write-Step "Cleanup" + Write-Host "" + Write-Host "Remove template-specific files? (y/N)" -ForegroundColor Yellow + Write-Host " Files to remove:" -ForegroundColor Gray + Write-Host " - scripts/setup.ps1 (this script)" -ForegroundColor Gray + Write-Host " - LICENSE-SELECTION.md" -ForegroundColor Gray + Write-Host "" + Write-Host " Note: TEMPLATE-PLACEHOLDERS.md will remain for your reference." -ForegroundColor Cyan + Write-Host " Delete it manually when you've reviewed it and no longer need it." -ForegroundColor Cyan + Write-Host "" + Write-Host "Remove template files? (y/N): " -NoNewline -ForegroundColor Yellow + $cleanup = Read-Host + + if ($cleanup -eq 'y' -or $cleanup -eq 'Y') { + $filesToRemove = @( + 'scripts/setup.ps1', + 'LICENSE-SELECTION.md' + ) + + foreach ($file in $filesToRemove) { + if (Test-Path $file) { + Remove-Item $file -Force + Write-Success "Removed: $file" + } + } + } + else { + Write-Info "Keeping template files. You can remove them manually later." + } + + # Success! + Write-Host "" + Write-Host "╔════════════════════════════════════════════════════════════════╗" -ForegroundColor Green + Write-Host "ā•‘ ā•‘" -ForegroundColor Green + Write-Host "ā•‘ šŸŽ‰ Setup Complete! šŸŽ‰ ā•‘" -ForegroundColor Green + Write-Host "ā•‘ ā•‘" -ForegroundColor Green + Write-Host "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" -ForegroundColor Green + Write-Host "" + + # Git operations + Write-Step "Git Operations" + Write-Host "" + + # Step 1: Create branch and commit changes + Write-Host "Create a branch and commit these changes? (Y/n): " -NoNewline -ForegroundColor Yellow + $commitChanges = Read-Host + if ([string]::IsNullOrEmpty($commitChanges) -or $commitChanges -eq 'Y' -or $commitChanges -eq 'y') { + # Generate branch name + $branchName = "setup/configure-from-template-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + + Write-Info "Step 1/4: Creating branch '$branchName'..." + git checkout -b $branchName + if ($LASTEXITCODE -eq 0) { + Write-Success "Branch created successfully!" + Write-Host "" + + Write-Info "Step 2/4: Committing changes..." + git add . + if ($LASTEXITCODE -eq 0) { + git commit -m "Configure repository from template" + if ($LASTEXITCODE -eq 0) { + Write-Success "Changes committed successfully!" + Write-Host "" + + # Step 3: Push to GitHub + Write-Info "Step 3/4: Pushing branch to GitHub..." + git push -u origin $branchName + if ($LASTEXITCODE -eq 0) { + Write-Success "Branch pushed to GitHub successfully!" + Write-Host "" + + # Step 4: Create Pull Request + Write-Info "Step 4/4: Creating pull request..." + + # Check if gh command is available + try { + $null = Get-Command gh -ErrorAction Stop + + gh pr create --title "Configure repository from template" --body "This PR contains the initial repository configuration from the template setup script.`n`nPlease review the changes, make any necessary adjustments, and merge to main when ready." --base main --head $branchName + if ($LASTEXITCODE -eq 0) { + Write-Success "Pull request created successfully!" + Write-Host "" + + # Get PR URL (best-effort; fall back to generic instruction on failure) + $prUrl = gh pr view $branchName --json url --jq .url 2>$null + if ($LASTEXITCODE -eq 0 -and $prUrl) { + Write-Host "╔════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan + Write-Host "ā•‘ ā•‘" -ForegroundColor Cyan + Write-Host "ā•‘ šŸ“‹ Review Required ā•‘" -ForegroundColor Cyan + Write-Host "ā•‘ ā•‘" -ForegroundColor Cyan + Write-Host "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" -ForegroundColor Cyan + Write-Host "" + Write-Host "Branch: $branchName" -ForegroundColor Yellow + Write-Host "Pull Request: $prUrl" -ForegroundColor Yellow + Write-Host "" + Write-Info "Please review the pull request, make any necessary changes, and merge it to main before continuing with development." + Write-Host "" + } + else { + Write-Host "╔════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan + Write-Host "ā•‘ ā•‘" -ForegroundColor Cyan + Write-Host "ā•‘ šŸ“‹ Review Required ā•‘" -ForegroundColor Cyan + Write-Host "ā•‘ ā•‘" -ForegroundColor Cyan + Write-Host "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" -ForegroundColor Cyan + Write-Host "" + Write-Host "Branch: $branchName" -ForegroundColor Yellow + Write-Host "" + Write-Info "Please review the pull request, make any necessary changes, and merge it to main before continuing with development." + Write-Info "You can view the pull request with: gh pr view $branchName --web" + Write-Host "" + } + } + else { + Write-TemplateWarning "Failed to create pull request. You can create it manually with:" + Write-Host " gh pr create --title ""Configure repository from template"" --body ""Initial setup"" --base main --head $branchName" -ForegroundColor Gray + Write-Host "" + } + } + catch { + Write-TemplateWarning "GitHub CLI (gh) is not installed or not available in PATH." + Write-TemplateWarning "Please install it from https://cli.github.com/ to enable automatic PR creation." + Write-Host "" + Write-Info "You can create the pull request manually with:" + Write-Host " gh pr create --title ""Configure repository from template"" --body ""Initial setup"" --base main --head $branchName" -ForegroundColor Gray + Write-Host "" + } + } + else { + Write-TemplateWarning "Push failed. You can push manually later with:" + Write-Host " git push -u origin $branchName" -ForegroundColor Gray + Write-Host "" + } + } + else { + Write-TemplateWarning "Commit failed. You can commit manually later with:" + Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray + Write-Host " git push -u origin $branchName" -ForegroundColor Gray + Write-Host "" + } + } + else { + Write-TemplateWarning "Git add failed. You can commit manually later with:" + Write-Host " git add ." -ForegroundColor Gray + Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray + Write-Host " git push -u origin $branchName" -ForegroundColor Gray + Write-Host "" + } + } + else { + Write-TemplateWarning "Failed to create branch. You can create it manually with:" + Write-Host " git checkout -b $branchName" -ForegroundColor Gray + Write-Host " git add ." -ForegroundColor Gray + Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray + Write-Host " git push -u origin $branchName" -ForegroundColor Gray + Write-Host "" + } + } + else { + Write-Info "Skipping branch creation and commit. You can do this manually later with:" + Write-Host " git checkout -b setup/configure-from-template-" -ForegroundColor Gray + Write-Host " git add ." -ForegroundColor Gray + Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray + Write-Host " git push -u origin setup/configure-from-template-" -ForegroundColor Gray + Write-Host " gh pr create --title ""Configure repository from template"" --base main" -ForegroundColor Gray + Write-Host "" + } + + # Next steps + Write-Host "āœ… Next Steps:" -ForegroundColor Cyan + Write-Host "" + Write-Host "1. Configure branch protection (see REPO-INSTRUCTIONS.md if kept)" -ForegroundColor Yellow + Write-Host "" + Write-Host "2. Start developing!" -ForegroundColor Yellow + if ($solutionName) { + Write-Host " # Solution file created: $solutionName.slnx" -ForegroundColor Gray + Write-Host " # Add your projects to src/ and tests/" -ForegroundColor Gray + } + else { + Write-Host " dotnet new sln -n $projectName" -ForegroundColor Gray + Write-Host " # Add your projects to src/ and tests/" -ForegroundColor Gray + } + Write-Host "" + + Write-Info "Your repository is now configured and ready for development!" + Write-Host "" +} + +# Run setup +try { + Start-Setup +} +catch { + Write-Error "Setup failed: $_" + Write-Host $_.ScriptStackTrace -ForegroundColor Red + exit 1 +} From 9a10ff07d8a183b18ef63830883595c15c30aac9 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:20:49 -0400 Subject: [PATCH 05/18] Address Copilot review comments on PR #19 - setup.ps1: Allow dotfiles at repo root in .slnx generation (fixes .editorconfig, .gitignore, .globalconfig being excluded) - Setup-GitHubPages.ps1: Remove hardcoded repo default, auto-detect via gh repo view - build-pr.ps1: Install gitleaks to ~/.local/bin instead of /usr/local/bin (avoids sudo requirement) - build-pr.ps1: Fail when no test projects found (matches CI behavior) Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/Setup-GitHubPages.ps1 | 15 ++++++++++++++- scripts/build-pr.ps1 | 8 ++++++-- scripts/setup.ps1 | 5 +++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/Setup-GitHubPages.ps1 b/scripts/Setup-GitHubPages.ps1 index e7a7488..de6776b 100644 --- a/scripts/Setup-GitHubPages.ps1 +++ b/scripts/Setup-GitHubPages.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository = "Chris-Wolfgang/In-memory-Logger", + [string]$Repository, [Parameter()] [switch]$EnablePages, @@ -62,6 +62,19 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Auto-detect repository if not provided +if (-not $Repository) { + try { + $Repository = (gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>$null) + if (-not $Repository) { + throw "Could not auto-detect repository" + } + } catch { + Write-Error "Repository not specified and auto-detection failed. Use -Repository 'owner/repo'." + exit 1 + } +} + # Color output functions function Write-Success { param([string]$Message) diff --git a/scripts/build-pr.ps1 b/scripts/build-pr.ps1 index d7fd64c..611d126 100644 --- a/scripts/build-pr.ps1 +++ b/scripts/build-pr.ps1 @@ -84,7 +84,8 @@ if (-not $SkipTests -and $failed.Count -eq 0) { $testProjects = @(Get-ChildItem -Path './tests' -Recurse -File -Include '*.csproj', '*.vbproj', '*.fsproj' -ErrorAction SilentlyContinue) if ($testProjects.Count -eq 0) { - Write-Host "No test projects found in ./tests — skipping" + Write-Fail "No test projects found in ./tests" + $failed += "Tests" } else { foreach ($testProj in $testProjects) { @@ -267,7 +268,10 @@ if (-not $SkipSecurity) { else { $archive = "gitleaks_${version}_linux_x64.tar.gz" $url = "https://github.com/gitleaks/gitleaks/releases/download/v${version}/$archive" - curl -sSfL $url | tar xz -C /usr/local/bin gitleaks + $dest = Join-Path $HOME ".local/bin" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + curl -sSfL $url | tar xz -C $dest gitleaks + $env:PATH = "$dest$([IO.Path]::PathSeparator)$env:PATH" } } diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 37a218f..5134333 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -629,10 +629,11 @@ function Start-Setup { return $false } - # Exclude hidden files (starting with .) except those in .github directory + # Exclude hidden files (starting with .) except those in .github or at the repository root $fileName = [System.IO.Path]::GetFileName($relativePath) $isInGitHubDir = $relativePath -like '.github/*' - if ($fileName.StartsWith('.') -and -not $isInGitHubDir) { + $isAtRepoRoot = -not $relativePath.Contains('/') + if ($fileName.StartsWith('.') -and -not $isInGitHubDir -and -not $isAtRepoRoot) { return $false } From 018a9cf9d748631b159b31d60beaca2938ff03f6 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:57:30 -0400 Subject: [PATCH 06/18] Sync template-tracked file contents from repo-template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring template-tracked files in line with the canonical version in repo-template. Repo-specific files (Directory.Build.props, README.md, LICENSE, .gitignore, Solution.slnx, docfx_project/docfx.json, docfx_project/index.md, .github/CODEOWNERS, .github/dependabot.yml, .github/copilot-instructions.md) are intentionally NOT changed in this PR. Files updated: - .github/workflows/pr.yaml — picks up the MSBuild-based TFM detection fix and the graceful skip-when-no-tests fix - .github/workflows/docfx.yaml — picks up the SemVer prerelease sort fix - .github/workflows/release.yaml — sync with template - .editorconfig — sync with template - scripts/Setup-BranchRuleset.ps1, scripts/Setup-GitHubPages.ps1, scripts/build-pr.ps1, scripts/setup.ps1 — sync with template --- .editorconfig | 627 +++++++++++++++++++------------- .github/workflows/docfx.yaml | 368 +++++++++++++++++-- .github/workflows/pr.yaml | 105 ++++-- .github/workflows/release.yaml | 587 ++++++++++++++++++++++++++++-- scripts/Setup-BranchRuleset.ps1 | 6 +- scripts/Setup-GitHubPages.ps1 | 19 +- scripts/build-pr.ps1 | 8 +- scripts/setup.ps1 | 5 +- 8 files changed, 1335 insertions(+), 390 deletions(-) diff --git a/.editorconfig b/.editorconfig index 6c43eef..7b0f7a6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,280 +1,290 @@ root = true -# Top-most EditorConfig file # All files [*] charset = utf-8 -end_of_line = crlf -insert_final_newline = true -indent_style = tab +end_of_line = lf +indent_style = space indent_size = 4 +insert_final_newline = true trim_trailing_whitespace = true -# Microsoft .NET properties -csharp_style_expression_bodied_constructors = false:warning -dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion - -# ReSharper properties -resharper_csharp_wrap_lines = false -resharper_local_function_body = expression_body -dotnet_style_operator_placement_when_wrapping = beginning_of_line -tab_width = 4 -dotnet_style_prefer_compound_assignment = true:suggestion -dotnet_diagnostic.CA1707.severity = none -dotnet_style_allow_statement_immediately_after_block_experimental = true:silent - -# C# files -[*.cs] -# Use file-scoped namespaces -csharp_style_namespace_declarations = file_scoped:suggestion - -# Prefer `var` when type is apparent -csharp_style_var_when_type_is_apparent = true:suggestion -# Prefer explicit type when type is not apparent -csharp_style_var_elsewhere = true:suggestion - -# Prefer expression-bodied members -csharp_style_expression_bodied_methods = true:suggestion +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 -# Prefer 'this.' qualification for members -dotnet_style_qualification_for_field = true:suggestion -dotnet_style_qualification_for_property = true:suggestion -dotnet_style_qualification_for_method = false:suggestion - -# Analyzer severity: treat warnings as errors for analyzers -dotnet_analyzer_diagnostic.severity = suggestion - -# Example: StyleCop rule as error (if StyleCop is installed) -dotnet_diagnostic.sa1101.severity = error - -# Suppress a specific warning (example: unused variable) -dotnet_diagnostic.cs0168.severity = none - -# Allow underscores in test method names -dotnet_diagnostic.CA1707.severity = none +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 -# Require 'using' directives to be inside namespaces -dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = true - -# Organize usings on save (supported in some editors) -csharp_indent_labels = one_less_than_current -csharp_using_directive_placement = outside_namespace:silent -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_braces = true:silent -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_top_level_statements = true:silent -csharp_style_prefer_primary_constructors = true:suggestion -csharp_prefer_system_threading_lock = true:suggestion -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_lambdas = true:silent -csharp_style_expression_bodied_local_functions = false:silent -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_style_var_for_built_in_types = true:suggestion -#### Core EditorConfig Options #### - -# Indentation and spacing -indent_style = space -tab_width = 4 - -# New line preferences -insert_final_newline = false +# JSON files +[*.json] +indent_size = 2 -#### .NET Code Actions #### +# YAML files +[*.{yml,yaml}] +indent_size = 2 -# Type members -dotnet_hide_advanced_members = false -dotnet_member_insertion_location = with_other_members_of_the_same_kind -dotnet_property_generation_behavior = prefer_throwing_properties - -# Symbol search -dotnet_search_reference_assemblies = true - -#### .NET Coding Conventions #### +# 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 -# Organize usings -dotnet_separate_import_directive_groups = false -file_header_template = - -# this. and Me. preferences -dotnet_style_qualification_for_event = false:warning -dotnet_style_qualification_for_field = false -dotnet_style_qualification_for_method = false:warning -dotnet_style_qualification_for_property = false:warning - -# Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true -dotnet_style_predefined_type_for_member_access = true - -# Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity -dotnet_style_parentheses_in_other_operators = never_if_unnecessary -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity - -# Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members - -# Expression-level preferences -dotnet_prefer_system_hash_code = true -dotnet_style_coalesce_expression = true -dotnet_style_collection_initializer = true -dotnet_style_explicit_tuple_names = true -dotnet_style_namespace_match_folder = true -dotnet_style_null_propagation = true -dotnet_style_object_initializer = true -dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_prefer_auto_properties = true -dotnet_style_prefer_collection_expression = when_types_loosely_match -dotnet_style_prefer_compound_assignment = true -dotnet_style_prefer_conditional_expression_over_assignment = true -dotnet_style_prefer_conditional_expression_over_return = true -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed -dotnet_style_prefer_inferred_anonymous_type_member_names = true -dotnet_style_prefer_inferred_tuple_names = true -dotnet_style_prefer_is_null_check_over_reference_equality_method = true -dotnet_style_prefer_simplified_boolean_expressions = true -dotnet_style_prefer_simplified_interpolation = true - -# Field preferences -dotnet_style_readonly_field = true - -# Parameter preferences -dotnet_code_quality_unused_parameters = all - -# Suppression preferences -dotnet_remove_unnecessary_suppression_exclusions = none +# C# files +[*.cs] -# New line preferences -dotnet_style_allow_multiple_blank_lines_experimental = true -dotnet_style_allow_statement_immediately_after_block_experimental = true - -#### C# Coding Conventions #### - -# var preferences -csharp_style_var_elsewhere = true:warning -csharp_style_var_for_built_in_types = true:warning -csharp_style_var_when_type_is_apparent = true:warning - -# Expression-bodied members -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_constructors = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_lambdas = true:silent -csharp_style_expression_bodied_local_functions = false:silent -csharp_style_expression_bodied_methods = true:silent -csharp_style_expression_bodied_operators = true:silent -csharp_style_expression_bodied_properties = true:silent - -# Pattern matching preferences -csharp_style_pattern_matching_over_as_with_null_check = true -csharp_style_pattern_matching_over_is_with_cast_check = true -csharp_style_prefer_extended_property_pattern = true -csharp_style_prefer_not_pattern = true -csharp_style_prefer_pattern_matching = true -csharp_style_prefer_switch_expression = true - -# Null-checking preferences -csharp_style_conditional_delegate_call = true - -# Modifier preferences -csharp_prefer_static_anonymous_function = true -csharp_prefer_static_local_function = true -csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async -csharp_style_prefer_readonly_struct = true -csharp_style_prefer_readonly_struct_member = true - -# Code-block preferences -csharp_prefer_braces = true:warning -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_system_threading_lock = true:suggestion -csharp_style_namespace_declarations = file_scoped:warning -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_primary_constructors = true:suggestion -csharp_style_prefer_top_level_statements = true:silent - -# Expression-level preferences -csharp_prefer_simple_default_expression = true -csharp_style_deconstructed_variable_declaration = true -csharp_style_implicit_object_creation_when_type_is_apparent = true -csharp_style_inlined_variable_declaration = true -csharp_style_prefer_index_operator = true -csharp_style_prefer_local_over_anonymous_function = true -csharp_style_prefer_null_check_over_type_check = true -csharp_style_prefer_range_operator = true -csharp_style_prefer_tuple_swap = true -csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion -csharp_style_prefer_utf8_string_literals = true -csharp_style_throw_expression = true -csharp_style_unused_value_assignment_preference = discard_variable -csharp_style_unused_value_expression_statement_preference = discard_variable - -# 'using' directive preferences -csharp_using_directive_placement = outside_namespace:silent +# SA0001 - Disable XML documentation file requirement +dotnet_diagnostic.SA0001.severity = none -# New line preferences -csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true -csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true -csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true -csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true -csharp_style_allow_embedded_statements_on_same_line_experimental = true +# .NET Code Analysis Rules +# Enable .NET analyzers with conservative defaults +dotnet_analyzer_diagnostic.severity = suggestion -#### C# Formatting Rules #### +# 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_catch = true +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_anonymous_types = true csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_open_brace = all +csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_between_query_expression_clauses = true # Indentation preferences -csharp_indent_block_contents = true -csharp_indent_braces = false csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = 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_after_keywords_in_control_flow_statements = true +csharp_space_before_dot = false csharp_space_after_semicolon_in_for_statement = true -csharp_space_around_binary_operators = before_and_after +csharp_space_before_semicolon_in_for_statement = false csharp_space_around_declaration_statements = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_before_comma = false -csharp_space_before_dot = false csharp_space_before_open_square_brackets = false -csharp_space_before_semicolon_in_for_statement = false csharp_space_between_empty_square_brackets = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_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_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = 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 -csharp_preserve_single_line_statements = true - -#### Naming styles #### -# Naming rules +# 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 @@ -288,39 +298,144 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_m 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.interface.required_modifiers = 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.types.required_modifiers = 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 -dotnet_naming_symbols.non_field_members.required_modifiers = # 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.required_suffix = -dotnet_naming_style.begins_with_i.word_separator = dotnet_naming_style.begins_with_i.capitalization = pascal_case -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = -dotnet_naming_style.pascal_case.capitalization = pascal_case -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_auto_properties = true:silent -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:silent -dotnet_diagnostic.ca1707.severity = suggestion -dotnet_style_qualification_for_event = false:silent +# 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 -dotnet_diagnostic.ide0051.severity = none +# Banned API Analyzer - Allow in examples for demonstration purposes +dotnet_diagnostic.RS0030.severity = none # Allow banned APIs in examples for demonstration diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index f821240..b716439 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -1,63 +1,357 @@ 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 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 - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: docfx_project/_site # The path to the folder to upload + 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 - 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: 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 + PreRelease = $Matches['prerelease'] + } + } + } + + # Sort descending by full SemVer precedence (Major, Minor, Patch, Stable, PreRelease) + # Convert to a mutable list so we can use a custom comparison for proper SemVer prerelease ordering. + $tagList = [System.Collections.Generic.List[object]]::new() + foreach ($item in $taggedVersions) { + [void]$tagList.Add($item) + } + + $comparison = [System.Comparison[object]]{ + param($a, $b) + + # Compare Major, Minor, Patch (descending) + if ($a.Major -ne $b.Major) { return [Math]::Sign($b.Major - $a.Major) } + if ($a.Minor -ne $b.Minor) { return [Math]::Sign($b.Minor - $a.Minor) } + if ($a.Patch -ne $b.Patch) { return [Math]::Sign($b.Patch - $a.Patch) } + + # Compare Stable flag (descending: stable=1 > prerelease=0) + if ($a.Stable -ne $b.Stable) { return [Math]::Sign($b.Stable - $a.Stable) } + + # At this point, Major/Minor/Patch/Stable are equal. + # If both are stable (no prerelease), they are equal for our purposes. + $aPre = [string]$a.PreRelease + $bPre = [string]$b.PreRelease + + if ([string]::IsNullOrEmpty($aPre) -and [string]::IsNullOrEmpty($bPre)) { return 0 } + + # Both should be prereleases when Stable is 0, but handle any unexpected cases gracefully. + if ([string]::IsNullOrEmpty($aPre) -and -not [string]::IsNullOrEmpty($bPre)) { return -1 } + if (-not [string]::IsNullOrEmpty($aPre) -and [string]::IsNullOrEmpty($bPre)) { return 1 } + + $aIds = $aPre -split '\.' + $bIds = $bPre -split '\.' + $maxLen = [Math]::Max($aIds.Length, $bIds.Length) + + for ($i = 0; $i -lt $maxLen; $i++) { + if ($i -ge $aIds.Length) { return -1 } # a has fewer identifiers -> lower precedence + if ($i -ge $bIds.Length) { return 1 } # b has fewer identifiers -> lower precedence + + $aId = $aIds[$i] + $bId = $bIds[$i] + + $aIsNum = [int]::TryParse($aId, [ref]([int]$null)) + $bIsNum = [int]::TryParse($bId, [ref]([int]$null)) + + if ($aIsNum -and $bIsNum) { + $aVal = [int]$aId + $bVal = [int]$bId + if ($aVal -ne $bVal) { return [Math]::Sign($bVal - $aVal) } + } + elseif ($aIsNum -and -not $bIsNum) { + # Numeric identifiers have lower precedence than non-numeric. + return 1 + } + elseif (-not $aIsNum -and $bIsNum) { + return -1 + } + else { + $cmp = [string]::CompareOrdinal($aId, $bId) + if ($cmp -ne 0) { return -$cmp } + } + } + + # All identifiers equal + return 0 + } + + $tagList.Sort($comparison) + # We implemented comparison for descending SemVer order directly in the comparison. + $orderedTags = $tagList | 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: 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 docs to GitHub Pages + # Assembles the full gh-pages state and pushes a single commit, avoiding the + # multiple sequential pushes that would trigger pages-build-deployment repeatedly. + # + # Layout written to gh-pages in one commit: + # versions// – versioned docs (real DocFX index.html) + # versions/latest/ – latest alias (real DocFX index.html) [deploy_as_latest only] + # / – version-picker index.html + shared assets [deploy_as_latest only] + # + # Stale root files from the previous build are removed before copying new content, + # so outdated DocFX assets do not linger. The versions/ folder is always preserved + # so that all prior versioned docs remain accessible. + if: inputs.deploy_to_pages != false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + VERSION_DIR: ${{ steps.dest.outputs.dir }} + DEPLOY_AS_LATEST: ${{ inputs.deploy_as_latest != false }} + shell: pwsh + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git remote set-url origin "https://x-access-token:$($env:GITHUB_TOKEN)@github.com/$($env:GITHUB_REPOSITORY).git" + + $WORK_DIR = Join-Path $env:RUNNER_TEMP 'gh-pages-deploy' + $siteDir = Resolve-Path 'docfx_project/_site' + + # Set up gh-pages worktree (or start fresh if the branch does not exist yet) + $branchExists = git ls-remote --heads origin gh-pages + if ($branchExists) { + git fetch origin gh-pages + git show-ref --verify --quiet refs/heads/gh-pages + if ($LASTEXITCODE -ne 0) { git branch gh-pages origin/gh-pages } + git worktree remove $WORK_DIR --force 2>&1 | Out-Null + if (Test-Path $WORK_DIR) { Remove-Item $WORK_DIR -Recurse -Force } + git worktree add $WORK_DIR gh-pages + } else { + Write-Host "ā„¹ļø gh-pages does not exist yet — starting fresh." + New-Item -ItemType Directory -Force -Path $WORK_DIR | Out-Null + } + + # Remove stale root files; preserve versions/, .git, .nojekyll, CNAME + Get-ChildItem -Path $WORK_DIR -Force | Where-Object { + $_.Name -notin @('.git', 'CNAME', '.nojekyll', 'versions') + } | Remove-Item -Recurse -Force + + # Ensure .nojekyll exists so GitHub Pages does not run Jekyll + New-Item -ItemType File -Path (Join-Path $WORK_DIR '.nojekyll') -Force | Out-Null + + # Deploy versioned docs (real DocFX index.html — before version picker overwrites it) + $versionedDir = Join-Path $WORK_DIR "versions/$($env:VERSION_DIR)" + New-Item -ItemType Directory -Force -Path $versionedDir | Out-Null + Copy-Item -Path "$siteDir/*" -Destination $versionedDir -Recurse -Force + Write-Host "āœ… Copied docs to versions/$($env:VERSION_DIR)/" + + if ($env:DEPLOY_AS_LATEST -eq 'true') { + # Deploy to versions/latest/ (real DocFX index.html) + $latestDir = Join-Path $WORK_DIR 'versions/latest' + New-Item -ItemType Directory -Force -Path $latestDir | Out-Null + Copy-Item -Path "$siteDir/*" -Destination $latestDir -Recurse -Force + Write-Host "āœ… Copied docs to versions/latest/" + + # Generate version-picker index.html and overwrite _site/index.html. + # This happens AFTER the versioned copies above, so those directories + # retain the real DocFX landing page while the root gets the picker. + $repoName = ($env:GITHUB_REPOSITORY -split '/')[-1] + $title = if ($repoName) { "$repoName Documentation" } else { "Documentation" } + $versions = Get-Content "$siteDir/versions.json" -Raw | ConvertFrom-Json + + $listItems = foreach ($v in $versions) { + $liClass = if ($v.version -eq 'latest') { ' class="latest"' } else { '' } + $label = if ($v.version -eq 'latest') { 'latest (stable)' } else { $v.version } + " $label" + } + $listHtml = $listItems -join "`n" + + if (-not (Test-Path '.github/version-picker-template.html')) { + Write-Error "Error: .github/version-picker-template.html not found; cannot generate root index.html." + exit 1 + } + + $template = Get-Content '.github/version-picker-template.html' -Raw + $html = $template -replace '\{\{TITLE\}\}', $title ` + -replace '\{\{VERSION_LIST\}\}', $listHtml + $html | Set-Content -Path "$siteDir/index.html" -Encoding utf8NoBOM + Write-Host "Generated version-picker index.html with $($versions.Count) version link(s)." + + # Deploy version picker + shared assets to site root + Copy-Item -Path "$siteDir/*" -Destination $WORK_DIR -Recurse -Force + Write-Host "āœ… Copied version picker to site root" + } + + # Single commit and push + git -C $WORK_DIR add -A + git -C $WORK_DIR diff --cached --quiet + if ($LASTEXITCODE -ne 0) { + $msg = if ($env:DEPLOY_AS_LATEST -eq 'true') { + "docs: deploy $($env:VERSION_DIR) and update latest" + } else { + "docs: deploy $($env:VERSION_DIR)" + } + git -C $WORK_DIR commit -m $msg + git -C $WORK_DIR push origin HEAD:gh-pages + Write-Host "āœ… Documentation deployed in a single commit." + } else { + Write-Host "ā„¹ļø No documentation changes to deploy." + } + + git worktree remove $WORK_DIR --force 2>&1 | Out-Null diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index fe0ef48..5939209 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -362,10 +362,17 @@ jobs: for proj in "${projects[@]}"; do echo "Building: $proj" - # Extract target frameworks from the project file - # Support both (single) and (multiple) - # Collapse newlines so multi-line values are handled correctly - frameworks=$(tr '\n' ' ' < "$proj" | grep -oP '\s*\K[^<]+' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp[0-9.]+|netstandard[0-9.]+)$' || true) + # Extract target frameworks via MSBuild property evaluation. + # This handles multi-line XML, conditional property groups, + # and TFMs inherited from Directory.Build.props — all of which break grep-based parsing. + # Falls back from (multiple) to (single). + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp[0-9.]+|netstandard[0-9.]+)$' || true) if [ -z "$frameworks" ]; then echo "āš ļø No Linux-compatible frameworks found in $proj" @@ -395,12 +402,20 @@ jobs: - name: Run tests with coverage (.NET Core 5.0 - 10.0) run: | - # Find all test projects (C#, VB.NET, F#) + # Find all test projects (C#, VB.NET, F#). + # Gracefully skip if there is no ./tests directory (e.g. template-publishing + # repos or library repos in early development that have no tests yet). + # The downstream coverage steps already handle the no-coverage-files case. + if [ ! -d ./tests ]; then + echo "ā„¹ļø No ./tests directory — skipping test stage." + exit 0 + fi + mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) - + if [ ${#test_projects[@]} -eq 0 ]; then - echo "āŒ No test projects found in ./tests directory!" - exit 1 + echo "ā„¹ļø No test projects found under ./tests — skipping test stage." + exit 0 fi echo "==========================================" @@ -414,9 +429,16 @@ jobs: echo "Testing project: $test_proj" echo "==========================================" - # Extract target frameworks from the project file - # Support both (single) and (multiple) - frameworks=$(grep -oP '\K[^<]+' "$test_proj" | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp3\.1)$' || true) + # Extract target frameworks via MSBuild property evaluation (handles multi-line XML + # and Directory.Build.props inheritance — both break grep-based parsing). + # Falls back from (multiple) to (single). + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp3\.1)$' || true) if [ -z "$frameworks" ]; then echo "⊘ Skipping: No compatible .NET 5.0-10.0 target frameworks found" @@ -660,12 +682,19 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - + + # Gracefully skip if there is no ./tests directory (e.g. template-publishing + # repos or library repos in early development that have no tests yet). + if (-not (Test-Path -Path './tests' -PathType Container)) { + Write-Host "ā„¹ļø No ./tests directory — skipping test stage." + exit 0 + } + $testProjects = @(Get-ChildItem -Path './tests/*' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj') - + if (@($testProjects).Count -eq 0) { - Write-Error "āŒ No test projects found in ./tests directory!" - exit 1 + Write-Host "ā„¹ļø No test projects found under ./tests — skipping test stage." + exit 0 } Write-Host "==========================================" -ForegroundColor Cyan @@ -982,10 +1011,16 @@ jobs: for proj in "${projects[@]}"; do echo "Building: $proj" - # Extract target frameworks from the project file - # Support both (single) and (multiple) - # Trim whitespace from each framework before filtering - frameworks=$(tr -d '\n\r' < "$proj" | sed -n -E 's/.*[[:space:]]*>([^<]+)<\/TargetFrameworks?>.*/\1/p' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) + # Extract target frameworks via MSBuild property evaluation (handles multi-line XML + # and Directory.Build.props inheritance). Filter to .NET 6+ for macOS ARM64 compatibility. + # Falls back from (multiple) to (single). + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) if [ -z "$frameworks" ]; then echo "āš ļø No macOS ARM64-compatible frameworks found in $proj" @@ -1015,15 +1050,22 @@ jobs: - name: Run tests (.NET 6.0 - 10.0 only - ARM64 compatible) run: | - # Find all test projects (C#, VB.NET, F#) + # Find all test projects (C#, VB.NET, F#). + # Gracefully skip if there is no ./tests directory (e.g. template-publishing + # repos or library repos in early development that have no tests yet). + if [ ! -d ./tests ]; then + echo "ā„¹ļø No ./tests directory — skipping test stage." + exit 0 + fi + test_projects=() while IFS= read -r -d '' file; do test_projects+=("$file") - done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) - + done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) + if [ ${#test_projects[@]} -eq 0 ]; then - echo "āŒ No test projects found in ./tests directory!" - exit 1 + echo "ā„¹ļø No test projects found under ./tests — skipping test stage." + exit 0 fi echo "==========================================" @@ -1037,11 +1079,16 @@ jobs: echo "Testing project: $test_proj" echo "==========================================" - # Extract target frameworks from the project file - # Support both (single) and (multiple) - # Only include .NET 6.0+ (ARM64 compatible on macOS) - # Normalize line endings to handle multi-line / elements - frameworks=$(tr -d '\n\r' < "$test_proj" | grep -Eo '[^<]+' | sed -E 's///' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) + # Extract target frameworks via MSBuild property evaluation (handles multi-line XML + # and Directory.Build.props inheritance). Filter to .NET 6+ for macOS ARM64 compatibility. + # Falls back from (multiple) to (single). + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) if [ -z "$frameworks" ]; then echo "⊘ Skipping: No compatible .NET 6.0-10.0 target frameworks found (ARM64 required)" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8d9b107..569b7ed 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,20 +1,27 @@ -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 + # 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 @@ -33,32 +40,261 @@ jobs: - name: Build Solution (Release) run: dotnet build --no-restore --configuration Release - - name: Run tests for all test projects + - name: Run multi-framework tests with coverage shell: pwsh run: | - Get-ChildItem -Path './tests' -Recurse -Filter '*Test*.csproj' | ForEach-Object { - Write-Host "Running tests for $($_.FullName)" - dotnet test $_.FullName --no-build --configuration Release - if ($LASTEXITCODE -ne 0) { - Write-Error "Tests failed for $($_.FullName)" - exit $LASTEXITCODE + $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: Upload test results + - name: Verify coverage threshold + shell: pwsh + run: | + # 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 coverage report if: always() uses: actions/upload-artifact@v4 with: - name: test-results - path: '**/TestResults*.trx' + name: release-coverage + path: CoverageReport/ - publish: - name: Pack and Publish NuGet - needs: build-and-test + # 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 @@ -71,45 +307,316 @@ 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 Packages + - name: Pack NuGet packages + id: check-packages shell: pwsh run: | + # Create output directory for NuGet packages $packagesPath = Join-Path $PWD 'nuget-packages' New-Item -ItemType Directory -Force -Path $packagesPath | Out-Null - Get-ChildItem -Path 'src' -Recurse -Filter '*.csproj' | ForEach-Object { - Write-Host "Packing $($_.FullName)" - dotnet pack $_.FullName --no-build --configuration Release --output $packagesPath + # 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 "dotnet pack failed for $($_.FullName)" + 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: | + $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'." + } + + $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: Generate SBOM (CycloneDX) + if: steps.check-packages.outputs.has-packages == 'true' + shell: pwsh + run: | + dotnet tool install --global CycloneDX + + $sbomDir = Join-Path $PWD 'nuget-packages' + $srcProjects = Get-ChildItem -Path 'src' -Filter '*.csproj' -Recurse -ErrorAction SilentlyContinue + + if ($srcProjects.Count -eq 0) { + Write-Warning "No projects found in src/ - skipping SBOM generation" + return + } + + foreach ($proj in $srcProjects) { + $sbomName = "$($proj.BaseName).bom.json" + $sbomPath = Join-Path $sbomDir $sbomName + + Write-Host "šŸ“‹ Generating SBOM for $($proj.Name)" -ForegroundColor Cyan + dotnet CycloneDX $proj.FullName --output $sbomDir --filename $sbomName --json + + if ($LASTEXITCODE -ne 0) { + Write-Warning "āš ļø SBOM generation failed for $($proj.Name) - continuing" + } else { + Write-Host "āœ… SBOM generated: $sbomName" -ForegroundColor Green + } + } - - name: Upload NuGet packages as artifacts + + - name: Upload NuGet packages uses: actions/upload-artifact@v4 - with: + 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: ./nuget-packages/*.nupkg - retention-days: 30 + path: ./packages - - name: Publish NuGet Package + - name: Validate NuGet API key shell: pwsh env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: | - $packagesPath = Join-Path $PWD 'nuget-packages' - Get-ChildItem -Path $packagesPath -Filter '*.nupkg' | ForEach-Object { - Write-Host "Publishing $($_.FullName)" - dotnet nuget push $_.FullName --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate + 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 -Path './packages' -Filter '*.nupkg' -ErrorAction SilentlyContinue + + if ($packages.Count -eq 0) { + Write-Warning "No .nupkg files found - nothing to publish" + exit 0 + } + + 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 "dotnet nuget push failed for $($_.FullName)" + 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 + + # 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 + + - name: Download coverage report artifact + uses: actions/download-artifact@v4 + with: + 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 + ./nuget-packages/*.bom.json + release-coverage.zip + diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1 index c9dc7ae..f8ef40e 100644 --- a/scripts/Setup-BranchRuleset.ps1 +++ b/scripts/Setup-BranchRuleset.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository = "Chris-Wolfgang/In-memory-Logger", + [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", [Parameter()] [string]$BranchName = "main" @@ -78,7 +78,7 @@ try { } # Determine repository -if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $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 { @@ -86,7 +86,7 @@ if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { $Repository = $repoInfo.nameWithOwner Write-Host "āœ… Using repository: $Repository" -ForegroundColor Green } catch { - if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { + 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." diff --git a/scripts/Setup-GitHubPages.ps1 b/scripts/Setup-GitHubPages.ps1 index de6776b..334266d 100644 --- a/scripts/Setup-GitHubPages.ps1 +++ b/scripts/Setup-GitHubPages.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository, + [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", [Parameter()] [switch]$EnablePages, @@ -62,19 +62,6 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -# Auto-detect repository if not provided -if (-not $Repository) { - try { - $Repository = (gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>$null) - if (-not $Repository) { - throw "Could not auto-detect repository" - } - } catch { - Write-Error "Repository not specified and auto-detection failed. Use -Repository 'owner/repo'." - exit 1 - } -} - # Color output functions function Write-Success { param([string]$Message) @@ -224,7 +211,7 @@ try { } # Determine repository -if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $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 { @@ -232,7 +219,7 @@ if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { $Repository = $repoInfo.nameWithOwner Write-Success "Using repository: $Repository" } catch { - if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { + 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." diff --git a/scripts/build-pr.ps1 b/scripts/build-pr.ps1 index 611d126..d7fd64c 100644 --- a/scripts/build-pr.ps1 +++ b/scripts/build-pr.ps1 @@ -84,8 +84,7 @@ if (-not $SkipTests -and $failed.Count -eq 0) { $testProjects = @(Get-ChildItem -Path './tests' -Recurse -File -Include '*.csproj', '*.vbproj', '*.fsproj' -ErrorAction SilentlyContinue) if ($testProjects.Count -eq 0) { - Write-Fail "No test projects found in ./tests" - $failed += "Tests" + Write-Host "No test projects found in ./tests — skipping" } else { foreach ($testProj in $testProjects) { @@ -268,10 +267,7 @@ if (-not $SkipSecurity) { else { $archive = "gitleaks_${version}_linux_x64.tar.gz" $url = "https://github.com/gitleaks/gitleaks/releases/download/v${version}/$archive" - $dest = Join-Path $HOME ".local/bin" - New-Item -ItemType Directory -Force -Path $dest | Out-Null - curl -sSfL $url | tar xz -C $dest gitleaks - $env:PATH = "$dest$([IO.Path]::PathSeparator)$env:PATH" + curl -sSfL $url | tar xz -C /usr/local/bin gitleaks } } diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 5134333..37a218f 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -629,11 +629,10 @@ function Start-Setup { return $false } - # Exclude hidden files (starting with .) except those in .github or at the repository root + # Exclude hidden files (starting with .) except those in .github directory $fileName = [System.IO.Path]::GetFileName($relativePath) $isInGitHubDir = $relativePath -like '.github/*' - $isAtRepoRoot = -not $relativePath.Contains('/') - if ($fileName.StartsWith('.') -and -not $isInGitHubDir -and -not $isAtRepoRoot) { + if ($fileName.StartsWith('.') -and -not $isInGitHubDir) { return $false } From 821107ec24a6c6c8c890c6c12fd105a5fe4c0aa5 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:56:20 -0400 Subject: [PATCH 07/18] Sync missing files from repo-template Add files present in repo-template but missing from this repo. No existing files are modified or removed in this PR. Scripts: - scripts/Fix-BranchRuleset.ps1 - scripts/format.ps1 Issue templates: - .github/ISSUE_TEMPLATE/feature_request.yaml (new YAML form) Documentation: - README-TEMPLATE.md - REPO-INSTRUCTIONS.md - docs/RELEASE-WORKFLOW-SETUP.md - docs/SECURITY.md DocFX assets: - docfx_project/api/README.md - docfx_project/api/index.md - docfx_project/docs/index.md - docfx_project/toc.yml - docfx_project/logo.svg License variants (multi-license support): - LICENSE-APACHE-2.0.txt - LICENSE-MIT.txt - LICENSE-MPL-2.0.txt --- .github/ISSUE_TEMPLATE/feature_request.yaml | 50 +++ LICENSE-APACHE-2.0.txt | 201 +++++++++++ LICENSE-MIT.txt | 21 ++ LICENSE-MPL-2.0.txt | 373 ++++++++++++++++++++ README-TEMPLATE.md | 180 ++++++++++ REPO-INSTRUCTIONS.md | 265 ++++++++++++++ docfx_project/api/README.md | 22 ++ docfx_project/api/index.md | 17 + docfx_project/docs/index.md | 9 + docfx_project/logo.svg | 23 ++ docfx_project/toc.yml | 5 + docs/RELEASE-WORKFLOW-SETUP.md | 221 ++++++++++++ docs/SECURITY.md | 22 ++ scripts/Fix-BranchRuleset.ps1 | 257 ++++++++++++++ scripts/format.ps1 | 104 ++++++ 15 files changed, 1770 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yaml create mode 100644 LICENSE-APACHE-2.0.txt create mode 100644 LICENSE-MIT.txt create mode 100644 LICENSE-MPL-2.0.txt create mode 100644 README-TEMPLATE.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/index.md create mode 100644 docfx_project/logo.svg create mode 100644 docfx_project/toc.yml create mode 100644 docs/RELEASE-WORKFLOW-SETUP.md create mode 100644 docs/SECURITY.md create mode 100644 scripts/Fix-BranchRuleset.ps1 create mode 100644 scripts/format.ps1 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 diff --git a/LICENSE-APACHE-2.0.txt b/LICENSE-APACHE-2.0.txt new file mode 100644 index 0000000..fca1857 --- /dev/null +++ b/LICENSE-APACHE-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {{YEAR}} {{COPYRIGHT_HOLDER}} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT.txt b/LICENSE-MIT.txt new file mode 100644 index 0000000..594cd6b --- /dev/null +++ b/LICENSE-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) {{YEAR}} {{COPYRIGHT_HOLDER}} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE-MPL-2.0.txt b/LICENSE-MPL-2.0.txt new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/LICENSE-MPL-2.0.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README-TEMPLATE.md b/README-TEMPLATE.md new file mode 100644 index 0000000..47109dd --- /dev/null +++ b/README-TEMPLATE.md @@ -0,0 +1,180 @@ +# {{PROJECT_NAME}} + +{{PROJECT_DESCRIPTION}} + +[![License: {{LICENSE_TYPE}}](https://img.shields.io/badge/License-{{LICENSE_TYPE}}-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)]({{GITHUB_REPO_URL}}) + +--- + +## šŸ“¦ Installation + +```bash +dotnet add package {{PACKAGE_NAME}} +``` + +**NuGet Package:** {{NUGET_STATUS}} + +--- + +## šŸ“„ License + +This project is licensed under the **{{LICENSE_TYPE}} License**. See the [LICENSE](LICENSE) file for details. + +--- + +## šŸ“š Documentation + +- **GitHub Repository:** [{{GITHUB_REPO_URL}}]({{GITHUB_REPO_URL}}) +- **API Documentation:** {{DOCS_URL}} +- **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 Core 3.1 | +| .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 {{GITHUB_REPO_URL}}.git +cd {{REPO_NAME}} + +# 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/REPO-INSTRUCTIONS.md b/REPO-INSTRUCTIONS.md new file mode 100644 index 0000000..f6f8e0f --- /dev/null +++ b/REPO-INSTRUCTIONS.md @@ -0,0 +1,265 @@ +# 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](docs/TEMPLATE-PLACEHOLDERS.md)** +**For license selection guidance, see [LICENSE-SELECTION.md](docs/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 `{{TEMPLATE_REPO_OWNER}}/{{TEMPLATE_REPO_NAME}}` +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, `{{PROJECT_NAME}}`) 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 `{{GITHUB_USERNAME}}` 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 ({{PROJECT_NAME}}, {{DOCS_URL}}, 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/<tag>/`. 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..6a63e1a --- /dev/null +++ b/docfx_project/api/README.md @@ -0,0 +1,22 @@ +# 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: +- `{{PROJECT_NAME}}` - 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..7f4f2db --- /dev/null +++ b/docfx_project/api/index.md @@ -0,0 +1,17 @@ +# API Reference + +Welcome to the {{PROJECT_NAME}} 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/docs/index.md b/docfx_project/docs/index.md new file mode 100644 index 0000000..47ce1f1 --- /dev/null +++ b/docfx_project/docs/index.md @@ -0,0 +1,9 @@ +# {{PROJECT_NAME}} 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/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 @@ +<svg width="48" height="48" viewBox="0 0 48 48" + xmlns="http://www.w3.org/2000/svg"> + <defs> + <!-- Soft purple glow for dark backgrounds --> + <filter id="glow" x="-40%" y="-40%" width="180%" height="180%"> + <feDropShadow dx="0" dy="0" stdDeviation="2" flood-color="#7c23bb" flood-opacity="0.5"/> + </filter> + </defs> + <style> + @font-face { + font-family: 'DM Serif Display'; + src: url('https://fonts.gstatic.com/s/dmserifdisplay/v13/-nF6OG414u0gLgBvIEjRpMqB6mM.woff2') format('woff2'); + } + .fancy { + font-family: 'DM Serif Display', serif; + font-size: 34px; + fill: #7c23bb; + letter-spacing: 1px; + filter: url(#glow); + } + </style> + <text x="7" y="37" class="fancy">W</text> +</svg> \ No newline at end of file diff --git a/docfx_project/toc.yml b/docfx_project/toc.yml new file mode 100644 index 0000000..8e62d99 --- /dev/null +++ b/docfx_project/toc.yml @@ -0,0 +1,5 @@ +- name: Documentation + href: docs/toc.yml +- name: API Reference + href: api/toc.yml + homepage: api/index.md 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 `<TargetFrameworks>` +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/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..e670a48 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,22 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability, please follow these steps: + +1. **Do not** create an issue on this repository. **Do not** disclose the vulnerability publicly. +1. In the top navigation of this repository, click the **Security** tab. +1. In the top right, click the **Report a vulnerability** button. +1. Fill out the provided form. It will request information like: + - A description of the vulnerability + - Steps to reproduce the issue + - Potential impact on student data or website functionality + - Suggested fix (if you have one) + +## Response Timeline +TBD/ASAP + +## Thank You + +Your help is greatly appreciated! +Responsible disclosure of security vulnerabilities helps protect our entire community diff --git a/scripts/Fix-BranchRuleset.ps1 b/scripts/Fix-BranchRuleset.ps1 new file mode 100644 index 0000000..f69c673 --- /dev/null +++ b/scripts/Fix-BranchRuleset.ps1 @@ -0,0 +1,257 @@ +<# +.SYNOPSIS + Fixes branch rulesets by disabling existing ones and recreating with the correct configuration. + +.DESCRIPTION + This script inspects the existing branch rulesets for a repository, disables all of them, + and renames any ruleset named "Protect main branch" to "Protect main branch (old)" so that + Setup-BranchRuleset.ps1 can create a fresh ruleset without conflicts. + + The script presents a plan of all changes before executing and prompts for confirmation. + +.PARAMETER Repository + The repository in owner/repo format. If not provided, uses the current repository. + +.PARAMETER Force + Skip the confirmation prompt and proceed automatically. Alias: -y + +.EXAMPLE + .\Fix-BranchRuleset.ps1 + Inspects and fixes rulesets for the current repository with interactive confirmation + +.EXAMPLE + .\Fix-BranchRuleset.ps1 -Force + Inspects and fixes rulesets without prompting for confirmation + +.EXAMPLE + .\Fix-BranchRuleset.ps1 -Repository "Chris-Wolfgang/my-repo" + Inspects and fixes rulesets for a specific repository + +.NOTES + Requires: GitHub CLI (gh) authenticated with admin permissions + Install gh: https://cli.github.com/ +#> + +[CmdletBinding()] +param( + [Parameter()] + [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", + + [Parameter()] + [Alias("y")] + [switch]$Force +) + +# Check if gh CLI is installed +try { + $null = gh --version +} catch { + Write-Error "GitHub CLI (gh) is not installed or not in PATH." + Write-Host "Install from: https://cli.github.com/" -ForegroundColor Yellow + exit 1 +} + +# Check if authenticated +try { + $null = gh auth status 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "Not authenticated with GitHub CLI." + Write-Host "Run: gh auth login" -ForegroundColor Yellow + exit 1 + } +} catch { + Write-Error "Failed to check GitHub CLI authentication status." + exit 1 +} + +# Determine repository +if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { + Write-Host "Detecting current repository..." -ForegroundColor Cyan + try { + $repoInfo = gh repo view --json nameWithOwner | ConvertFrom-Json + $Repository = $repoInfo.nameWithOwner + Write-Host "Using repository: $Repository" -ForegroundColor Green + } catch { + if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { + Write-Error "Could not detect repository. Please run the setup script first to replace placeholders, or specify -Repository parameter." + } else { + Write-Error "Could not detect repository. Please run from within a git repository or specify -Repository parameter." + } + exit 1 + } +} else { + Write-Host "Using specified repository: $Repository" -ForegroundColor Green +} + +# Fetch all rulesets +Write-Host "`nFetching existing rulesets..." -ForegroundColor Cyan + +try { + $rulesetsJson = gh api ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/rulesets" ` + --paginate 2>&1 + + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to fetch rulesets: $rulesetsJson" + exit 1 + } + + $rulesets = $rulesetsJson | ConvertFrom-Json +} catch { + Write-Error "Failed to fetch rulesets: $($_.Exception.Message)" + exit 1 +} + +if (-not $rulesets -or $rulesets.Count -eq 0) { + Write-Host "No rulesets found for $Repository. Nothing to fix." -ForegroundColor Green + exit 0 +} + +# Build the plan +$plan = @() +$targetRulesetName = "Protect main branch" + +Write-Host "`nFound $($rulesets.Count) ruleset(s):" -ForegroundColor Cyan +Write-Host "" + +foreach ($ruleset in $rulesets) { + $status = if ($ruleset.enforcement -eq "disabled") { "disabled" } else { $ruleset.enforcement } + Write-Host " [$($ruleset.id)] $($ruleset.name) (enforcement: $status)" -ForegroundColor Gray + + $actions = @() + + # If this is the target name, rename it + if ($ruleset.name -eq $targetRulesetName) { + $actions += @{ + type = "rename" + description = "Rename '$($ruleset.name)' -> '$($ruleset.name) (old)'" + newName = "$($ruleset.name) (old)" + } + } + + # If not already disabled, disable it + if ($ruleset.enforcement -ne "disabled") { + $actions += @{ + type = "disable" + description = "Disable '$($ruleset.name)' (currently: $status)" + } + } + + if ($actions.Count -gt 0) { + $plan += @{ + ruleset = $ruleset + actions = $actions + } + } +} + +Write-Host "" + +# Present the plan +if ($plan.Count -eq 0) { + Write-Host "All rulesets are already disabled and none need renaming. Nothing to do." -ForegroundColor Green + exit 0 +} + +Write-Host "Planned changes:" -ForegroundColor Yellow +Write-Host "" + +$stepNumber = 1 +foreach ($item in $plan) { + foreach ($action in $item.actions) { + Write-Host " $stepNumber. $($action.description)" -ForegroundColor White + $stepNumber++ + } +} + +Write-Host "" + +# Prompt for confirmation +if ($Force) { + Write-Host "Auto-confirmed via -Force flag." -ForegroundColor Green +} else { + $response = Read-Host "Proceed with these changes? (y/N)" + if ($response -ne 'y' -and $response -ne 'Y') { + Write-Host "Cancelled. No changes were made." -ForegroundColor Yellow + exit 0 + } +} + +Write-Host "" + +# Execute the plan +$errors = 0 + +foreach ($item in $plan) { + $ruleset = $item.ruleset + $rulesetId = $ruleset.id + + # Build the update payload — apply rename and disable together in one API call + $updatePayload = @{} + + foreach ($action in $item.actions) { + switch ($action.type) { + "rename" { + $updatePayload["name"] = $action.newName + } + "disable" { + $updatePayload["enforcement"] = "disabled" + } + } + } + + if ($updatePayload.Count -gt 0) { + $descriptions = ($item.actions | ForEach-Object { $_.description }) -join " + " + Write-Host " Updating ruleset [$rulesetId]: $descriptions..." -ForegroundColor Cyan + + $jsonPayload = $updatePayload | ConvertTo-Json -Depth 5 + $tempFile = [System.IO.Path]::GetTempFileName() + $jsonPayload | Out-File -FilePath $tempFile -Encoding utf8NoBOM + + try { + $result = gh api ` + --method PUT ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + "/repos/$Repository/rulesets/$rulesetId" ` + --input $tempFile 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Host " Done." -ForegroundColor Green + } else { + Write-Host " Failed: $result" -ForegroundColor Red + $errors++ + } + } catch { + Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red + $errors++ + } finally { + if (Test-Path $tempFile) { + Remove-Item $tempFile -Force + } + } + } +} + +Write-Host "" + +if ($errors -gt 0) { + Write-Host "$errors action(s) failed. Review the errors above." -ForegroundColor Red + exit 1 +} else { + Write-Host "All changes applied successfully." -ForegroundColor Green + Write-Host "" + + # Invoke Setup-BranchRuleset.ps1 to create a fresh ruleset + $setupScript = Join-Path $PSScriptRoot "Setup-BranchRuleset.ps1" + if (Test-Path $setupScript) { + Write-Host "Running Setup-BranchRuleset.ps1 to create a fresh ruleset..." -ForegroundColor Cyan + Write-Host "" + & $setupScript -Repository $Repository + } else { + Write-Host "Setup-BranchRuleset.ps1 not found. Run it manually to create a fresh ruleset." -ForegroundColor Yellow + Write-Host "View rulesets at: https://github.com/$Repository/settings/rules" -ForegroundColor Cyan + } +} diff --git a/scripts/format.ps1 b/scripts/format.ps1 new file mode 100644 index 0000000..1c4dc1e --- /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 38a9116b1559cfaa2174cf94d00e6a9e687f2249 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:56:52 -0400 Subject: [PATCH 08/18] Remove stale files no longer in repo-template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files removed: - .github/ISSUE_TEMPLATE/feature_request.md — superseded by the YAML form .github/ISSUE_TEMPLATE/feature_request.yaml (added in companion PR) - .github/workflows/create-labels.yaml — repo-template manages labels via scripts/Setup-Labels.ps1 instead; the workflow is no longer used in the template - docfx_project/_site/manifest.json — DocFX build output that should not be checked in - docs/index.html — generated artifact; repo-template does not check in a docs/index.html - SETUP.md — replaced by REPO-INSTRUCTIONS.md (added in companion PR) --- .github/ISSUE_TEMPLATE/feature_request.md | 20 ------ .github/workflows/create-labels.yaml | 86 ----------------------- SETUP.md | 84 ---------------------- docfx_project/_site/manifest.json | 4 -- docs/index.html | 4 -- 5 files changed, 198 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/workflows/create-labels.yaml delete mode 100644 SETUP.md delete mode 100644 docfx_project/_site/manifest.json delete mode 100644 docs/index.html 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/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/SETUP.md b/SETUP.md deleted file mode 100644 index 5dea6d3..0000000 --- a/SETUP.md +++ /dev/null @@ -1,84 +0,0 @@ -# Setting Up Your Repository -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 - -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 - -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. Select your PR workflow (it will be listed after it runs at least once). -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` - 2. Check `Require review from Code Owners` - 3. Check `Require pull request review from Copilot` -8. Check `Block force pushes` -9. Check `Require code scanning` - - -## Add Custom Labels - -1. On the `Actions` tab select `Create Custom Labels` -2. Select `Run workflow` -3. Select `main` branch and click `Run` -You will need to create the following custom labels - -If that doesn't work try the following - -Go to `Issues` tab at the top of your repo and the select `Labels` and click `New label` - -1. dependabot-dependencies -2. dependabot-security - - -## Creating the project - -1. Create a blank solution and save it in the root folder -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 -``` diff --git a/docfx_project/_site/manifest.json b/docfx_project/_site/manifest.json deleted file mode 100644 index bf7358d..0000000 --- a/docfx_project/_site/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "source_base_path": "C:/Source/GitHub/DbContextBuilder/docfx_project", - "files": [] -} \ No newline at end of file diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index b21f38e..0000000 --- a/docs/index.html +++ /dev/null @@ -1,4 +0,0 @@ -<h1>Welcome to {repository name}</h1> - - -To Do: Add description From a440d4f92482fb166cca1dabed3fe686e509a71d Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:09:08 -0400 Subject: [PATCH 09/18] Restore scripts/ to match main Removes script changes from this PR. The four script files contained post-setup customizations (resolved repo name, bug fixes from PR #19's review feedback) that shouldn't be reverted by syncing from repo-template: - Setup-BranchRuleset.ps1: keep resolved Chris-Wolfgang/In-memory-Logger - Setup-GitHubPages.ps1: keep resolved repo name and auto-detect block - build-pr.ps1: keep gitleaks ~/.local/bin install (no sudo) and fail-on-no-test-projects behavior - setup.ps1: keep dotfiles-at-repo-root fix for .slnx generation This PR now only syncs the truly template-tracked files: .editorconfig and the three workflow yamls. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- scripts/Setup-BranchRuleset.ps1 | 6 +++--- scripts/Setup-GitHubPages.ps1 | 19 ++++++++++++++++--- scripts/build-pr.ps1 | 8 ++++++-- scripts/setup.ps1 | 5 +++-- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1 index f8ef40e..c9dc7ae 100644 --- a/scripts/Setup-BranchRuleset.ps1 +++ b/scripts/Setup-BranchRuleset.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", + [string]$Repository = "Chris-Wolfgang/In-memory-Logger", [Parameter()] [string]$BranchName = "main" @@ -78,7 +78,7 @@ try { } # Determine repository -if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { +if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { # Placeholders not replaced or no repository specified - auto-detect Write-Host "šŸ” Detecting current repository..." -ForegroundColor Cyan try { @@ -86,7 +86,7 @@ if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { $Repository = $repoInfo.nameWithOwner Write-Host "āœ… Using repository: $Repository" -ForegroundColor Green } catch { - if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { + if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { 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." diff --git a/scripts/Setup-GitHubPages.ps1 b/scripts/Setup-GitHubPages.ps1 index 334266d..de6776b 100644 --- a/scripts/Setup-GitHubPages.ps1 +++ b/scripts/Setup-GitHubPages.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", + [string]$Repository, [Parameter()] [switch]$EnablePages, @@ -62,6 +62,19 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Auto-detect repository if not provided +if (-not $Repository) { + try { + $Repository = (gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>$null) + if (-not $Repository) { + throw "Could not auto-detect repository" + } + } catch { + Write-Error "Repository not specified and auto-detection failed. Use -Repository 'owner/repo'." + exit 1 + } +} + # Color output functions function Write-Success { param([string]$Message) @@ -211,7 +224,7 @@ try { } # Determine repository -if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { +if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { # Placeholders not replaced or no repository specified - auto-detect Write-Info "Detecting current repository..." try { @@ -219,7 +232,7 @@ if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { $Repository = $repoInfo.nameWithOwner Write-Success "Using repository: $Repository" } catch { - if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { + if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { 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." diff --git a/scripts/build-pr.ps1 b/scripts/build-pr.ps1 index d7fd64c..611d126 100644 --- a/scripts/build-pr.ps1 +++ b/scripts/build-pr.ps1 @@ -84,7 +84,8 @@ if (-not $SkipTests -and $failed.Count -eq 0) { $testProjects = @(Get-ChildItem -Path './tests' -Recurse -File -Include '*.csproj', '*.vbproj', '*.fsproj' -ErrorAction SilentlyContinue) if ($testProjects.Count -eq 0) { - Write-Host "No test projects found in ./tests — skipping" + Write-Fail "No test projects found in ./tests" + $failed += "Tests" } else { foreach ($testProj in $testProjects) { @@ -267,7 +268,10 @@ if (-not $SkipSecurity) { else { $archive = "gitleaks_${version}_linux_x64.tar.gz" $url = "https://github.com/gitleaks/gitleaks/releases/download/v${version}/$archive" - curl -sSfL $url | tar xz -C /usr/local/bin gitleaks + $dest = Join-Path $HOME ".local/bin" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + curl -sSfL $url | tar xz -C $dest gitleaks + $env:PATH = "$dest$([IO.Path]::PathSeparator)$env:PATH" } } diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 37a218f..5134333 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -629,10 +629,11 @@ function Start-Setup { return $false } - # Exclude hidden files (starting with .) except those in .github directory + # Exclude hidden files (starting with .) except those in .github or at the repository root $fileName = [System.IO.Path]::GetFileName($relativePath) $isInGitHubDir = $relativePath -like '.github/*' - if ($fileName.StartsWith('.') -and -not $isInGitHubDir) { + $isAtRepoRoot = -not $relativePath.Contains('/') + if ($fileName.StartsWith('.') -and -not $isInGitHubDir -and -not $isAtRepoRoot) { return $false } From 49c67f643f2ba762be72052cdedd4f74c62f6b16 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:57:30 -0400 Subject: [PATCH 10/18] Sync template-tracked file contents from repo-template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring template-tracked files in line with the canonical version in repo-template. Repo-specific files (Directory.Build.props, README.md, LICENSE, .gitignore, Solution.slnx, docfx_project/docfx.json, docfx_project/index.md, .github/CODEOWNERS, .github/dependabot.yml, .github/copilot-instructions.md) are intentionally NOT changed in this PR. Files updated: - .github/workflows/pr.yaml — picks up the MSBuild-based TFM detection fix and the graceful skip-when-no-tests fix - .github/workflows/docfx.yaml — picks up the SemVer prerelease sort fix - .github/workflows/release.yaml — sync with template - .editorconfig — sync with template - scripts/Setup-BranchRuleset.ps1, scripts/Setup-GitHubPages.ps1, scripts/build-pr.ps1, scripts/setup.ps1 — sync with template --- .editorconfig | 627 +++++++++++++++++++------------- .github/workflows/docfx.yaml | 368 +++++++++++++++++-- .github/workflows/pr.yaml | 105 ++++-- .github/workflows/release.yaml | 587 ++++++++++++++++++++++++++++-- scripts/Setup-BranchRuleset.ps1 | 6 +- scripts/Setup-GitHubPages.ps1 | 19 +- scripts/build-pr.ps1 | 8 +- scripts/setup.ps1 | 5 +- 8 files changed, 1335 insertions(+), 390 deletions(-) diff --git a/.editorconfig b/.editorconfig index 6c43eef..7b0f7a6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,280 +1,290 @@ root = true -# Top-most EditorConfig file # All files [*] charset = utf-8 -end_of_line = crlf -insert_final_newline = true -indent_style = tab +end_of_line = lf +indent_style = space indent_size = 4 +insert_final_newline = true trim_trailing_whitespace = true -# Microsoft .NET properties -csharp_style_expression_bodied_constructors = false:warning -dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion - -# ReSharper properties -resharper_csharp_wrap_lines = false -resharper_local_function_body = expression_body -dotnet_style_operator_placement_when_wrapping = beginning_of_line -tab_width = 4 -dotnet_style_prefer_compound_assignment = true:suggestion -dotnet_diagnostic.CA1707.severity = none -dotnet_style_allow_statement_immediately_after_block_experimental = true:silent - -# C# files -[*.cs] -# Use file-scoped namespaces -csharp_style_namespace_declarations = file_scoped:suggestion - -# Prefer `var` when type is apparent -csharp_style_var_when_type_is_apparent = true:suggestion -# Prefer explicit type when type is not apparent -csharp_style_var_elsewhere = true:suggestion - -# Prefer expression-bodied members -csharp_style_expression_bodied_methods = true:suggestion +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 -# Prefer 'this.' qualification for members -dotnet_style_qualification_for_field = true:suggestion -dotnet_style_qualification_for_property = true:suggestion -dotnet_style_qualification_for_method = false:suggestion - -# Analyzer severity: treat warnings as errors for analyzers -dotnet_analyzer_diagnostic.severity = suggestion - -# Example: StyleCop rule as error (if StyleCop is installed) -dotnet_diagnostic.sa1101.severity = error - -# Suppress a specific warning (example: unused variable) -dotnet_diagnostic.cs0168.severity = none - -# Allow underscores in test method names -dotnet_diagnostic.CA1707.severity = none +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 -# Require 'using' directives to be inside namespaces -dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = true - -# Organize usings on save (supported in some editors) -csharp_indent_labels = one_less_than_current -csharp_using_directive_placement = outside_namespace:silent -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_braces = true:silent -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_top_level_statements = true:silent -csharp_style_prefer_primary_constructors = true:suggestion -csharp_prefer_system_threading_lock = true:suggestion -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_lambdas = true:silent -csharp_style_expression_bodied_local_functions = false:silent -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_style_var_for_built_in_types = true:suggestion -#### Core EditorConfig Options #### - -# Indentation and spacing -indent_style = space -tab_width = 4 - -# New line preferences -insert_final_newline = false +# JSON files +[*.json] +indent_size = 2 -#### .NET Code Actions #### +# YAML files +[*.{yml,yaml}] +indent_size = 2 -# Type members -dotnet_hide_advanced_members = false -dotnet_member_insertion_location = with_other_members_of_the_same_kind -dotnet_property_generation_behavior = prefer_throwing_properties - -# Symbol search -dotnet_search_reference_assemblies = true - -#### .NET Coding Conventions #### +# 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 -# Organize usings -dotnet_separate_import_directive_groups = false -file_header_template = - -# this. and Me. preferences -dotnet_style_qualification_for_event = false:warning -dotnet_style_qualification_for_field = false -dotnet_style_qualification_for_method = false:warning -dotnet_style_qualification_for_property = false:warning - -# Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true -dotnet_style_predefined_type_for_member_access = true - -# Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity -dotnet_style_parentheses_in_other_operators = never_if_unnecessary -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity - -# Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members - -# Expression-level preferences -dotnet_prefer_system_hash_code = true -dotnet_style_coalesce_expression = true -dotnet_style_collection_initializer = true -dotnet_style_explicit_tuple_names = true -dotnet_style_namespace_match_folder = true -dotnet_style_null_propagation = true -dotnet_style_object_initializer = true -dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_prefer_auto_properties = true -dotnet_style_prefer_collection_expression = when_types_loosely_match -dotnet_style_prefer_compound_assignment = true -dotnet_style_prefer_conditional_expression_over_assignment = true -dotnet_style_prefer_conditional_expression_over_return = true -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed -dotnet_style_prefer_inferred_anonymous_type_member_names = true -dotnet_style_prefer_inferred_tuple_names = true -dotnet_style_prefer_is_null_check_over_reference_equality_method = true -dotnet_style_prefer_simplified_boolean_expressions = true -dotnet_style_prefer_simplified_interpolation = true - -# Field preferences -dotnet_style_readonly_field = true - -# Parameter preferences -dotnet_code_quality_unused_parameters = all - -# Suppression preferences -dotnet_remove_unnecessary_suppression_exclusions = none +# C# files +[*.cs] -# New line preferences -dotnet_style_allow_multiple_blank_lines_experimental = true -dotnet_style_allow_statement_immediately_after_block_experimental = true - -#### C# Coding Conventions #### - -# var preferences -csharp_style_var_elsewhere = true:warning -csharp_style_var_for_built_in_types = true:warning -csharp_style_var_when_type_is_apparent = true:warning - -# Expression-bodied members -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_constructors = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_lambdas = true:silent -csharp_style_expression_bodied_local_functions = false:silent -csharp_style_expression_bodied_methods = true:silent -csharp_style_expression_bodied_operators = true:silent -csharp_style_expression_bodied_properties = true:silent - -# Pattern matching preferences -csharp_style_pattern_matching_over_as_with_null_check = true -csharp_style_pattern_matching_over_is_with_cast_check = true -csharp_style_prefer_extended_property_pattern = true -csharp_style_prefer_not_pattern = true -csharp_style_prefer_pattern_matching = true -csharp_style_prefer_switch_expression = true - -# Null-checking preferences -csharp_style_conditional_delegate_call = true - -# Modifier preferences -csharp_prefer_static_anonymous_function = true -csharp_prefer_static_local_function = true -csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async -csharp_style_prefer_readonly_struct = true -csharp_style_prefer_readonly_struct_member = true - -# Code-block preferences -csharp_prefer_braces = true:warning -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_system_threading_lock = true:suggestion -csharp_style_namespace_declarations = file_scoped:warning -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_primary_constructors = true:suggestion -csharp_style_prefer_top_level_statements = true:silent - -# Expression-level preferences -csharp_prefer_simple_default_expression = true -csharp_style_deconstructed_variable_declaration = true -csharp_style_implicit_object_creation_when_type_is_apparent = true -csharp_style_inlined_variable_declaration = true -csharp_style_prefer_index_operator = true -csharp_style_prefer_local_over_anonymous_function = true -csharp_style_prefer_null_check_over_type_check = true -csharp_style_prefer_range_operator = true -csharp_style_prefer_tuple_swap = true -csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion -csharp_style_prefer_utf8_string_literals = true -csharp_style_throw_expression = true -csharp_style_unused_value_assignment_preference = discard_variable -csharp_style_unused_value_expression_statement_preference = discard_variable - -# 'using' directive preferences -csharp_using_directive_placement = outside_namespace:silent +# SA0001 - Disable XML documentation file requirement +dotnet_diagnostic.SA0001.severity = none -# New line preferences -csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true -csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true -csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true -csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true -csharp_style_allow_embedded_statements_on_same_line_experimental = true +# .NET Code Analysis Rules +# Enable .NET analyzers with conservative defaults +dotnet_analyzer_diagnostic.severity = suggestion -#### C# Formatting Rules #### +# 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<T> 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<string> 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(<T>)" should implement "IEquatable<T>" +dotnet_diagnostic.S3898.severity = warning # Value types should implement "IEquatable<T>" +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_catch = true +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_anonymous_types = true csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_open_brace = all +csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_between_query_expression_clauses = true # Indentation preferences -csharp_indent_block_contents = true -csharp_indent_braces = false csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = 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_after_keywords_in_control_flow_statements = true +csharp_space_before_dot = false csharp_space_after_semicolon_in_for_statement = true -csharp_space_around_binary_operators = before_and_after +csharp_space_before_semicolon_in_for_statement = false csharp_space_around_declaration_statements = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_before_comma = false -csharp_space_before_dot = false csharp_space_before_open_square_brackets = false -csharp_space_before_semicolon_in_for_statement = false csharp_space_between_empty_square_brackets = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_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_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = 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 -csharp_preserve_single_line_statements = true - -#### Naming styles #### -# Naming rules +# 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 @@ -288,39 +298,144 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_m 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.interface.required_modifiers = 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.types.required_modifiers = 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 -dotnet_naming_symbols.non_field_members.required_modifiers = # 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.required_suffix = -dotnet_naming_style.begins_with_i.word_separator = dotnet_naming_style.begins_with_i.capitalization = pascal_case -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = -dotnet_naming_style.pascal_case.capitalization = pascal_case -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_auto_properties = true:silent -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:silent -dotnet_diagnostic.ca1707.severity = suggestion -dotnet_style_qualification_for_event = false:silent +# 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 -dotnet_diagnostic.ide0051.severity = none +# Banned API Analyzer - Allow in examples for demonstration purposes +dotnet_diagnostic.RS0030.severity = none # Allow banned APIs in examples for demonstration diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index f821240..b716439 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -1,63 +1,357 @@ 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 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 - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: docfx_project/_site # The path to the folder to upload + 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 - 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: Generate versions.json + # Produces versions.json consumed by the DocFX version-switcher dropdown. + # Site layout: + # /<repo>/ ← version-picker index.html (deployed to root) + # /<repo>/versions/latest/ ← latest docs (alias under versions/) + # /<repo>/versions/v1.2.3/ ← versioned docs (under versions/) + # versions.json format expected by the dropdown: + # [{ "version": "latest", "url": "/<repo>/versions/latest/" }, { "version": "v1.2.3", "url": "/<repo>/versions/v1.2.3/" }] + # URLs must include the repo path segment because this is a GitHub Pages project site + # (https://<owner>.github.io/<repo>/), 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(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>[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 + PreRelease = $Matches['prerelease'] + } + } + } + + # Sort descending by full SemVer precedence (Major, Minor, Patch, Stable, PreRelease) + # Convert to a mutable list so we can use a custom comparison for proper SemVer prerelease ordering. + $tagList = [System.Collections.Generic.List[object]]::new() + foreach ($item in $taggedVersions) { + [void]$tagList.Add($item) + } + + $comparison = [System.Comparison[object]]{ + param($a, $b) + + # Compare Major, Minor, Patch (descending) + if ($a.Major -ne $b.Major) { return [Math]::Sign($b.Major - $a.Major) } + if ($a.Minor -ne $b.Minor) { return [Math]::Sign($b.Minor - $a.Minor) } + if ($a.Patch -ne $b.Patch) { return [Math]::Sign($b.Patch - $a.Patch) } + + # Compare Stable flag (descending: stable=1 > prerelease=0) + if ($a.Stable -ne $b.Stable) { return [Math]::Sign($b.Stable - $a.Stable) } + + # At this point, Major/Minor/Patch/Stable are equal. + # If both are stable (no prerelease), they are equal for our purposes. + $aPre = [string]$a.PreRelease + $bPre = [string]$b.PreRelease + + if ([string]::IsNullOrEmpty($aPre) -and [string]::IsNullOrEmpty($bPre)) { return 0 } + + # Both should be prereleases when Stable is 0, but handle any unexpected cases gracefully. + if ([string]::IsNullOrEmpty($aPre) -and -not [string]::IsNullOrEmpty($bPre)) { return -1 } + if (-not [string]::IsNullOrEmpty($aPre) -and [string]::IsNullOrEmpty($bPre)) { return 1 } + + $aIds = $aPre -split '\.' + $bIds = $bPre -split '\.' + $maxLen = [Math]::Max($aIds.Length, $bIds.Length) + + for ($i = 0; $i -lt $maxLen; $i++) { + if ($i -ge $aIds.Length) { return -1 } # a has fewer identifiers -> lower precedence + if ($i -ge $bIds.Length) { return 1 } # b has fewer identifiers -> lower precedence + + $aId = $aIds[$i] + $bId = $bIds[$i] + + $aIsNum = [int]::TryParse($aId, [ref]([int]$null)) + $bIsNum = [int]::TryParse($bId, [ref]([int]$null)) + + if ($aIsNum -and $bIsNum) { + $aVal = [int]$aId + $bVal = [int]$bId + if ($aVal -ne $bVal) { return [Math]::Sign($bVal - $aVal) } + } + elseif ($aIsNum -and -not $bIsNum) { + # Numeric identifiers have lower precedence than non-numeric. + return 1 + } + elseif (-not $aIsNum -and $bIsNum) { + return -1 + } + else { + $cmp = [string]::CompareOrdinal($aId, $bId) + if ($cmp -ne 0) { return -$cmp } + } + } + + # All identifiers equal + return 0 + } + + $tagList.Sort($comparison) + # We implemented comparison for descending SemVer order directly in the comparison. + $orderedTags = $tagList | Select-Object -ExpandProperty Tag + + # Build the base path for this GitHub Pages project site: /<repo-name>/ + # 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/<tag>/. + [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: 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 docs to GitHub Pages + # Assembles the full gh-pages state and pushes a single commit, avoiding the + # multiple sequential pushes that would trigger pages-build-deployment repeatedly. + # + # Layout written to gh-pages in one commit: + # versions/<version>/ – versioned docs (real DocFX index.html) + # versions/latest/ – latest alias (real DocFX index.html) [deploy_as_latest only] + # / – version-picker index.html + shared assets [deploy_as_latest only] + # + # Stale root files from the previous build are removed before copying new content, + # so outdated DocFX assets do not linger. The versions/ folder is always preserved + # so that all prior versioned docs remain accessible. + if: inputs.deploy_to_pages != false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + VERSION_DIR: ${{ steps.dest.outputs.dir }} + DEPLOY_AS_LATEST: ${{ inputs.deploy_as_latest != false }} + shell: pwsh + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git remote set-url origin "https://x-access-token:$($env:GITHUB_TOKEN)@github.com/$($env:GITHUB_REPOSITORY).git" + + $WORK_DIR = Join-Path $env:RUNNER_TEMP 'gh-pages-deploy' + $siteDir = Resolve-Path 'docfx_project/_site' + + # Set up gh-pages worktree (or start fresh if the branch does not exist yet) + $branchExists = git ls-remote --heads origin gh-pages + if ($branchExists) { + git fetch origin gh-pages + git show-ref --verify --quiet refs/heads/gh-pages + if ($LASTEXITCODE -ne 0) { git branch gh-pages origin/gh-pages } + git worktree remove $WORK_DIR --force 2>&1 | Out-Null + if (Test-Path $WORK_DIR) { Remove-Item $WORK_DIR -Recurse -Force } + git worktree add $WORK_DIR gh-pages + } else { + Write-Host "ā„¹ļø gh-pages does not exist yet — starting fresh." + New-Item -ItemType Directory -Force -Path $WORK_DIR | Out-Null + } + + # Remove stale root files; preserve versions/, .git, .nojekyll, CNAME + Get-ChildItem -Path $WORK_DIR -Force | Where-Object { + $_.Name -notin @('.git', 'CNAME', '.nojekyll', 'versions') + } | Remove-Item -Recurse -Force + + # Ensure .nojekyll exists so GitHub Pages does not run Jekyll + New-Item -ItemType File -Path (Join-Path $WORK_DIR '.nojekyll') -Force | Out-Null + + # Deploy versioned docs (real DocFX index.html — before version picker overwrites it) + $versionedDir = Join-Path $WORK_DIR "versions/$($env:VERSION_DIR)" + New-Item -ItemType Directory -Force -Path $versionedDir | Out-Null + Copy-Item -Path "$siteDir/*" -Destination $versionedDir -Recurse -Force + Write-Host "āœ… Copied docs to versions/$($env:VERSION_DIR)/" + + if ($env:DEPLOY_AS_LATEST -eq 'true') { + # Deploy to versions/latest/ (real DocFX index.html) + $latestDir = Join-Path $WORK_DIR 'versions/latest' + New-Item -ItemType Directory -Force -Path $latestDir | Out-Null + Copy-Item -Path "$siteDir/*" -Destination $latestDir -Recurse -Force + Write-Host "āœ… Copied docs to versions/latest/" + + # Generate version-picker index.html and overwrite _site/index.html. + # This happens AFTER the versioned copies above, so those directories + # retain the real DocFX landing page while the root gets the picker. + $repoName = ($env:GITHUB_REPOSITORY -split '/')[-1] + $title = if ($repoName) { "$repoName Documentation" } else { "Documentation" } + $versions = Get-Content "$siteDir/versions.json" -Raw | ConvertFrom-Json + + $listItems = foreach ($v in $versions) { + $liClass = if ($v.version -eq 'latest') { ' class="latest"' } else { '' } + $label = if ($v.version -eq 'latest') { 'latest (stable)' } else { $v.version } + " <li${liClass}><a href=`"$($v.url)`">$label</a></li>" + } + $listHtml = $listItems -join "`n" + + if (-not (Test-Path '.github/version-picker-template.html')) { + Write-Error "Error: .github/version-picker-template.html not found; cannot generate root index.html." + exit 1 + } + + $template = Get-Content '.github/version-picker-template.html' -Raw + $html = $template -replace '\{\{TITLE\}\}', $title ` + -replace '\{\{VERSION_LIST\}\}', $listHtml + $html | Set-Content -Path "$siteDir/index.html" -Encoding utf8NoBOM + Write-Host "Generated version-picker index.html with $($versions.Count) version link(s)." + + # Deploy version picker + shared assets to site root + Copy-Item -Path "$siteDir/*" -Destination $WORK_DIR -Recurse -Force + Write-Host "āœ… Copied version picker to site root" + } + + # Single commit and push + git -C $WORK_DIR add -A + git -C $WORK_DIR diff --cached --quiet + if ($LASTEXITCODE -ne 0) { + $msg = if ($env:DEPLOY_AS_LATEST -eq 'true') { + "docs: deploy $($env:VERSION_DIR) and update latest" + } else { + "docs: deploy $($env:VERSION_DIR)" + } + git -C $WORK_DIR commit -m $msg + git -C $WORK_DIR push origin HEAD:gh-pages + Write-Host "āœ… Documentation deployed in a single commit." + } else { + Write-Host "ā„¹ļø No documentation changes to deploy." + } + + git worktree remove $WORK_DIR --force 2>&1 | Out-Null diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index fe0ef48..5939209 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -362,10 +362,17 @@ jobs: for proj in "${projects[@]}"; do echo "Building: $proj" - # Extract target frameworks from the project file - # Support both <TargetFramework> (single) and <TargetFrameworks> (multiple) - # Collapse newlines so multi-line <TargetFrameworks> values are handled correctly - frameworks=$(tr '\n' ' ' < "$proj" | grep -oP '<TargetFramework[s]?>\s*\K[^<]+' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp[0-9.]+|netstandard[0-9.]+)$' || true) + # Extract target frameworks via MSBuild property evaluation. + # This handles multi-line <TargetFrameworks> XML, conditional property groups, + # and TFMs inherited from Directory.Build.props — all of which break grep-based parsing. + # Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single). + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp[0-9.]+|netstandard[0-9.]+)$' || true) if [ -z "$frameworks" ]; then echo "āš ļø No Linux-compatible frameworks found in $proj" @@ -395,12 +402,20 @@ jobs: - name: Run tests with coverage (.NET Core 5.0 - 10.0) run: | - # Find all test projects (C#, VB.NET, F#) + # Find all test projects (C#, VB.NET, F#). + # Gracefully skip if there is no ./tests directory (e.g. template-publishing + # repos or library repos in early development that have no tests yet). + # The downstream coverage steps already handle the no-coverage-files case. + if [ ! -d ./tests ]; then + echo "ā„¹ļø No ./tests directory — skipping test stage." + exit 0 + fi + mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) - + if [ ${#test_projects[@]} -eq 0 ]; then - echo "āŒ No test projects found in ./tests directory!" - exit 1 + echo "ā„¹ļø No test projects found under ./tests — skipping test stage." + exit 0 fi echo "==========================================" @@ -414,9 +429,16 @@ jobs: echo "Testing project: $test_proj" echo "==========================================" - # Extract target frameworks from the project file - # Support both <TargetFramework> (single) and <TargetFrameworks> (multiple) - frameworks=$(grep -oP '<TargetFramework[s]?>\K[^<]+' "$test_proj" | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp3\.1)$' || true) + # Extract target frameworks via MSBuild property evaluation (handles multi-line XML + # and Directory.Build.props inheritance — both break grep-based parsing). + # Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single). + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp3\.1)$' || true) if [ -z "$frameworks" ]; then echo "⊘ Skipping: No compatible .NET 5.0-10.0 target frameworks found" @@ -660,12 +682,19 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - + + # Gracefully skip if there is no ./tests directory (e.g. template-publishing + # repos or library repos in early development that have no tests yet). + if (-not (Test-Path -Path './tests' -PathType Container)) { + Write-Host "ā„¹ļø No ./tests directory — skipping test stage." + exit 0 + } + $testProjects = @(Get-ChildItem -Path './tests/*' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj') - + if (@($testProjects).Count -eq 0) { - Write-Error "āŒ No test projects found in ./tests directory!" - exit 1 + Write-Host "ā„¹ļø No test projects found under ./tests — skipping test stage." + exit 0 } Write-Host "==========================================" -ForegroundColor Cyan @@ -982,10 +1011,16 @@ jobs: for proj in "${projects[@]}"; do echo "Building: $proj" - # Extract target frameworks from the project file - # Support both <TargetFramework> (single) and <TargetFrameworks> (multiple) - # Trim whitespace from each framework before filtering - frameworks=$(tr -d '\n\r' < "$proj" | sed -n -E 's/.*<TargetFrameworks?>[[:space:]]*>([^<]+)<\/TargetFrameworks?>.*/\1/p' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) + # Extract target frameworks via MSBuild property evaluation (handles multi-line XML + # and Directory.Build.props inheritance). Filter to .NET 6+ for macOS ARM64 compatibility. + # Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single). + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) if [ -z "$frameworks" ]; then echo "āš ļø No macOS ARM64-compatible frameworks found in $proj" @@ -1015,15 +1050,22 @@ jobs: - name: Run tests (.NET 6.0 - 10.0 only - ARM64 compatible) run: | - # Find all test projects (C#, VB.NET, F#) + # Find all test projects (C#, VB.NET, F#). + # Gracefully skip if there is no ./tests directory (e.g. template-publishing + # repos or library repos in early development that have no tests yet). + if [ ! -d ./tests ]; then + echo "ā„¹ļø No ./tests directory — skipping test stage." + exit 0 + fi + test_projects=() while IFS= read -r -d '' file; do test_projects+=("$file") - done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) - + done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) + if [ ${#test_projects[@]} -eq 0 ]; then - echo "āŒ No test projects found in ./tests directory!" - exit 1 + echo "ā„¹ļø No test projects found under ./tests — skipping test stage." + exit 0 fi echo "==========================================" @@ -1037,11 +1079,16 @@ jobs: echo "Testing project: $test_proj" echo "==========================================" - # Extract target frameworks from the project file - # Support both <TargetFramework> (single) and <TargetFrameworks> (multiple) - # Only include .NET 6.0+ (ARM64 compatible on macOS) - # Normalize line endings to handle multi-line <TargetFramework> / <TargetFrameworks> elements - frameworks=$(tr -d '\n\r' < "$test_proj" | grep -Eo '<TargetFrameworks?>[^<]+' | sed -E 's/<TargetFrameworks?>//' | tr ';' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) + # Extract target frameworks via MSBuild property evaluation (handles multi-line XML + # and Directory.Build.props inheritance). Filter to .NET 6+ for macOS ARM64 compatibility. + # Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single). + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) if [ -z "$frameworks" ]; then echo "⊘ Skipping: No compatible .NET 6.0-10.0 target frameworks found (ARM64 required)" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8d9b107..569b7ed 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,20 +1,27 @@ -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 + # 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 @@ -33,32 +40,261 @@ jobs: - name: Build Solution (Release) run: dotnet build --no-restore --configuration Release - - name: Run tests for all test projects + - name: Run multi-framework tests with coverage shell: pwsh run: | - Get-ChildItem -Path './tests' -Recurse -Filter '*Test*.csproj' | ForEach-Object { - Write-Host "Running tests for $($_.FullName)" - dotnet test $_.FullName --no-build --configuration Release - if ($LASTEXITCODE -ne 0) { - Write-Error "Tests failed for $($_.FullName)" - exit $LASTEXITCODE + $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: Upload test results + - name: Verify coverage threshold + shell: pwsh + run: | + # 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 coverage report if: always() uses: actions/upload-artifact@v4 with: - name: test-results - path: '**/TestResults*.trx' + name: release-coverage + path: CoverageReport/ - publish: - name: Pack and Publish NuGet - needs: build-and-test + # 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 @@ -71,45 +307,316 @@ 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 Packages + - name: Pack NuGet packages + id: check-packages shell: pwsh run: | + # Create output directory for NuGet packages $packagesPath = Join-Path $PWD 'nuget-packages' New-Item -ItemType Directory -Force -Path $packagesPath | Out-Null - Get-ChildItem -Path 'src' -Recurse -Filter '*.csproj' | ForEach-Object { - Write-Host "Packing $($_.FullName)" - dotnet pack $_.FullName --no-build --configuration Release --output $packagesPath + # 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 "dotnet pack failed for $($_.FullName)" + 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: | + $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'." + } + + $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 = @( + '<?xml version="1.0" encoding="utf-8"?>' + '<configuration>' + ' <packageSources>' + ' <clear />' + ' <add key="local" value="../nuget-packages" />' + ' </packageSources>' + ' <packageSourceMapping>' + ' <packageSource key="local">' + ' <package pattern="*" />' + ' </packageSource>' + ' </packageSourceMapping>' + '</configuration>' + ) + $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: Generate SBOM (CycloneDX) + if: steps.check-packages.outputs.has-packages == 'true' + shell: pwsh + run: | + dotnet tool install --global CycloneDX + + $sbomDir = Join-Path $PWD 'nuget-packages' + $srcProjects = Get-ChildItem -Path 'src' -Filter '*.csproj' -Recurse -ErrorAction SilentlyContinue + + if ($srcProjects.Count -eq 0) { + Write-Warning "No projects found in src/ - skipping SBOM generation" + return + } + + foreach ($proj in $srcProjects) { + $sbomName = "$($proj.BaseName).bom.json" + $sbomPath = Join-Path $sbomDir $sbomName + + Write-Host "šŸ“‹ Generating SBOM for $($proj.Name)" -ForegroundColor Cyan + dotnet CycloneDX $proj.FullName --output $sbomDir --filename $sbomName --json + + if ($LASTEXITCODE -ne 0) { + Write-Warning "āš ļø SBOM generation failed for $($proj.Name) - continuing" + } else { + Write-Host "āœ… SBOM generated: $sbomName" -ForegroundColor Green + } + } - - name: Upload NuGet packages as artifacts + + - name: Upload NuGet packages uses: actions/upload-artifact@v4 - with: + 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: ./nuget-packages/*.nupkg - retention-days: 30 + path: ./packages - - name: Publish NuGet Package + - name: Validate NuGet API key shell: pwsh env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: | - $packagesPath = Join-Path $PWD 'nuget-packages' - Get-ChildItem -Path $packagesPath -Filter '*.nupkg' | ForEach-Object { - Write-Host "Publishing $($_.FullName)" - dotnet nuget push $_.FullName --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate + 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 -Path './packages' -Filter '*.nupkg' -ErrorAction SilentlyContinue + + if ($packages.Count -eq 0) { + Write-Warning "No .nupkg files found - nothing to publish" + exit 0 + } + + 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 "dotnet nuget push failed for $($_.FullName)" + 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 + + # 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 + + - name: Download coverage report artifact + uses: actions/download-artifact@v4 + with: + 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 + ./nuget-packages/*.bom.json + release-coverage.zip + diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1 index c9dc7ae..f8ef40e 100644 --- a/scripts/Setup-BranchRuleset.ps1 +++ b/scripts/Setup-BranchRuleset.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository = "Chris-Wolfgang/In-memory-Logger", + [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", [Parameter()] [string]$BranchName = "main" @@ -78,7 +78,7 @@ try { } # Determine repository -if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $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 { @@ -86,7 +86,7 @@ if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { $Repository = $repoInfo.nameWithOwner Write-Host "āœ… Using repository: $Repository" -ForegroundColor Green } catch { - if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { + 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." diff --git a/scripts/Setup-GitHubPages.ps1 b/scripts/Setup-GitHubPages.ps1 index de6776b..334266d 100644 --- a/scripts/Setup-GitHubPages.ps1 +++ b/scripts/Setup-GitHubPages.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository, + [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", [Parameter()] [switch]$EnablePages, @@ -62,19 +62,6 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -# Auto-detect repository if not provided -if (-not $Repository) { - try { - $Repository = (gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>$null) - if (-not $Repository) { - throw "Could not auto-detect repository" - } - } catch { - Write-Error "Repository not specified and auto-detection failed. Use -Repository 'owner/repo'." - exit 1 - } -} - # Color output functions function Write-Success { param([string]$Message) @@ -224,7 +211,7 @@ try { } # Determine repository -if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $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 { @@ -232,7 +219,7 @@ if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { $Repository = $repoInfo.nameWithOwner Write-Success "Using repository: $Repository" } catch { - if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { + 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." diff --git a/scripts/build-pr.ps1 b/scripts/build-pr.ps1 index 611d126..d7fd64c 100644 --- a/scripts/build-pr.ps1 +++ b/scripts/build-pr.ps1 @@ -84,8 +84,7 @@ if (-not $SkipTests -and $failed.Count -eq 0) { $testProjects = @(Get-ChildItem -Path './tests' -Recurse -File -Include '*.csproj', '*.vbproj', '*.fsproj' -ErrorAction SilentlyContinue) if ($testProjects.Count -eq 0) { - Write-Fail "No test projects found in ./tests" - $failed += "Tests" + Write-Host "No test projects found in ./tests — skipping" } else { foreach ($testProj in $testProjects) { @@ -268,10 +267,7 @@ if (-not $SkipSecurity) { else { $archive = "gitleaks_${version}_linux_x64.tar.gz" $url = "https://github.com/gitleaks/gitleaks/releases/download/v${version}/$archive" - $dest = Join-Path $HOME ".local/bin" - New-Item -ItemType Directory -Force -Path $dest | Out-Null - curl -sSfL $url | tar xz -C $dest gitleaks - $env:PATH = "$dest$([IO.Path]::PathSeparator)$env:PATH" + curl -sSfL $url | tar xz -C /usr/local/bin gitleaks } } diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 5134333..37a218f 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -629,11 +629,10 @@ function Start-Setup { return $false } - # Exclude hidden files (starting with .) except those in .github or at the repository root + # Exclude hidden files (starting with .) except those in .github directory $fileName = [System.IO.Path]::GetFileName($relativePath) $isInGitHubDir = $relativePath -like '.github/*' - $isAtRepoRoot = -not $relativePath.Contains('/') - if ($fileName.StartsWith('.') -and -not $isInGitHubDir -and -not $isAtRepoRoot) { + if ($fileName.StartsWith('.') -and -not $isInGitHubDir) { return $false } From ccef4f4ed6791d0ed87042740b2731b42cdc665e Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:09:08 -0400 Subject: [PATCH 11/18] Restore scripts/ to match main Removes script changes from this PR. The four script files contained post-setup customizations (resolved repo name, bug fixes from PR #19's review feedback) that shouldn't be reverted by syncing from repo-template: - Setup-BranchRuleset.ps1: keep resolved Chris-Wolfgang/In-memory-Logger - Setup-GitHubPages.ps1: keep resolved repo name and auto-detect block - build-pr.ps1: keep gitleaks ~/.local/bin install (no sudo) and fail-on-no-test-projects behavior - setup.ps1: keep dotfiles-at-repo-root fix for .slnx generation This PR now only syncs the truly template-tracked files: .editorconfig and the three workflow yamls. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- scripts/Setup-BranchRuleset.ps1 | 6 +++--- scripts/Setup-GitHubPages.ps1 | 19 ++++++++++++++++--- scripts/build-pr.ps1 | 8 ++++++-- scripts/setup.ps1 | 5 +++-- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1 index f8ef40e..c9dc7ae 100644 --- a/scripts/Setup-BranchRuleset.ps1 +++ b/scripts/Setup-BranchRuleset.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", + [string]$Repository = "Chris-Wolfgang/In-memory-Logger", [Parameter()] [string]$BranchName = "main" @@ -78,7 +78,7 @@ try { } # Determine repository -if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { +if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { # Placeholders not replaced or no repository specified - auto-detect Write-Host "šŸ” Detecting current repository..." -ForegroundColor Cyan try { @@ -86,7 +86,7 @@ if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { $Repository = $repoInfo.nameWithOwner Write-Host "āœ… Using repository: $Repository" -ForegroundColor Green } catch { - if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { + if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { 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." diff --git a/scripts/Setup-GitHubPages.ps1 b/scripts/Setup-GitHubPages.ps1 index 334266d..de6776b 100644 --- a/scripts/Setup-GitHubPages.ps1 +++ b/scripts/Setup-GitHubPages.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", + [string]$Repository, [Parameter()] [switch]$EnablePages, @@ -62,6 +62,19 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Auto-detect repository if not provided +if (-not $Repository) { + try { + $Repository = (gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>$null) + if (-not $Repository) { + throw "Could not auto-detect repository" + } + } catch { + Write-Error "Repository not specified and auto-detection failed. Use -Repository 'owner/repo'." + exit 1 + } +} + # Color output functions function Write-Success { param([string]$Message) @@ -211,7 +224,7 @@ try { } # Determine repository -if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { +if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { # Placeholders not replaced or no repository specified - auto-detect Write-Info "Detecting current repository..." try { @@ -219,7 +232,7 @@ if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { $Repository = $repoInfo.nameWithOwner Write-Success "Using repository: $Repository" } catch { - if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { + if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { 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." diff --git a/scripts/build-pr.ps1 b/scripts/build-pr.ps1 index d7fd64c..611d126 100644 --- a/scripts/build-pr.ps1 +++ b/scripts/build-pr.ps1 @@ -84,7 +84,8 @@ if (-not $SkipTests -and $failed.Count -eq 0) { $testProjects = @(Get-ChildItem -Path './tests' -Recurse -File -Include '*.csproj', '*.vbproj', '*.fsproj' -ErrorAction SilentlyContinue) if ($testProjects.Count -eq 0) { - Write-Host "No test projects found in ./tests — skipping" + Write-Fail "No test projects found in ./tests" + $failed += "Tests" } else { foreach ($testProj in $testProjects) { @@ -267,7 +268,10 @@ if (-not $SkipSecurity) { else { $archive = "gitleaks_${version}_linux_x64.tar.gz" $url = "https://github.com/gitleaks/gitleaks/releases/download/v${version}/$archive" - curl -sSfL $url | tar xz -C /usr/local/bin gitleaks + $dest = Join-Path $HOME ".local/bin" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + curl -sSfL $url | tar xz -C $dest gitleaks + $env:PATH = "$dest$([IO.Path]::PathSeparator)$env:PATH" } } diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 37a218f..5134333 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -629,10 +629,11 @@ function Start-Setup { return $false } - # Exclude hidden files (starting with .) except those in .github directory + # Exclude hidden files (starting with .) except those in .github or at the repository root $fileName = [System.IO.Path]::GetFileName($relativePath) $isInGitHubDir = $relativePath -like '.github/*' - if ($fileName.StartsWith('.') -and -not $isInGitHubDir) { + $isAtRepoRoot = -not $relativePath.Contains('/') + if ($fileName.StartsWith('.') -and -not $isInGitHubDir -and -not $isAtRepoRoot) { return $false } From 22f1980bef204ed7bf6268837b3aac65df515fdb Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:31:14 -0400 Subject: [PATCH 12/18] Configure repository from template --- BannedSymbols.txt | 3 +- LICENSE | 394 +--------------- README.md | 182 +++++++- REPO-INSTRUCTIONS.md | 266 +++++++++++ scripts/setup.ps1 | 1043 ------------------------------------------ 5 files changed, 469 insertions(+), 1419 deletions(-) create mode 100644 REPO-INSTRUCTIONS.md delete mode 100644 scripts/setup.ps1 diff --git a/BannedSymbols.txt b/BannedSymbols.txt index 804798a..63c1a92 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -1,4 +1,4 @@ -# BannedSymbols.txt - Async-First Enforcement for {{PROJECT_NAME}} +# BannedSymbols.txt - Async-First Enforcement for Wolfgang.InMemoryLogger # Format: <API Documentation ID>; <Reason/Alternative> # T: = Type, M: = Method, P: = Property, F: = Field # Task.Wait() - All overloads - Absolutely NOT allowed in async code @@ -79,3 +79,4 @@ 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/LICENSE b/LICENSE index d0a1fa1..194219f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,373 +1,21 @@ -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at https://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. +MIT License + +Copyright (c) 2026 Chris Wolfgang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9153ae3..f39afb5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,181 @@ -# Repository Template +# Wolfgang.InMemoryLogger + +Implementations of ILogger and ILogger<T> that write log events to an in-memory list. Useful for testing + +[![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/In-memory-Logger) + +--- + +## šŸ“¦ Installation + +```bash +dotnet add package Wolfgang.InMemoryLogger +``` + +**NuGet Package:** Coming soon to 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/In-memory-Logger](https://github.com/Chris-Wolfgang/In-memory-Logger) +- **API Documentation:** https://Chris-Wolfgang.github.io/In-memory-Logger/ +- **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 Core 3.1 | +| .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/In-memory-Logger.git +cd In-memory-Logger + +# 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}} -This repository is intended to be used as a template for creating other repositories. diff --git a/REPO-INSTRUCTIONS.md b/REPO-INSTRUCTIONS.md new file mode 100644 index 0000000..e378526 --- /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](docs/TEMPLATE-PLACEHOLDERS.md)** +**For license selection guidance, see [LICENSE-SELECTION.md](docs/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.InMemoryLogger`) 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.InMemoryLogger, https://Chris-Wolfgang.github.io/In-memory-Logger/, 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/<tag>/`. 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/scripts/setup.ps1 b/scripts/setup.ps1 deleted file mode 100644 index 5134333..0000000 --- a/scripts/setup.ps1 +++ /dev/null @@ -1,1043 +0,0 @@ -#!/usr/bin/env pwsh -#Requires -Version 7.0 - -<# -.SYNOPSIS - Automated setup script for .NET repository template -.DESCRIPTION - This script automates the process of configuring a new repository created from this template. - It prompts for project information, replaces placeholders, sets up the license, and validates changes. - - The script automatically ensures it runs from the repository root directory: - - If run from the scripts/ directory, it will automatically change to the repository root - - If run from any other location, it will display an error and exit - -.EXAMPLE - # Recommended: Run from repository root - pwsh ./scripts/setup.ps1 - -.EXAMPLE - # Also works: Run from scripts directory (auto-corrects to root) - cd scripts - pwsh ./setup.ps1 - -.NOTES - Requires PowerShell Core 7.0 or later (cross-platform) -#> - -[CmdletBinding()] -param() - -# Enable strict mode -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -# Color output functions -function Write-Success { - param([string]$Message) - Write-Host "āœ… $Message" -ForegroundColor Green -} - -function Write-Info { - param([string]$Message) - Write-Host "ā„¹ļø $Message" -ForegroundColor Cyan -} - -function Write-TemplateWarning { - param([string]$Message) - Write-Host "āš ļø $Message" -ForegroundColor Yellow -} - -function Write-TemplateError { - param([string]$Message) - Write-Host "āŒ $Message" -ForegroundColor Red -} - -function Write-Step { - param([string]$Message) - Write-Host "`nšŸ”§ $Message" -ForegroundColor Magenta -} - -# Banner -function Show-Banner { - Write-Host @" - -╔════════════════════════════════════════════════════════════════╗ -ā•‘ ā•‘ -ā•‘ .NET Repository Template - Automated Setup ā•‘ -ā•‘ ā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• - -"@ -ForegroundColor Cyan -} - -# Ensure script is running from repository root -function Set-RepositoryRoot { - # Get the directory where the script is located - $scriptDir = Split-Path -Parent $PSCommandPath - - # If we're in the scripts directory, move up one level to the repository root - if ((Split-Path -Leaf $scriptDir) -eq 'scripts') { - $repoRoot = Split-Path -Parent $scriptDir - Set-Location $repoRoot - Write-Info "Changed working directory to repository root: $repoRoot" - } - - # Verify we're in the repository root by checking for key marker files - $markerFiles = @('README.md', '.gitignore', 'CONTRIBUTING.md') - $foundMarkers = @($markerFiles | Where-Object { Test-Path $_ }) - - if ($foundMarkers.Count -lt 2) { - Write-TemplateError "This script must be run from the repository root directory." - Write-Host "Expected to find key files like: $($markerFiles -join ', ')" -ForegroundColor Red - Write-Host "Current directory: $(Get-Location)" -ForegroundColor Yellow - Write-Host "" - Write-Host "Please run the script from the repository root:" -ForegroundColor Yellow - Write-Host " pwsh ./scripts/setup.ps1" -ForegroundColor Cyan - throw "Script not running from repository root" - } -} - -# Auto-detect git information -function Get-GitInfo { - $gitInfo = @{ - RemoteUrl = '' - RepoName = '' - Username = '' - UserEmail = '' - FullName = '' - } - - try { - # Get remote URL - $remoteUrl = git remote get-url origin 2>$null - if ($remoteUrl) { - $gitInfo.RemoteUrl = $remoteUrl -replace '\.git$', '' - - # Extract repo name - if ($remoteUrl -match '/([^/]+?)(?:\.git)?$') { - $gitInfo.RepoName = $matches[1] - } - - # Extract username (for GitHub URLs) - if ($remoteUrl -match 'github\.com[:/]([^/]+)/') { - $gitInfo.Username = "@$($matches[1])" - } - } - - # Get git user name - $userName = git config user.name 2>$null - if ($userName) { - $gitInfo.FullName = $userName - } - - # Get git user email - $userEmail = git config user.email 2>$null - if ($userEmail) { - $gitInfo.UserEmail = $userEmail - } - } - catch { - Write-Warning "Could not auto-detect git information" - } - - return $gitInfo -} - -# Prompt for input with default and example -function Read-Input { - param( - [string]$Prompt, - [string]$Default = '', - [string]$Example = '', - [switch]$Required - ) - - $message = $Prompt - if ($Example) { - $message += "`n Example: $Example" - } - if ($Default) { - $message += "`n Default: $Default" - } - $message += "`n > " - - do { - Write-Host $message -NoNewline -ForegroundColor Yellow - $userInput = Read-Host - - if ([string]::IsNullOrWhiteSpace($userInput) -and $Default) { - return $Default - } - - if ([string]::IsNullOrWhiteSpace($userInput) -and $Required) { - Write-TemplateError "This field is required. Please enter a value." - continue - } - - return $userInput - } while ($true) -} - -# Replace placeholders in a file -function Replace-Placeholders { - param( - [string]$FilePath, - [hashtable]$Replacements - ) - - if (-not (Test-Path $FilePath)) { - Write-Warning "File not found: $FilePath" - return - } - - $content = Get-Content $FilePath -Raw - $modified = $false - - foreach ($key in $Replacements.Keys) { - $placeholder = "{{$key}}" - if ($content -match [regex]::Escape($placeholder)) { - $pattern = [regex]::Escape($placeholder) - $content = [regex]::Replace( - $content, - $pattern, - [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $Replacements[$key] } - ) - $modified = $true - } - } - - if ($modified) { - Set-Content -Path $FilePath -Value $content - Write-Success "Updated: $FilePath" - } -} - -# Main setup function -function Start-Setup { - Show-Banner - - # Ensure we're in the repository root - Set-RepositoryRoot - - Write-Info "This script will configure your new repository." - Write-Info "It will prompt you for project information and replace all placeholders." - Write-Host "" - - # Auto-detect git info - Write-Step "Auto-detecting git repository information..." - $gitInfo = Get-GitInfo - - if ($gitInfo.RemoteUrl) { - Write-Success "Detected repository: $($gitInfo.RemoteUrl)" - } - - # Collect project information - Write-Step "Collecting project information..." - Write-Host "" - - # Ask if creating NuGet package - Write-Host "Will this project be published as a NuGet package? (Y/n): " -NoNewline -ForegroundColor Yellow - $createNugetPackage = Read-Host - if ([string]::IsNullOrEmpty($createNugetPackage) -or $createNugetPackage -eq 'Y' -or $createNugetPackage -eq 'y') { - $isNugetPackage = $true - } - else { - $isNugetPackage = $false - } - Write-Host "" - - $projectName = Read-Input ` - -Prompt "Project Name (e.g., Wolfgang.Extensions.IAsyncEnumerable)" ` - -Example "MyCompany.MyLibrary" ` - -Required - - $projectDescription = Read-Input ` - -Prompt "Project Description (one-line description)" ` - -Example "High-performance extension methods for IAsyncEnumerable<T>" ` - -Required - - if ($isNugetPackage) { - $packageName = Read-Input ` - -Prompt "NuGet Package Name" ` - -Default $projectName ` - -Example $projectName - } - else { - $packageName = $projectName - } - - $githubRepoUrl = Read-Input ` - -Prompt "GitHub Repository URL" ` - -Default $gitInfo.RemoteUrl ` - -Example "https://github.com/username/repo-name" ` - -Required - - # Extract repo name from URL if not already detected - $repoName = $gitInfo.RepoName - if ([string]::IsNullOrWhiteSpace($repoName) -and $githubRepoUrl -match '/([^/]+?)(?:\.git)?$') { - $repoName = $matches[1] - } - if ([string]::IsNullOrWhiteSpace($repoName)) { - $repoName = Read-Input ` - -Prompt "Repository Name" ` - -Example "my-repo-name" ` - -Required - } - - $githubUsername = Read-Input ` - -Prompt "GitHub Username (with @)" ` - -Default $gitInfo.Username ` - -Example "@YourUsername" ` - -Required - - # Ensure @ prefix - if ($githubUsername -notmatch '^@') { - $githubUsername = "@$githubUsername" - } - - # Normalize GitHub URL and generate docs URL - # Handle SSH URLs (git@github.com:org/repo.git) and HTTPS URLs - # Remove trailing .git and normalize to https://github.com/<owner>/<repo> - $normalizedUrl = $githubRepoUrl - - # Convert SSH URL to HTTPS format - if ($normalizedUrl -match '^git@github\.com:(.+)$') { - $normalizedUrl = "https://github.com/$($matches[1])" - } - - # Remove trailing .git - $normalizedUrl = $normalizedUrl -replace '\.git$', '' - - # Extract owner and repo from normalized HTTPS URL - $docsUrl = $normalizedUrl -replace 'https://github\.com/([^/]+)/([^/]+).*', 'https://$1.github.io/$2/' - - $docsUrl = Read-Input ` - -Prompt "Documentation URL (GitHub Pages)" ` - -Default $docsUrl ` - -Example "https://username.github.io/repo-name/" - - # Get copyright holder - $copyrightHolder = Read-Input ` - -Prompt "Copyright Holder Name" ` - -Default $gitInfo.FullName ` - -Example "John Doe" ` - -Required - - $currentYear = (Get-Date).Year - $year = Read-Input ` - -Prompt "Copyright Year" ` - -Default $currentYear.ToString() ` - -Example $currentYear.ToString() - - if ($isNugetPackage) { - $nugetStatus = Read-Input ` - -Prompt "NuGet Package Status" ` - -Default "Coming soon to NuGet.org" ` - -Example "Available on NuGet.org" - } - else { - $nugetStatus = "Not applicable" - } - - # License selection - Write-Step "Selecting License..." - Write-Host "" - Write-Host "Available licenses:" -ForegroundColor Yellow - Write-Host " 1) MIT - Most permissive, simple, business-friendly" - Write-Host " 2) Apache-2.0 - Permissive with patent grant" - Write-Host " 3) MPL-2.0 - Weak copyleft, file-level" - Write-Host "" - Write-Host "For detailed comparison, see LICENSE-SELECTION.md" -ForegroundColor Cyan - Write-Host "" - - do { - Write-Host "Select license (1-3): " -NoNewline -ForegroundColor Yellow - $licenseChoice = Read-Host - - switch ($licenseChoice) { - '1' { - $licenseType = 'MIT' - $licenseFile = 'LICENSE-MIT.txt' - break - } - '2' { - $licenseType = 'Apache-2.0' - $licenseFile = 'LICENSE-APACHE-2.0.txt' - break - } - '3' { - $licenseType = 'MPL-2.0' - $licenseFile = 'LICENSE-MPL-2.0.txt' - break - } - default { - Write-TemplateError "Invalid choice. Please enter 1, 2, or 3." - continue - } - } - break - } while ($true) - - Write-Success "Selected: $licenseType License" - - # Template repository info (for REPO-INSTRUCTIONS.md) - $templateRepoOwner = Read-Input ` - -Prompt "Template Repository Owner (the GitHub user/org that owns the template you used)" ` - -Default "Chris-Wolfgang" ` - -Example "YourUsername" - - $templateRepoName = Read-Input ` - -Prompt "Template Repository Name (the name of the template repository you used)" ` - -Default "repo-template" ` - -Example "my-template" - - # Solution creation - Write-Step "Solution Creation" - Write-Host "" - Write-Host "Create a default solution? (y/N): " -NoNewline -ForegroundColor Yellow - $createSolution = Read-Host - - $solutionName = '' - if ($createSolution -eq 'y' -or $createSolution -eq 'Y') { - $isValidSolutionName = $false - while (-not $isValidSolutionName) { - $solutionName = Read-Input ` - -Prompt "Solution Name" ` - -Default $repoName ` - -Example $repoName ` - -Required - - $invalidFileNameChars = [System.IO.Path]::GetInvalidFileNameChars() - if ($solutionName.IndexOfAny($invalidFileNameChars) -ne -1) { - $invalidCharsDisplay = -join $invalidFileNameChars - Write-Error "Solution name contains invalid characters. Please avoid any of: $invalidCharsDisplay" -ErrorAction Continue - } - else { - $isValidSolutionName = $true - } - } - } - - # Summary - Write-Step "Configuration Summary" - Write-Host "" - Write-Host "Project Information:" -ForegroundColor Cyan - Write-Host " Project Name: $projectName" - Write-Host " Description: $projectDescription" - Write-Host " Package Name: $packageName" - Write-Host " Repository URL: $githubRepoUrl" - Write-Host " Repository Name: $repoName" - Write-Host " GitHub Username: $githubUsername" - Write-Host " Documentation URL: $docsUrl" - Write-Host " License: $licenseType" - Write-Host " Copyright Holder: $copyrightHolder" - Write-Host " Copyright Year: $year" - Write-Host " NuGet Status: $nugetStatus" - Write-Host " Template Owner: $templateRepoOwner" - Write-Host " Template Name: $templateRepoName" - if ($solutionName) { - Write-Host " Solution Name: $solutionName" - } - Write-Host "" - - Write-Host "Proceed with configuration? (Y/n): " -NoNewline -ForegroundColor Yellow - $confirm = Read-Host - if ($confirm -and $confirm -ne 'Y' -and $confirm -ne 'y') { - Write-Warning "Setup cancelled." - exit 0 - } - - # Create replacements hashtable - $replacements = @{ - 'PROJECT_NAME' = $projectName - 'PROJECT_DESCRIPTION' = $projectDescription - 'PACKAGE_NAME' = $packageName - 'GITHUB_REPO_URL' = $githubRepoUrl - 'REPO_NAME' = $repoName - 'GITHUB_USERNAME' = $githubUsername - 'DOCS_URL' = $docsUrl - 'LICENSE_TYPE' = $licenseType - 'YEAR' = $year - 'COPYRIGHT_HOLDER' = $copyrightHolder - 'NUGET_STATUS' = $nugetStatus - 'TEMPLATE_REPO_OWNER' = $templateRepoOwner - 'TEMPLATE_REPO_NAME' = $templateRepoName - } - - # Perform setup - Write-Step "Performing setup..." - Write-Host "" - - $totalSteps = if ($solutionName) { 5 } else { 4 } - - # Step 1: README swap - Write-Info "Step 1/${totalSteps}: Swapping README files..." - if (Test-Path 'README.md') { - Remove-Item 'README.md' -Force - Write-Success "Deleted template README.md" - } - - if (Test-Path 'README-TEMPLATE.md') { - Rename-Item 'README-TEMPLATE.md' 'README.md' - Write-Success "Renamed README-TEMPLATE.md → README.md" - } - else { - Write-Error "README-TEMPLATE.md not found!" - exit 1 - } - - # Step 2: Replace placeholders - Write-Info "Step 2/${totalSteps}: Replacing placeholders in files..." - - $filesToUpdate = @( - 'README.md', - 'CONTRIBUTING.md', - '.github/CODEOWNERS', - 'REPO-INSTRUCTIONS.md', - 'scripts/Setup-BranchRuleset.ps1', - 'docfx_project/docfx.json', - 'docfx_project/index.md', - 'docfx_project/api/index.md', - 'docfx_project/api/README.md', - 'docfx_project/docs/toc.yml', - 'docfx_project/docs/introduction.md', - 'docfx_project/docs/getting-started.md', - 'BannedSymbols.txt' - ) - - foreach ($file in $filesToUpdate) { - Replace-Placeholders -FilePath $file -Replacements $replacements - } - - # Step 3: Set up LICENSE - Write-Info "Step 3/${totalSteps}: Setting up LICENSE file..." - - if (Test-Path $licenseFile) { - # Read license template - $licenseContent = Get-Content $licenseFile -Raw - - # Replace placeholders using safe regex replacement with MatchEvaluator - $licenseContent = [regex]::Replace( - $licenseContent, - [regex]::Escape('{{YEAR}}'), - [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $year } - ) - $licenseContent = [regex]::Replace( - $licenseContent, - [regex]::Escape('{{COPYRIGHT_HOLDER}}'), - [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $copyrightHolder } - ) - - # Save as LICENSE - Set-Content -Path 'LICENSE' -Value $licenseContent -NoNewline - Write-Success "Created LICENSE file ($licenseType)" - - # Delete all license templates - Remove-Item 'LICENSE-MIT.txt' -Force -ErrorAction SilentlyContinue - Remove-Item 'LICENSE-APACHE-2.0.txt' -Force -ErrorAction SilentlyContinue - Remove-Item 'LICENSE-MPL-2.0.txt' -Force -ErrorAction SilentlyContinue - Write-Success "Removed license template files" - } - else { - Write-Error "License template file not found: $licenseFile" - exit 1 - } - - # Step 4: Create solution (if requested) - if ($solutionName) { - Write-Info "Step 4/${totalSteps}: Creating solution file..." - - # Create blank solution in .slnx format - # Note: .slnx format requires Visual Studio 2022 version 17.10 or later - $solutionFileName = "$solutionName.slnx" - - # Build the solution XML structure - $xmlBuilder = New-Object System.Text.StringBuilder - [void]$xmlBuilder.AppendLine('<Solution>') - - # Build .root folder with all remaining files - # Exclude files and directories that have their own solution folders or are build artifacts - # Note: .git directory is excluded separately below - $excludePatterns = @( - 'obj', # Build output - 'bin', # Build output - 'TestResults', # Test artifacts - 'CoverageReport', # Coverage artifacts - 'node_modules', # Node dependencies - '*.user', # User-specific files - '*.suo', # Visual Studio user options - '*.sln', # Solution files (prevent including solution in itself) - '*.slnx', # Solution files (prevent including solution in itself) - '*.env', # Environment files (may contain secrets) - '*.key', # Key files (may contain secrets) - '*.pem', # Certificate files (may contain secrets) - 'secrets*', # Secret files - 'benchmarks', # Has its own solution folder - 'examples', # Has its own solution folder - 'src', # Has its own solution folder - 'tests', # Has its own solution folder - 'docfx_project' # Documentation source (built separately) - ) - - # Get current directory for relative path calculation - $currentDir = Get-Location - - # Helper function to get relative path safely - function Get-SafeRelativePath { - param($FullPath) - try { - # Use Resolve-Path with -Relative for safe relative path calculation - $rel = Resolve-Path -Path $FullPath -Relative -ErrorAction Stop - # Remove leading .\ or ./ prefix properly - if ($rel.StartsWith('.\')) { - $rel = $rel.Substring(2) - } - elseif ($rel.StartsWith('./')) { - $rel = $rel.Substring(2) - } - return $rel.Replace('\', '/') - } - catch { - # Fallback: manual calculation - $path = $FullPath - if ($path.StartsWith($currentDir.Path, [System.StringComparison]::OrdinalIgnoreCase)) { - $baseLength = $currentDir.Path.Length - # Ensure we only strip the base path when it's a complete directory component - if ($path.Length -eq $baseLength -or - ($path.Length -gt $baseLength -and - ($path[$baseLength] -eq [System.IO.Path]::DirectorySeparatorChar -or - $path[$baseLength] -eq [System.IO.Path]::AltDirectorySeparatorChar))) { - # Remove the base path and any leading separator - $path = $path.Substring($baseLength) - if ($path.StartsWith('\') -or $path.StartsWith('/')) { - $path = $path.Substring(1) - } - } - } - return $path.Replace('\', '/') - } - } - - # Get all files in the repository - $allFiles = Get-ChildItem -Recurse -File -Force | Where-Object { - # Get relative path safely - $relativePath = Get-SafeRelativePath $_.FullName - - # Exclude files under .git directory specifically (not .github) - if ($relativePath -like '.git/*') { - return $false - } - - # Exclude hidden files (starting with .) except those in .github or at the repository root - $fileName = [System.IO.Path]::GetFileName($relativePath) - $isInGitHubDir = $relativePath -like '.github/*' - $isAtRepoRoot = -not $relativePath.Contains('/') - if ($fileName.StartsWith('.') -and -not $isInGitHubDir -and -not $isAtRepoRoot) { - return $false - } - - # Exclude files matching patterns using precise matching - $shouldExclude = $false - $pathSegments = $relativePath -split '[\\/]+' - $fileExtension = [System.IO.Path]::GetExtension($relativePath) - - foreach ($pattern in $excludePatterns) { - # Handle extension patterns like '*.user' or '*.suo' - if ($pattern.StartsWith('*.')) { - $ext = $pattern.Substring(1) - if ($fileExtension -ieq $ext) { - $shouldExclude = $true - break - } - } - # Handle wildcard patterns like 'secrets*' - elseif ($pattern.Contains('*')) { - if ($relativePath -like $pattern) { - $shouldExclude = $true - break - } - } - # Treat as a path segment name and match against segments - else { - if ($pathSegments -contains $pattern) { - $shouldExclude = $true - break - } - } - } - - -not $shouldExclude - } - - # Group files by directory for .root structure - # Cache relative paths to avoid recalculating - $filesByDirectory = @{} - $relativePathCache = @{} - - foreach ($file in $allFiles) { - # Get relative path safely (use cached if available) - if (-not $relativePathCache.ContainsKey($file.FullName)) { - $relativePathCache[$file.FullName] = Get-SafeRelativePath $file.FullName - } - $relativePath = $relativePathCache[$file.FullName] - $directory = Split-Path $relativePath -Parent - if ([string]::IsNullOrEmpty($directory)) { - $directory = '.' - } - else { - $directory = $directory.Replace('\', '/') - } - - if (-not $filesByDirectory.ContainsKey($directory)) { - $filesByDirectory[$directory] = @() - } - $filesByDirectory[$directory] += $relativePath - } - - # Sort directories to ensure proper nesting order - $sortedDirectories = $filesByDirectory.Keys | Sort-Object - - # Build folder structure with XML escaping - foreach ($directory in $sortedDirectories) { - if ($directory -eq '.') { - # Root files - [void]$xmlBuilder.AppendLine(' <Folder Name="/.root/">') - foreach ($filePath in ($filesByDirectory[$directory] | Sort-Object)) { - $escapedPath = [System.Security.SecurityElement]::Escape($filePath) - [void]$xmlBuilder.AppendLine(" <File Path=""$escapedPath"" />") - } - [void]$xmlBuilder.AppendLine(' </Folder>') - } - else { - # Subdirectory files - $folderName = "/.root/$directory/" - $escapedFolderName = [System.Security.SecurityElement]::Escape($folderName) - [void]$xmlBuilder.AppendLine(" <Folder Name=""$escapedFolderName"">") - foreach ($filePath in ($filesByDirectory[$directory] | Sort-Object)) { - $escapedPath = [System.Security.SecurityElement]::Escape($filePath) - [void]$xmlBuilder.AppendLine(" <File Path=""$escapedPath"" />") - } - [void]$xmlBuilder.AppendLine(' </Folder>') - } - } - - # Add solution folders for benchmarks, examples, src, tests (only if directories exist) - # These are added after .root to prioritize configuration files in solution explorer - $solutionFolders = @('benchmarks', 'examples', 'src', 'tests') - foreach ($folder in $solutionFolders) { - if (Test-Path -Path $folder -PathType Container) { - [void]$xmlBuilder.AppendLine(" <Folder Name=""/$folder/"" />") - } - } - - [void]$xmlBuilder.AppendLine('</Solution>') - - # Write solution file with error handling - try { - Set-Content -Path $solutionFileName -Value $xmlBuilder.ToString() -ErrorAction Stop - Write-Success "Created solution file: $solutionFileName" - - # Show summary - $fileCount = $allFiles.Count - $folderCount = $filesByDirectory.Keys.Count - Write-Info "Added $fileCount files in $folderCount folders to .root/" - } - catch { - Write-TemplateWarning "Failed to create solution file '$solutionFileName'. Repository setup will continue." - Write-TemplateWarning "Error: $($_.Exception.Message)" - # Clear solutionFileName so Next Steps won't reference it - $solutionFileName = '' - } - } - - # Step 5: Validation - Write-Info "Step ${totalSteps}/${totalSteps}: Validating changes..." - - # Core placeholders that should have been replaced by the script - # Note: YEAR and COPYRIGHT_HOLDER are handled in LICENSE file generation, not in FILES_TO_UPDATE - $corePlaceholders = @( - 'PROJECT_NAME', 'PROJECT_DESCRIPTION', 'PACKAGE_NAME', - 'GITHUB_REPO_URL', 'REPO_NAME', 'GITHUB_USERNAME', - 'DOCS_URL', 'LICENSE_TYPE', - 'NUGET_STATUS', 'TEMPLATE_REPO_OWNER', 'TEMPLATE_REPO_NAME' - ) - - # Optional placeholders that users fill in manually as they develop - $optionalPlaceholderDescriptions = @{ - 'QUICK_START_EXAMPLE' = 'Code example showing basic usage' - 'FEATURES_TABLE' = 'Markdown table listing features' - 'FEATURE_EXAMPLES' = 'Code examples demonstrating features' - 'TARGET_FRAMEWORKS' = 'List of supported .NET frameworks' - 'ACKNOWLEDGMENTS' = 'Credits for libraries/tools used' - } - - # Collect placeholders grouped by placeholder name - $corePlaceholdersByName = @{} - $optionalPlaceholdersByName = @{} - - foreach ($file in $filesToUpdate) { - if (Test-Path $file) { - $content = Get-Content $file -Raw - $matches = [regex]::Matches($content, '\{\{([A-Z_]+)\}\}') - foreach ($match in $matches) { - $placeholderName = $match.Groups[1].Value - - # Categorize placeholder - if ($corePlaceholders -contains $placeholderName) { - if (-not $corePlaceholdersByName.ContainsKey($placeholderName)) { - $corePlaceholdersByName[$placeholderName] = @() - } - if ($corePlaceholdersByName[$placeholderName] -notcontains $file) { - $corePlaceholdersByName[$placeholderName] += $file - } - } - elseif ($optionalPlaceholderDescriptions.ContainsKey($placeholderName)) { - if (-not $optionalPlaceholdersByName.ContainsKey($placeholderName)) { - $optionalPlaceholdersByName[$placeholderName] = @() - } - if ($optionalPlaceholdersByName[$placeholderName] -notcontains $file) { - $optionalPlaceholdersByName[$placeholderName] += $file - } - } - } - } - } - - # Report core placeholders that weren't replaced (this is an error) - if ($corePlaceholdersByName.Count -gt 0) { - Write-TemplateError "Error: The following required placeholders were not replaced:" - Write-Host "" - foreach ($placeholderName in ($corePlaceholdersByName.Keys | Sort-Object)) { - Write-Host " {{$placeholderName}}" -ForegroundColor Red - Write-Host " Found in:" -ForegroundColor Gray - foreach ($file in $corePlaceholdersByName[$placeholderName]) { - Write-Host " - $file" -ForegroundColor Gray - } - Write-Host "" - } - Write-Warning "This indicates the script did not replace all required placeholders. Please review the files and replace these manually." - Write-Host "" - exit 1 - } - else { - Write-Success "All required placeholders replaced successfully!" - } - - # Report optional placeholders that need manual updates - if ($optionalPlaceholdersByName.Count -gt 0) { - Write-Host "" - Write-Info "Optional content placeholders to fill in as you develop your project:" - Write-Host "" - - foreach ($placeholderName in ($optionalPlaceholdersByName.Keys | Sort-Object)) { - $description = $optionalPlaceholderDescriptions[$placeholderName] - - Write-Host " {{$placeholderName}}" -ForegroundColor Yellow - Write-Host " Description: $description" -ForegroundColor Gray - Write-Host " Found in:" -ForegroundColor Gray - foreach ($file in $optionalPlaceholdersByName[$placeholderName]) { - Write-Host " - $file" -ForegroundColor Gray - } - Write-Host "" - } - Write-Info "See TEMPLATE-PLACEHOLDERS.md for details on each placeholder." - } - - # Optional cleanup - Write-Step "Cleanup" - Write-Host "" - Write-Host "Remove template-specific files? (y/N)" -ForegroundColor Yellow - Write-Host " Files to remove:" -ForegroundColor Gray - Write-Host " - scripts/setup.ps1 (this script)" -ForegroundColor Gray - Write-Host " - LICENSE-SELECTION.md" -ForegroundColor Gray - Write-Host "" - Write-Host " Note: TEMPLATE-PLACEHOLDERS.md will remain for your reference." -ForegroundColor Cyan - Write-Host " Delete it manually when you've reviewed it and no longer need it." -ForegroundColor Cyan - Write-Host "" - Write-Host "Remove template files? (y/N): " -NoNewline -ForegroundColor Yellow - $cleanup = Read-Host - - if ($cleanup -eq 'y' -or $cleanup -eq 'Y') { - $filesToRemove = @( - 'scripts/setup.ps1', - 'LICENSE-SELECTION.md' - ) - - foreach ($file in $filesToRemove) { - if (Test-Path $file) { - Remove-Item $file -Force - Write-Success "Removed: $file" - } - } - } - else { - Write-Info "Keeping template files. You can remove them manually later." - } - - # Success! - Write-Host "" - Write-Host "╔════════════════════════════════════════════════════════════════╗" -ForegroundColor Green - Write-Host "ā•‘ ā•‘" -ForegroundColor Green - Write-Host "ā•‘ šŸŽ‰ Setup Complete! šŸŽ‰ ā•‘" -ForegroundColor Green - Write-Host "ā•‘ ā•‘" -ForegroundColor Green - Write-Host "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" -ForegroundColor Green - Write-Host "" - - # Git operations - Write-Step "Git Operations" - Write-Host "" - - # Step 1: Create branch and commit changes - Write-Host "Create a branch and commit these changes? (Y/n): " -NoNewline -ForegroundColor Yellow - $commitChanges = Read-Host - if ([string]::IsNullOrEmpty($commitChanges) -or $commitChanges -eq 'Y' -or $commitChanges -eq 'y') { - # Generate branch name - $branchName = "setup/configure-from-template-$(Get-Date -Format 'yyyyMMdd-HHmmss')" - - Write-Info "Step 1/4: Creating branch '$branchName'..." - git checkout -b $branchName - if ($LASTEXITCODE -eq 0) { - Write-Success "Branch created successfully!" - Write-Host "" - - Write-Info "Step 2/4: Committing changes..." - git add . - if ($LASTEXITCODE -eq 0) { - git commit -m "Configure repository from template" - if ($LASTEXITCODE -eq 0) { - Write-Success "Changes committed successfully!" - Write-Host "" - - # Step 3: Push to GitHub - Write-Info "Step 3/4: Pushing branch to GitHub..." - git push -u origin $branchName - if ($LASTEXITCODE -eq 0) { - Write-Success "Branch pushed to GitHub successfully!" - Write-Host "" - - # Step 4: Create Pull Request - Write-Info "Step 4/4: Creating pull request..." - - # Check if gh command is available - try { - $null = Get-Command gh -ErrorAction Stop - - gh pr create --title "Configure repository from template" --body "This PR contains the initial repository configuration from the template setup script.`n`nPlease review the changes, make any necessary adjustments, and merge to main when ready." --base main --head $branchName - if ($LASTEXITCODE -eq 0) { - Write-Success "Pull request created successfully!" - Write-Host "" - - # Get PR URL (best-effort; fall back to generic instruction on failure) - $prUrl = gh pr view $branchName --json url --jq .url 2>$null - if ($LASTEXITCODE -eq 0 -and $prUrl) { - Write-Host "╔════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan - Write-Host "ā•‘ ā•‘" -ForegroundColor Cyan - Write-Host "ā•‘ šŸ“‹ Review Required ā•‘" -ForegroundColor Cyan - Write-Host "ā•‘ ā•‘" -ForegroundColor Cyan - Write-Host "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" -ForegroundColor Cyan - Write-Host "" - Write-Host "Branch: $branchName" -ForegroundColor Yellow - Write-Host "Pull Request: $prUrl" -ForegroundColor Yellow - Write-Host "" - Write-Info "Please review the pull request, make any necessary changes, and merge it to main before continuing with development." - Write-Host "" - } - else { - Write-Host "╔════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan - Write-Host "ā•‘ ā•‘" -ForegroundColor Cyan - Write-Host "ā•‘ šŸ“‹ Review Required ā•‘" -ForegroundColor Cyan - Write-Host "ā•‘ ā•‘" -ForegroundColor Cyan - Write-Host "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" -ForegroundColor Cyan - Write-Host "" - Write-Host "Branch: $branchName" -ForegroundColor Yellow - Write-Host "" - Write-Info "Please review the pull request, make any necessary changes, and merge it to main before continuing with development." - Write-Info "You can view the pull request with: gh pr view $branchName --web" - Write-Host "" - } - } - else { - Write-TemplateWarning "Failed to create pull request. You can create it manually with:" - Write-Host " gh pr create --title ""Configure repository from template"" --body ""Initial setup"" --base main --head $branchName" -ForegroundColor Gray - Write-Host "" - } - } - catch { - Write-TemplateWarning "GitHub CLI (gh) is not installed or not available in PATH." - Write-TemplateWarning "Please install it from https://cli.github.com/ to enable automatic PR creation." - Write-Host "" - Write-Info "You can create the pull request manually with:" - Write-Host " gh pr create --title ""Configure repository from template"" --body ""Initial setup"" --base main --head $branchName" -ForegroundColor Gray - Write-Host "" - } - } - else { - Write-TemplateWarning "Push failed. You can push manually later with:" - Write-Host " git push -u origin $branchName" -ForegroundColor Gray - Write-Host "" - } - } - else { - Write-TemplateWarning "Commit failed. You can commit manually later with:" - Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray - Write-Host " git push -u origin $branchName" -ForegroundColor Gray - Write-Host "" - } - } - else { - Write-TemplateWarning "Git add failed. You can commit manually later with:" - Write-Host " git add ." -ForegroundColor Gray - Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray - Write-Host " git push -u origin $branchName" -ForegroundColor Gray - Write-Host "" - } - } - else { - Write-TemplateWarning "Failed to create branch. You can create it manually with:" - Write-Host " git checkout -b $branchName" -ForegroundColor Gray - Write-Host " git add ." -ForegroundColor Gray - Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray - Write-Host " git push -u origin $branchName" -ForegroundColor Gray - Write-Host "" - } - } - else { - Write-Info "Skipping branch creation and commit. You can do this manually later with:" - Write-Host " git checkout -b setup/configure-from-template-<timestamp>" -ForegroundColor Gray - Write-Host " git add ." -ForegroundColor Gray - Write-Host " git commit -m ""Configure repository from template""" -ForegroundColor Gray - Write-Host " git push -u origin setup/configure-from-template-<timestamp>" -ForegroundColor Gray - Write-Host " gh pr create --title ""Configure repository from template"" --base main" -ForegroundColor Gray - Write-Host "" - } - - # Next steps - Write-Host "āœ… Next Steps:" -ForegroundColor Cyan - Write-Host "" - Write-Host "1. Configure branch protection (see REPO-INSTRUCTIONS.md if kept)" -ForegroundColor Yellow - Write-Host "" - Write-Host "2. Start developing!" -ForegroundColor Yellow - if ($solutionName) { - Write-Host " # Solution file created: $solutionName.slnx" -ForegroundColor Gray - Write-Host " # Add your projects to src/ and tests/" -ForegroundColor Gray - } - else { - Write-Host " dotnet new sln -n $projectName" -ForegroundColor Gray - Write-Host " # Add your projects to src/ and tests/" -ForegroundColor Gray - } - Write-Host "" - - Write-Info "Your repository is now configured and ready for development!" - Write-Host "" -} - -# Run setup -try { - Start-Setup -} -catch { - Write-Error "Setup failed: $_" - Write-Host $_.ScriptStackTrace -ForegroundColor Red - exit 1 -} From e49d26dd72ccefa02cc631081a3838d5600d2ded Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:56:38 -0400 Subject: [PATCH 13/18] Use full project name Wolfgang.Extensions.Logging.InMemoryLogger Replace Wolfgang.InMemoryLogger references with the full project name that matches the src folder, so README install instructions and DocFX placeholders point at the correct NuGet package ID. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- BannedSymbols.txt | 2 +- README.md | 4 ++-- REPO-INSTRUCTIONS.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/BannedSymbols.txt b/BannedSymbols.txt index 63c1a92..ace175e 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -1,4 +1,4 @@ -# BannedSymbols.txt - Async-First Enforcement for Wolfgang.InMemoryLogger +# BannedSymbols.txt - Async-First Enforcement for Wolfgang.Extensions.Logging.InMemoryLogger # Format: <API Documentation ID>; <Reason/Alternative> # T: = Type, M: = Method, P: = Property, F: = Field # Task.Wait() - All overloads - Absolutely NOT allowed in async code diff --git a/README.md b/README.md index f39afb5..682248c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Wolfgang.InMemoryLogger +# Wolfgang.Extensions.Logging.InMemoryLogger Implementations of ILogger and ILogger<T> that write log events to an in-memory list. Useful for testing @@ -11,7 +11,7 @@ Implementations of ILogger and ILogger<T> that write log events to an in-memory ## šŸ“¦ Installation ```bash -dotnet add package Wolfgang.InMemoryLogger +dotnet add package Wolfgang.Extensions.Logging.InMemoryLogger ``` **NuGet Package:** Coming soon to NuGet.org diff --git a/REPO-INSTRUCTIONS.md b/REPO-INSTRUCTIONS.md index e378526..4132777 100644 --- a/REPO-INSTRUCTIONS.md +++ b/REPO-INSTRUCTIONS.md @@ -186,7 +186,7 @@ After creating your repository from the template, update the following files wit ### Update CONTRIBUTING.md 1. Open `CONTRIBUTING.md` -2. Ensure any project name placeholders (for example, `Wolfgang.InMemoryLogger`) have been replaced with your actual project name (the automated setup scripts should normally do this for you) +2. Ensure any project name placeholders (for example, `Wolfgang.Extensions.Logging.InMemoryLogger`) 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 @@ -210,7 +210,7 @@ If you want to publish your DocFX documentation to GitHub Pages automatically wh - **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.InMemoryLogger, https://Chris-Wolfgang.github.io/In-memory-Logger/, etc.) + - **Replace placeholders** in DocFX files (Wolfgang.Extensions.Logging.InMemoryLogger, https://Chris-Wolfgang.github.io/In-memory-Logger/, 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` From f33c1c0df5590befc80cfd9a6b054dde75671f6b Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:59:56 -0400 Subject: [PATCH 14/18] Fill DocFX placeholders in docfx_project/docfx.json Replace `src/<path>/<project>.csproj` with the standard `src/**/*.csproj` glob used by sibling repos so DocFX picks up the source project. Set `_appName` and `_appTitle` to Wolfgang.Extensions.Logging.InMemoryLogger. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- docfx_project/docfx.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docfx_project/docfx.json b/docfx_project/docfx.json index 0bcc566..4cd30de 100644 --- a/docfx_project/docfx.json +++ b/docfx_project/docfx.json @@ -5,7 +5,7 @@ "src": [ { "files": [ - "src/<path>/<project>.csproj" + "src/**/*.csproj" ], "src": "../" } @@ -37,8 +37,8 @@ "default" ], "globalMetadata": { - "_appName": "", - "_appTitle": "<title>", + "_appName": "Wolfgang.Extensions.Logging.InMemoryLogger", + "_appTitle": "Wolfgang.Extensions.Logging.InMemoryLogger Documentation", "_enableSearch": true, "pdf": true } From ca3a1c8bae7bdbe863b7a5fb50410b4579c0f288 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:58:38 -0400 Subject: [PATCH 15/18] Revert BannedSymbols.txt to match main The {{PROJECT_NAME}} fill-in is being applied separately because BannedSymbols.txt is a CI-protected configuration file. See #25. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- BannedSymbols.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/BannedSymbols.txt b/BannedSymbols.txt index ace175e..804798a 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -1,4 +1,4 @@ -# BannedSymbols.txt - Async-First Enforcement for Wolfgang.Extensions.Logging.InMemoryLogger +# BannedSymbols.txt - Async-First Enforcement for {{PROJECT_NAME}} # Format: <API Documentation ID>; <Reason/Alternative> # T: = Type, M: = Method, P: = Property, F: = Field # Task.Wait() - All overloads - Absolutely NOT allowed in async code @@ -79,4 +79,3 @@ M:System.Console.ReadLine(); Blocking - avoid in async code paths M:System.Console.Read(); Blocking - avoid in async code paths M:System.Console.ReadKey(); Blocking - avoid in async code paths M:System.Console.ReadKey(System.Boolean); Blocking - avoid in async code paths - From 57cddedda52afe2103d7f063f71d134a78b66072 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:58:12 -0400 Subject: [PATCH 16/18] Fill {{PROJECT_NAME}} placeholder in BannedSymbols.txt Replace the template placeholder in the header comment with the actual project name, Wolfgang.Extensions.Logging.InMemoryLogger. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- BannedSymbols.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BannedSymbols.txt b/BannedSymbols.txt index 804798a..ace175e 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -1,4 +1,4 @@ -# BannedSymbols.txt - Async-First Enforcement for {{PROJECT_NAME}} +# BannedSymbols.txt - Async-First Enforcement for Wolfgang.Extensions.Logging.InMemoryLogger # Format: <API Documentation ID>; <Reason/Alternative> # T: = Type, M: = Method, P: = Property, F: = Field # Task.Wait() - All overloads - Absolutely NOT allowed in async code @@ -79,3 +79,4 @@ M:System.Console.ReadLine(); Blocking - avoid in async code paths M:System.Console.Read(); Blocking - avoid in async code paths M:System.Console.ReadKey(); Blocking - avoid in async code paths M:System.Console.ReadKey(System.Boolean); Blocking - avoid in async code paths + From 0197cf4995eff840c1a33210d2c73e4d02406146 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:58:39 -0400 Subject: [PATCH 17/18] Remove template-only files now that the repo is configured README-TEMPLATE.md and the LICENSE-*.txt alternatives are placeholders the configure-from-template flow leaves behind. README.md and LICENSE are now project-specific, so these stand-in copies aren't needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- LICENSE-APACHE-2.0.txt | 201 ---------------------- LICENSE-MIT.txt | 21 --- LICENSE-MPL-2.0.txt | 373 ----------------------------------------- README-TEMPLATE.md | 180 -------------------- 4 files changed, 775 deletions(-) delete mode 100644 LICENSE-APACHE-2.0.txt delete mode 100644 LICENSE-MIT.txt delete mode 100644 LICENSE-MPL-2.0.txt delete mode 100644 README-TEMPLATE.md diff --git a/LICENSE-APACHE-2.0.txt b/LICENSE-APACHE-2.0.txt deleted file mode 100644 index fca1857..0000000 --- a/LICENSE-APACHE-2.0.txt +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {{YEAR}} {{COPYRIGHT_HOLDER}} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/LICENSE-MIT.txt b/LICENSE-MIT.txt deleted file mode 100644 index 594cd6b..0000000 --- a/LICENSE-MIT.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) {{YEAR}} {{COPYRIGHT_HOLDER}} - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE-MPL-2.0.txt b/LICENSE-MPL-2.0.txt deleted file mode 100644 index d0a1fa1..0000000 --- a/LICENSE-MPL-2.0.txt +++ /dev/null @@ -1,373 +0,0 @@ -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at https://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/README-TEMPLATE.md b/README-TEMPLATE.md deleted file mode 100644 index 47109dd..0000000 --- a/README-TEMPLATE.md +++ /dev/null @@ -1,180 +0,0 @@ -# {{PROJECT_NAME}} - -{{PROJECT_DESCRIPTION}} - -[![License: {{LICENSE_TYPE}}](https://img.shields.io/badge/License-{{LICENSE_TYPE}}-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)]({{GITHUB_REPO_URL}}) - ---- - -## šŸ“¦ Installation - -```bash -dotnet add package {{PACKAGE_NAME}} -``` - -**NuGet Package:** {{NUGET_STATUS}} - ---- - -## šŸ“„ License - -This project is licensed under the **{{LICENSE_TYPE}} License**. See the [LICENSE](LICENSE) file for details. - ---- - -## šŸ“š Documentation - -- **GitHub Repository:** [{{GITHUB_REPO_URL}}]({{GITHUB_REPO_URL}}) -- **API Documentation:** {{DOCS_URL}} -- **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 Core 3.1 | -| .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 {{GITHUB_REPO_URL}}.git -cd {{REPO_NAME}} - -# 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}} From b3438e89ec0a2b919521416e3f53e3267a570190 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:07:49 -0400 Subject: [PATCH 18/18] Fill remaining DocFX content placeholders - docfx_project/index.md: '# Title' -> '# Wolfgang.Extensions.Logging.InMemoryLogger' - docfx_project/docs/index.md: '{{PROJECT_NAME}} Documentation' -> project name - docfx_project/api/index.md: '{{PROJECT_NAME}} API documentation' -> project name - docfx_project/docs/toc.yml: 'Project website' link pointed at DbContextBuilder by mistake; fix to In-memory-Logger Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- docfx_project/api/index.md | 2 +- docfx_project/docs/index.md | 2 +- docfx_project/docs/toc.yml | 2 +- docfx_project/index.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docfx_project/api/index.md b/docfx_project/api/index.md index 7f4f2db..be9b2bb 100644 --- a/docfx_project/api/index.md +++ b/docfx_project/api/index.md @@ -1,6 +1,6 @@ # API Reference -Welcome to the {{PROJECT_NAME}} API documentation. +Welcome to the Wolfgang.Extensions.Logging.InMemoryLogger API documentation. This section contains the complete API reference, automatically generated from XML documentation comments in the source code. diff --git a/docfx_project/docs/index.md b/docfx_project/docs/index.md index 47ce1f1..aa987fe 100644 --- a/docfx_project/docs/index.md +++ b/docfx_project/docs/index.md @@ -1,4 +1,4 @@ -# {{PROJECT_NAME}} Documentation +# Wolfgang.Extensions.Logging.InMemoryLogger Documentation Welcome to the documentation section. Browse the topics in the navigation menu to get started. diff --git a/docfx_project/docs/toc.yml b/docfx_project/docs/toc.yml index 669b07d..35b4a8e 100644 --- a/docfx_project/docs/toc.yml +++ b/docfx_project/docs/toc.yml @@ -5,4 +5,4 @@ - name: Getting Started href: getting-started.md - name: Project website - href: https://github.com/Chris-Wolfgang/DbContextBuilder + href: https://github.com/Chris-Wolfgang/In-memory-Logger diff --git a/docfx_project/index.md b/docfx_project/index.md index 9bc17e9..b001639 100644 --- a/docfx_project/index.md +++ b/docfx_project/index.md @@ -2,7 +2,7 @@ _layout: landing --- -# Title +# Wolfgang.Extensions.Logging.InMemoryLogger ## Quick Start Notes: